mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 13:59:40 +00:00
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:
parent
4c6777dc68
commit
a870eb380e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
35
packages/secubox-toolbox/conf/nft-toolbox-tor.nft
Normal file
35
packages/secubox-toolbox/conf/nft-toolbox-tor.nft
Normal 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
|
||||
}
|
||||
}
|
||||
20
packages/secubox-toolbox/conf/torrc-toolbox-egress.conf
Normal file
20
packages/secubox-toolbox/conf/torrc-toolbox-egress.conf
Normal 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
|
||||
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
81
packages/secubox-toolbox/sbin/secubox-toolbox-tor-reconcile
Executable file
81
packages/secubox-toolbox/sbin/secubox-toolbox-tor-reconcile
Executable 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 "$@"
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
102
packages/secubox-toolbox/secubox_toolbox/tor_ctl.py
Normal file
102
packages/secubox-toolbox/secubox_toolbox/tor_ctl.py
Normal 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}
|
||||
14
packages/secubox-toolbox/systemd/secubox-toolbox-tor.path
Normal file
14
packages/secubox-toolbox/systemd/secubox-toolbox-tor.path
Normal 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
|
||||
18
packages/secubox-toolbox/systemd/secubox-toolbox-tor.service
Normal file
18
packages/secubox-toolbox/systemd/secubox-toolbox-tor.service
Normal 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
|
||||
124
packages/secubox-toolbox/tests/test_tor_switch.py
Normal file
124
packages/secubox-toolbox/tests/test_tor_switch.py
Normal 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
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user