From 54d39b9d61cee02a04bb91f8d197bc23405229da Mon Sep 17 00:00:00 2001 From: DAProgs Date: Mon, 9 Mar 2026 06:51:28 -0400 Subject: [PATCH] adding graph cpm --- .../__pycache__/admin.cpython-312.pyc | Bin 31496 -> 33882 bytes .../__pycache__/server.cpython-312.pyc | Bin 4887 -> 5171 bytes .../__pycache__/stats.cpython-312.pyc | Bin 5539 -> 7287 bytes portspoof_py/admin.py | 187 ++++++++++-------- portspoof_py/stats.py | 24 +++ 5 files changed, 128 insertions(+), 83 deletions(-) diff --git a/portspoof_py/__pycache__/admin.cpython-312.pyc b/portspoof_py/__pycache__/admin.cpython-312.pyc index 0c47301cbc88be6aa723fb334d6bc1d92aa863c5..1c8dd728907431145d51a677a581fcc1ff434eea 100644 GIT binary patch delta 8051 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 delta 5662 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 S#toEGd*3LtM11S}ul+9?-wAjC diff --git a/portspoof_py/__pycache__/server.cpython-312.pyc b/portspoof_py/__pycache__/server.cpython-312.pyc index 684225f876384c3f3be6672477eef0ce1d235b93..93397d6f958c2e9b3ec9375dbd0c6dd8d9a1e567 100644 GIT binary patch delta 1215 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 delta 978 zcmZ8fOK1~O6rDG}O#Yu~Q=8V>)RZ!>Z-T)lQ(mUl>cUMH z1%HdXXhnpAAQat57lJDl#DyRzu7V;6f)&ANp+Z;QHwpgU;@tD@J@=kBZ{E-0yFG!I ze!oY+HCuU*AMrg5oR+S9#O;z`3TZ-xv`8bAm{LxpVp&PcR5BGRQza);wJfJq(?w;c z>MG0k`5atnt)A0R;YRMkZZ~o-)v3pnFyk|oW>n%7FHZH-2I{Nl17;p2Igyv^5<2*MH>9;Kw;WQLyiGIco@}@1;vQMdD+ljq0y3H69_jX;E8-r zw8QXJ)xB=aJFB*b!-^Z2(n!3ps7%2J=W{-(SX%gvi+Got7A=c*Y20J)OhT(DWYikmV z=o7)f_{FjqhZYWf@GP{8q}INL2vMDb48vx4l8nP>@Eh53q}wTeYEYv-+EDKl%bOd(%n2YJVS4igB5%7!@a=im(6%h{j0Qo8}CYZ`i+ zljJyLnwO(wowpcdoxgg6Fp&oxT4UWucOtFGG@rFuFQ?ipv(VjMsJU>1id3>$%~`=M z+s8rW@Y|X3PhcnDPOPo#J<_u9U34jlm4OWbLpADElFJiS0mI6v4IZj(@Flh=R#mvs ra@jb`yPcoZ$XS{Le@`rou%(R_EIK!1rdS60TL*(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