feat(toolbox): kbin Tor egress quick-switch — switch + tunnel, DARK (ref #683)

Routes the R3 mitm-wg worker uid's upstream egress + DNS through Tor
(TransPort 9040 / DNSPort 5353) via an nft owner-match table, so MITM
inspection (ad-block/poison/banner/safe-browsing) is fully preserved —
only the exit IP + network identity change. Ships default-OFF, fail-closed.

- filters: tor_mode / tor_preset flags (validated)
- api: GET/POST /admin/tor/{state,on,off,newnym,check-leaks} (kbin-gated)
- tor_ctl.py: reuses secubox-tor control-port logic (status/NEWNYM), no JWT
- tunnel: conf/nft-toolbox-tor.nft (fail-closed kill-switch + v6 drop) +
  torrc drop-in + root path-triggered reconciler (portal stays
  NoNewPrivileges=true; nft loaded before tor = no clearnet window)
- WebUI: 🧅 Tor tab (badge, toggle, NEWNYM, leak probe)
- packaging: Depends jq; Recommends tor, python3-socksio; postinst adds
  secubox-toolbox to debian-tor group; prerm disarms on real removal
- 166 tests green (10 new); toolbox 2.7.1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-06-19 12:19:52 +02:00
parent 4c6777dc68
commit a870eb380e
19 changed files with 749 additions and 14 deletions

View File

@ -3,6 +3,32 @@
---
## 2026-06-19 — kbin Tor egress quick-switch implemented DARK (#683, ToolBoX 2.7.1)
- **Switch + tunnel** for routing kbin surfing through Tor, shipped **default-OFF /
fail-closed** on `feature/683`. Reuses existing secubox components per the user ask.
- **Transport decision (USER): torify the MITM egress.** nft owner-match on the
`secubox-toolbox` (mitm-wg) uid → Tor TransPort 9040 / DNSPort 5353. Clients →
TPROXY → mitm decrypts/ad-blocks/poisons/banners/re-encrypts → exits via Tor.
**Inspection fully preserved**; only the exit IP + network identity change. (Rejected:
SOCKS5 Go-core dialer = blocked on #662; transparent client torify = breaks inspection.)
- **Switch**: `filters.json` flags `tor_mode`/`tor_preset`; API (kbin-gated, admin.gk2
only for actions) `GET/POST /admin/tor/{state,on,off,newnym,check-leaks}`; 🧅 WebUI tab
(badge bootstrap/circuits/exit-IP, toggle, NEWNYM, SOCKS leak probe). `tor_ctl.py`
reuses secubox-tor's control-port code — no cross-service JWT.
- **Tunnel arms via reconciler**: root, path-triggered (`secubox-toolbox-tor.path`
watches filters.json) → portal stays `NoNewPrivileges=true`, no sudo. nft loaded
BEFORE tor (no clearnet window); IPv6 worker egress dropped (no v6 leak); prerm
disarms on real removal (not upgrade). Depends jq; Recommends tor + python3-socksio;
postinst adds secubox-toolbox to debian-tor group.
- **Verified**: 166 toolbox tests green (10 new), nft syntax valid (user-resolve only),
maintainer scripts `sh -n` clean, license headers OK, changelog parses 2.7.1.
- **Granularity = global kbin Tor mode** (owner-match can't be per-client). Per-client
(WG-hash) Tor tracked under #662 (Go-core SOCKS5 dialer). NOT yet flipped/deployed —
needs soak + off-board leak test + tls_splice(#649)-OFF before arming.
---
## 2026-06-19 — kbin milestone: ToolBoX 2.7.0 (middle release) + Tor chapter staged (#683)
- **End-of-session checkpoint** — docs + positioning + version, no runtime behaviour change.

View File

@ -3,6 +3,13 @@
---
## 🔄 2026-06-19 : kbin Tor egress (#683) — ToolBoX 2.7.1, implémenté DARK
Switch + tunnel Tor quick-switch livrés sur `feature/683`, **défaut OFF / fail-closed**.
Détail dans la section "Implémenté DARK" ci-dessous + HISTORY 2026-06-19.
---
## 🔄 2026-06-19 : kbin milestone — ToolBoX 2.7.0 + chapitre Tor (plan)
Checkpoint de fin de session. Pas de changement de comportement runtime — docs +
@ -19,17 +26,28 @@ positionnement + version + plan de la lame suivante.
endpoint Tor quick-switch (egress sortant, fail-closed, opt-in, no DNS leak,
inspection préservée). Dépend du cœur Go #662.
### ⬜ Next Up — chapitre Tor (#683)
### ✅ Implémenté DARK — chapitre Tor (#683, ToolBoX 2.7.1, branche feature/683)
- **Décider le transport** : Option A (dialer SOCKS5 upstream via le cœur Go #662,
*préféré*) vs Option B (nft mark → Tor TransPort, fallback pré-#662).
- **Profil Tor egress** dans `secubox-exposure` (ou unit `tor-egress` dédié) —
egress-only, pas de relay/hidden-service dans ce profil.
- **API toolbox** : `POST /admin/tor/{on,off}` (par client, WG-hash), `GET /tor/state`,
`POST /tor/newnym` + état SQLite + bandeau 🧅 UI.
- **Leak-guard nft** + DNS-over-Tor (test : exit IP + resolver ≠ Unbound local).
- **Caveat** : en mode Tor, forcer `tls_splice` OFF pour ce client (sinon les flux
asset fuient l'IP réelle). Soak DARK (flag présent, UI cachée) avant flip.
- ✅ **Transport tranché** : *torify l'egress MITM* (owner-match nft sur l'uid
`secubox-toolbox`/mitm-wg → Tor TransPort 9040 / DNSPort 5353). Inspection
préservée. Décision USER (vs dialer SOCKS5 #662 = bloqué, vs torify client = casse
l'inspection).
- ✅ **Switch** : flags `tor_mode`/`tor_preset` (filters.json) ; API kbin-gated
`GET/POST /admin/tor/{state,on,off,newnym,check-leaks}` ; onglet 🧅 WebUI (badge,
toggle, NEWNYM, sonde fuite). `tor_ctl.py` réutilise le control-port de secubox-tor.
- ✅ **Tunnel** : `conf/nft-toolbox-tor.nft` (fail-closed kill-switch + drop v6) +
`conf/torrc-toolbox-egress.conf` + reconciler root path-triggered
(`secubox-toolbox-tor.path` surveille filters.json → portail reste
NoNewPrivileges=true). nft chargé AVANT tor (pas de fenêtre clearnet).
- ✅ 166 tests verts ; license headers OK ; changelog 2.7.1.
#### ⬜ Avant flip ON (USER)
- Soak DARK puis `tor_mode=true` via l'onglet (admin.gk2).
- Test de fuite **hors-board** : l'IP réelle de la box ne doit jamais apparaître.
- Forcer `tls_splice` (#649) OFF quand armé (sinon flux asset fuient l'IP réelle).
- **Per-client (WG-hash)** : nécessite le dialer SOCKS5 du cœur Go #662 (l'owner-match
est global). Suivi sous #662.
---

View File

@ -1,6 +1,17 @@
# Design — kbin Tor endpoint: quick-switch anonymized web surfing
*Spec · 2026-06-19 · issue [#683](https://github.com/CyberMind-FR/secubox-deb/issues/683) · status: PLAN (no code yet)*
*Spec · 2026-06-19 · issue [#683](https://github.com/CyberMind-FR/secubox-deb/issues/683) · status: IMPLEMENTED DARK in secubox-toolbox 2.7.1*
> **Implemented (Option A-variant: torify MITM egress).** Switch + tunnel shipped
> default-OFF / fail-closed. Tunnel = nft owner-match on the `secubox-toolbox`
> (mitm-wg) uid → Tor TransPort 9040 / DNSPort 5353; loaded by a root,
> path-triggered reconciler (`secubox-toolbox-tor.path`) so the portal stays
> `NoNewPrivileges=true`. API `GET/POST /admin/tor/*` (kbin-gated) + 🧅 WebUI tab.
> Control/status/NEWNYM reuse secubox-tor's control-port code (`tor_ctl.py`).
> **Granularity is global kbin Tor mode** (owner-match can't be per-client);
> per-client (WG-hash) Tor needs the #662 Go-core SOCKS5 dialer — tracked as a
> follow-up. Before flipping ON: soak + off-board leak test (real board IP must
> never appear); `tls_splice` (#649) should be OFF for torified flows.
## Problem

View File

@ -32,7 +32,14 @@ traverse le pipeline de forge MITM SecuBox — sans configuration, sans app obli
---
## La lame suivante : 🧅 Tor quick-switch (plan #683)
## La lame suivante : 🧅 Tor quick-switch (#683 — implémenté DARK en 2.7.1)
> **Statut** : switch + tunnel livrés dans `secubox-toolbox` 2.7.1, **défaut OFF /
> fail-closed**. Onglet 🧅 Tor dans la WebUI opérateur (badge bootstrap/circuits/IP
> de sortie, toggle arm/désarm, nouvelle identité NEWNYM, sonde de fuite SOCKS).
> Granularité = mode Tor **global** (l'owner-match nft ne peut pas être par-client ;
> le per-client viendra avec le dialer SOCKS5 du cœur Go #662). Avant de basculer
> ON : soak + test de fuite hors-board (l'IP réelle ne doit jamais apparaître).
C'est la **pointe manquante** : l'anonymat de la sortie.

View File

@ -0,0 +1,35 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# kbin Tor egress tunnel (#683) — torify ONLY the MITM worker uid's upstream.
#
# The R3 mitm-wg workers run as user `secubox-toolbox` and dial the *real*
# upstream AFTER decrypting + inspecting + poisoning + bannering. We redirect
# only that uid's internet egress (80/443) and ALL its DNS through Tor's
# TransPort/DNSPort, so inspection is fully preserved and only the exit IP +
# network identity change. Loaded/flushed by secubox-toolbox-tor-reconcile.
#
# Invariants: inspection preserved · no DNS leak · fail-closed (kill-switch
# drops any worker egress that escaped the redirect — never leaks the real IP).
table inet toolbox_tor {
# ── NAT: redirect the worker uid's egress into Tor (TransPort/DNSPort) ──
chain output_nat {
type nat hook output priority -100; policy accept;
oifname "lo" return
# local plumbing (captive subnet, LXC/WG perimeter, loopback) untouched
ip daddr { 127.0.0.0/8, 10.99.0.0/16, 10.100.0.0/16 } return
meta skuid "secubox-toolbox" meta l4proto { tcp, udp } th dport 53 redirect to :5353
meta skuid "secubox-toolbox" tcp dport { 80, 443 } redirect to :9040
}
# ── Kill-switch: fail-closed safety net (runs after the NAT redirect) ──
chain output_killswitch {
type filter hook output priority 0; policy accept;
oifname "lo" return
ip daddr { 127.0.0.0/8, 10.99.0.0/16, 10.100.0.0/16 } return
# IPv4 that escaped the redirect (Tor down / rule gap) → drop, no leak
meta skuid "secubox-toolbox" tcp dport { 80, 443 } ip daddr != 127.0.0.0/8 drop
meta skuid "secubox-toolbox" meta l4proto { tcp, udp } th dport 53 ip daddr != 127.0.0.0/8 drop
# IPv6 is not carried over Tor here → drop worker v6 egress (no v6 leak)
meta skuid "secubox-toolbox" meta nfproto ipv6 tcp dport { 80, 443 } drop
meta skuid "secubox-toolbox" meta nfproto ipv6 meta l4proto { tcp, udp } th dport 53 drop
}
}

View File

@ -0,0 +1,20 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# kbin Tor egress drop-in (#683) — installed to /etc/tor/torrc.d/ by the
# reconciler when kbin Tor mode is armed. Provides the transparent egress
# ports the nft tunnel redirects into, plus a control port for status/NEWNYM.
#
# SOCKS (9050) stays default for the leak-check probe and any future Go-core
# (#662) SOCKS5 dialer. TransPort/DNSPort give the transparent egress path.
SocksPort 127.0.0.1:9050
TransPort 127.0.0.1:9040
DNSPort 127.0.0.1:5353
AutomapHostsOnResolve 1
VirtualAddrNetworkIPv4 10.192.0.0/10
# Control port for bootstrap/circuit status + SIGNAL NEWNYM (cookie auth,
# group-readable so the secubox-toolbox portal — added to the debian-tor
# group by postinst — can authenticate without a password or secret on disk).
ControlPort 9051
CookieAuthentication 1
CookieAuthFileGroupReadable 1

View File

@ -1,3 +1,25 @@
secubox-toolbox (2.7.1-1~bookworm1) bookworm; urgency=medium
* feat(#683): kbin Tor egress quick-switch — switch + tunnel, ships DARK
(tor_mode default OFF, fail-closed). Routes ONLY the R3 mitm-wg worker uid's
upstream egress + DNS through Tor's TransPort(9040)/DNSPort(5353) via an nft
owner-match table, so inspection (ad-block/poison/banner/safe-browsing) is
fully preserved — only the exit IP + network identity change.
* New filters flags `tor_mode` / `tor_preset` (anonymous|stealth|minimal).
* API (kbin-gated, admin.gk2 only for actions): GET /admin/tor/state,
POST /admin/tor/{on,off,newnym,check-leaks}. State read-only-safe on kbin.
* WebUI: 🧅 Tor tab — status badge (bootstrap/circuits/exit IP), arm/disarm
toggle, preset selector, New-Identity (NEWNYM), SOCKS leak probe. Reuses the
secubox-tor control-port logic (new tor_ctl.py) — no cross-service JWT.
* Tunnel arms via a root, path-triggered reconciler (secubox-toolbox-tor.path
watches filters.json) — portal stays NoNewPrivileges=true, no sudo. nft is
loaded BEFORE tor starts (fail-closed: no clearnet window). IPv6 worker
egress dropped (no v6 leak). prerm disarms on real removal (not upgrade).
* Depends: jq; Recommends: tor, python3-socksio. postinst adds secubox-toolbox
to the debian-tor group for control-cookie access.
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 16:00:00 +0200
secubox-toolbox (2.7.0-1~bookworm1) bookworm; urgency=medium
* MIDDLE RELEASE — caps the 2.6.x line (ad-intelligence / Anti-Track v2 /

View File

@ -24,6 +24,7 @@ Depends: ${misc:Depends}, ${python3:Depends},
hostapd,
dnsmasq,
mitmproxy,
jq,
openssl,
adduser,
fonts-dejavu-core,
@ -34,7 +35,9 @@ Depends: ${misc:Depends}, ${python3:Depends},
python3-geoip2 | geoipupdate,
lxc,
debian-archive-keyring
Recommends: bridge-utils
Recommends: bridge-utils,
tor,
python3-socksio
Description: SecuBox-DEB ToolBoX — Gondwana Cabine Numérique (captive AP + MITM analyzer)
Phase 1 du parent #474 (ToolBoX Pipeline). Productionize le PoC captive
portal en package dédié : splash + consent R2 + CA distribution iOS

View File

@ -182,9 +182,24 @@ fi
# Phase 7 (#498) : boot-time peer-restore service for wg-toolbox.
# Keeps the enrolled WG peers alive across reboots.
systemctl enable secubox-toolbox-wg-restore.service || true
# #683 : kbin Tor egress — enable the path watcher so flipping tor_mode
# in filters.json arms/disarms the tunnel (reconciler runs as root; the
# portal stays NoNewPrivileges=true). Default tor_mode=false → reconcile
# is a no-op until the operator flips it. Enable+start the .path; run one
# reconcile so a fresh/false flag leaves no tunnel behind.
systemctl enable secubox-toolbox-tor.path 2>/dev/null || true
systemctl start secubox-toolbox-tor.path 2>/dev/null || true
/usr/sbin/secubox-toolbox-tor-reconcile reconcile 2>/dev/null || true
# Pas de start auto — l'opérateur doit configurer mesh.toml et lancer toolbox-up
fi
# #683 : let the portal authenticate to Tor's control port (cookie is
# group-readable by debian-tor — see torrc-toolbox-egress.conf) so the
# Tor card can read bootstrap/circuits + SIGNAL NEWNYM without a secret.
if getent group debian-tor >/dev/null 2>&1; then
usermod -aG debian-tor secubox-toolbox 2>/dev/null || true
fi
# Phase 7 (#498) : install nft drop-in for boot-survival + reload nft so
# the running ruleset matches what nftables.service will load at next boot.
# Phase 10 (#501 perf) : ALSO deploy the Phase 9 fanout drop-in as

View File

@ -9,5 +9,16 @@ case "$1" in
fi
;;
esac
# #683 : on real removal, tear the kbin Tor tunnel down (flush nft table +
# torrc drop-in + stop tor) so the box never stays torified post-uninstall.
# NOT on upgrade — that must preserve runtime state.
case "$1" in
remove)
systemctl stop secubox-toolbox-tor.path 2>/dev/null || true
systemctl disable secubox-toolbox-tor.path 2>/dev/null || true
[ -x /usr/sbin/secubox-toolbox-tor-reconcile ] && \
/usr/sbin/secubox-toolbox-tor-reconcile disarm 2>/dev/null || true
;;
esac
#DEBHELPER#
exit 0

View File

@ -52,6 +52,15 @@ override_dh_installsystemd:
# activation + rollback recipe.
install -m 0644 systemd/secubox-toolbox-mitm-wg-worker@.service \
debian/secubox-toolbox/lib/systemd/system/
# #683 : kbin Tor egress reconciler (root, oneshot) + path watcher on
# filters.json. conf/nft-toolbox-tor.nft + conf/torrc-toolbox-egress.conf
# ship via the `cp -r conf` above (→ /usr/lib/secubox/toolbox/conf/).
install -m 0755 sbin/secubox-toolbox-tor-reconcile \
debian/secubox-toolbox/usr/sbin/
install -m 0644 systemd/secubox-toolbox-tor.service \
debian/secubox-toolbox/lib/systemd/system/
install -m 0644 systemd/secubox-toolbox-tor.path \
debian/secubox-toolbox/lib/systemd/system/
# Primary unit goes via dh_installsystemd which also handles the enable helpers.
cp systemd/secubox-toolbox.service debian/secubox-toolbox.service
dh_installsystemd --no-start --no-enable

View File

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
#
# SecuBox-Deb :: toolbox :: kbin Tor egress reconciler (#683)
#
# Reconciles the kbin Tor egress tunnel to match filters.json `tor_mode`.
# Runs as root (path-triggered by secubox-toolbox-tor.path on filters.json,
# or invoked directly with `arm`/`disarm`). The portal API never escalates —
# it only flips the flag; this privileged, hardening-preserving step does the
# nft + tor work asynchronously.
#
# Fail-closed: nft (incl. the kill-switch) is loaded BEFORE tor starts, so if
# tor is down the redirect target is simply unreachable — egress is refused,
# never leaked in the clear.
set -euo pipefail
readonly MODULE="toolbox-tor"
FILTERS="${SECUBOX_FILTERS_PATH:-/etc/secubox/toolbox/filters.json}"
NFT_FILE=/usr/lib/secubox/toolbox/conf/nft-toolbox-tor.nft
TORRC_DROPIN_SRC=/usr/lib/secubox/toolbox/conf/torrc-toolbox-egress.conf
TORRC_DROPIN=/etc/tor/torrc.d/10-secubox-toolbox-egress.conf
TORRC_MAIN=/etc/tor/torrc
log() { logger -t "secubox-${MODULE}" -- "$*" 2>/dev/null || true; echo "[$MODULE] $*"; }
table_present() { nft list table inet toolbox_tor >/dev/null 2>&1; }
want_from_flag() {
# default false on any parse error (fail-safe = off)
jq -r '.tor_mode // false' "$FILTERS" 2>/dev/null || echo false
}
arm() {
log "arming kbin Tor egress (TransPort 9040 / DNSPort 5353)"
mkdir -p /etc/tor/torrc.d
install -m 0644 "$TORRC_DROPIN_SRC" "$TORRC_DROPIN"
# Debian's torrc does not %include torrc.d by default — add it idempotently.
if ! grep -qE '^\s*%include\s+/etc/tor/torrc\.d/' "$TORRC_MAIN" 2>/dev/null; then
echo '%include /etc/tor/torrc.d/*.conf' >> "$TORRC_MAIN"
fi
# Load nft FIRST (kill-switch active) so there is never a clearnet window.
nft -f "$NFT_FILE"
systemctl restart tor 2>/dev/null || systemctl start tor 2>/dev/null || \
log "WARN tor failed to (re)start — egress fail-closed until it does"
log "ARMED"
}
disarm() {
log "disarming kbin Tor egress"
table_present && nft delete table inet toolbox_tor 2>/dev/null || true
rm -f "$TORRC_DROPIN"
# Stop tor to minimise attack surface when not in use (no hidden services
# are owned by this drop-in; secubox-tor manages those separately).
systemctl stop tor 2>/dev/null || true
log "DISARMED"
}
main() {
local arg="${1:-reconcile}" want
case "$arg" in
arm) want=true ;;
disarm) want=false ;;
reconcile) want="$(want_from_flag)" ;;
*) echo "usage: $0 {arm|disarm|reconcile}" >&2; exit 1 ;;
esac
if [ "$want" = "true" ]; then
table_present && { log "already armed — no-op"; exit 0; }
arm
else
if ! table_present && [ ! -f "$TORRC_DROPIN" ]; then
log "already disarmed — no-op"; exit 0
fi
disarm
fi
}
main "$@"

View File

@ -2845,6 +2845,116 @@ async def admin_filters_set(request: Request) -> dict:
return set_filters(patch)
# ── kbin Tor egress switch (#683) — reuses the secubox-tor control plane ──
_TOR_EXIT_IP_CACHE = "/tmp/tor_exit_ip" # shared with secubox-tor
def _tor_exit_ip_cached():
try:
if os.path.exists(_TOR_EXIT_IP_CACHE):
return (open(_TOR_EXIT_IP_CACHE).read().strip() or None)
except Exception:
pass
return None
def _require_tor_admin(request: Request) -> None:
"""Tor on/off/newnym/leak-check are operator actions — blocked on the
public kbin vhost (defense-in-depth, mirrors /admin/filter-control)."""
if _is_public_kbin(request):
raise HTTPException(status_code=403,
detail="Tor controls are admin-only (use admin.gk2.secubox.in)")
@router.get("/admin/tor/state")
async def admin_tor_state() -> dict:
"""kbin Tor mode status: the flag + live daemon bootstrap/circuits/exit IP.
Read-only safe to expose on the public kbin vhost (no secrets)."""
from .filters import get_filters
from . import tor_ctl
f = get_filters(force=True)
st = tor_ctl.status()
st.update({
"tor_mode": bool(f.get("tor_mode", False)),
"tor_preset": f.get("tor_preset", "anonymous"),
"exit_ip": _tor_exit_ip_cached(),
})
return st
@router.post("/admin/tor/on")
async def admin_tor_on(request: Request) -> dict:
"""Arm kbin Tor mode. Flips the flag only; the privileged tunnel arm runs
async via secubox-toolbox-tor.path reconcile (portal stays unprivileged)."""
_require_tor_admin(request)
from .filters import set_filters
try:
body = await request.json()
except Exception:
body = {}
patch = {"tor_mode": True}
if isinstance(body, dict) and body.get("tor_preset"):
patch["tor_preset"] = body["tor_preset"]
out = set_filters(patch)
return {"tor_mode": out["tor_mode"], "tor_preset": out["tor_preset"], "armed_async": True}
@router.post("/admin/tor/off")
async def admin_tor_off(request: Request) -> dict:
"""Disarm kbin Tor mode (flag flip; reconciler tears the tunnel down)."""
_require_tor_admin(request)
from .filters import set_filters
out = set_filters({"tor_mode": False})
return {"tor_mode": out["tor_mode"], "disarmed_async": True}
@router.post("/admin/tor/newnym")
async def admin_tor_newnym(request: Request) -> dict:
"""Request a fresh Tor circuit (new exit IP) — SIGNAL NEWNYM via control port."""
_require_tor_admin(request)
from . import tor_ctl
ok = tor_ctl.new_identity()
try:
if os.path.exists(_TOR_EXIT_IP_CACHE):
os.unlink(_TOR_EXIT_IP_CACHE)
except Exception:
pass
return {"success": ok}
@router.post("/admin/tor/check-leaks")
async def admin_tor_check_leaks(request: Request) -> dict:
"""Probe the egress IP via Tor SOCKS (9050) and cache it for the badge.
A real off-board leak test is still the source of truth; this confirms the
SOCKS path resolves and surfaces the current exit IP."""
_require_tor_admin(request)
from . import tor_ctl
if not tor_ctl.tor_running():
return {"ok": False, "error": "tor not running"}
import httpx
result: dict = {"ok": True}
# httpx renamed proxies= → proxy= (0.26+, removed proxies in 0.28); board
# ships bookworm's 0.23 (proxies=). Support both.
try:
_client = httpx.AsyncClient(timeout=15.0, proxy="socks5://127.0.0.1:9050")
except TypeError:
_client = httpx.AsyncClient(timeout=15.0, proxies="socks5://127.0.0.1:9050")
try:
async with _client as c:
tor_ip = (await c.get("https://api.ipify.org")).text.strip()
result["tor_ip"] = tor_ip
try:
with open(_TOR_EXIT_IP_CACHE, "w") as fh:
fh.write(tor_ip)
except Exception:
pass
except Exception:
result["ok"] = False
result["tor_ip"] = None
result["error"] = "SOCKS probe failed (python3-socksio installed? tor bootstrapped?)"
return result
@router.get("/admin/filters/ui", response_class=HTMLResponse)
async def admin_filters_ui() -> HTMLResponse:
"""#566 — minimal filter toggle panel for the toolbox WebUI."""

View File

@ -27,6 +27,9 @@ DEFAULTS: Dict = {
"autolearn": True, # #589 also block auto-learned bad hosts
"ad_learn": True, # #656 aggressive ad-URL learning toggle
"tls_splice": "observe", # #649 off | observe | on (asset SNI-splice)
# ── kbin Tor egress (#683) — ships dark; arm via reconciler after soak ──
"tor_mode": False, # route MITM upstream egress through Tor (global kbin Tor mode)
"tor_preset": "anonymous", # anonymous | stealth | minimal (secubox-tor preset)
# ── Anti-Track v2 (#633) — ships dark; arm after observe-only soak ──
"privacy_enforce": False, # master switch; off = observe-only
"privacy_poison": True, # forge stable fake id for loadbearing trackers
@ -44,6 +47,7 @@ DEFAULTS: Dict = {
_VALID_PROTECTIVE = ("off", "alert", "spoof")
_VALID_SPLICE = ("off", "observe", "on")
_VALID_TOR_PRESET = ("anonymous", "stealth", "minimal")
_cache: Dict = {}
_cache_ts: float = 0.0
@ -71,6 +75,8 @@ def get_filters(force: bool = False) -> Dict:
out["protective"] = DEFAULTS["protective"]
if out.get("tls_splice") not in _VALID_SPLICE:
out["tls_splice"] = DEFAULTS["tls_splice"]
if out.get("tor_preset") not in _VALID_TOR_PRESET:
out["tor_preset"] = DEFAULTS["tor_preset"]
_cache = out
_cache_ts = now
return out
@ -90,11 +96,13 @@ def set_filters(patch: Dict) -> Dict:
cur["protective"] = v
elif k == "tls_splice" and v in _VALID_SPLICE:
cur["tls_splice"] = v
elif k == "tor_preset" and v in _VALID_TOR_PRESET:
cur["tor_preset"] = v
elif k == "fortknox_sites" and isinstance(v, list):
cur["fortknox_sites"] = [str(s).strip().lower() for s in v if str(s).strip()]
elif k in ("banner", "ad_ghost", "ad_ghost_block", "media_cache", "autolearn",
"privacy_enforce", "privacy_poison", "privacy_anonymize",
"privacy_ip_drop", "privacy_dns_feed", "ad_learn"):
"privacy_ip_drop", "privacy_dns_feed", "ad_learn", "tor_mode"):
cur[k] = bool(v)
try:
os.makedirs(os.path.dirname(FILTERS_PATH), exist_ok=True)

View File

@ -0,0 +1,102 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: toolbox :: Tor control-port helpers (#683)
CyberMind https://cybermind.fr
Thin, dependency-free Tor control-port client, mirrored from the proven
``secubox-tor`` package (api/main.py ``tor_control``). The kbin Tor switch
reads status (bootstrap / circuits / exit IP) and requests a new identity
(NEWNYM) directly over the control port no cross-service JWT, no root.
The control cookie is group-readable by ``debian-tor``; the toolbox postinst
adds ``secubox-toolbox`` to that group so the FastAPI portal can authenticate.
All functions are best-effort and never raise a dead/absent Tor daemon
yields ``running=False`` rather than a 500, so the UI degrades gracefully.
"""
from __future__ import annotations
import socket
import subprocess
from pathlib import Path
from typing import Dict
CONTROL_PORT = 9051
SOCKS_PORT = 9050
CONTROL_SOCKET = Path("/run/tor/control")
COOKIE_FILE = Path("/run/tor/control.authcookie")
def _connect() -> socket.socket:
if CONTROL_SOCKET.exists():
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect(str(CONTROL_SOCKET))
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect(("127.0.0.1", CONTROL_PORT))
return sock
def tor_control(command: str) -> str:
"""Authenticate (cookie if present) and run one control-port command.
Returns the raw reply, or "" on any failure."""
try:
sock = _connect()
try:
if COOKIE_FILE.exists():
cookie = COOKIE_FILE.read_bytes().hex()
sock.send(f"AUTHENTICATE {cookie}\r\n".encode())
else:
sock.send(b"AUTHENTICATE\r\n")
if "250 OK" not in sock.recv(1024).decode(errors="replace"):
return ""
sock.send(f"{command}\r\n".encode())
return sock.recv(4096).decode(errors="replace")
finally:
sock.close()
except Exception:
return ""
def tor_running() -> bool:
try:
r = subprocess.run(["pgrep", "-x", "tor"], capture_output=True, text=True, timeout=5)
return r.returncode == 0 and r.stdout.strip() != ""
except Exception:
return False
def bootstrap_progress(reply: str | None = None) -> int:
"""Parse ``GETINFO status/bootstrap-phase`` PROGRESS=NN (0..100)."""
resp = reply if reply is not None else tor_control("GETINFO status/bootstrap-phase")
if "PROGRESS=" in resp:
try:
return int(resp.split("PROGRESS=")[1].split()[0])
except Exception:
pass
return 0
def circuit_count(reply: str | None = None) -> int:
"""Count BUILT circuits from ``GETINFO circuit-status``."""
resp = reply if reply is not None else tor_control("GETINFO circuit-status")
return sum(1 for line in resp.splitlines() if " BUILT " in f" {line} ")
def new_identity() -> bool:
"""Request a fresh Tor circuit (SIGNAL NEWNYM). True on 250 OK."""
return "250 OK" in tor_control("SIGNAL NEWNYM")
def status() -> Dict:
"""Best-effort consolidated status for the kbin Tor card."""
if not tor_running():
return {"running": False, "bootstrap": 0, "circuits": 0}
boot = bootstrap_progress()
circ = circuit_count()
return {"running": True, "bootstrap": boot, "circuits": circ}

View File

@ -0,0 +1,14 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Watches the toolbox filter config; any change re-runs the Tor reconciler so
# flipping `tor_mode` from the WebUI arms/disarms the tunnel within seconds —
# no portal privilege escalation, no restart. The reconciler is idempotent
# (no-op when desired == current) so unrelated filter toggles are cheap.
[Unit]
Description=SecuBox-Deb ToolBoX — watch filters.json for kbin Tor mode (#683)
[Path]
PathModified=/etc/secubox/toolbox/filters.json
Unit=secubox-toolbox-tor.service
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,18 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# kbin Tor egress reconciler (#683) — oneshot, root. Triggered by
# secubox-toolbox-tor.path on every filters.json change, and once at boot,
# to make the nft+tor tunnel match `tor_mode`. The portal API only flips the
# flag (NoNewPrivileges=true); this unit does the privileged work.
[Unit]
Description=SecuBox-Deb ToolBoX — reconcile kbin Tor egress tunnel (#683)
After=network-online.target nftables.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/sbin/secubox-toolbox-tor-reconcile reconcile
# Keep failures visible but never block the trigger chain.
SuccessExitStatus=0
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,124 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""kbin Tor switch (#683): filters flags + control-port parsing."""
import importlib
import json
import pytest
@pytest.fixture()
def filters_mod(tmp_path, monkeypatch):
monkeypatch.setenv("SECUBOX_FILTERS_PATH", str(tmp_path / "filters.json"))
import secubox_toolbox.filters as f
importlib.reload(f)
return f
def test_tor_defaults_off(filters_mod):
cur = filters_mod.get_filters(force=True)
assert cur["tor_mode"] is False
assert cur["tor_preset"] == "anonymous"
def test_tor_mode_is_bool_coerced(filters_mod):
out = filters_mod.set_filters({"tor_mode": 1})
assert out["tor_mode"] is True
on_disk = json.loads((filters_mod.FILTERS_PATH and open(filters_mod.FILTERS_PATH).read()))
assert on_disk["tor_mode"] is True
def test_tor_preset_enum_guarded(filters_mod):
out = filters_mod.set_filters({"tor_preset": "bogus"})
assert out["tor_preset"] == "anonymous" # rejected, default kept
out = filters_mod.set_filters({"tor_preset": "stealth"})
assert out["tor_preset"] == "stealth"
def test_get_filters_clamps_bad_preset_on_disk(filters_mod):
with open(filters_mod.FILTERS_PATH, "w") as fh:
json.dump({"tor_preset": "evil"}, fh)
assert filters_mod.get_filters(force=True)["tor_preset"] == "anonymous"
def test_bootstrap_progress_parse():
from secubox_toolbox import tor_ctl
reply = "250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=100 TAG=done SUMMARY=\"Done\"\r\n250 OK\r\n"
assert tor_ctl.bootstrap_progress(reply) == 100
assert tor_ctl.bootstrap_progress("garbage") == 0
def test_circuit_count_parse():
from secubox_toolbox import tor_ctl
reply = (
"250+circuit-status=\r\n"
"1 BUILT $AAA~a,$BBB~b PURPOSE=GENERAL\r\n"
"2 BUILT $CCC~c PURPOSE=GENERAL\r\n"
"3 LAUNCHED PURPOSE=GENERAL\r\n"
".\r\n250 OK\r\n"
)
assert tor_ctl.circuit_count(reply) == 2
class _FakeReq:
def __init__(self, host="admin.gk2.secubox.in", body=None):
self.headers = {"host": host}
self._body = body or {}
async def json(self):
return self._body
@pytest.fixture()
def api_mod(tmp_path, monkeypatch):
monkeypatch.setenv("SECUBOX_FILTERS_PATH", str(tmp_path / "filters.json"))
import secubox_toolbox.filters as f
importlib.reload(f)
import secubox_toolbox.api as api
return api
@pytest.mark.asyncio
async def test_tor_on_off_flips_flag(api_mod):
out = await api_mod.admin_tor_on(_FakeReq(body={"tor_preset": "stealth"}))
assert out["tor_mode"] is True and out["tor_preset"] == "stealth"
out = await api_mod.admin_tor_off(_FakeReq())
assert out["tor_mode"] is False
@pytest.mark.asyncio
async def test_tor_actions_blocked_on_public_kbin(api_mod):
from fastapi import HTTPException
for fn in (api_mod.admin_tor_on, api_mod.admin_tor_off,
api_mod.admin_tor_newnym, api_mod.admin_tor_check_leaks):
with pytest.raises(HTTPException) as ei:
await fn(_FakeReq(host="kbin.gk2.secubox.in"))
assert ei.value.status_code == 403
@pytest.mark.asyncio
async def test_tor_state_shape_offline(api_mod, monkeypatch):
from secubox_toolbox import tor_ctl
monkeypatch.setattr(tor_ctl, "tor_running", lambda: False)
st = await api_mod.admin_tor_state()
assert st["tor_mode"] is False
assert st["running"] is False
assert "bootstrap" in st and "circuits" in st and "exit_ip" in st
def test_nft_tunnel_failclosed_invariants():
"""The nft tunnel MUST keep its fail-closed safety net — guard against
accidental removal of the kill-switch / redirect / v6-leak rules."""
import pathlib
nft = pathlib.Path(__file__).resolve().parents[1] / "conf" / "nft-toolbox-tor.nft"
text = nft.read_text()
# redirect into Tor TransPort + DNSPort
assert "redirect to :9040" in text
assert "redirect to :5353" in text
# kill-switch drops (fail-closed) for v4 escape + v6 leak
assert "ip daddr != 127.0.0.0/8 drop" in text
assert "meta nfproto ipv6" in text and "drop" in text
# only the worker uid is torified (not a blanket rule)
assert text.count('meta skuid "secubox-toolbox"') >= 4
# loopback + local subnets are exempted (no plumbing breakage)
assert "10.99.0.0/16" in text and "10.100.0.0/16" in text

View File

@ -22,6 +22,11 @@
.header{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.5rem;border:1px solid var(--border);background:var(--bg-card);margin-bottom:1rem}
.header h1{font-size:1.4rem;color:var(--p31-hot);text-shadow:var(--bloom-text)}
.badge{font-size:0.85rem;color:var(--p31-dim);padding:0.2rem 0.6rem;border:1px solid var(--border);border-radius:3px}
/* Tor switch status badges (#683) */
.badge-green{color:var(--p31-hot);border-color:var(--p31-peak);text-shadow:var(--bloom-soft)}
.badge-yellow{color:var(--p31-decay);border-color:var(--p31-decay)}
.badge-red{color:var(--red);border-color:var(--red)}
.btn-green{border-color:var(--p31-peak)!important;color:var(--p31-hot)!important}
/* Sub-tab nav (#513) */
.tabs{display:flex;gap:0.3rem;margin-bottom:1.2rem;border-bottom:1px solid var(--border);flex-wrap:wrap}
.tab{font-family:inherit;font-size:0.9rem;padding:0.5rem 1rem;background:transparent;color:var(--p31-dim);border:1px solid var(--border);border-bottom:none;cursor:pointer;border-radius:3px 3px 0 0}
@ -78,6 +83,7 @@
<button class="tab" data-tab="filtres" onclick="switchTab('filtres')">🚦 Filtres MITM</button>
<button class="tab" data-tab="social" onclick="switchTab('social')">🕸️ Cartographie sociale</button>
<button class="tab" data-tab="ads" onclick="switchTab('ads')">🛑 Pubs</button>
<button class="tab" data-tab="tor" onclick="switchTab('tor')">🧅 Tor</button>
<button class="tab" data-tab="config" onclick="switchTab('config')">⚙ Config</button>
</nav>
@ -192,6 +198,37 @@
</div>
</section>
<!-- Tor egress quick-switch (#683) -->
<section class="panel" id="panel-tor">
<div class="toolbar">
<button onclick="loadTor()">🔁 Refresh</button>
<button type="button" onclick="torNewnym()">🔄 Nouvelle identité</button>
<button type="button" onclick="torLeaks()">🔍 Vérifier l'IP de sortie</button>
</div>
<div class="card" style="margin-bottom:1rem">
<h2>🧅 Mode Tor <span class="badge" id="tor-badge"></span></h2>
<p style="font-size:0.82rem;color:var(--p31-dim,#888);margin-bottom:0.6rem">
Quick-switch : route l'egress des workers MITM par le réseau Tor. L'inspection
(ad-block, poison, bannière, safe-browsing) reste intacte — seule l'IP de sortie
change. Fail-closed (pas de repli clearnet), sans fuite DNS. Armement asynchrone
(quelques secondes via le reconciler).
</p>
<div class="kv" id="tor-stats"><span class="k">loading…</span><span class="v"></span></div>
<div style="margin-top:0.8rem;display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">
<button type="button" class="btn-green" id="tor-on-btn" onclick="torSet(true)">🛡️ Activer le mode Tor</button>
<button type="button" id="tor-off-btn" onclick="torSet(false)">⏹ Désactiver</button>
<label style="font-size:0.8rem;color:var(--p31-dim,#888)">Preset
<select id="tor-preset">
<option value="anonymous">anonymous</option>
<option value="stealth">stealth (bridges)</option>
<option value="minimal">minimal (SOCKS)</option>
</select>
</label>
<span id="tor-msg" style="font-size:0.8rem;color:var(--p31-peak,#0a0)"></span>
</div>
</div>
</section>
<!-- Config -->
<section class="panel" id="panel-config">
<div class="toolbar">
@ -215,6 +252,7 @@ function switchTab(name) {
if (name === 'filtres') loadFilters();
if (name === 'social') loadSocial();
if (name === 'ads') loadAds();
if (name === 'tor') loadTor();
location.hash = name;
}
@ -424,6 +462,69 @@ async function loadFilters() {
}).join('');
}
// ── kbin Tor egress quick-switch (#683) ──
async function loadTor() {
const st = await J('/admin/tor/state');
const badge = document.getElementById('tor-badge');
const stats = document.getElementById('tor-stats');
if (st.__error) {
badge.textContent = 'err'; badge.className = 'badge badge-red';
stats.innerHTML = `<span class="k">err</span><span class="v">${st.__error}</span>`;
return;
}
const on = !!st.tor_mode;
badge.textContent = on ? (st.running ? `ON · ${st.bootstrap||0}%` : 'ON (tor down)') : 'OFF';
badge.className = 'badge ' + (on ? (st.running && st.bootstrap >= 100 ? 'badge-green' : 'badge-yellow') : '');
const psel = document.getElementById('tor-preset');
if (psel && st.tor_preset) psel.value = st.tor_preset;
stats.innerHTML = `
<span class="k">Mode Tor</span> <span class="v">${on ? '🧅 activé' : '⚪ désactivé'}</span>
<span class="k">Daemon</span> <span class="v">${st.running ? '🟢 running' : '⚪ stopped'}</span>
<span class="k">Bootstrap</span> <span class="v">${st.bootstrap || 0}%</span>
<span class="k">Circuits</span> <span class="v">${st.circuits || 0}</span>
<span class="k">IP de sortie</span><span class="v">${st.exit_ip || '— (cliquez « Vérifier »)'}</span>
<span class="k">Preset</span> <span class="v">${st.tor_preset || 'anonymous'}</span>
`;
}
async function torSet(on) {
const msg = document.getElementById('tor-msg');
const preset = (document.getElementById('tor-preset') || {}).value || 'anonymous';
msg.textContent = on ? 'armement…' : 'désactivation…';
try {
const body = on ? JSON.stringify({tor_preset: preset}) : '{}';
const r = await fetch(`${API}/admin/tor/${on ? 'on' : 'off'}`, {
method: 'POST', headers: {'content-type': 'application/json'},
body, credentials: 'same-origin'
});
if (!r.ok) throw new Error('HTTP ' + r.status + (r.status === 403 ? ' (admin.gk2 only)' : ''));
msg.textContent = on ? 'armé ✓ (tunnel dans quelques secondes)' : 'désarmé ✓';
setTimeout(() => { msg.textContent = ''; loadTor(); }, 1500);
} catch (e) { msg.textContent = 'échec : ' + e.message; }
}
async function torNewnym() {
const msg = document.getElementById('tor-msg');
msg.textContent = 'nouveau circuit…';
try {
const r = await fetch(`${API}/admin/tor/newnym`, {method: 'POST', credentials: 'same-origin'});
const d = await r.json();
msg.textContent = (r.ok && d.success) ? 'nouvelle identité ✓' : 'échec NEWNYM';
setTimeout(() => { msg.textContent = ''; loadTor(); }, 1500);
} catch (e) { msg.textContent = 'échec : ' + e.message; }
}
async function torLeaks() {
const msg = document.getElementById('tor-msg');
msg.textContent = 'sonde SOCKS…';
try {
const r = await fetch(`${API}/admin/tor/check-leaks`, {method: 'POST', credentials: 'same-origin'});
const d = await r.json();
msg.textContent = d.ok ? `IP de sortie : ${d.tor_ip}` : ('échec : ' + (d.error || 'tor down'));
setTimeout(() => loadTor(), 800);
} catch (e) { msg.textContent = 'échec : ' + e.message; }
}
async function loadSocial() {
const agg = await J('/admin/social-aggregate?hours=24');
const kpi = document.getElementById('social-kpi');