diff --git a/portspoof_py/admin.py b/portspoof_py/admin.py
index 884c3ed..9d8e29c 100644
--- a/portspoof_py/admin.py
+++ b/portspoof_py/admin.py
@@ -19,6 +19,7 @@ from typing import Optional, Tuple
from urllib.parse import parse_qs, urlparse
from .config import Config
+from .geo import country as _geo_country
from .notifier import Notifier
from .stats import Stats
@@ -303,6 +304,7 @@ tr:hover td { background: #1c2128; }
.hex { color: #a5d6ff; font-size: 11px;
max-width: 240px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
+.cc { color: #8b949e; font-size: 11px; }
.badge {
display: inline-block; background: #21262d;
border-radius: 3px; padding: 1px 7px; font-size: 11px;
@@ -452,12 +454,29 @@ def _empty_row(cols: int, msg: str) -> str:
return f'
| {msg} |
'
-def _render_dashboard(stats: Stats, cfg: Config) -> str:
+async def _render_dashboard(stats: Stats, cfg: Config) -> str:
# ── top IPs ──
top_ips = stats.top_ips()
+
+ # ── recent connections ──
+ recent = stats.recent_connections(50)
+
+ # Prefetch geo for all unique IPs in one batch
+ unique_ips = {ip for ip, _ in top_ips} | {e['src_ip'] for e in recent}
+ if unique_ips:
+ await asyncio.gather(*(_geo_country(ip) for ip in unique_ips))
+
+ def _ip_cell(ip: str) -> str:
+ from .geo import _cache as _geo_cache
+ code = _geo_cache.get(ip, '')
+ esc = html.escape(ip)
+ if code:
+ return f'{esc} ({html.escape(code)})'
+ return f'{esc}'
+
ip_rows = (
''.join(
- f'| {html.escape(ip)} | {c} |
'
+ f'| {_ip_cell(ip)} | {c} |
'
for ip, c in top_ips
) if top_ips else _empty_row(2, 'no data yet')
)
@@ -474,13 +493,11 @@ def _render_dashboard(stats: Stats, cfg: Config) -> str:
# ── CPM chart ──
cpm_chart = _render_cpm_chart(stats.cpm_history(60))
- # ── recent connections ──
- recent = stats.recent_connections(50)
conn_rows = (
''.join(
''
f'| {html.escape(e["timestamp"][:19].replace("T", " "))} | '
- f'{html.escape(e["src_ip"])} | '
+ f'{_ip_cell(e["src_ip"])} | '
f'{e["src_port"]} | '
f'{e["dst_port"]} | '
f''
@@ -637,7 +654,7 @@ async def _handle(
return
elif path == '/':
- body = _render_dashboard(stats, cfg)
+ body = await _render_dashboard(stats, cfg)
ct = 'text/html; charset=utf-8'
elif path == '/api/stats':
diff --git a/portspoof_py/geo.py b/portspoof_py/geo.py
new file mode 100644
index 0000000..4953998
--- /dev/null
+++ b/portspoof_py/geo.py
@@ -0,0 +1,29 @@
+"""IP geolocation — async lookup with in-memory cache. Zero external deps."""
+import asyncio
+import urllib.request
+
+_cache: dict[str, str] = {}
+_sem = asyncio.Semaphore(5)
+
+
+async def country(ip: str) -> str:
+ """Return two-letter country code for *ip*, or '' on error/unknown."""
+ if ip in _cache:
+ return _cache[ip]
+ async with _sem:
+ if ip in _cache:
+ return _cache[ip]
+
+ def _fetch() -> str:
+ url = f'https://www.daprogs.com/ip/?raw=1&ip={ip}'
+ with urllib.request.urlopen(url, timeout=3) as r:
+ return r.read().decode('ascii', errors='replace')
+
+ try:
+ text = await asyncio.to_thread(_fetch)
+ parts = text.split('|')
+ code = parts[3].strip() if len(parts) > 3 else ''
+ except Exception:
+ code = ''
+ _cache[ip] = code
+ return code
|