mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-30 15:54:07 +00:00
Compare commits
5 Commits
a48f43607b
...
1a315317e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a315317e7 | ||
|
|
04598482fb | ||
| be0497e6de | |||
| 7db7a73d65 | |||
| 3ade5619d0 |
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
118
packages/secubox-toolbox/tests/test_wg_endpoints_geoflag.py
Normal file
118
packages/secubox-toolbox/tests/test_wg_endpoints_geoflag.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user