diff --git a/portspoof_py/__pycache__/admin.cpython-312.pyc b/portspoof_py/__pycache__/admin.cpython-312.pyc index 0c47301..1c8dd72 100644 Binary files a/portspoof_py/__pycache__/admin.cpython-312.pyc and b/portspoof_py/__pycache__/admin.cpython-312.pyc differ diff --git a/portspoof_py/__pycache__/server.cpython-312.pyc b/portspoof_py/__pycache__/server.cpython-312.pyc index 684225f..93397d6 100644 Binary files a/portspoof_py/__pycache__/server.cpython-312.pyc and b/portspoof_py/__pycache__/server.cpython-312.pyc differ diff --git a/portspoof_py/__pycache__/stats.cpython-312.pyc b/portspoof_py/__pycache__/stats.cpython-312.pyc index 32c932a..e8df5c0 100644 Binary files a/portspoof_py/__pycache__/stats.cpython-312.pyc and b/portspoof_py/__pycache__/stats.cpython-312.pyc differ diff --git a/portspoof_py/admin.py b/portspoof_py/admin.py index ee45083..884c3ed 100644 --- a/portspoof_py/admin.py +++ b/portspoof_py/admin.py @@ -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 = """\

portspoof admin

Auto-refreshes every 5 s · - reset lookup · - email alerts · +

+ +
+ · email alerts · JSON stats

@@ -363,7 +346,13 @@ _HTML = """\

Last connection

{last_seen}
- + +
+

Connections / min last 60 min

+ {cpm_chart} +
+ +

Top source IPs

@@ -380,18 +369,6 @@ _HTML = """\ {top_ports_rows}
- -
-

Banner lookup

-
-
- - -
-
- {lookup_html} -
@@ -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'' + f'{lbl}' + ) + + # 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'{lbl}' + ) + + # Axes + parts.append( + f'' + f'' + ) + + # 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'') + parts.append( + f'' + ) + + return ( + f'' + + ''.join(parts) + + '' + ) + + def _empty_row(cols: int, msg: str) -> str: return f'{msg}' -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'{html.escape(ip)}{c}' for ip, c in top_ips - ) - else: - ip_rows = _empty_row(2, 'no data yet') + ) if top_ips else _empty_row(2, 'no data yet') + ) # ── top ports ── top_ports = stats.top_ports() - if top_ports: - port_rows = ''.join( + port_rows = ( + ''.join( f'{p}{c}' for p, c in top_ports - ) - else: - port_rows = _empty_row(2, 'no data yet') + ) if top_ports else _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""" -
-
Port {port_num} — {len(banner)} bytes
-
{hex_safe}
-
{txt_safe}
-
""" - except (ValueError, TypeError): - lookup_html = '
Invalid port number.
' + # ── 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( '' f'{html.escape(e["timestamp"][:19].replace("T", " "))}' f'{html.escape(e["src_ip"])}' f'{e["src_port"]}' f'{e["dst_port"]}' - f'{html.escape(e["banner_hex"][:48])}{"…" if len(e["banner_hex"]) > 48 else ""}' + f'' + f'{html.escape(e["banner_hex"][:48])}{"…" if len(e["banner_hex"]) > 48 else ""}' f'{e["banner_len"]}' '' for e in recent - ) - else: - conn_rows = _empty_row(6, 'no connections yet') + ) if recent else _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': diff --git a/portspoof_py/stats.py b/portspoof_py/stats.py index 26cd1f6..77f4f8c 100644 --- a/portspoof_py/stats.py +++ b/portspoof_py/stats.py @@ -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