mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-30 15:54:07 +00:00
Compare commits
No commits in common. "1a315317e7e09e9991f536ae31d146a5166ded16" and "a48f43607b5aec27ca205898d994fc5fcde7d5c0" have entirely different histories.
1a315317e7
...
a48f43607b
|
|
@ -568,11 +568,6 @@ func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict
|
||||||
resp.ContentLength = int64(len(body))
|
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)
|
writeResponse(tconn, resp, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,3 @@
|
||||||
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
|
secubox-toolbox-ng (0.1.13-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
* banner: INLINE the banner (server-side bundle fetch, baked literals) instead
|
* banner: INLINE the banner (server-side bundle fetch, baked literals) instead
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,9 @@ table inet wg-toolbox {
|
||||||
# outbound accept below. If it sits AFTER the accept it is never reached
|
# outbound accept below. If it sits AFTER the accept it is never reached
|
||||||
# (the accept terminates evaluation) → QUIC slips through and the whole
|
# (the accept terminates evaluation) → QUIC slips through and the whole
|
||||||
# MITM is bypassed (no inject, no ad-block, no metrics, no social). The
|
# MITM is bypassed (no inject, no ad-block, no metrics, no social). The
|
||||||
# REJECT (not drop) forces Chrome/Firefox to fall back to HTTP/2 over TCP
|
# drop forces Chrome/Firefox to fall back to HTTP/2 over TCP, which our
|
||||||
# IMMEDIATELY: a silent drop just makes the browser RETRY QUIC for tens of
|
# DNAT intercepts. ORDER IS LOAD-BEARING — keep this rule first.
|
||||||
# seconds (observed 199 retry packets, never falling back) — an ICMP
|
iif "wg-toolbox" udp dport 443 counter drop
|
||||||
# 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
|
# Outbound from tunnel → internet
|
||||||
iif "wg-toolbox" oif "lan0" accept
|
iif "wg-toolbox" oif "lan0" accept
|
||||||
# Return traffic
|
# Return traffic
|
||||||
|
|
|
||||||
|
|
@ -3119,14 +3119,6 @@ async def admin_clients_rich() -> dict:
|
||||||
# Use module-level imports so monkeypatching in tests works correctly.
|
# Use module-level imports so monkeypatching in tests works correctly.
|
||||||
_av = avatar_analysis
|
_av = avatar_analysis
|
||||||
_geo = geo
|
_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 = store.list_clients()
|
||||||
rows = sorted(rows, key=lambda r: (r.get("last_seen") or 0), reverse=True)
|
rows = sorted(rows, key=lambda r: (r.get("last_seen") or 0), reverse=True)
|
||||||
now = _t.time()
|
now = _t.time()
|
||||||
|
|
@ -3163,13 +3155,7 @@ async def admin_clients_rich() -> dict:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
# PRIVACY : the external endpoint IP is used transiently for the
|
gi = _geo.lookup(r.get("ip") or "")
|
||||||
# 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 ""
|
flag = gi.get("flag", "") or ""
|
||||||
country_iso = gi.get("country_iso", "") or ""
|
country_iso = gi.get("country_iso", "") or ""
|
||||||
asn_org = gi.get("asn_org", "") or ""
|
asn_org = gi.get("asn_org", "") or ""
|
||||||
|
|
|
||||||
|
|
@ -233,84 +233,3 @@ def revoke_client(client_pubkey: str) -> bool:
|
||||||
def _now_ts() -> float:
|
def _now_ts() -> float:
|
||||||
import time
|
import time
|
||||||
return time.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
|
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
# 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