Compare commits

...

5 Commits

Author SHA1 Message Date
CyberMind
1a315317e7
Merge pull request #682 from CyberMind-FR/feat/662-client-geoflag
Some checks are pending
License Headers / check (push) Waiting to run
feat(#662): per-client country flag from real external WG-endpoint IP
2026-06-19 11:24:01 +02:00
CyberMind
04598482fb
Merge pull request #681 from CyberMind-FR/fix/662-altsvc-strip
fix(#662): strip Alt-Svc — stop HTTP/3 so traffic stays on MITM-able TCP
2026-06-19 11:22:23 +02:00
be0497e6de fix(toolbox-ng): strip Alt-Svc to stop HTTP/3 advertisement → keep traffic on MITM-able TCP (ref #662) 2026-06-19 11:21:21 +02:00
7db7a73d65 fix(toolbox): QUIC udp443 reject (not drop) — drop made browsers retry QUIC 199x instead of TCP fallback; reject forces immediate fallback → MITM sees the traffic (ref #662) 2026-06-19 11:18:06 +02:00
3ade5619d0 feat(toolbox): per-client country flag from REAL external WG endpoint IP (ref #662)
/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).
2026-06-19 11:16:03 +02:00
6 changed files with 234 additions and 4 deletions

View File

@ -568,6 +568,11 @@ func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict
resp.ContentLength = int64(len(body))
}
}
// #662 — strip Alt-Svc so the browser is never told this origin offers HTTP/3
// (h3). With h3 unadvertised it keeps using HTTP/2 over TCP, which we MITM;
// otherwise it caches "h3 available" and keeps trying QUIC (UDP 443) — which
// bypasses this TCP proxy and is only best-effort blocked by the nft reject.
resp.Header.Del("Alt-Svc")
writeResponse(tconn, resp, body)
}

View File

@ -1,3 +1,12 @@
secubox-toolbox-ng (0.1.14-1~bookworm1) bookworm; urgency=medium
* quic/banner: strip Alt-Svc response header so browsers stop learning/preferring
HTTP/3 (h3) and stay on HTTP/2-over-TCP (MITM-able). Complements the nft
udp443 reject; addresses sites where browsers ignore the reject and keep
retrying QUIC, bypassing inject/adblock/metrics. (ref #662)
-- Gerald KERMA <devel@cybermind.fr> Thu, 19 Jun 2026 14:30:00 +0000
secubox-toolbox-ng (0.1.13-1~bookworm1) bookworm; urgency=medium
* banner: INLINE the banner (server-side bundle fetch, baked literals) instead

View File

@ -55,9 +55,12 @@ table inet wg-toolbox {
# outbound accept below. If it sits AFTER the accept it is never reached
# (the accept terminates evaluation) → QUIC slips through and the whole
# MITM is bypassed (no inject, no ad-block, no metrics, no social). The
# drop forces Chrome/Firefox to fall back to HTTP/2 over TCP, which our
# DNAT intercepts. ORDER IS LOAD-BEARING — keep this rule first.
iif "wg-toolbox" udp dport 443 counter drop
# REJECT (not drop) forces Chrome/Firefox to fall back to HTTP/2 over TCP
# IMMEDIATELY: a silent drop just makes the browser RETRY QUIC for tens of
# seconds (observed 199 retry packets, never falling back) — an ICMP
# port-unreachable tells it "no QUIC here" at once. First in the chain so
# it also breaks existing QUIC sessions (outbound). ORDER IS LOAD-BEARING.
iif "wg-toolbox" udp dport 443 counter reject
# Outbound from tunnel → internet
iif "wg-toolbox" oif "lan0" accept
# Return traffic

View File

@ -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 ""

View File

@ -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

View File

@ -0,0 +1,118 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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"