From 3ade5619d011c6278adde307250ead1aef8e1b80 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 19 Jun 2026 11:16:03 +0200 Subject: [PATCH] feat(toolbox): per-client country flag from REAL external WG endpoint IP (ref #662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /admin/clients/rich geo-resolved the stored client IP, which for WG clients is the internal 10.99.1.x (GeoIPs to nothing) → empty flags. The true origin is the peer's pre-tunnel WG endpoint (from wg show wg-toolbox dump). - wg.wg_endpoints(): parse `wg show wg-toolbox dump`, map sha256(pubkey)[:16] → external endpoint IP. Skips (none)/RFC1918/loopback/link-local. Best-effort (empty on missing wg/error), cached ~30s — no shell-out per row. - admin_clients_rich: geo-enrich from the external endpoint when present, else fall back to the stored ip (non-WG/captive clients still work). Within ENRICH_LIMIT. - PRIVACY: external IP used transiently for the GeoIP lookup only — never stored or returned. Country-granularity only (flag/ISO + existing asn_org). --- .../secubox-toolbox/secubox_toolbox/api.py | 16 ++- .../secubox-toolbox/secubox_toolbox/wg.py | 81 ++++++++++++ .../tests/test_wg_endpoints_geoflag.py | 118 ++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 packages/secubox-toolbox/tests/test_wg_endpoints_geoflag.py diff --git a/packages/secubox-toolbox/secubox_toolbox/api.py b/packages/secubox-toolbox/secubox_toolbox/api.py index fac9f4ef..ca927fba 100644 --- a/packages/secubox-toolbox/secubox_toolbox/api.py +++ b/packages/secubox-toolbox/secubox_toolbox/api.py @@ -3119,6 +3119,14 @@ async def admin_clients_rich() -> dict: # Use module-level imports so monkeypatching in tests works correctly. _av = avatar_analysis _geo = geo + # Phase 6 (#662) : map each WG client to its REAL external (pre-tunnel) + # endpoint IP so the flag reflects the client's true origin country, not + # the internal 10.99.1.x (which GeoIPs to nothing). Best-effort, cached. + try: + from . import wg as _wg + _wg_eps = _wg.wg_endpoints() + except Exception: + _wg_eps = {} rows = store.list_clients() rows = sorted(rows, key=lambda r: (r.get("last_seen") or 0), reverse=True) now = _t.time() @@ -3155,7 +3163,13 @@ async def admin_clients_rich() -> dict: except Exception: pass try: - gi = _geo.lookup(r.get("ip") or "") + # PRIVACY : the external endpoint IP is used transiently for the + # GeoIP lookup ONLY — it is NEVER stored or returned in the API + # response. The appliance is privacy-focused: country-granularity + # only (flag / ISO), never the raw client origin IP. Fall back to + # the stored (internal) IP for non-WG / captive clients. + geo_key = _wg_eps.get(r.get("mac_hash") or "") or (r.get("ip") or "") + gi = _geo.lookup(geo_key) flag = gi.get("flag", "") or "" country_iso = gi.get("country_iso", "") or "" asn_org = gi.get("asn_org", "") or "" diff --git a/packages/secubox-toolbox/secubox_toolbox/wg.py b/packages/secubox-toolbox/secubox_toolbox/wg.py index 2064dce5..24570ceb 100644 --- a/packages/secubox-toolbox/secubox_toolbox/wg.py +++ b/packages/secubox-toolbox/secubox_toolbox/wg.py @@ -233,3 +233,84 @@ def revoke_client(client_pubkey: str) -> bool: def _now_ts() -> float: import time return time.time() + + +# Phase 6 (#662) : map each WG peer to its REAL external (pre-tunnel) endpoint IP +# so the admin client table can show the client's true origin country flag — +# the stored client IP is the internal 10.99.1.x which GeoIPs to nothing. + +import hashlib as _hashlib +import ipaddress as _ipaddress + +_ENDPOINTS_CACHE: dict[str, str] = {} +_ENDPOINTS_TS: float = 0.0 +_ENDPOINTS_TTL = 30.0 # endpoints change rarely; don't shell out per request/row + + +def _is_private_or_loopback(ip: str) -> bool: + """True for RFC1918 / loopback / link-local / ULA — non-routable, no + meaningful country (a client on the local LAN has no public geo).""" + try: + a = _ipaddress.ip_address(ip) + except ValueError: + return True + return ( + a.is_private # 10/8, 172.16/12, 192.168/16, fc00::/7 + or a.is_loopback # 127/8, ::1 + or a.is_link_local # 169.254/16, fe80::/10 + or a.is_unspecified + ) + + +def _strip_endpoint_port(endpoint: str) -> str | None: + """`IP:port` or `[IPv6]:port` → bare IP. None for `(none)` / malformed.""" + ep = (endpoint or "").strip() + if not ep or ep == "(none)": + return None + if ep.startswith("["): # IPv6 literal: [2001:db8::1]:51820 + host = ep[1:].split("]", 1)[0] + return host or None + # IPv4 (or bare host): split off the last :port + return ep.rsplit(":", 1)[0] or None + + +def wg_endpoints() -> dict[str, str]: + """Return {mac_hash: external_ip} for every WG peer with a real, routable + endpoint, derived from `wg show wg-toolbox dump`. + + mac_hash = sha256(pubkey)[:16] — the SAME derivation used when the peer is + registered (api.wg_profile_new). The external IP is the peer's pre-tunnel + endpoint, i.e. its true public origin. RFC1918 / loopback / link-local + endpoints and `(none)` are skipped (no meaningful country). + + Best-effort : empty dict on any error or if `wg` is missing. Cached ~30s. + """ + global _ENDPOINTS_CACHE, _ENDPOINTS_TS + now = _now_ts() + if _ENDPOINTS_CACHE and (now - _ENDPOINTS_TS) < _ENDPOINTS_TTL: + return _ENDPOINTS_CACHE + out: dict[str, str] = {} + try: + proc = subprocess.run( + ["wg", "show", WG_INTERFACE, "dump"], + capture_output=True, text=True, timeout=2, check=False, + ) + lines = proc.stdout.splitlines() + # First line is the interface (privkey, pubkey, port, fwmark) — skip it. + # Peer lines: pubkey presharedkey endpoint allowed-ips ... + for line in lines[1:]: + fields = line.split("\t") + if len(fields) < 3: + continue + pubkey = fields[0].strip() + ip = _strip_endpoint_port(fields[2]) + if not pubkey or not ip or _is_private_or_loopback(ip): + continue + mac_hash = _hashlib.sha256(pubkey.encode()).hexdigest()[:16] + out[mac_hash] = ip + except Exception as e: # missing wg, timeout, permission, parse error + log.debug("wg_endpoints unavailable: %s", e) + return _ENDPOINTS_CACHE or {} + _ENDPOINTS_CACHE = out + _ENDPOINTS_TS = now + return out diff --git a/packages/secubox-toolbox/tests/test_wg_endpoints_geoflag.py b/packages/secubox-toolbox/tests/test_wg_endpoints_geoflag.py new file mode 100644 index 00000000..f60dc8fa --- /dev/null +++ b/packages/secubox-toolbox/tests/test_wg_endpoints_geoflag.py @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma + +"""Phase 6 (#662) — per-client country flag from the REAL external WG endpoint IP.""" +import asyncio +import hashlib +from types import SimpleNamespace + +from secubox_toolbox import api +from secubox_toolbox import wg + + +# A `wg show wg-toolbox dump` blob. First line = interface (skipped). +# Peer fields are TAB-separated: pubkey psk endpoint allowed-ips ... +_PUB_PUBLIC = "cVZ7s8d2pubkeyAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" # real public endpoint +_PUB_NONE = "noneZZZpubkeyBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" # endpoint (none) +_PUB_LAN = "lanZZZZZpubkeyCCCCCCCCCCCCCCCCCCCCCCCCCCCC=" # RFC1918 endpoint +_PUB_V6 = "v6ZZZZZZpubkeyDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=" # IPv6 endpoint + +_DUMP = "\t".join([ + "srvPrivKey", "srvPubKey", "51820", "off", +]) + "\n" + "\n".join([ + "\t".join([_PUB_PUBLIC, "(none)", "88.163.66.208:51820", "10.99.1.2/32", "0", "0", "0"]), + "\t".join([_PUB_NONE, "(none)", "(none)", "10.99.1.3/32", "0", "0", "0"]), + "\t".join([_PUB_LAN, "(none)", "192.168.1.50:41234", "10.99.1.4/32", "0", "0", "0"]), + "\t".join([_PUB_V6, "(none)", "[2606:4700:4700::1111]:51820", "10.99.1.5/32", "0", "0", "0"]), +]) + + +def _hash(pub: str) -> str: + return hashlib.sha256(pub.encode()).hexdigest()[:16] + + +def _fake_run(blob): + def _run(cmd, **kw): + return SimpleNamespace(stdout=blob, stderr="", returncode=0) + return _run + + +def test_wg_endpoints_parsing(monkeypatch): + # Bust the 30s cache between tests. + wg._ENDPOINTS_CACHE, wg._ENDPOINTS_TS = {}, 0.0 + monkeypatch.setattr(wg.subprocess, "run", _fake_run(_DUMP)) + + eps = wg.wg_endpoints() + + # Public IPv4 endpoint → mapped, port stripped. + assert eps[_hash(_PUB_PUBLIC)] == "88.163.66.208" + # mac_hash derivation matches the known pubkey→hash. + assert _hash(_PUB_PUBLIC) == "ad32e736309b1348" + # IPv6 endpoint → bracket + port stripped (global IPv6 kept). + assert eps[_hash(_PUB_V6)] == "2606:4700:4700::1111" + # `(none)` endpoint skipped. + assert _hash(_PUB_NONE) not in eps + # RFC1918 LAN endpoint skipped (no meaningful country). + assert _hash(_PUB_LAN) not in eps + + +def test_wg_endpoints_besteffort_empty(monkeypatch): + wg._ENDPOINTS_CACHE, wg._ENDPOINTS_TS = {}, 0.0 + + def _boom(*a, **k): + raise FileNotFoundError("wg not installed") + + monkeypatch.setattr(wg.subprocess, "run", _boom) + assert wg.wg_endpoints() == {} + + +def test_clients_rich_uses_external_endpoint_flag(monkeypatch): + wg_pub = "clientPubKeyEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=" + wg_mac = _hash(wg_pub) + rows = [ + # WG client: stored ip is internal 10.99.1.x, has a public endpoint. + {"mac_hash": wg_mac, "ip": "10.99.1.7", "state": "active", + "level": "r3", "score": 0, "last_seen": 100.0, "first_seen": 0.0}, + # Non-WG / captive client: no endpoint → falls back to stored ip. + {"mac_hash": "captive01", "ip": "203.0.113.9", "state": "active", + "level": "r1", "score": 0, "last_seen": 50.0, "first_seen": 0.0}, + ] + monkeypatch.setattr(api.store, "list_clients", lambda: rows) + monkeypatch.setattr(api.store, "latest_user_agent", lambda mh: "") + + # External endpoint for the WG client only. admin_clients_rich does a lazy + # `from . import wg`, so patching the wg module attribute is what takes effect. + import secubox_toolbox.wg as _wgmod + monkeypatch.setattr(_wgmod, "wg_endpoints", lambda: {wg_mac: "88.163.66.208"}) + + seen_keys = [] + + def fake_lookup(key): + seen_keys.append(key) + if key == "88.163.66.208": + return {"flag": "🇫🇷", "country_iso": "FR", "asn_org": "Orange"} + if key == "203.0.113.9": + return {"flag": "🇺🇸", "country_iso": "US", "asn_org": "Example"} + return {"flag": "", "country_iso": "", "asn_org": ""} + + monkeypatch.setattr(api.geo, "lookup", fake_lookup) + + out = asyncio.run(api.admin_clients_rich()) + clients = {c["mac_hash"]: c for c in out["clients"]} + + # WG client: flag derived from the EXTERNAL IP, not the internal 10.99.1.7. + assert clients[wg_mac]["flag"] == "🇫🇷" + assert clients[wg_mac]["country_iso"] == "FR" + assert "88.163.66.208" in seen_keys + assert "10.99.1.7" not in seen_keys # internal IP never geo-looked-up + + # Non-WG client: falls back to the stored ip. + assert clients["captive01"]["flag"] == "🇺🇸" + assert "203.0.113.9" in seen_keys + + # PRIVACY: the raw external IP must NOT appear anywhere in the response. + import json + dumped = json.dumps(out, default=str) + assert "88.163.66.208" not in dumped + # The stored (internal) ip is still the only ip field exposed. + assert clients[wg_mac]["ip"] == "10.99.1.7"