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;
|
border-radius: 3px; padding: 1px 7px; font-size: 11px;
|
||||||
margin-left: 6px; vertical-align: middle;
|
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; }
|
.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 = """\
|
_HTML = """\
|
||||||
@@ -349,8 +330,10 @@ _HTML = """\
|
|||||||
<h1>portspoof admin</h1>
|
<h1>portspoof admin</h1>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
Auto-refreshes every 5 s ·
|
Auto-refreshes every 5 s ·
|
||||||
<a href="/">reset lookup</a> ·
|
<form method="post" action="/api/stats/reset" style="display:inline">
|
||||||
<a href="/config">email alerts</a> ·
|
<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>
|
<a href="/api/stats" target="_blank">JSON stats</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -363,7 +346,13 @@ _HTML = """\
|
|||||||
<div class="card stat"><h3>Last connection</h3><div class="val last">{last_seen}</div></div>
|
<div class="card stat"><h3>Last connection</h3><div class="val last">{last_seen}</div></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="row">
|
||||||
<div class="card half">
|
<div class="card half">
|
||||||
<h2>Top source IPs</h2>
|
<h2>Top source IPs</h2>
|
||||||
@@ -380,18 +369,6 @@ _HTML = """\
|
|||||||
{top_ports_rows}
|
{top_ports_rows}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- recent connections -->
|
<!-- 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:
|
def _empty_row(cols: int, msg: str) -> str:
|
||||||
return f'<tr><td colspan="{cols}" class="empty">{msg}</td></tr>'
|
return f'<tr><td colspan="{cols}" class="empty">{msg}</td></tr>'
|
||||||
|
|
||||||
|
|
||||||
def _render_dashboard(
|
def _render_dashboard(stats: Stats, cfg: Config) -> str:
|
||||||
stats: Stats,
|
|
||||||
cfg: Config,
|
|
||||||
port_q: Optional[str],
|
|
||||||
) -> str:
|
|
||||||
# ── top IPs ──
|
# ── top IPs ──
|
||||||
top_ips = stats.top_ips()
|
top_ips = stats.top_ips()
|
||||||
if top_ips:
|
ip_rows = (
|
||||||
ip_rows = ''.join(
|
''.join(
|
||||||
f'<tr><td class="ip">{html.escape(ip)}</td><td>{c}</td></tr>'
|
f'<tr><td class="ip">{html.escape(ip)}</td><td>{c}</td></tr>'
|
||||||
for ip, c in top_ips
|
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 ──
|
||||||
top_ports = stats.top_ports()
|
top_ports = stats.top_ports()
|
||||||
if top_ports:
|
port_rows = (
|
||||||
port_rows = ''.join(
|
''.join(
|
||||||
f'<tr><td class="port">{p}</td><td>{c}</td></tr>'
|
f'<tr><td class="port">{p}</td><td>{c}</td></tr>'
|
||||||
for p, c in top_ports
|
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 ──
|
# ── CPM chart ──
|
||||||
lookup_html = ''
|
cpm_chart = _render_cpm_chart(stats.cpm_history(60))
|
||||||
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>'
|
|
||||||
|
|
||||||
# ── recent connections ──
|
# ── recent connections ──
|
||||||
recent = stats.recent_connections(50)
|
recent = stats.recent_connections(50)
|
||||||
if recent:
|
conn_rows = (
|
||||||
conn_rows = ''.join(
|
''.join(
|
||||||
'<tr>'
|
'<tr>'
|
||||||
f'<td class="ts">{html.escape(e["timestamp"][:19].replace("T", " "))}</td>'
|
f'<td class="ts">{html.escape(e["timestamp"][:19].replace("T", " "))}</td>'
|
||||||
f'<td class="ip">{html.escape(e["src_ip"])}</td>'
|
f'<td class="ip">{html.escape(e["src_ip"])}</td>'
|
||||||
f'<td>{e["src_port"]}</td>'
|
f'<td>{e["src_port"]}</td>'
|
||||||
f'<td class="port">{e["dst_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>'
|
f'<td>{e["banner_len"]}</td>'
|
||||||
'</tr>'
|
'</tr>'
|
||||||
for e in recent
|
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 = stats.last_connection
|
||||||
last_seen = last[:19].replace('T', ' ') + ' UTC' if last else '—'
|
last_seen = last[:19].replace('T', ' ') + ' UTC' if last else '—'
|
||||||
@@ -484,10 +501,9 @@ def _render_dashboard(
|
|||||||
uptime=stats.uptime_str(),
|
uptime=stats.uptime_str(),
|
||||||
ports=len(cfg.port_map),
|
ports=len(cfg.port_map),
|
||||||
last_seen=html.escape(last_seen),
|
last_seen=html.escape(last_seen),
|
||||||
|
cpm_chart=cpm_chart,
|
||||||
top_ips_rows=ip_rows,
|
top_ips_rows=ip_rows,
|
||||||
top_ports_rows=port_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),
|
recent_count=len(recent),
|
||||||
conn_rows=conn_rows,
|
conn_rows=conn_rows,
|
||||||
)
|
)
|
||||||
@@ -614,9 +630,14 @@ async def _handle(
|
|||||||
ct = 'application/json'
|
ct = 'application/json'
|
||||||
|
|
||||||
# ── standard routes ───────────────────────────────────────────────────
|
# ── standard routes ───────────────────────────────────────────────────
|
||||||
|
elif path == '/api/stats/reset' and method == 'POST':
|
||||||
|
stats.reset()
|
||||||
|
writer.write(_redirect('/'))
|
||||||
|
await writer.drain()
|
||||||
|
return
|
||||||
|
|
||||||
elif path == '/':
|
elif path == '/':
|
||||||
port_q = qs.get('port', [None])[0]
|
body = _render_dashboard(stats, cfg)
|
||||||
body = _render_dashboard(stats, cfg, port_q)
|
|
||||||
ct = 'text/html; charset=utf-8'
|
ct = 'text/html; charset=utf-8'
|
||||||
|
|
||||||
elif path == '/api/stats':
|
elif path == '/api/stats':
|
||||||
|
|||||||
@@ -29,9 +29,21 @@ class Stats:
|
|||||||
self._last_ts: str | None = None # ISO timestamp of last connection
|
self._last_ts: str | None = None # ISO timestamp of last connection
|
||||||
self._recent: deque = deque(maxlen=max_recent)
|
self._recent: deque = deque(maxlen=max_recent)
|
||||||
self._timestamps: deque = deque() # monotonic timestamps for rolling rate
|
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_ips = Counter()
|
||||||
self._top_ports = 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) ────────────────────
|
# ── write side (called from logger writer coroutine) ────────────────────
|
||||||
|
|
||||||
def record(self, event: dict) -> None:
|
def record(self, event: dict) -> None:
|
||||||
@@ -46,6 +58,9 @@ class Stats:
|
|||||||
self._last_ts = event['timestamp']
|
self._last_ts = event['timestamp']
|
||||||
self._top_ips[event['src_ip']] += 1
|
self._top_ips[event['src_ip']] += 1
|
||||||
self._top_ports[event['dst_port']] += 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) ───────────────────────────
|
# ── read side (called from admin HTTP handler) ───────────────────────────
|
||||||
|
|
||||||
@@ -73,6 +88,15 @@ class Stats:
|
|||||||
def top_ports(self, n: int = 10) -> List[Tuple[int, int]]:
|
def top_ports(self, n: int = 10) -> List[Tuple[int, int]]:
|
||||||
return self._top_ports.most_common(n)
|
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
|
@property
|
||||||
def last_connection(self) -> str | None:
|
def last_connection(self) -> str | None:
|
||||||
return self._last_ts
|
return self._last_ts
|
||||||
|
|||||||
Reference in New Issue
Block a user