adding graph cpm
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 ·
|
||||
<a href="/">reset lookup</a> ·
|
||||
<a href="/config">email alerts</a> ·
|
||||
<form method="post" action="/api/stats/reset" style="display:inline">
|
||||
<button type="submit" class="link-btn">reset stats</button>
|
||||
</form>
|
||||
· <a href="/config">email alerts</a> ·
|
||||
<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–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
|
||||
)
|
||||
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'<tr><td class="port">{p}</td><td>{c}</td></tr>'
|
||||
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"""
|
||||
<div class="lookup-result">
|
||||
<div class="lbl">Port {port_num} — {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
|
||||
)
|
||||
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':
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user