adding graph cpm

This commit is contained in:
2026-03-09 06:51:28 -04:00
parent 3cb3c9ccb7
commit 54d39b9d61
5 changed files with 128 additions and 83 deletions

View File

@@ -308,31 +308,12 @@ tr:hover td { background: #1c2128; }
border-radius: 3px; padding: 1px 7px; font-size: 11px;
margin-left: 6px; vertical-align: middle;
}
/* lookup form */
.form-row { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
input[type=number] {
background: #0d1117; border: 1px solid #30363d;
color: #c9d1d9; padding: 6px 10px; border-radius: 4px;
width: 110px; font-family: inherit; font-size: 13px;
}
button {
background: #238636; border: 1px solid #2ea043;
color: #fff; padding: 6px 14px; border-radius: 4px;
cursor: pointer; font-family: inherit; font-size: 13px;
}
button:hover { background: #2ea043; }
.lookup-result {
background: #0d1117; border: 1px solid #30363d;
border-radius: 4px; padding: 10px; margin-top: 8px;
}
.lookup-result .lbl { color: #8b949e; font-size: 11px; margin-bottom: 2px; }
.lookup-result .hexval { color: #a5d6ff; word-break: break-all; }
.lookup-result .txtval {
color: #c9d1d9; white-space: pre; overflow-x: auto;
max-height: 120px; font-size: 12px; margin-top: 6px;
border-top: 1px solid #21262d; padding-top: 6px;
}
.empty { color: #8b949e; font-style: italic; padding: 8px 0; }
.link-btn {
background: none; border: none; color: #58a6ff; padding: 0;
font-family: inherit; font-size: inherit; cursor: pointer;
}
.link-btn:hover { text-decoration: underline; }
"""
_HTML = """\
@@ -349,8 +330,10 @@ _HTML = """\
<h1>portspoof admin</h1>
<p class="sub">
Auto-refreshes every 5 s &middot;
<a href="/">reset lookup</a> &middot;
<a href="/config">email alerts</a> &middot;
<form method="post" action="/api/stats/reset" style="display:inline">
<button type="submit" class="link-btn">reset stats</button>
</form>
&middot; <a href="/config">email alerts</a> &middot;
<a href="/api/stats" target="_blank">JSON stats</a>
</p>
@@ -363,7 +346,13 @@ _HTML = """\
<div class="card stat"><h3>Last connection</h3><div class="val last">{last_seen}</div></div>
</div>
<!-- top ips / top ports / banner lookup -->
<!-- connections / min chart -->
<div class="card full">
<h2>Connections / min <span class="badge">last 60 min</span></h2>
{cpm_chart}
</div>
<!-- top ips / top ports -->
<div class="row">
<div class="card half">
<h2>Top source IPs</h2>
@@ -380,18 +369,6 @@ _HTML = """\
{top_ports_rows}
</table>
</div>
<div class="card half">
<h2>Banner lookup</h2>
<form method="get" action="/">
<div class="form-row">
<input type="number" name="port" min="0" max="65535"
placeholder="0&ndash;65535" value="{port_q}" required>
<button type="submit">Look up</button>
</div>
</form>
{lookup_html}
</div>
</div>
<!-- recent connections -->
@@ -408,71 +385,111 @@ _HTML = """\
"""
def _render_cpm_chart(history: list) -> str:
"""Render a 60-minute connections-per-minute SVG line chart (server-side, no JS)."""
n = len(history)
# Plot area inside the SVG viewBox
PX, PY = 38, 8 # top-left corner of plot area
W, H = 572, 92 # plot area width and height
VW, VH = PX + W + 4, PY + H + 22 # total viewBox size
max_val = max(history) if any(history) else 0
y_max = max(max_val, 1)
parts: list = []
# Horizontal grid lines + Y-axis labels
for frac, label_fn in ((0.25, lambda v: str(int(v * 0.25))),
(0.5, lambda v: str(int(v * 0.5))),
(0.75, lambda v: str(int(v * 0.75))),
(1.0, lambda v: str(int(v)))):
gy = PY + H - int(H * frac)
lbl = label_fn(y_max)
parts.append(
f'<line x1="{PX}" y1="{gy}" x2="{PX+W}" y2="{gy}" '
f'stroke="#21262d" stroke-width="1"/>'
f'<text x="{PX-4}" y="{gy+4}" text-anchor="end" '
f'fill="#8b949e" font-size="9">{lbl}</text>'
)
# X-axis labels at fixed minute offsets
for idx, lbl in ((0, '-60m'), (15, '-45m'), (30, '-30m'), (45, '-15m'), (59, 'now')):
gx = PX + int(idx * W / (n - 1)) if n > 1 else PX
parts.append(
f'<text x="{gx}" y="{PY+H+14}" text-anchor="middle" '
f'fill="#8b949e" font-size="9">{lbl}</text>'
)
# Axes
parts.append(
f'<line x1="{PX}" y1="{PY}" x2="{PX}" y2="{PY+H}" stroke="#30363d" stroke-width="1"/>'
f'<line x1="{PX}" y1="{PY+H}" x2="{PX+W}" y2="{PY+H}" stroke="#30363d" stroke-width="1"/>'
)
# Line + filled area
if n > 1:
coords = [
(PX + int(i * W / (n - 1)), PY + H - int(H * v / y_max))
for i, v in enumerate(history)
]
line_pts = ' '.join(f'{x},{y}' for x, y in coords)
area_pts = f'{PX},{PY+H} ' + line_pts + f' {PX+W},{PY+H}'
parts.append(f'<polygon points="{area_pts}" fill="#1f3a5f" opacity="0.6"/>')
parts.append(
f'<polyline points="{line_pts}" fill="none" stroke="#58a6ff" '
f'stroke-width="1.5" stroke-linejoin="round"/>'
)
return (
f'<svg viewBox="0 0 {VW} {VH}" width="100%" '
f'xmlns="http://www.w3.org/2000/svg" style="display:block;margin-top:8px">'
+ ''.join(parts)
+ '</svg>'
)
def _empty_row(cols: int, msg: str) -> str:
return f'<tr><td colspan="{cols}" class="empty">{msg}</td></tr>'
def _render_dashboard(
stats: Stats,
cfg: Config,
port_q: Optional[str],
) -> str:
def _render_dashboard(stats: Stats, cfg: Config) -> str:
# ── top IPs ──
top_ips = stats.top_ips()
if top_ips:
ip_rows = ''.join(
ip_rows = (
''.join(
f'<tr><td class="ip">{html.escape(ip)}</td><td>{c}</td></tr>'
for ip, c in top_ips
) if top_ips else _empty_row(2, 'no data yet')
)
else:
ip_rows = _empty_row(2, 'no data yet')
# ── top ports ──
top_ports = stats.top_ports()
if top_ports:
port_rows = ''.join(
port_rows = (
''.join(
f'<tr><td class="port">{p}</td><td>{c}</td></tr>'
for p, c in top_ports
) if top_ports else _empty_row(2, 'no data yet')
)
else:
port_rows = _empty_row(2, 'no data yet')
# ── banner lookup ──
lookup_html = ''
if port_q is not None:
try:
port_num = int(port_q)
if not 0 <= port_num <= 65535:
raise ValueError
banner = cfg.get_banner(port_num)
txt_preview = banner.decode('latin-1', errors='replace')
txt_safe = html.escape(txt_preview)
hex_safe = html.escape(banner.hex())
lookup_html = f"""
<div class="lookup-result">
<div class="lbl">Port {port_num} &mdash; {len(banner)} bytes</div>
<div class="hexval">{hex_safe}</div>
<div class="txtval">{txt_safe}</div>
</div>"""
except (ValueError, TypeError):
lookup_html = '<div class="lookup-result"><div class="lbl">Invalid port number.</div></div>'
# ── CPM chart ──
cpm_chart = _render_cpm_chart(stats.cpm_history(60))
# ── recent connections ──
recent = stats.recent_connections(50)
if recent:
conn_rows = ''.join(
conn_rows = (
''.join(
'<tr>'
f'<td class="ts">{html.escape(e["timestamp"][:19].replace("T", " "))}</td>'
f'<td class="ip">{html.escape(e["src_ip"])}</td>'
f'<td>{e["src_port"]}</td>'
f'<td class="port">{e["dst_port"]}</td>'
f'<td class="hex" title="{html.escape(e["banner_hex"])}">{html.escape(e["banner_hex"][:48])}{"" if len(e["banner_hex"]) > 48 else ""}</td>'
f'<td class="hex" title="{html.escape(e["banner_hex"])}">'
f'{html.escape(e["banner_hex"][:48])}{"" if len(e["banner_hex"]) > 48 else ""}</td>'
f'<td>{e["banner_len"]}</td>'
'</tr>'
for e in recent
) if recent else _empty_row(6, 'no connections yet')
)
else:
conn_rows = _empty_row(6, 'no connections yet')
last = stats.last_connection
last_seen = last[:19].replace('T', ' ') + ' UTC' if last else ''
@@ -484,10 +501,9 @@ def _render_dashboard(
uptime=stats.uptime_str(),
ports=len(cfg.port_map),
last_seen=html.escape(last_seen),
cpm_chart=cpm_chart,
top_ips_rows=ip_rows,
top_ports_rows=port_rows,
port_q=html.escape(str(port_q)) if port_q is not None else '',
lookup_html=lookup_html,
recent_count=len(recent),
conn_rows=conn_rows,
)
@@ -614,9 +630,14 @@ async def _handle(
ct = 'application/json'
# ── standard routes ───────────────────────────────────────────────────
elif path == '/api/stats/reset' and method == 'POST':
stats.reset()
writer.write(_redirect('/'))
await writer.drain()
return
elif path == '/':
port_q = qs.get('port', [None])[0]
body = _render_dashboard(stats, cfg, port_q)
body = _render_dashboard(stats, cfg)
ct = 'text/html; charset=utf-8'
elif path == '/api/stats':

View File

@@ -29,9 +29,21 @@ class Stats:
self._last_ts: str | None = None # ISO timestamp of last connection
self._recent: deque = deque(maxlen=max_recent)
self._timestamps: deque = deque() # monotonic timestamps for rolling rate
self._minute_buckets: dict = {} # int(wall_time // 60) → connection count
self._top_ips = Counter()
self._top_ports = Counter()
def reset(self) -> None:
"""Clear all counters and restart the uptime clock."""
self._start = time.monotonic()
self._total = 0
self._last_ts = None
self._recent.clear()
self._timestamps.clear()
self._minute_buckets.clear()
self._top_ips.clear()
self._top_ports.clear()
# ── write side (called from logger writer coroutine) ────────────────────
def record(self, event: dict) -> None:
@@ -46,6 +58,9 @@ class Stats:
self._last_ts = event['timestamp']
self._top_ips[event['src_ip']] += 1
self._top_ports[event['dst_port']] += 1
# Per-minute bucket for the chart
bucket = int(time.time() // 60)
self._minute_buckets[bucket] = self._minute_buckets.get(bucket, 0) + 1
# ── read side (called from admin HTTP handler) ───────────────────────────
@@ -73,6 +88,15 @@ class Stats:
def top_ports(self, n: int = 10) -> List[Tuple[int, int]]:
return self._top_ports.most_common(n)
def cpm_history(self, n: int = 60) -> List[int]:
"""Return n per-minute connection counts, oldest first, ending at the current minute."""
now_bucket = int(time.time() // 60)
# Prune buckets older than our window to keep the dict bounded
cutoff = now_bucket - n
for k in [k for k in self._minute_buckets if k < cutoff]:
del self._minute_buckets[k]
return [self._minute_buckets.get(now_bucket - (n - 1 - i), 0) for i in range(n)]
@property
def last_connection(self) -> str | None:
return self._last_ts