Compare commits

...

12 Commits

Author SHA1 Message Date
CyberMind
69d4f0bd5c
Merge pull request #684 from CyberMind-FR/feature/683-plan-kbin-tor-endpoint-quick-switch-anon
Some checks are pending
License Headers / check (push) Waiting to run
feat(#683): kbin Tor egress quick-switch + clients + landing/report redesign
2026-06-19 21:30:13 +02:00
47eae4a774 feat(toolbox): restyle landing to match the new report (ref #683)
System font, rounded --panel/--line cards, cleaner accents, softer rounded SVG
bars + helper lines; R3 panel + arch note mention the 🧅 Tor egress option.
Dynamic bits unchanged: live KPIs + auto-refresh JS, per-OS install panels,
cert-probe, ?mh links. Verified live on kbin (2.7.11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:18:59 +02:00
db5d5dbcf1 feat(toolbox): regenerate /report — verdict-first, graphs, collapsible (ref #683)
Simple, easy-to-process report:
- Verdict hero: conic-gradient score gauge + plain-language verdict + helper.
- 6 KPIs (connexions/hôtes/trackers/pays/apps/cert-pin).
- 3 graphs computed server-side (_build_report_charts): trackers donut
  (conic-gradient), countries bars, apps bars — with one-line helpers.
- ALL deep technical cards (threat-intel/DGA/beaconing, hosts, apps, cookies,
  avatar, transparency+per-host grades, identity, reco) collapsed into <details>.
- Mobile-first, system-font, rounded cards.
- /report/me/html resolves identity via shared _client_mac_hash (?mh → R3 WG
  peer → captive ARP) so R3 clients reach it without ?mh.

Verified live on kbin: 200, 11.7KB, gauge + 3 graphs + details render for an
R3 peer via X-R3-Peer and via ?mh. toolbox 2.7.10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:07:41 +02:00
41dbdadaa2 fix(toolbox): landing report/carto links carry ?mh= → no 'identity unresolved' (ref #683)
Clicking "Ma carto" / "Mon rapport" / "Qui me piste ?" hit /social/me +
/report/me with no ?mh=, so identity was re-resolved at click-time and could
400 "client identity unresolved" (off-tunnel/captive, or when X-R3-Peer wasn't
present on that request). The landing already knows the caller — now it resolves
mac_hash (new _client_mac_hash: ?mh → R3 WG peer → captive ARP) and bakes ?mh=
into the links so they always open the right client's view.

Verified live: R3 peer 10.99.1.2 → links carry ?mh=1b0ec958…; captive caller →
?mh from ARP. toolbox 2.7.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:32:35 +02:00
e1b2e6ccbb fix(toolbox): injected banner trackers/cookies stuck at 0 — count live (ref #683)
The bar counted trackers (Resource Timing) + cookies (document.cookie) ONCE at
render time, which fires early — before resources/cookies have loaded — so it
showed 0, and the 2s poll's ensure() early-returned once the banner existed, so
it never refreshed. Spans now carry ids (sbx-trk/sbx-ck) and updateCounts()
re-counts on the poll → values climb to real within ~2s. toolbox 2.7.7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:21:04 +02:00
79c6166181 fix(toolbox): 🧅 Tor indicator on the REAL injected banner (bundle) (ref #683)
The live page banner is the client-side stream-inject bundle (bundle.py:
"SecuBox · LEVEL · 🛰️ trackers · 🍪 cookies · report ▸ · ✕"), not the
server-side inject_banner chip I'd added earlier. Added tor_mode to the
decision bundle + a "🧅 Tor" span in the shared banner render() (_BANNER_CORE,
used by both the loader and the #662 inline/service-worker path).

Verified live on kbin: /__toolbox/bundle → tor_mode:true; loader.js + inline
both carry the 🧅 span. toolbox 2.7.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:13:13 +02:00
55955867af feat(clients): persistent WG identity (APK) + Tor status in webext/APK (ref #683)
APK (0.4.0):
- FIX lost-referrer: persist the WG profile in app-internal filesDir and REUSE
  it. /wg/profile/new mints a fresh keypair each call and onboarding runs every
  boot, so the device kept re-keying → new sha256(pubkey) identity → stats reset
  each reboot. Now one stable identity across reboot/reconnect/restart.
  (Reinstall still wipes filesDir; allowBackup stays off for CSPN.)
- Silent root onboarding (CA system-store + native WG) already runs on boot
  (#538/#558); it now provisions the STABLE profile.
- Surfaces 🧅 kbin Tor-egress status after onboarding.

webext (0.1.5):
- popup shows a 🧅 Tor indicator (exit anonymised) next to the R3 dot,
  via the new public /wg/tor-status endpoint.

toolbox (2.7.5):
- public, kbin-safe GET /wg/tor-status {tor_mode,running,bootstrap,exit_ip}
  (mirrors /wg/r3-check; /admin/tor/* stays admin-gated). Verified live on kbin.

13 toolbox tests green. .xpi 0.1.5 built; .apk builds via build-android-apk.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:03:15 +02:00
55c7d925a6 fix(toolbox): Tor mode must not torify the box's own services (ref #683)
kbin/admin resolve to the WAN IP (reached via hairpin) and the LAN was not
exempt, so with Tor armed the portal's self-traffic round-tripped through Tor
(~18x slower, measured 0.9s vs 0.05s) → kbin landing + dashboard graphs/metrics
loaded empty/slow for clients behind the MITM, and the banner page degraded.

nft tunnel now carries a reconciler-populated `tor_exempt` set: loopback +
board-local connected subnets (LAN/WG/LXC, from `ip route scope link`) + the
board's own public IP (detected direct). Self-traffic stays DIRECT; real
internet still exits via Tor; DNS stays Tor-routed (no leak); the Tor automap
range (10.192.0.0/10) stays torified (never a connected route).

Verified live on gk2: worker->kbin direct 0.36s/200, worker->internet exits
45.84.107.76 (Tor, != board 82.67.100.75), kbin 200, stats populated. toolbox 2.7.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:05:17 +02:00
982a27ce38 fix(toolbox): dashboard MITM metrics stuck at 0 — derive from real data (ref #683)
The "Live metrics" MITM trio (connections / hosts / cert-pin blocks) was
permanently 0: it scraped "server connect" from journalctl, but the workers run
at --log-level warning so those INFO lines are never emitted. Now sourced from
the cumulative stats (DPI flow count + top-hosts, same as the landing page) and
the auto-learned mitm-bypass-dynamic host count. Verified live on gk2:
connections 29238, unique_hosts 15 (were 0). toolbox 2.7.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:55:45 +02:00
aef5b00b85 feat(toolbox): 🧅 Tor chip on banner + tunnel survives nft reloads (ref #683)
- banner: inject_banner shows a 🧅 Tor chip first when kbin Tor mode is armed,
  so the client sees they are anonymised (filters-driven, 5s-cached).
- fix: the runtime toolbox_tor table was flushed by any nftables reload (other
  postinsts trigger one) → flag-on/tunnel-off leak window. Reconciler now
  persists it as /etc/nftables.d/zz-secubox-toolbox-tor.nft so reloads re-apply
  it; added a 2-min re-arm timer (self-heals bare `nft flush ruleset`) and a
  final postinst reconcile after the nft reloads.
- fix: postinst also restarts secubox-toolbox-mitm-wg.service so mitm addon
  updates take effect without a manual restart.
- toolbox 2.7.2. Verified live on gk2: table survives `systemctl reload
  nftables`, egress stays Tor, kbin 200, no leak window post-upgrade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:03:41 +02:00
03346907e4 fix(toolbox): make the Tor switch actually persist + activate (ref #683)
The switch returned OK but never armed Tor — three stacked perm/sandbox
blockers, found by live debugging on gk2:

1. filters.json was root:root and /etc/secubox/toolbox is 0750 → no service
   user could create the atomic-rename tmp. set_filters now falls back to an
   in-place write (file-write only) — which also reliably fires the .path
   watcher (in-place modify, not a rename).
2. The host portal (the real handler; admin.gk2 proxies to it) had
   ReadOnlyPaths=/etc/secubox → write hit EROFS. Add
   ReadWritePaths=/etc/secubox/toolbox (CA key stays 0600 root, unreadable).
3. filters.json owner fixed to secubox-toolbox so the portal owner-writes;
   postinst re-applies every install to repair pre-existing drift.

Verified live on gk2 (tor_mode armed): worker uid egress = Tor exit
(185.220.101.24) vs real WAN (82.67.100.75); fail-closed confirmed (tor down
→ egress blocked, rc=28, no real-IP leak); kbin stays 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:49:36 +02:00
a870eb380e 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>
2026-06-19 12:19:52 +02:00
35 changed files with 1665 additions and 784 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) ## 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. - **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) ## 🔄 2026-06-19 : kbin milestone — ToolBoX 2.7.0 + chapitre Tor (plan)
Checkpoint de fin de session. Pas de changement de comportement runtime — docs + 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, endpoint Tor quick-switch (egress sortant, fail-closed, opt-in, no DNS leak,
inspection préservée). Dépend du cœur Go #662. 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, - ✅ **Transport tranché** : *torify l'egress MITM* (owner-match nft sur l'uid
*préféré*) vs Option B (nft mark → Tor TransPort, fallback pré-#662). `secubox-toolbox`/mitm-wg → Tor TransPort 9040 / DNSPort 5353). Inspection
- **Profil Tor egress** dans `secubox-exposure` (ou unit `tor-egress` dédié) — préservée. Décision USER (vs dialer SOCKS5 #662 = bloqué, vs torify client = casse
egress-only, pas de relay/hidden-service dans ce profil. l'inspection).
- **API toolbox** : `POST /admin/tor/{on,off}` (par client, WG-hash), `GET /tor/state`, - ✅ **Switch** : flags `tor_mode`/`tor_preset` (filters.json) ; API kbin-gated
`POST /tor/newnym` + état SQLite + bandeau 🧅 UI. `GET/POST /admin/tor/{state,on,off,newnym,check-leaks}` ; onglet 🧅 WebUI (badge,
- **Leak-guard nft** + DNS-over-Tor (test : exit IP + resolver ≠ Unbound local). toggle, NEWNYM, sonde fuite). `tor_ctl.py` réutilise le control-port de secubox-tor.
- **Caveat** : en mode Tor, forcer `tls_splice` OFF pour ce client (sinon les flux - ✅ **Tunnel** : `conf/nft-toolbox-tor.nft` (fail-closed kill-switch + drop v6) +
asset fuient l'IP réelle). Soak DARK (flag présent, UI cachée) avant flip. `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

@ -12,8 +12,8 @@ android {
applicationId = "in.secubox.toolbox" applicationId = "in.secubox.toolbox"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 3 versionCode = 4
versionName = "0.3.0" versionName = "0.4.0"
} }
buildTypes { buildTypes {

View File

@ -88,12 +88,23 @@ fun OnboardApp() {
busy = false; status = "Borne injoignable — vérifie le réseau." busy = false; status = "Borne injoignable — vérifie le réseau."
} else { } else {
step = Step.RootAuto step = Step.RootAuto
val onb = RootOnboard(api, ctx.cacheDir) val onb = RootOnboard(api, ctx.cacheDir, ctx.filesDir)
val out = withContext(Dispatchers.IO) { val out = withContext(Dispatchers.IO) {
onb.runSilent { line -> scope.launch(Dispatchers.Main) { rootLog.add(line) } } onb.runSilent { line -> scope.launch(Dispatchers.Main) { rootLog.add(line) } }
} }
busy = false busy = false
onTunnel = out.verified onTunnel = out.verified
// #683 — surface kbin Tor egress status (anonymised exit) if on.
rootLog.add(withContext(Dispatchers.IO) {
val t = api.torStatus()
when {
t == null -> "• Statut Tor : indisponible"
!t.optBoolean("tor_mode", false) -> "• Mode Tor : inactif"
t.optBoolean("running", false) ->
"🧅 Mode Tor ACTIF — sortie anonymisée${t.optString("exit_ip", "").let { if (it.isNotBlank() && it != "null") " ($it)" else "" }}"
else -> "🧅 Mode Tor activé — tunnel Tor en démarrage…"
}
})
when { when {
out.verified -> step = Step.Done out.verified -> step = Step.Done
out.wgViaApp -> { step = Step.ImportProfile out.wgViaApp -> { step = Step.ImportProfile

View File

@ -50,7 +50,7 @@ class OnboardService : Service() {
kotlinx.coroutines.delay(2000) kotlinx.coroutines.delay(2000)
} }
if (!ok) return if (!ok) return
RootOnboard(api, cacheDir).runSilent { /* headless: no UI log */ } RootOnboard(api, cacheDir, filesDir).runSilent { /* headless: no UI log */ }
} }
private fun buildNotification(): Notification { private fun buildNotification(): Notification {

View File

@ -9,7 +9,14 @@ import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
class RootOnboard(private val api: ToolboxApi, private val cacheDir: File) { class RootOnboard(
private val api: ToolboxApi,
private val cacheDir: File,
// #683: app-internal storage for the STABLE WG identity (survives reboot).
// Defaults to cacheDir so older call sites still compile, but real callers
// pass filesDir so the identity persists instead of churning each boot.
private val filesDir: File = cacheDir,
) {
/** A line appended to the on-screen log during the silent run. */ /** A line appended to the on-screen log during the silent run. */
fun interface Logger { fun log(line: String) } fun interface Logger { fun log(line: String) }
@ -123,8 +130,10 @@ class RootOnboard(private val api: ToolboxApi, private val cacheDir: File) {
log.log("• Noyau sans module WireGuard — bascule sur l'app WireGuard") log.log("• Noyau sans module WireGuard — bascule sur l'app WireGuard")
return false return false
} }
log.log("• Génération du profil WireGuard…") log.log("• Profil WireGuard (identité stable)…")
val conf = api.downloadProfile(cacheDir).readText() // #683: reuse the persisted keypair so the device keeps ONE identity
// across reboots (no more stats reset to a fresh empty hash each boot).
val conf = api.persistentProfile(filesDir).readText()
val wg = parse(conf) ?: run { log.log("✗ profil illisible"); return false } val wg = parse(conf) ?: run { log.log("✗ profil illisible"); return false }
val iface = "wg-village3b" val iface = "wg-village3b"
val r = RootShell.runScript( val r = RootShell.runScript(

View File

@ -51,6 +51,41 @@ class ToolboxApi(rawHost: String) {
fun downloadCa(cacheDir: File): File = download("/wg/ca.crt", "village3b-ca.crt", cacheDir) fun downloadCa(cacheDir: File): File = download("/wg/ca.crt", "village3b-ca.crt", cacheDir)
fun downloadProfile(cacheDir: File): File = download("/wg/profile/new", "village3b-toolbox.conf", cacheDir) fun downloadProfile(cacheDir: File): File = download("/wg/profile/new", "village3b-toolbox.conf", cacheDir)
/**
* The device's STABLE WireGuard identity (#683 lost-referrer fix).
*
* `/wg/profile/new` mints a FRESH keypair on every call. The onboarding
* runs on every boot, so calling it each time gave the device a NEW pubkey
* new sha256(pubkey) identity hash its stats/social history reset to an
* empty bucket on every reboot/reconnect. Here we fetch a peer ONCE and
* persist the .conf in app-internal `filesDir` (survives reboots, unlike the
* evictable cacheDir). Every later call reuses the SAME keypair SAME
* identity the device keeps one continuous history.
*
* Survives reboot/reconnect/app-restart. (Reinstall still wipes filesDir;
* cross-reinstall persistence would need allowBackup kept off for CSPN.)
*/
fun persistentProfile(filesDir: File): File {
val stored = File(filesDir, "identity-wg.conf")
if (stored.exists() && stored.length() > 0L &&
stored.readText().contains("PrivateKey", ignoreCase = true)) {
return stored
}
val fresh = download("/wg/profile/new", "identity-wg.conf.tmp", filesDir)
fresh.copyTo(stored, overwrite = true)
fresh.delete()
return stored
}
/** kbin Tor egress status for the client UI (read-only, kbin-safe). */
fun torStatus(): JSONObject? {
val c = open("/wg/tor-status")
return try {
if (c.responseCode !in 200..299) null
else JSONObject(c.inputStream.bufferedReader().readText())
} catch (_: Exception) { null } finally { c.disconnect() }
}
/** R3 tunnel status. Returns (onTunnel, peerIp?). */ /** R3 tunnel status. Returns (onTunnel, peerIp?). */
fun r3Check(): Pair<Boolean, String?> { fun r3Check(): Pair<Boolean, String?> {
val c = open("/wg/r3-check") val c = open("/wg/r3-check")

View File

@ -65,6 +65,17 @@ async function r3Check(host) {
} }
} }
// #683 — kbin Tor egress status (public, kbin-safe endpoint).
async function torStatus(host) {
try {
const resp = await fetch(`${baseUrl(host)}/wg/tor-status`, { credentials: "omit" });
if (!resp.ok) return { tor_mode: false };
return await resp.json();
} catch (_) {
return { tor_mode: false };
}
}
// graph: the per-session cartographie JSON. Throws on HTTP error so the // graph: the per-session cartographie JSON. Throws on HTTP error so the
// caller can show "token expired — re-pair". // caller can show "token expired — re-pair".
async function graph(host, token, since) { async function graph(host, token, since) {
@ -133,6 +144,7 @@ const SbxApi = {
setConfig, setConfig,
pair, pair,
r3Check, r3Check,
torStatus,
graph, graph,
wipe, wipe,
ghost, ghost,

View File

@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "SecuBox ToolBoX — Cartographie sociale", "name": "SecuBox ToolBoX — Cartographie sociale",
"version": "0.1.4", "version": "0.1.5",
"description": "Surface the SecuBox R3 toolbox live tracker analysis (cartographie sociale) in your browser: live badge, per-session trackers, mini Round-Eye graph, RGPD wipe + PDF report.", "description": "Surface the SecuBox R3 toolbox live tracker analysis (cartographie sociale) in your browser: live badge, per-session trackers, mini Round-Eye graph, RGPD wipe + PDF report.",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {

View File

@ -10,6 +10,7 @@
<body> <body>
<header> <header>
<span class="logo">👁️ VILLAGE3B</span> <span class="logo">👁️ VILLAGE3B</span>
<span id="tordot" class="r3 off" title="Mode Tor" style="display:none">🧅</span>
<span id="r3dot" class="r3 off" title="État du tunnel R3">R3</span> <span id="r3dot" class="r3 off" title="État du tunnel R3">R3</span>
</header> </header>

View File

@ -111,6 +111,21 @@ async function load() {
dot.title = r.tunnel ? `Tunnel R3 actif (${r.peer_ip || "?"})` : "Hors tunnel R3"; dot.title = r.tunnel ? `Tunnel R3 actif (${r.peer_ip || "?"})` : "Hors tunnel R3";
}); });
// #683 — Tor egress indicator (only visible when kbin Tor mode is on)
api.torStatus(cfg.host).then((t) => {
const dot = $("tordot");
if (!dot) return;
if (t && t.tor_mode) {
dot.style.display = "";
dot.className = "r3 " + (t.running ? "on" : "off");
dot.title = t.running
? `Mode Tor actif — sortie anonymisée${t.exit_ip ? " (" + t.exit_ip + ")" : ""}`
: "Mode Tor activé — démarrage du tunnel…";
} else {
dot.style.display = "none";
}
});
if (!cfg.token) { if (!cfg.token) {
$("host").value = cfg.host; $("host").value = cfg.host;
show("pair"); show("pair");

View File

@ -1,6 +1,17 @@
# Design — kbin Tor endpoint: quick-switch anonymized web surfing # 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 ## 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. C'est la **pointe manquante** : l'anonymat de la sortie.

View File

@ -1,7 +1,8 @@
{# SPDX-License-Identifier: LicenseRef-CMSD-1.0 #} {# SPDX-License-Identifier: LicenseRef-CMSD-1.0 #}
{# Public landing page — kbin.gk2.secubox.in #} {# Public landing page — kbin.gk2.secubox.in #}
{# Radical-simplify redesign (#543): animated hero + one CTA + install panel {# #683 restyle: aligned with the new /report look — system font, rounded
up top ; everything else folded behind "En savoir plus". #} --panel/--line cards, cleaner accents. Dynamic bits (data-live KPIs + JS,
install panels, cert-probe, ?mh links) unchanged. #}
<!DOCTYPE html> <!DOCTYPE html>
<html lang=fr><head> <html lang=fr><head>
<meta charset=UTF-8> <meta charset=UTF-8>
@ -10,108 +11,84 @@
<title>👁️ VILLAGE3B — Qui te piste ?</title> <title>👁️ VILLAGE3B — Qui te piste ?</title>
<link rel=manifest href=/manifest.json> <link rel=manifest href=/manifest.json>
<style> <style>
:root{--bg:#0a0a0f;--bg2:#0e0e15;--phos:#00dd44;--phos-hot:#00ff55;--dim:#006622;--text:#e8e6d9;--purple:#9e76ff;--gold:#c9a84c;--amber:#ffb347;--red:#ff4466;--cyan:#00d4ff} :root{--bg:#0a0a0f;--panel:#11131a;--soft:#0d0f15;--phos:#00dd44;--phos-hot:#00ff55;--dim:#5a6b60;--line:#1e2630;--text:#e8e6d9;--purple:#9e76ff;--gold:#c9a84c;--amber:#ffb347;--red:#ff4466;--cyan:#66bbff}
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Courier New',Menlo,monospace;background:var(--bg);color:var(--text);line-height:1.55;padding-bottom:3rem} body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);line-height:1.55;padding-bottom:3rem}
a{color:var(--phos);text-decoration:none} a{color:var(--phos);text-decoration:none}a:hover{text-decoration:underline}
a:hover{text-decoration:underline} .help{color:var(--dim);font-size:.8rem;font-style:italic}
/* ── HERO ── */ /* ── HERO ── */
.hero{position:relative;overflow:hidden;background:radial-gradient(120% 120% at 50% -10%,#221041 0%,#0a0a0f 60%);padding:3rem 1.5rem 2.4rem;text-align:center;border-bottom:2px solid var(--phos)} .hero{position:relative;overflow:hidden;background:radial-gradient(120% 120% at 50% -10%,#1a1030 0%,#0a0a0f 62%);padding:3rem 1.5rem 2.4rem;text-align:center;border-bottom:1px solid var(--line)}
.eye{font-size:3.4rem;line-height:1;display:inline-block;animation:gaze 5s ease-in-out infinite;filter:drop-shadow(0 0 14px rgba(0,255,85,0.55))} .eye{font-size:3.4rem;line-height:1;display:inline-block;animation:gaze 5s ease-in-out infinite;filter:drop-shadow(0 0 14px rgba(0,255,85,.5))}
@keyframes gaze{0%,100%{transform:translateX(0) scale(1)}25%{transform:translateX(-6px) scale(1.04)}60%{transform:translateX(7px) scale(1.04)}} @keyframes gaze{0%,100%{transform:translateX(0) scale(1)}25%{transform:translateX(-6px) scale(1.04)}60%{transform:translateX(7px) scale(1.04)}}
.hero h1{font-size:2.6rem;color:var(--phos-hot);text-shadow:0 0 10px var(--phos);letter-spacing:0.08em;margin-top:0.3rem} .hero h1{font-size:2.4rem;color:var(--phos-hot);letter-spacing:.06em;margin-top:.3rem;font-weight:800}
.hero .punch{color:var(--text);font-size:1.25rem;margin-top:0.6rem;font-weight:bold} .hero .punch{color:var(--text);font-size:1.2rem;margin-top:.6rem;font-weight:700}.hero .punch b{color:var(--gold)}
.hero .punch b{color:var(--gold)} .hero .sub{color:var(--dim);font-size:.82rem;margin-top:.5rem;max-width:560px;margin-left:auto;margin-right:auto}
.hero .sub{color:var(--dim);font-size:0.82rem;margin-top:0.5rem;max-width:560px;margin-left:auto;margin-right:auto}
/* floating tracker dots = "who's watching" */
.dots{position:absolute;inset:0;pointer-events:none;z-index:0} .dots{position:absolute;inset:0;pointer-events:none;z-index:0}
.dots i{position:absolute;width:7px;height:7px;border-radius:50%;opacity:0.0;animation:float 7s ease-in-out infinite} .dots i{position:absolute;width:7px;height:7px;border-radius:50%;opacity:0;animation:float 7s ease-in-out infinite}
.dots i:nth-child(1){left:12%;top:30%;background:var(--cyan);animation-delay:.0s} .dots i:nth-child(1){left:12%;top:30%;background:var(--cyan);animation-delay:0s}
.dots i:nth-child(2){left:82%;top:24%;background:var(--amber);animation-delay:1.1s} .dots i:nth-child(2){left:82%;top:24%;background:var(--amber);animation-delay:1.1s}
.dots i:nth-child(3){left:24%;top:68%;background:var(--red);animation-delay:2.3s} .dots i:nth-child(3){left:24%;top:68%;background:var(--red);animation-delay:2.3s}
.dots i:nth-child(4){left:70%;top:64%;background:var(--purple);animation-delay:.7s} .dots i:nth-child(4){left:70%;top:64%;background:var(--purple);animation-delay:.7s}
.dots i:nth-child(5){left:50%;top:14%;background:var(--cyan);animation-delay:3.0s} .dots i:nth-child(5){left:50%;top:14%;background:var(--cyan);animation-delay:3s}
.dots i:nth-child(6){left:90%;top:54%;background:var(--red);animation-delay:1.8s} .dots i:nth-child(6){left:90%;top:54%;background:var(--red);animation-delay:1.8s}
@keyframes float{0%{opacity:0;transform:translateY(8px) scale(.6)}30%{opacity:.85}70%{opacity:.7}100%{opacity:0;transform:translateY(-14px) scale(1.1)}} @keyframes float{0%{opacity:0;transform:translateY(8px) scale(.6)}30%{opacity:.85}70%{opacity:.7}100%{opacity:0;transform:translateY(-14px) scale(1.1)}}
.hero>*{position:relative;z-index:1} .hero>*{position:relative;z-index:1}
/* ── CTA row ── */
/* ── big CTA row ── */ .ctas{margin-top:1.4rem;display:flex;gap:.6rem;justify-content:center;flex-wrap:wrap}
.ctas{margin-top:1.4rem;display:flex;gap:0.6rem;justify-content:center;flex-wrap:wrap} .cta{display:inline-block;padding:.85rem 1.6rem;font-weight:700;border-radius:10px;font-size:1.02rem;transition:transform .12s}
.cta{display:inline-block;padding:0.85rem 1.6rem;font-weight:bold;border-radius:8px;font-size:1.02rem;text-shadow:none;transition:transform .12s,box-shadow .12s}
.cta:hover{text-decoration:none;transform:translateY(-2px)} .cta:hover{text-decoration:none;transform:translateY(-2px)}
.cta.go{background:var(--phos);color:#0a0a0f;box-shadow:0 4px 18px rgba(0,221,68,0.4)} .cta.go{background:var(--phos);color:#06140a;box-shadow:0 4px 18px rgba(0,221,68,.35)}
.cta.go:hover{box-shadow:0 6px 24px rgba(0,221,68,0.6)}
.cta.alt{background:transparent;color:var(--purple);border:1px solid var(--purple)} .cta.alt{background:transparent;color:var(--purple);border:1px solid var(--purple)}
.cta.alt:hover{background:rgba(158,118,255,0.12)} .cta.alt:hover{background:rgba(158,118,255,.12)}
/* ── quicknav ── */
/* ── quicknav (trimmed) ── */ .quicknav{display:flex;flex-wrap:wrap;justify-content:center;gap:.6rem;margin:1.4rem auto 0;max-width:620px}
.quicknav{display:flex;flex-wrap:wrap;justify-content:center;gap:0.6rem;margin-top:1.4rem;max-width:620px;margin-left:auto;margin-right:auto} .qi{display:flex;flex-direction:column;align-items:center;gap:4px;padding:.55rem .45rem;min-width:74px;background:var(--soft);border:1px solid var(--line);border-radius:12px;color:var(--text);transition:.12s;font-family:inherit}
.qi{display:flex;flex-direction:column;align-items:center;gap:4px;padding:0.5rem 0.4rem;min-width:74px;background:rgba(110,64,201,0.08);border:1px solid var(--purple);border-radius:8px;text-decoration:none;color:var(--text);transition:all 0.12s;font-family:inherit} .qi:hover{border-color:var(--purple);transform:translateY(-2px);text-decoration:none}
.qi:hover{background:rgba(110,64,201,0.22);transform:translateY(-2px);box-shadow:0 4px 14px rgba(158,118,255,0.35);text-decoration:none} .qi-emoji{font-size:1.5rem;line-height:1}.qi-label{font-size:.62rem;letter-spacing:.04em;color:var(--phos-hot);font-weight:700;white-space:nowrap}
.qi-emoji{font-size:1.5rem;line-height:1} /* ── layout ── */
.qi-label{font-size:0.62rem;letter-spacing:0.04em;color:var(--phos-hot);font-weight:bold;white-space:nowrap} .container{max-width:760px;margin:auto;padding:1.6rem 1.1rem}
.section{margin-bottom:1.7rem}
.container{max-width:1080px;margin:auto;padding:2rem 1.5rem} h2{color:var(--phos-hot);font-size:1.12rem;margin-bottom:.6rem;letter-spacing:.02em}
.section{margin-bottom:2.5rem} h3{color:var(--purple);font-size:.95rem;margin-bottom:.4rem}
h2{color:var(--phos-hot);text-shadow:0 0 4px var(--phos);font-size:1.3rem;margin-bottom:0.8rem;border-bottom:1px solid var(--dim);padding-bottom:0.4rem;letter-spacing:0.04em} .grid{display:grid;gap:1rem}.grid-2{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.grid-4{grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}
h3{color:var(--purple);font-size:1rem;margin-bottom:0.5rem} .card{border:1px solid var(--line);background:var(--panel);padding:1rem 1.1rem;border-radius:12px}
.grid{display:grid;gap:1rem} .card.purple{border-color:rgba(158,118,255,.4)}.card.amber{border-color:rgba(255,179,71,.4)}
.grid-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))} .kpi{text-align:center;padding:.8rem .4rem;background:var(--soft);border:1px solid var(--line);border-radius:12px}
.grid-4{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))} .kpi .v{font-size:1.7rem;font-weight:800;color:var(--phos-hot);display:block}.kpi .l{font-size:.66rem;color:var(--dim)}
.card{border:1px solid var(--dim);background:var(--bg2);padding:1rem 1.2rem;border-radius:4px} .level{display:flex;align-items:start;gap:.8rem;padding:.85rem;border-radius:12px}
.card.purple{border-color:var(--purple);background:rgba(110,64,201,0.05)} .level .emj{font-size:1.7rem;flex-shrink:0}.level .body{flex:1}.level .body b{display:block;font-size:.98rem;margin-bottom:.2rem}.level .body .desc{font-size:.84rem;color:var(--text);opacity:.85}
.card.amber{border-color:var(--amber);background:rgba(255,179,71,0.05)} .level.r0{background:var(--soft);border:1px solid var(--line)}.level.r1{background:rgba(0,221,68,.08);border:1px solid var(--phos)}.level.r2{background:rgba(255,179,71,.08);border:1px solid var(--amber)}.level.r3{background:rgba(158,118,255,.08);border:1px solid var(--purple)}
.kpi{text-align:center;padding:1rem;background:rgba(0,221,68,0.05);border:1px solid var(--phos);border-radius:4px} .tag-recommended{display:inline-block;background:var(--phos);color:#06140a;font-size:.65rem;padding:.1rem .45rem;border-radius:99px;font-weight:700;margin-left:.3rem;vertical-align:middle}
.kpi .v{font-size:2rem;font-weight:bold;color:var(--phos-hot);text-shadow:0 0 6px var(--phos);display:block} .tag-new{display:inline-block;background:var(--purple);color:#fff;font-size:.65rem;padding:.1rem .45rem;border-radius:99px;font-weight:700;margin-left:.3rem;vertical-align:middle}
.kpi .l{font-size:0.75rem;color:var(--dim)}
.level{display:flex;align-items:start;gap:0.8rem;padding:0.9rem;border-radius:4px}
.level .emj{font-size:1.8rem;flex-shrink:0}
.level .body{flex:1}
.level .body b{display:block;font-size:1rem;margin-bottom:0.2rem}
.level .body .desc{font-size:0.85rem;color:var(--text);opacity:0.85}
.level.r0{background:rgba(255,255,255,0.03);border:1px solid var(--dim)}
.level.r1{background:rgba(0,221,68,0.08);border:1px solid var(--phos);color:var(--phos-hot)}
.level.r2{background:rgba(255,179,71,0.08);border:1px solid var(--amber)}
.level.r3{background:rgba(158,118,255,0.08);border:1px solid var(--purple)}
.tag-recommended{display:inline-block;background:var(--phos);color:#0a0a0f;font-size:0.65rem;padding:0.1rem 0.4rem;border-radius:99px;font-weight:bold;margin-left:0.3rem;vertical-align:middle}
.tag-new{display:inline-block;background:var(--purple);color:#fff;font-size:0.65rem;padding:0.1rem 0.4rem;border-radius:99px;font-weight:bold;margin-left:0.3rem;vertical-align:middle}
svg.chart{width:100%;max-width:400px;height:auto} svg.chart{width:100%;max-width:400px;height:auto}
.svg-bar{fill:var(--phos);transition:fill 0.3s} .svg-bar{fill:var(--phos)}.svg-bar.medium{fill:var(--amber)}.svg-bar.high{fill:var(--red)}
.svg-bar.medium{fill:var(--amber)} code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82rem;color:var(--phos-hot);font-family:ui-monospace,Menlo,monospace}
.svg-bar.high{fill:var(--red)} .cta-sm{display:inline-block;background:var(--phos);color:#06140a;padding:.6rem 1.2rem;font-weight:700;border-radius:10px;margin:.4rem .3rem .4rem 0}.cta-sm.outline{background:transparent;color:var(--phos);border:1px solid var(--phos)}
code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;color:var(--phos-hot)} .footer{text-align:center;font-size:.7rem;color:var(--dim);padding:1.4rem;border-top:1px solid var(--line);margin-top:2rem}
.cta-sm{display:inline-block;background:var(--phos);color:#0a0a0f;padding:0.7rem 1.4rem;text-decoration:none;font-weight:bold;border-radius:4px;margin:0.5rem 0.3rem 0.5rem 0;text-shadow:none} .arch{font-family:ui-monospace,Menlo,monospace;font-size:.74rem;color:var(--phos-hot);background:var(--soft);padding:1rem;border:1px solid var(--line);border-radius:12px;overflow-x:auto;white-space:pre;line-height:1.4}
.cta-sm.outline{background:transparent;color:var(--phos);border:1px solid var(--phos)} /* ── install panel ── */
.footer{text-align:center;font-size:0.78rem;color:var(--dim);padding:1.5rem;border-top:1px solid var(--dim);margin-top:2rem} .install-panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:.7rem 1rem;margin:.5rem 0;text-align:left}
.arch{font-family:monospace;font-size:0.75rem;color:var(--phos-hot);text-shadow:0 0 4px var(--phos);background:var(--bg2);padding:1rem;border:1px solid var(--dim);border-radius:4px;overflow-x:auto;white-space:pre;line-height:1.4} .install-panel summary{cursor:pointer;font-size:.95rem;color:var(--phos-hot);list-style:none;outline:none;font-weight:700}
/* ── install panel (kept up top) ── */
.install-panel{background:rgba(0,255,65,0.04);border:1px solid rgba(0,255,65,0.25);border-radius:6px;padding:0.6rem 0.9rem;margin:0.45rem 0;text-align:left}
.install-panel summary{cursor:pointer;font-size:0.95rem;color:var(--phos-hot);list-style:none;outline:none}
.install-panel summary::-webkit-details-marker{display:none} .install-panel summary::-webkit-details-marker{display:none}
.install-panel[open] summary{margin-bottom:0.6rem} .install-panel[open] summary{margin-bottom:.6rem}
.install-panel .emoji{font-size:1.1rem;margin-right:0.3rem} .install-panel .emoji{font-size:1.1rem;margin-right:.3rem}
.install-panel ol{padding-left:1.1rem;line-height:1.5;font-size:0.85rem} .install-panel ol{padding-left:1.1rem;line-height:1.5;font-size:.85rem}
.install-panel .btn{display:inline-block;padding:0.45rem 0.75rem;margin:0.25rem 0.2rem 0.25rem 0;background:var(--purple);color:#fff;text-decoration:none;border-radius:5px;font-weight:bold;font-size:0.82rem} .install-panel .btn{display:inline-block;padding:.45rem .8rem;margin:.25rem .2rem .25rem 0;background:var(--purple);color:#fff;text-decoration:none;border-radius:8px;font-weight:700;font-size:.82rem}
.install-panel .btn.alt{background:transparent;border:1px solid var(--purple);color:var(--purple)} .install-panel .btn.alt{background:transparent;border:1px solid var(--purple);color:var(--purple)}
.install-panel code{background:rgba(0,0,0,0.4);padding:0.1rem 0.35rem;border-radius:3px;font-size:0.8rem;color:var(--phos-hot)} .install-panel code{background:var(--soft);padding:.1rem .35rem;border-radius:4px;font-size:.8rem;color:var(--phos-hot)}
.install-panel .note{color:var(--dim);font-size:0.78rem;margin-top:0.6rem;border-left:2px solid var(--amber);padding-left:0.6rem} .install-panel .note{color:var(--dim);font-size:.78rem;margin-top:.6rem;border-left:2px solid var(--amber);padding-left:.6rem}
.install-panel img{max-width:100%;border-radius:5px;margin:0.4rem 0} .install-panel img{max-width:100%;border-radius:8px;margin:.4rem 0}
.install-panel pre{background:rgba(0,0,0,0.4);padding:0.5rem 0.7rem;border-radius:4px;overflow-x:auto;font-size:0.78rem;margin:0.4rem 0} .install-panel pre{background:var(--soft);padding:.5rem .7rem;border-radius:8px;overflow-x:auto;font-size:.76rem;margin:.4rem 0}
/* ── "En savoir plus" fold ── */ /* ── "En savoir plus" fold ── */
.more{max-width:1080px;margin:0 auto;padding:0 1.5rem} .more{max-width:760px;margin:0 auto;padding:0 1.1rem}
.more>summary{cursor:pointer;list-style:none;text-align:center;color:var(--purple);font-size:0.95rem;letter-spacing:0.05em;padding:0.9rem;border:1px dashed var(--purple);border-radius:8px;margin-bottom:1rem;transition:background .12s} .more>summary{cursor:pointer;list-style:none;text-align:center;color:var(--purple);font-size:.92rem;letter-spacing:.04em;padding:.85rem;border:1px solid var(--line);border-radius:12px;margin-bottom:1rem;transition:background .12s}
.more>summary::-webkit-details-marker{display:none} .more>summary::-webkit-details-marker{display:none}
.more>summary:hover{background:rgba(158,118,255,0.1)} .more>summary:hover{background:rgba(158,118,255,.08)}
.more[open]>summary{margin-bottom:1.6rem} .more[open]>summary{margin-bottom:1.4rem}
.more>summary .chev{display:inline-block;transition:transform .2s} .more>summary .chev{display:inline-block;transition:transform .2s}
.more[open]>summary .chev{transform:rotate(90deg)} .more[open]>summary .chev{transform:rotate(90deg)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}} .v.tick{animation:flash .6s}@keyframes flash{0%{color:var(--gold);transform:scale(1.15)}100%{color:var(--phos-hot);transform:scale(1)}}
.v.tick{animation:flash 0.6s}
@keyframes flash{0%{color:var(--gold);transform:scale(1.15)}100%{color:var(--phos-hot);transform:scale(1)}}
</style></head><body> </style></head><body>
<div class=hero> <div class=hero>
@ -123,19 +100,17 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
<div class=ctas> <div class=ctas>
<a href="/wg/r3-install" class="cta go">✨ Protège-moi (R3)</a> <a href="/wg/r3-install" class="cta go">✨ Protège-moi (R3)</a>
<a href="/social/me" class="cta alt">🕸️ Qui me piste ?</a> <a href="/social/me{{ '?mh=' + mac_hash if mac_hash else '' }}" class="cta alt">🕸️ Qui me piste ?</a>
</div> </div>
{# trimmed quick-nav — CA iPhone / CA Android / QR profil moved into the
per-platform install panel below (#543) #}
<div class=quicknav> <div class=quicknav>
<a href="/wg/r3-install" class=qi title="Installer R3 WireGuard"> <a href="/wg/r3-install" class=qi title="Installer R3 WireGuard">
<span class=qi-emoji>🌐</span><span class=qi-label>R3 Install</span> <span class=qi-emoji>🌐</span><span class=qi-label>R3 Install</span>
</a> </a>
<a href="/report/me/html" class=qi title="Mon rapport live"> <a href="/report/me/html{{ '?mh=' + mac_hash if mac_hash else '' }}" class=qi title="Mon rapport live">
<span class=qi-emoji>📊</span><span class=qi-label>Mon rapport</span> <span class=qi-emoji>📊</span><span class=qi-label>Mon rapport</span>
</a> </a>
<a href="/social/me" class=qi title="Cartographie sociale — qui me piste, où ?"> <a href="/social/me{{ '?mh=' + mac_hash if mac_hash else '' }}" class=qi title="Cartographie sociale — qui me piste, où ?">
<span class=qi-emoji>🕸️</span><span class=qi-label>Ma carto</span> <span class=qi-emoji>🕸️</span><span class=qi-label>Ma carto</span>
</a> </a>
<a href="https://github.com/CyberMind-FR/secubox-deb/wiki/R3-WireGuard-install" class=qi title="Wiki R3 multi-OS"> <a href="https://github.com/CyberMind-FR/secubox-deb/wiki/R3-WireGuard-install" class=qi title="Wiki R3 multi-OS">
@ -151,12 +126,12 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
<div class=container> <div class=container>
<div class=section style="margin-bottom:1.5rem"> <div class=section style="margin-bottom:1.5rem">
<h2>📥 Installe en 1 tap</h2> <h2>📥 Installe en 1 tap</h2>
<p style="font-size:0.85rem;color:var(--dim);margin-bottom:0.8rem"> <p class=help style="margin-bottom:.8rem">
On a détecté <code>{{ install_platform }}</code> — le panneau adapté est ouvert. On a détecté <code>{{ install_platform }}</code> — le panneau adapté est ouvert.
Le CA, le QR et le profil sont dedans. Autre appareil ? Déplie le bon panneau. Le CA, le QR et le profil sont dedans. Autre appareil ? Déplie le bon panneau.
</p> </p>
{{ install_panels | safe }} {{ install_panels | safe }}
<p style="margin-top:0.8rem;font-size:0.78rem;color:var(--dim)"> <p class=help style="margin-top:.8rem">
R3 marche hors-cabine (4G/5G, autre WiFi), couvre tout le HTTPS, et se révoque R3 marche hors-cabine (4G/5G, autre WiFi), couvre tout le HTTPS, et se révoque
à tout moment. Page standalone : <a href=/wg/onboard>/wg/onboard</a>. à tout moment. Page standalone : <a href=/wg/onboard>/wg/onboard</a>.
</p> </p>
@ -170,8 +145,8 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
{# ── KPI live (auto-refresh 5s via /cumulative-stats.json) ── #} {# ── KPI live (auto-refresh 5s via /cumulative-stats.json) ── #}
<div class=section> <div class=section>
<h2>📊 Cabine en chiffres <h2>📊 Cabine en chiffres
<span id=live-badge style="font-size:0.7rem;background:var(--red);color:#fff;padding:0.15rem 0.5rem;border-radius:99px;margin-left:0.5rem;letter-spacing:0.05em;animation:pulse 1.5s infinite">🔴 LIVE</span> <span id=live-badge style="font-size:.7rem;background:var(--red);color:#fff;padding:.15rem .5rem;border-radius:99px;margin-left:.5rem;letter-spacing:.04em;animation:pulse 1.5s infinite">🔴 LIVE</span>
<span id=live-stamp style="font-size:0.7rem;color:var(--dim);margin-left:0.4rem">·</span> <span id=live-stamp style="font-size:.7rem;color:var(--dim);margin-left:.4rem">·</span>
</h2> </h2>
<div class="grid grid-4"> <div class="grid grid-4">
<div class=kpi><span class=v data-live=sessions.last_7d>{{ stats.sessions.last_7d if stats.sessions else 0 }}</span><span class=l>👥 sessions uniques (7j)</span></div> <div class=kpi><span class=v data-live=sessions.last_7d>{{ stats.sessions.last_7d if stats.sessions else 0 }}</span><span class=l>👥 sessions uniques (7j)</span></div>
@ -193,16 +168,16 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
<div class="card purple" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap"> <div class="card purple" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<div style="font-size:2.4rem;flex-shrink:0" id=cert-probe-emoji>❔</div> <div style="font-size:2.4rem;flex-shrink:0" id=cert-probe-emoji>❔</div>
<div style="flex:1;min-width:220px"> <div style="flex:1;min-width:220px">
<p id=cert-probe-text style="font-size:0.92rem;line-height:1.5"> <p id=cert-probe-text style="font-size:.92rem;line-height:1.5">
Clique sur <b>Tester</b> pour vérifier que ton CA R3 est bien installé sur ton device Clique sur <b>Tester</b> pour vérifier que ton CA R3 est bien installé sur ton device
(depuis le tunnel WireGuard). Le test télécharge une image HTTPS et vérifie le succès. (depuis le tunnel WireGuard). Le test télécharge une image HTTPS et vérifie le succès.
</p> </p>
<p style="font-size:0.75rem;color:var(--dim);margin-top:0.3rem"> <p class=help style="margin-top:.3rem">
Empreinte SHA1 CA R3 (à vérifier dans Réglages iPhone) : Empreinte SHA1 CA R3 (à vérifier dans Réglages iPhone) :
<code id=cert-fp-r3 style="font-size:0.7rem">…</code> <code id=cert-fp-r3 style="font-size:.7rem">…</code>
</p> </p>
</div> </div>
<button id=cert-probe-btn style="background:var(--purple);color:#0a0a0f;border:0;padding:0.7rem 1.2rem;font-weight:bold;border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9rem">🔬 Tester</button> <button id=cert-probe-btn style="background:var(--purple);color:#06140a;border:0;padding:.7rem 1.2rem;font-weight:700;border-radius:10px;cursor:pointer;font-family:inherit;font-size:.9rem">🔬 Tester</button>
</div> </div>
</div> </div>
@ -215,7 +190,7 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
niveau d'analyse, tu obtiens un rapport détaillé sur les apps, trackers, certificats, niveau d'analyse, tu obtiens un rapport détaillé sur les apps, trackers, certificats,
et risques observés pendant ta session. et risques observés pendant ta session.
</p> </p>
<p style="margin-top:0.6rem"> <p style="margin-top:.6rem">
<b>Conçu par CyberMind / Gérald Kerma</b> à Notre-Dame-du-Cruet (Savoie). Conforme <b>Conçu par CyberMind / Gérald Kerma</b> à Notre-Dame-du-Cruet (Savoie). Conforme
<b>CSPN ANSSI</b> + <b>LCEN</b> : consentement explicite, hash MAC quotidien rotatif, <b>CSPN ANSSI</b> + <b>LCEN</b> : consentement explicite, hash MAC quotidien rotatif,
données effacées après 24h, aucun envoi externe. données effacées après 24h, aucun envoi externe.
@ -248,10 +223,10 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
</div> </div>
</div> </div>
<div class="level r3"> <div class="level r3">
<span class=emj>🌐</span> <span class=emj>🧅</span>
<div class=body> <div class=body>
<b>R3 — WireGuard portable<span class=tag-new>NOUVEAU</span></b> <b>R3 — WireGuard portable<span class=tag-new>NOUVEAU</span></b>
<p class=desc>VPN tunnel mitm. Bandeau sur TOUT (HTTPS + QUIC). <b>Marche hors VILLAGE3B</b> (4G/5G, autre WiFi). Profile install 1 tap via QR.</p> <p class=desc>VPN tunnel mitm. Bandeau sur TOUT (HTTPS + QUIC). <b>Marche hors VILLAGE3B</b> (4G/5G, autre WiFi). Option sortie 🧅 Tor anonymisée. Profile install 1 tap via QR.</p>
</div> </div>
</div> </div>
</div> </div>
@ -269,14 +244,15 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
{% set mpct = (risk.medium * 100 / total) | round(0) | int %} {% set mpct = (risk.medium * 100 / total) | round(0) | int %}
{% set hpct = (risk.high * 100 / total) | round(0) | int %} {% set hpct = (risk.high * 100 / total) | round(0) | int %}
<svg class=chart viewBox="0 0 300 60" xmlns="http://www.w3.org/2000/svg"> <svg class=chart viewBox="0 0 300 60" xmlns="http://www.w3.org/2000/svg">
<rect x=0 y=20 width="{{ lpct * 3 }}" height=20 class=svg-bar/> <rect x=0 y=20 width="{{ lpct * 3 }}" height=20 rx=4 class=svg-bar/>
<rect x="{{ lpct * 3 }}" y=20 width="{{ mpct * 3 }}" height=20 class="svg-bar medium"/> <rect x="{{ lpct * 3 }}" y=20 width="{{ mpct * 3 }}" height=20 class="svg-bar medium"/>
<rect x="{{ (lpct + mpct) * 3 }}" y=20 width="{{ hpct * 3 }}" height=20 class="svg-bar high"/> <rect x="{{ (lpct + mpct) * 3 }}" y=20 width="{{ hpct * 3 }}" height=20 rx=4 class="svg-bar high"/>
<text x=10 y=15 fill="#00ff55" font-family=monospace font-size=10>🟢 {{ lpct }}% LOW</text> <text x=10 y=15 fill="#00ff55" font-size=10>🟢 {{ lpct }}% LOW</text>
<text x="{{ lpct * 3 + 10 }}" y=15 fill="#ffb347" font-family=monospace font-size=10>🟡 {{ mpct }}% MED</text> <text x="{{ lpct * 3 + 10 }}" y=15 fill="#ffb347" font-size=10>🟡 {{ mpct }}% MED</text>
<text x="{{ (lpct + mpct) * 3 + 10 }}" y=15 fill="#ff4466" font-family=monospace font-size=10>🔴 {{ hpct }}% HI</text> <text x="{{ (lpct + mpct) * 3 + 10 }}" y=15 fill="#ff4466" font-size=10>🔴 {{ hpct }}% HI</text>
<text x=10 y=55 fill="#666" font-family=monospace font-size=8>{{ total }} sessions analysées</text> <text x=10 y=55 fill="#5a6b60" font-size=8>{{ total }} sessions analysées</text>
</svg> </svg>
<p class=help>La plupart des sessions sont à faible risque.</p>
</div> </div>
<div class=card> <div class=card>
<h3>🛡 Niveau d'opt-in choisi par les visiteurs</h3> <h3>🛡 Niveau d'opt-in choisi par les visiteurs</h3>
@ -284,15 +260,16 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
{% set ltotal = (lvl.r0 + lvl.r1 + lvl.r2 + lvl.r3) or 1 %} {% set ltotal = (lvl.r0 + lvl.r1 + lvl.r2 + lvl.r3) or 1 %}
<svg class=chart viewBox="0 0 300 90" xmlns="http://www.w3.org/2000/svg"> <svg class=chart viewBox="0 0 300 90" xmlns="http://www.w3.org/2000/svg">
{% set ws = [(lvl.r0 * 300 / ltotal)|round|int, (lvl.r1 * 300 / ltotal)|round|int, (lvl.r2 * 300 / ltotal)|round|int, (lvl.r3 * 300 / ltotal)|round|int] %} {% set ws = [(lvl.r0 * 300 / ltotal)|round|int, (lvl.r1 * 300 / ltotal)|round|int, (lvl.r2 * 300 / ltotal)|round|int, (lvl.r3 * 300 / ltotal)|round|int] %}
<rect x=0 y=10 width="{{ ws[0] }}" height=15 fill="#666"/> <rect x=0 y=10 width="{{ ws[0] }}" height=15 rx=4 fill="#5a6b60"/>
<rect x=0 y=30 width="{{ ws[1] }}" height=15 fill="#00dd44"/> <rect x=0 y=30 width="{{ ws[1] }}" height=15 rx=4 fill="#00dd44"/>
<rect x=0 y=50 width="{{ ws[2] }}" height=15 fill="#ffb347"/> <rect x=0 y=50 width="{{ ws[2] }}" height=15 rx=4 fill="#ffb347"/>
<rect x=0 y=70 width="{{ ws[3] }}" height=15 fill="#9e76ff"/> <rect x=0 y=70 width="{{ ws[3] }}" height=15 rx=4 fill="#9e76ff"/>
<text x="{{ ws[0] + 5 }}" y=22 fill="#888" font-family=monospace font-size=10>🌐 R0 ({{ lvl.r0 }})</text> <text x="{{ ws[0] + 5 }}" y=22 fill="#888" font-size=10>🌐 R0 ({{ lvl.r0 }})</text>
<text x="{{ ws[1] + 5 }}" y=42 fill="#00ff55" font-family=monospace font-size=10>🛡 R1 ({{ lvl.r1 }})</text> <text x="{{ ws[1] + 5 }}" y=42 fill="#00ff55" font-size=10>🛡 R1 ({{ lvl.r1 }})</text>
<text x="{{ ws[2] + 5 }}" y=62 fill="#ffd6a0" font-family=monospace font-size=10>🔍 R2 ({{ lvl.r2 }})</text> <text x="{{ ws[2] + 5 }}" y=62 fill="#ffd6a0" font-size=10>🔍 R2 ({{ lvl.r2 }})</text>
<text x="{{ ws[3] + 5 }}" y=82 fill="#cbb6ff" font-family=monospace font-size=10>🌐 R3 ({{ lvl.r3 }})</text> <text x="{{ ws[3] + 5 }}" y=82 fill="#cbb6ff" font-size=10>🧅 R3 ({{ lvl.r3 }})</text>
</svg> </svg>
<p class=help>R1 (analyse passive) est le choix le plus courant.</p>
</div> </div>
</div> </div>
</div> </div>
@ -303,7 +280,7 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
<div class=arch> <div class=arch>
LXC mitmproxy 10.100.0.60 WAF (vhosts CyberMind) LXC mitmproxy 10.100.0.60 WAF (vhosts CyberMind)
LXC toolbox-mitm 10.100.0.61 R1/R2 transparent LXC toolbox-mitm 10.100.0.61 R1/R2 transparent
LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard (+🧅 Tor)
CAs séparées · addons partagés · DB unifiée CAs séparées · addons partagés · DB unifiée
@ -318,7 +295,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
Le code est <b>publié intégralement</b> sous licence <b>CyberMind Source-Disclosed v1.0</b> Le code est <b>publié intégralement</b> sous licence <b>CyberMind Source-Disclosed v1.0</b>
(audit citoyen possible, droits d'usage régis par licence CMSD). Pas de boîte noire. (audit citoyen possible, droits d'usage régis par licence CMSD). Pas de boîte noire.
</p> </p>
<div style="margin-top:0.6rem"> <div style="margin-top:.6rem">
<a href="https://github.com/CyberMind-FR/secubox-deb" class=cta-sm>📂 Code source GitHub</a> <a href="https://github.com/CyberMind-FR/secubox-deb" class=cta-sm>📂 Code source GitHub</a>
<a href="https://github.com/CyberMind-FR/secubox-deb/blob/master/LICENCE-CMSD-1.0.md" class="cta-sm outline">📜 Licence CMSD-1.0</a> <a href="https://github.com/CyberMind-FR/secubox-deb/blob/master/LICENCE-CMSD-1.0.md" class="cta-sm outline">📜 Licence CMSD-1.0</a>
</div> </div>
@ -326,11 +303,11 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
{# ── Contact ── #} {# ── Contact ── #}
<div class=section> <div class=section>
<h2>📡 Contact & soutiens</h2> <h2>📡 Contact &amp; soutiens</h2>
<div class="grid grid-2"> <div class="grid grid-2">
<div class=card> <div class=card>
<h3>💚 Soutenir le projet</h3> <h3>💚 Soutenir le projet</h3>
<ul style="list-style:none;padding-left:0;font-size:0.85rem"> <ul style="list-style:none;padding-left:0;font-size:.85rem">
<li>💰 Don récurrent : <a href="https://liberapay.com/cybermind">liberapay.com/cybermind</a></li> <li>💰 Don récurrent : <a href="https://liberapay.com/cybermind">liberapay.com/cybermind</a></li>
<li>💳 Don ponctuel : <a href="https://cybermind.fr/don">cybermind.fr/don</a></li> <li>💳 Don ponctuel : <a href="https://cybermind.fr/don">cybermind.fr/don</a></li>
<li>📧 Support : <a href="mailto:support@cybermind.fr">support@cybermind.fr</a></li> <li>📧 Support : <a href="mailto:support@cybermind.fr">support@cybermind.fr</a></li>
@ -338,7 +315,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
</div> </div>
<div class="card purple"> <div class="card purple">
<h3>🏢 Déploiement collectivité</h3> <h3>🏢 Déploiement collectivité</h3>
<ul style="list-style:none;padding-left:0;font-size:0.85rem"> <ul style="list-style:none;padding-left:0;font-size:.85rem">
<li>📡 Borne grand public : <a href="mailto:gondwana@cybermind.fr">gondwana@cybermind.fr</a></li> <li>📡 Borne grand public : <a href="mailto:gondwana@cybermind.fr">gondwana@cybermind.fr</a></li>
<li>🎓 Formation cybersécurité : <a href="mailto:contact@cybermind.fr">contact@cybermind.fr</a></li> <li>🎓 Formation cybersécurité : <a href="mailto:contact@cybermind.fr">contact@cybermind.fr</a></li>
<li>🛡 Audit SecuBox premium : <a href="mailto:contact@cybermind.fr">contact@cybermind.fr</a></li> <li>🛡 Audit SecuBox premium : <a href="mailto:contact@cybermind.fr">contact@cybermind.fr</a></li>
@ -376,7 +353,6 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
var ss = String(d.getSeconds()).padStart(2,'0'); var ss = String(d.getSeconds()).padStart(2,'0');
return 'maj ' + hh+':'+mm+':'+ss; return 'maj ' + hh+':'+mm+':'+ss;
} }
// count-up: animate each KPI from 0 → its server-rendered value, once.
function countUp(el, target){ function countUp(el, target){
var start = 0, dur = 900, t0 = null; var start = 0, dur = 900, t0 = null;
function step(ts){ function step(ts){
@ -403,7 +379,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
if (prev !== next) { if (prev !== next) {
el.textContent = next; el.textContent = next;
el.classList.remove('tick'); el.classList.remove('tick');
void el.offsetWidth; // force reflow void el.offsetWidth;
el.classList.add('tick'); el.classList.add('tick');
} }
}); });
@ -449,8 +425,8 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
} else { } else {
emj.textContent = '🔴'; emj.textContent = '🔴';
txt.innerHTML = '<b>Tunnel R3 actif mais CA R3 NON trusté</b> — HTTPS casse. ' + txt.innerHTML = '<b>Tunnel R3 actif mais CA R3 NON trusté</b> — HTTPS casse. ' +
'Installe le <a href=/wg/ca.mobileconfig style="color:var(--orange)">profil CA R3 iPhone</a> ' + 'Installe le <a href=/wg/ca.mobileconfig style="color:var(--amber)">profil CA R3 iPhone</a> ' +
'ou le <a href=/wg/ca.pem style="color:var(--orange)">ca.pem Android/PC</a>.'; 'ou le <a href=/wg/ca.pem style="color:var(--amber)">ca.pem Android/PC</a>.';
} }
} }

View File

@ -0,0 +1,52 @@
# 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,
# which also POPULATES the `tor_exempt` set (see below).
#
# Invariants: inspection preserved · no DNS leak · fail-closed (kill-switch
# drops any worker egress that escaped the redirect — never leaks the real IP).
#
# `tor_exempt` = destinations that must NEVER be torified: loopback, the board's
# own local subnets (LAN/WG/LXC), and the board's own PUBLIC IP. Without this the
# box torifies traffic to its OWN services (kbin/admin resolve to the WAN IP,
# reached via hairpin) → self-views round-trip Tor and load empty/slow. The
# reconciler fills the set at arm time (it is intentionally empty here). The Tor
# automap range (10.192.0.0/10) is deliberately NOT exempt so worker-initiated
# hostname connections still egress via Tor.
table inet toolbox_tor {
set tor_exempt {
type ipv4_addr
flags interval
}
# ── 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
# DNS always via Tor (no leak), even to local resolvers — before the
# tor_exempt return so own-subnet resolvers don't bypass it.
ip daddr != 127.0.0.0/8 meta skuid "secubox-toolbox" meta l4proto { tcp, udp } th dport 53 redirect to :5353
# Own services / LAN / loopback → DIRECT (never torified).
ip daddr @tor_exempt return
# Everything else from the worker uid → Tor TransPort.
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 @tor_exempt 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

@ -1,662 +1,325 @@
{# SPDX-License-Identifier: LicenseRef-CMSD-1.0 #} {# SPDX-License-Identifier: LicenseRef-CMSD-1.0 #}
{# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr> #} {# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr> #}
{# #683 report redesign — verdict-first, graphs, plain-language helpers, deep
technical cards collapsed into <details>. Same data model as before. #}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"><head> <html lang="fr"><head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="refresh" content="15"> <meta http-equiv="refresh" content="20">
{# Phase 3 (#492) : PWA tags for iOS Add-to-Home-Screen webclip experience #}
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="ToolBoX Cabine"> <meta name="apple-mobile-web-app-title" content="ToolBoX Cabine">
<meta name="theme-color" content="#0a0a0f"> <meta name="theme-color" content="#0a0a0f">
<title>Mon rapport Gondwana ToolBoX — live</title> <title>Mon rapport — VILLAGE3B</title>
<style> <style>
:root{--bg:#0a0a0f;--phos:#00dd44;--phos-hot:#00ff55;--dim:#006622;--text:#e8e6d9;--red:#ff4466;--amber:#ffb347} :root{--bg:#0a0a0f;--panel:#11131a;--phos:#00dd44;--phos-hot:#00ff55;--dim:#5a6b60;--line:#1e2630;--text:#e8e6d9;--red:#ff4466;--amber:#ffb347;--violet:#9e76ff;--blue:#66bbff}
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Courier New',Menlo,monospace;background:var(--bg);color:var(--text);padding:1rem;max-width:760px;margin:auto;line-height:1.5} body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);padding:1rem;max-width:740px;margin:auto;line-height:1.5}
h1{color:var(--phos-hot);text-shadow:0 0 6px var(--phos);font-size:1.6rem;margin-bottom:0.3rem;letter-spacing:0.05em} h1{color:var(--phos-hot);font-size:1.35rem;letter-spacing:.04em;display:flex;align-items:center;gap:.4rem}
.sub{color:var(--dim);font-size:0.85rem;margin-bottom:1rem;letter-spacing:0.05em} .sub{color:var(--dim);font-size:.82rem;margin-bottom:1.1rem}
.card{border:1px solid var(--dim);background:rgba(0,221,68,0.03);padding:0.9rem 1rem;margin-bottom:1rem} .help{color:var(--dim);font-size:.78rem;font-style:italic;margin-top:.25rem}
.card h2{color:var(--phos-hot);text-shadow:0 0 4px var(--phos);font-size:0.95rem;margin-bottom:0.5rem;border-bottom:1px solid var(--dim);padding-bottom:0.3rem;letter-spacing:0.05em} .card{border:1px solid var(--line);background:var(--panel);border-radius:12px;padding:1rem 1.1rem;margin-bottom:1rem}
.kv{display:grid;grid-template-columns:auto 1fr;gap:0.2rem 0.8rem;font-size:0.85rem} .card h2{color:var(--phos-hot);font-size:.95rem;margin-bottom:.5rem;letter-spacing:.03em}
.kv .k{color:var(--dim)} /* ── verdict hero ── */
.kv .v{color:var(--phos);text-shadow:0 0 4px var(--phos)} .hero{text-align:center;background:radial-gradient(120% 120% at 50% 0%,rgba(0,221,68,.10),rgba(110,64,201,.05) 70%,transparent);border-color:var(--phos)}
ul{list-style:none;padding-left:0.6rem} .gauge{width:170px;height:170px;border-radius:50%;margin:.4rem auto;display:flex;align-items:center;justify-content:center}
li{padding:0.15rem 0;font-size:0.85rem} .gauge-hole{width:124px;height:124px;border-radius:50%;background:var(--bg);display:flex;flex-direction:column;align-items:center;justify-content:center}
li::before{content:"▸ ";color:var(--phos);text-shadow:0 0 4px var(--phos)} .gauge-num{font-size:2.6rem;font-weight:800;line-height:1}
.score{display:inline-block;padding:0.3rem 1rem;font-size:1.2rem;font-weight:bold;border:2px solid;border-radius:4px} .gauge-max{font-size:.8rem;color:var(--dim)}
.score.low{color:var(--phos-hot);border-color:var(--phos);text-shadow:0 0 6px var(--phos)} .verdict{font-size:1.15rem;font-weight:700;margin-top:.3rem}
.score.med{color:var(--amber);border-color:var(--amber);text-shadow:0 0 6px var(--amber)} /* ── KPI row ── */
.score.high{color:var(--red);border-color:var(--red);text-shadow:0 0 6px var(--red)} .kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem;margin-top:.4rem}
.pin{color:var(--amber)} .kpi{background:#0d0f15;border:1px solid var(--line);border-radius:10px;padding:.6rem .3rem;text-align:center}
.url{font-family:monospace;font-size:0.78rem;color:var(--text);background:rgba(0,221,68,0.05);padding:0.15rem 0.4rem;border-radius:2px;margin:0.1rem 0;display:inline-block;max-width:100%;overflow-wrap:break-word;word-break:break-all} .kpi .e{font-size:1.15rem}
.actions{text-align:center;margin:1.5rem 0} .kpi .n{font-size:1.3rem;font-weight:800;color:var(--phos-hot)}
.actions a{display:inline-block;margin:0 0.3rem;padding:0.5rem 1rem;border:1px solid var(--phos);color:var(--phos-hot);text-decoration:none;text-shadow:0 0 4px var(--phos);font-size:0.85rem} .kpi .l{font-size:.62rem;color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
.actions a:hover{background:rgba(0,221,68,0.1)} /* ── graphs ── */
.refresh{text-align:center;font-size:0.7rem;color:var(--dim);margin-top:1rem;font-style:italic} .graphs{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.footer{text-align:center;font-size:0.65rem;color:var(--dim);margin-top:1.5rem;border-top:1px solid var(--dim);padding-top:0.6rem} @media(max-width:560px){.graphs{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,1fr)}}
.donut-wrap{display:flex;gap:.8rem;align-items:center}
.donut{width:96px;height:96px;border-radius:50%;flex:0 0 auto}
.donut-hole{width:54px;height:54px;border-radius:50%;background:var(--panel);margin:21px;display:flex;align-items:center;justify-content:center;font-size:.7rem;color:var(--dim);text-align:center}
.legend{font-size:.78rem;display:flex;flex-direction:column;gap:.2rem;min-width:0}
.legend .row{display:flex;align-items:center;gap:.35rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dot{width:.6rem;height:.6rem;border-radius:50%;flex:0 0 auto}
.bar-row{display:grid;grid-template-columns:5.5rem 1fr 2rem;gap:.45rem;align-items:center;font-size:.78rem;margin:.2rem 0}
.bar-lbl{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar-track{background:#0d0f15;border-radius:99px;height:.7rem;overflow:hidden}
.bar-fill{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,var(--phos),var(--phos-hot))}
.bar-val{text-align:right;color:var(--phos);font-weight:700}
.empty{color:var(--dim);font-size:.8rem;font-style:italic;padding:.4rem 0}
/* ── details ── */
details{border:1px solid var(--line);background:var(--panel);border-radius:12px;margin-bottom:1rem;overflow:hidden}
details>summary{cursor:pointer;padding:.85rem 1.1rem;font-weight:700;color:var(--phos-hot);font-size:.92rem;list-style:none}
details>summary::-webkit-details-marker{display:none}
details>summary::before{content:"▸ ";color:var(--phos)}
details[open]>summary::before{content:"▾ "}
details .inner{padding:0 1.1rem 1rem}
.kv{display:grid;grid-template-columns:auto 1fr;gap:.2rem .8rem;font-size:.84rem}
.kv .k{color:var(--dim)}.kv .v{color:var(--phos)}
table{width:100%;font-size:.8rem;border-collapse:collapse}
th{color:var(--dim);text-align:left;font-weight:600;border-bottom:1px solid var(--line);padding:.25rem .3rem}
td{padding:.2rem .3rem;border-bottom:1px solid rgba(255,255,255,.03)}
code{font-family:ui-monospace,Menlo,monospace;font-size:.74rem;color:var(--text);word-break:break-all}
ul{list-style:none;padding-left:.2rem}li{padding:.12rem 0;font-size:.82rem}li::before{content:"▸ ";color:var(--phos)}
.pin h2,.pin{color:var(--amber)}
.lvl{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem;margin-top:.4rem}
.lvl button{padding:.55rem;cursor:pointer;font-family:inherit;font-size:.85rem;border-radius:8px;background:transparent;color:var(--text);border:1px solid var(--line)}
.lvl button.on{border-width:2px;font-weight:700}
.actions{display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center;margin:1.2rem 0}
.actions a{padding:.55rem 1rem;border:1px solid var(--phos);color:var(--phos-hot);text-decoration:none;border-radius:8px;font-size:.85rem}
.footer{text-align:center;font-size:.66rem;color:var(--dim);margin-top:1.4rem;border-top:1px solid var(--line);padding-top:.7rem}
.url{font-family:ui-monospace,monospace;font-size:.72rem;background:#0d0f15;padding:.12rem .35rem;border-radius:4px;margin:.1rem 0;display:block;word-break:break-all}
</style></head> </style></head>
<body> <body>
<h1>📡 GONDWANA TOOLBOX</h1>
<p class="sub">// Rapport live — Cabine numérique VILLAGE3B</p>
{# Phase 3 (#492) : hero widgets — same shape as PDF #}
{% set m = metrics or {} %} {% set m = metrics or {} %}
{% set sc = risk_score or 0 %} {% set sc = risk_score|default(0) %}
{% set rl = risk_label or 'LOW' %} {% set rl = risk_label|default('LOW') %}
<div class="card" style="background:linear-gradient(135deg,rgba(0,221,68,0.08),rgba(110,64,201,0.05));border-color:var(--phos)"> {% set ch = charts or {} %}
<h2 style="display:flex;justify-content:space-between;align-items:center"> {% set gcol = 'var(--phos-hot)' if sc < 30 else ('var(--amber)' if sc < 70 else 'var(--red)') %}
📊 Ta session VILLAGE3B {% set palette = ['#00dd44','#9e76ff','#ff8866','#66bbff','#ffb347','#ff4466'] %}
<span style="font-size:0.75rem;padding:0.3rem 0.8rem;border-radius:99px; {% set dpi_cls = dpi_classified or {} %}
background:{% if sc < 30 %}#00cc44{% elif sc < 70 %}#ffb347{% else %}#ff4466{% endif %}; {% set cookies_p = cookies_providers or [] %}
color:#0a0a0f;font-weight:bold"> {% set geo_h = geo_top_hosts or [] %}
{% if sc < 30 %}🟢{% elif sc < 70 %}🟡{% else %}🔴{% endif %} {{ rl }} {{ sc }}/100 {% set n_apps = (dpi_cls.top_apps|default([])|selectattr('app','ne','?')|list|length) %}
</span> {% set n_trackers = (cookies_p|map(attribute='count')|sum) %}
</h2> {% set n_countries = (geo_h|map(attribute='country')|reject('equalto','')|list|unique|list|length) %}
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem;margin-top:0.8rem"> {% set _avatar = avatar_analysis or {} %}
{% for emoji, val, lbl in [
('🌐', m.connections|default(0), 'connexions'),
('📡', m.unique_hosts|default(0), 'hôtes uniques'),
('✅', m.successful|default(0), 'OK 2xx/3xx'),
('🔒', m.tls_pinned|default(0), 'cert-pinning')
] %}
<div style="padding:0.6rem;background:rgba(0,0,0,0.3);border-radius:4px;text-align:center">
<div style="font-size:1.2rem">{{ emoji }}</div>
<div style="font-size:1.3rem;font-weight:bold;color:var(--phos-hot);text-shadow:0 0 4px var(--phos)">{{ val }}</div>
<div style="font-size:0.65rem;color:var(--dim)">{{ lbl }}</div>
</div>
{% endfor %}
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem;margin-top:0.5rem">
{% set dpi_cls = dpi_classified or {} %}
{% set cookies_p = cookies_providers or [] %}
{% set geo_h = geo_top_hosts or [] %}
{% set n_apps = (dpi_cls.top_apps|default([])|selectattr('app','ne','?')|list|length) %}
{% set n_trackers = (cookies_p|map(attribute='count')|sum) %}
{% set countries = geo_h|map(attribute='country')|reject('equalto','')|list %}
{% set n_countries = countries|unique|list|length %}
<div style="padding:0.6rem;background:rgba(25,25,45,0.5);border-radius:4px;text-align:center">
<div style="font-size:1.2rem">📺</div>
<div style="font-size:1.3rem;font-weight:bold;color:#9e76ff">{{ n_apps }}</div>
<div style="font-size:0.65rem;color:var(--dim)">apps détectées</div>
</div>
<div style="padding:0.6rem;background:rgba(45,25,35,0.5);border-radius:4px;text-align:center">
<div style="font-size:1.2rem">🍪</div>
<div style="font-size:1.3rem;font-weight:bold;color:#ff8866">{{ n_trackers }}</div>
<div style="font-size:0.65rem;color:var(--dim)">trackers</div>
</div>
<div style="padding:0.6rem;background:rgba(25,40,35,0.5);border-radius:4px;text-align:center">
<div style="font-size:1.2rem">🌍</div>
<div style="font-size:1.3rem;font-weight:bold;color:#66bbff">{{ n_countries }}</div>
<div style="font-size:0.65rem;color:var(--dim)">pays/ASN</div>
</div>
<div style="padding:0.6rem;background:rgba(60,50,15,0.5);border-radius:4px;text-align:center">
<div style="font-size:1.2rem">{% if sc < 30 %}🟢{% elif sc < 70 %}🟡{% else %}🔴{% endif %}</div>
<div style="font-size:1.3rem;font-weight:bold;color:{% if sc < 30 %}var(--phos-hot){% elif sc < 70 %}#ffb347{% else %}#ff4466{% endif %}">{{ sc }}</div>
<div style="font-size:0.65rem;color:var(--dim)">risque /100</div>
</div>
</div>
{# 9th widget (poster sync #497) : 📱 empreinte device — full-width below #}
{% set _avatar = avatar_analysis or {} %}
{% set _dev_emj = _avatar.most_common_emoji or '❔' %}
{% set _dev_label = _avatar.most_common or 'unknown' %}
{% set _devices = _avatar.devices or {} %}
{% set _dev_info = _devices.get(_dev_label, {}) %}
{% set _os_label = _dev_info.os_label or _dev_label %}
{% set _browsers = _avatar.browsers or {} %}
<div style="margin-top:0.5rem;padding:0.7rem;background:rgba(45,25,60,0.5);border-radius:4px;text-align:center">
<div style="font-size:1.4rem">📱 <b style="color:#cbb6ff">{{ _dev_emj }} Empreinte device</b></div>
<div style="font-size:0.92rem;color:var(--phos-hot);margin-top:0.3rem;text-shadow:0 0 3px var(--phos)">
{{ _os_label }}
{% for br, info in _browsers.items() if br != 'unknown' %}
· {{ info.emoji or '' }} {{ info.label or br }}
{% endfor %}
</div>
<div style="font-size:0.65rem;color:var(--dim);margin-top:0.2rem">{{ _avatar.raw_count or 0 }} UAs distincts observés</div>
</div>
{# Top device + app + ASN line #}
{% set avatar = avatar_analysis or {} %}
{% set top_dev = avatar.most_common or '?' %}
{% set top_dev_emj = avatar.most_common_emoji or '' %}
{% set top_app = (dpi_cls.top_apps and dpi_cls.top_apps[0]) or {} %}
{% set top_geo = (geo_h and geo_h[0]) or {} %}
{% set top_asn_raw = top_geo.asn_org if top_geo.asn_org else '?' %}
<p style="font-size:0.78rem;color:var(--dim);margin-top:0.6rem;text-align:center;padding-top:0.5rem;border-top:1px solid var(--dim)">
Top device : {{ top_dev_emj }} {{ top_dev }} ·
Top app : {{ top_app.emoji|default('') }} {{ top_app.app|default('?') }} ·
Top ASN : {{ top_geo.flag|default('') }} {{ top_asn_raw[:30] }}
</p>
</div>
{# Phase 3 (#492) : filtering compromissions visibility #} <h1>👁️ VILLAGE3B <span style="font-size:.8rem;color:var(--dim);font-weight:400">· mon rapport</span></h1>
{% set t = transparency|default({}) %} <p class="sub">Diagnostic live de ce que ton appareil envoie sur le réseau · anonyme · se rafraîchit tout seul</p>
{% set sens = t.get('sensitivity') %}
<div class="card" style="background:rgba(255,68,102,0.06);border-color:var(--red)">
<h2 style="color:var(--red)">🚨 Filtering compromissions actif</h2>
<div class="kv" style="font-size:0.82rem">
<span class="k">Sensibilité actuelle</span>
<span class="v">{{ sens.label|default('—') if sens else '—' }}</span>
<span class="k">Description</span>
{% set sens_desc = (sens.description if sens and sens.description else 'Engine pas chargé') %}
<span class="v" style="font-size:0.75rem">{{ sens_desc[:90] }}</span>
<span class="k">🔍 Threat-intel feeds</span>
<span class="v">{{ (threat_intel_matches|default([]))|length }} matches actifs</span>
<span class="k">🎲 DGA candidates</span>
<span class="v">{{ (dga_candidates|default([]))|length }} hôtes suspects</span>
<span class="k">📡 Beaconing patterns</span>
<span class="v">{{ (beaconing_candidates|default([]))|length }} détectés</span>
<span class="k">🚫 Connexions bloquées</span>
<span class="v">{{ t.get('attempts', {}).get('blocked', 0) }} <span style="font-size:0.7rem;color:var(--dim)">(Phase 4 : observation only)</span></span>
</div>
<p style="font-size:0.7rem;color:var(--dim);margin-top:0.5rem;font-style:italic">
Le moteur détecte mais ne bloque pas encore — passive transparency. La règle engine
décidera blocage actif quand R3 / Phase 4 sera wired.
</p>
</div>
{# Phase 3 (#492) : welcome / switch confirmation banner — uses server-side
current_level (NOT request_args.level which is stale after refresh). #}
{% if request_args and (request_args.get('welcome') or request_args.get('switched')) %} {% if request_args and (request_args.get('welcome') or request_args.get('switched')) %}
<div class="card" style="background:rgba(0,221,68,0.08);border-color:var(--phos)"> <div class="card" style="border-color:var(--phos)">
<h2 style="color:var(--phos-hot)"> <b style="color:var(--phos-hot)">{% if request_args.get('switched') %}🔄 Niveau changé{% else %}🎉 Bienvenue !{% endif %}</b> —
{% if request_args.get('switched') %}🔄 Niveau changé{% else %}🎉 Bienvenue !{% endif %} tu es en mode
</h2> {% if current_level == 'r0' %}🌐 R0 (aucune analyse){% elif current_level == 'r2' %}🔍 R2 (analyse + bandeau){% elif current_level == 'r3' %}🧅 R3 (tunnel){% else %}🛡 R1 (analyse passive){% endif %}.
<p style="font-size:0.85rem"> Tu peux surfer normalement.
Tu es maintenant en mode
<b style="color:var(--phos-hot)">
{% if current_level == 'r0' %}🌐 R0 — Bypass complet (aucune analyse)
{% elif current_level == 'r2' %}🔍 R2 — Analyse + bandeau Safari
{% else %}🛡 R1 — Analyse passive recommandée{% endif %}
</b>.
Tu peux maintenant <a href="http://captive.apple.com/hotspot-detect.html" target="_blank" style="color:var(--phos-hot);text-decoration:underline">surfer normalement</a> — ce rapport se met à jour toutes les 15s.
</p>
</div> </div>
{% endif %} {% endif %}
{# Phase 3 (#492) : level switcher with active highlight from server-side {# ── VERDICT HERO : score gauge + plain verdict ── #}
current_level. Disables button if user clicks their own level (no-op). #} <div class="card hero">
<div class="gauge" style="background:conic-gradient({{ gcol }} {{ sc }}%, #1a1a22 {{ sc }}% 100%)">
<div class="gauge-hole">
<span class="gauge-num" style="color:{{ gcol }}">{{ sc }}</span>
<span class="gauge-max">/ 100</span>
</div>
</div>
<div class="verdict" style="color:{{ gcol }}">
{% if sc < 30 %}🟢 Tout va bien — {{ rl }}{% elif sc < 70 %}🟡 À surveiller — {{ rl }}{% else %}🔴 Attention — {{ rl }}{% endif %}
</div>
<p class="help">Score de risque de ton appareil. Plus il est <b>bas</b>, mieux tu es protégé.</p>
{% if risk_explanation %}<p style="font-size:.85rem;margin-top:.5rem">{{ risk_explanation }}</p>{% endif %}
</div>
{# ── KPI row ── #}
<div class="kpis">
<div class="kpi"><div class="e">🌐</div><div class="n">{{ m.connections|default(0) }}</div><div class="l">connexions</div></div>
<div class="kpi"><div class="e">📡</div><div class="n">{{ m.unique_hosts|default(0) }}</div><div class="l">hôtes</div></div>
<div class="kpi"><div class="e">🍪</div><div class="n">{{ n_trackers }}</div><div class="l">trackers</div></div>
<div class="kpi"><div class="e">🌍</div><div class="n">{{ n_countries }}</div><div class="l">pays</div></div>
<div class="kpi"><div class="e">📺</div><div class="n">{{ n_apps }}</div><div class="l">apps</div></div>
<div class="kpi"><div class="e">🔒</div><div class="n">{{ m.tls_pinned|default(0) }}</div><div class="l">cert-pin</div></div>
</div>
<p class="help" style="text-align:center;margin-bottom:1rem">Ton appareil a contacté {{ m.unique_hosts|default(0) }} serveurs dans {{ n_countries }} pays, avec {{ n_trackers }} traceurs repérés.</p>
{# ── GRAPHS ── #}
<div class="card"> <div class="card">
<h2>🔀 Mon niveau d'opt-in</h2> <h2>📊 En un coup d'œil</h2>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.5rem"> <div class="graphs">
📍 Tu es actuellement en
<b style="color:{% if current_level == 'r0' %}var(--text){% elif current_level == 'r2' %}#ffd6a0{% else %}var(--phos-hot){% endif %}"> {# trackers donut #}
{% if current_level == 'r0' %}🌐 R0 — Bypass complet <div>
{% elif current_level == 'r2' %}🔍 R2 — Analyse + bandeau <div style="font-size:.82rem;color:var(--dim);margin-bottom:.4rem">🍪 Qui te trace</div>
{% else %}🛡 R1 — Analyse passive{% endif %} {% if ch.trackers %}
</b> <div class="donut-wrap">
· clique sur un autre niveau pour switcher <div class="donut" style="background:conic-gradient({% for t in ch.trackers %}{{ palette[loop.index0 % palette|length] }} {{ t.start }}% {{ t.end }}%{% if not loop.last %},{% endif %}{% endfor %})">
</p> <div class="donut-hole">{{ n_trackers }}<br>traceurs</div>
<form method="POST" action="/change-level" style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.4rem"> </div>
<button type="submit" name="level" value="r0" <div class="legend">
style="padding:0.5rem;cursor:pointer;font-family:inherit;font-size:0.85rem; {% for t in ch.trackers %}
{% if current_level == 'r0' %} <span class="row"><span class="dot" style="background:{{ palette[loop.index0 % palette|length] }}"></span>{{ t.emoji }} {{ t.label[:14] }} <b style="color:var(--text)">{{ t.pct }}%</b></span>
background:rgba(255,255,255,0.08);color:var(--text);border:2px solid var(--text);font-weight:bold; {% endfor %}
{% else %} </div>
background:transparent;color:var(--text);border:1px solid var(--dim); </div>
{% endif %}"> {% else %}<div class="empty">Aucun traceur repéré 🎉</div>{% endif %}
{% if current_level == 'r0' %}✓ {% endif %}🌐 R0 </div>
</button>
<button type="submit" name="level" value="r1" {# countries bars #}
style="padding:0.5rem;cursor:pointer;font-family:inherit;font-size:0.85rem; <div>
{% if current_level == 'r1' %} <div style="font-size:.82rem;color:var(--dim);margin-bottom:.4rem">🌍 Vers quels pays</div>
background:rgba(0,221,68,0.25);color:var(--phos-hot);border:2px solid var(--phos);font-weight:bold; {% if ch.countries %}
{% else %} {% for c in ch.countries %}
background:transparent;color:var(--phos);border:1px solid var(--dim); <div class="bar-row"><span class="bar-lbl">{{ c.flag }} {{ c.label[:8] }}</span><span class="bar-track"><span class="bar-fill" style="width:{{ c.pct }}%"></span></span><span class="bar-val">{{ c.count }}</span></div>
{% endif %}"> {% endfor %}
{% if current_level == 'r1' %}✓ {% endif %}🛡 R1 {% else %}<div class="empty">Pas encore de données géo</div>{% endif %}
</button> </div>
<button type="submit" name="level" value="r2"
style="padding:0.5rem;cursor:pointer;font-family:inherit;font-size:0.85rem; {# apps bars #}
{% if current_level == 'r2' %} <div style="grid-column:1/-1">
background:rgba(255,179,71,0.25);color:#ffd6a0;border:2px solid #ffb347;font-weight:bold; <div style="font-size:.82rem;color:var(--dim);margin-bottom:.4rem">📺 Quelles apps / services</div>
{% else %} {% if ch.apps %}
background:transparent;color:#9d7846;border:1px solid var(--dim); {% for a in ch.apps %}
{% endif %}"> <div class="bar-row"><span class="bar-lbl">{{ a.emoji }} {{ a.label[:16] }}</span><span class="bar-track"><span class="bar-fill" style="width:{{ a.pct }}%;background:linear-gradient(90deg,var(--violet),#c9b6ff)"></span></span><span class="bar-val" style="color:var(--violet)">{{ a.count }}</span></div>
{% if current_level == 'r2' %}✓ {% endif %}🔍 R2 {% endfor %}
</button> {% else %}<div class="empty">Aucune app classifiée</div>{% endif %}
</div>
</div>
<p class="help">Les traceurs suivent ta navigation entre sites. Les apps cert-pinning (🔒) refusent l'analyse — c'est bon signe.</p>
</div>
{# ── LEVEL SWITCHER (action) ── #}
<div class="card">
<h2>🔀 Mon niveau de protection</h2>
<form method="POST" action="/change-level" class="lvl">
<button type="submit" name="level" value="r0" class="{{ 'on' if current_level=='r0' }}" style="{% if current_level=='r0' %}border-color:var(--text){% endif %}">{% if current_level=='r0' %}✓ {% endif %}🌐 R0</button>
<button type="submit" name="level" value="r1" class="{{ 'on' if current_level=='r1' }}" style="{% if current_level=='r1' %}border-color:var(--phos);color:var(--phos-hot){% endif %}">{% if current_level=='r1' %}✓ {% endif %}🛡 R1</button>
<button type="submit" name="level" value="r2" class="{{ 'on' if current_level=='r2' }}" style="{% if current_level=='r2' %}border-color:var(--amber);color:var(--amber){% endif %}">{% if current_level=='r2' %}✓ {% endif %}🔍 R2</button>
</form> </form>
{% if wg_enabled|default(false) %} <p class="help">R0 = aucune analyse · R1 = analyse passive (recommandé) · R2 = analyse + bandeau.{% if wg_enabled|default(false) %} <a href="/wg/r3-install" style="color:var(--violet)">🧅 R3 tunnel mobile</a>.{% endif %}</p>
{# Phase 6 (#496) : R3 WireGuard separate action (different flow — install profile first) #}
<p style="font-size:0.7rem;color:var(--dim);margin-top:0.5rem;text-align:center">
Pour le mode R3 WireGuard (mobile + tout-décrypté) :
<a href="/wg/r3-install" style="color:#9e76ff;text-decoration:underline">🌐 installer le profil</a>
</p>
{% if current_level == 'r3' %}
<p style="font-size:0.72rem;color:#9e76ff;margin-top:0.3rem;text-align:center;font-weight:bold">
✓ Tu es actuellement en mode R3 — tunnel WG actif
</p>
{% endif %}
{% endif %}
</div> </div>
<div class="card"> {# ════════════ DÉTAILS TECHNIQUES (repliés) ════════════ #}
<h2>👤 Identifiant anonyme</h2>
<div class="kv">
<span class="k">Hash session</span> <span class="v">{{ mac_hash }}</span>
<span class="k">Sandbox IP</span> <span class="v">{{ ip }}</span>
<span class="k">Appareil</span> <span class="v">{{ device_type }}</span>
</div>
</div>
<div class="card"> <details>
<h2>📊 Métriques session</h2> <summary>🎯 Analyse de compromission &amp; score</summary>
<div class="kv"> <div class="inner">
<span class="k">Connexions</span> <span class="v">{{ metrics.connections }}</span> {% if risk_explanation %}<p style="font-size:.85rem;margin-bottom:.6rem">{{ risk_explanation }}</p>{% endif %}
<span class="k">Hosts uniques</span> <span class="v">{{ metrics.unique_hosts }}</span> <ul>{% for ind in indicators %}<li>{{ ind }}</li>{% endfor %}</ul>
<span class="k">Réussies</span> <span class="v">{{ metrics.successful }}</span> {% if scoring and scoring.breakdown %}
<span class="k">Cert-pin block</span> <span class="v">{{ metrics.tls_pinned }}</span> <table style="margin-top:.6rem"><thead><tr><th>Catégorie</th><th style="text-align:right">Signaux</th><th style="text-align:right">Poids</th></tr></thead><tbody>
</div> {% for b in scoring.breakdown %}
</div> <tr><td>{{ b.category }}</td><td style="text-align:right">{{ b.raw_signal_count }}</td><td style="text-align:right;color:var(--amber)">+{{ b.weight_subtotal }}</td></tr>
{% endfor %}
<div class="card"> </tbody></table>
<h2>🎯 Analyse compromission</h2>
<p style="text-align:center;margin:0.5rem 0">
{% if risk_score < 30 %}
<span class="score low">Score : {{ risk_score }}/100 — {{ risk_label|default('LOW') }}</span>
{% elif risk_score < 70 %}
<span class="score med">Score : {{ risk_score }}/100 — {{ risk_label|default('MEDIUM') }}</span>
{% else %}
<span class="score high">Score : {{ risk_score }}/100 — {{ risk_label|default('HIGH') }}</span>
{% endif %} {% endif %}
</p> </div>
{% if risk_explanation %} </details>
<p style="font-size:0.85rem;color:var(--text);margin-bottom:0.8rem;padding:0.5rem;background:rgba(0,221,68,0.05);border-left:2px solid var(--phos)">{{ risk_explanation }}</p>
{% endif %}
<ul>
{% for ind in indicators %}<li>{{ ind }}</li>{% endfor %}
</ul>
</div>
{% if scoring and scoring.breakdown %} {% if threat_intel_matches or dga_candidates or beaconing_candidates %}
<div class="card"> <details>
<h2>🔬 Breakdown du score (transparent)</h2> <summary class="pin">🚨 Menaces détectées ({{ (threat_intel_matches|default([]))|length + (dga_candidates|default([]))|length + (beaconing_candidates|default([]))|length }})</summary>
<table style="width:100%;font-size:0.82rem;border-collapse:collapse"> <div class="inner">
<thead><tr style="color:var(--dim);border-bottom:1px solid var(--dim)"> {% if threat_intel_matches %}<p style="color:var(--amber);font-size:.82rem;margin:.3rem 0">🚨 Threat-intel (feeds malware)</p><ul>
<th style="padding:0.2rem 0.4rem;text-align:left">Catégorie</th> {% for x in threat_intel_matches[:10] %}<li>{{ x.flag }} <span style="color:var(--red)">[{{ x.source }}]</span> <b>{{ x.label }}</b> <code>{{ x.ioc[:50] }}</code></li>{% endfor %}</ul>{% endif %}
<th style="padding:0.2rem 0.4rem;text-align:right">Signaux</th> {% if dga_candidates %}<p style="color:var(--amber);font-size:.82rem;margin:.5rem 0 .2rem">🔠 Domaines générés (DGA)</p><ul>
<th style="padding:0.2rem 0.4rem;text-align:right">Poids</th> {% for d in dga_candidates[:8] %}<li>{{ d.flag }} [{{ d.score }}] <code>{{ d.host[:50] }}</code></li>{% endfor %}</ul>{% endif %}
</tr></thead> {% if beaconing_candidates %}<p style="color:var(--amber);font-size:.82rem;margin:.5rem 0 .2rem">📡 Beaconing (périodique)</p><ul>
<tbody> {% for b in beaconing_candidates[:8] %}<li>{{ b.flag }} [{{ b.score }}] <code>{{ b.host[:40] }}</code> · {{ b.median_seconds }}s</li>{% endfor %}</ul>{% endif %}
{% for b in scoring.breakdown %} </div>
<tr><td style="padding:0.15rem 0.4rem">{{ b.category }}</td> </details>
<td style="padding:0.15rem 0.4rem;text-align:right">{{ b.raw_signal_count }}</td>
<td style="padding:0.15rem 0.4rem;text-align:right;color:var(--amber);text-shadow:0 0 4px var(--amber)">+{{ b.weight_subtotal }}</td></tr>
{% if b.examples %}
<tr><td colspan="3" style="padding:0;font-size:0.75rem;color:var(--text)">
{% for ex in b.examples[:3] %}<div style="margin-left:1rem;padding:0.1rem 0">▸ <code>{{ ex }}</code></div>{% endfor %}
</td></tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if threat_intel_matches %}
<div class="card">
<h2 class="pin">🚨 Threat-intel : matches feeds malware</h2>
<ul>
{% for m in threat_intel_matches[:10] %}
<li>{{ m.flag }} <span style="color:var(--red)">[{{ m.source }}/{{ m.weight }}]</span> <b>{{ m.label }}</b> : <code>{{ m.ioc[:60] }}</code>{% if m.asn_org %} <span style="font-size:0.75rem;color:var(--dim)">({{ m.asn_org }})</span>{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if dga_candidates %}
<div class="card">
<h2 class="pin">🔠 DGA — domaines suspects</h2>
<ul>
{% for d in dga_candidates[:8] %}
<li>{{ d.flag }} <span style="color:var(--amber)">[{{ d.score }}]</span> <code>{{ d.host[:60] }}</code> <span style="font-size:0.75rem;color:var(--dim)">({{ d.indicators|join(', ') }})</span></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if beaconing_candidates %}
<div class="card">
<h2 class="pin">📡 Beaconing — patterns périodiques suspects</h2>
<table style="width:100%;font-size:0.82rem">
<thead><tr style="color:var(--dim);text-align:left">
<th>🚩</th><th>Score</th><th>Host</th><th style="text-align:right">Median</th><th style="text-align:right">CV</th><th>ASN</th>
</tr></thead>
<tbody>
{% for b in beaconing_candidates[:8] %}
<tr><td>{{ b.flag }}</td><td style="color:var(--amber)">{{ b.score }}</td><td><code>{{ b.host[:40] }}</code></td>
<td style="text-align:right">{{ b.median_seconds }}s</td>
<td style="text-align:right">{{ b.cv }}</td>
<td style="font-size:0.78rem;color:var(--dim)">{{ b.asn_org[:30] if b.asn_org else '' }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if dpi_classified and dpi_classified.top_apps %}
<div class="card">
<h2>🧭 Apps détectées (classification nDPI-style)</h2>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.5rem">
Catégories : {% for cat, n in dpi_classified.by_category.items() %}{{ dpi_classified.category_emoji.get(cat, '❔') }} {{ cat }}({{ n }}){% if not loop.last %} · {% endif %}{% endfor %}
</p>
<table style="width:100%;font-size:0.85rem">
<thead><tr style="color:var(--dim);text-align:left">
<th>App</th><th>Catégorie</th><th style="text-align:right">Connexions</th>
</tr></thead>
<tbody>
{% for a in dpi_classified.top_apps[:15] %}
<tr><td>{{ a.emoji }} <b>{{ a.app }}</b></td><td style="font-size:0.78rem;color:var(--dim)">{{ a.category }}</td>
<td style="text-align:right;color:var(--phos);text-shadow:0 0 4px var(--phos)">{{ a.count }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %} {% endif %}
{% if geo_top_hosts %} {% if geo_top_hosts %}
<div class="card"> <details>
<h2>🌍 Hôtes contactés (par pays + ASN + app)</h2> <summary>🌍 Hôtes contactés ({{ geo_top_hosts|length }})</summary>
<table style="width:100%;font-size:0.82rem"> <div class="inner"><table><thead><tr><th>🚩</th><th>App</th><th>Hôte</th><th style="text-align:right">Hits</th></tr></thead><tbody>
<thead><tr style="color:var(--dim);text-align:left"> {% for h in geo_top_hosts[:20] %}
<th>🚩</th><th>App</th><th>Hôte</th><th>ASN</th><th style="text-align:right">Hits</th> <tr><td>{{ h.flag }}</td><td>{{ h.emoji }} {{ h.app[:14] }}</td><td><code>{{ h.host[:42] }}</code></td><td style="text-align:right;color:var(--phos)">{{ h.count }}</td></tr>
</tr></thead>
<tbody>
{% for h in geo_top_hosts[:15] %}
<tr><td>{{ h.flag }}</td><td>{{ h.emoji }} {{ h.app[:18] }}</td><td><code style="font-size:0.78rem">{{ h.host[:45] }}</code></td>
<td style="font-size:0.75rem;color:var(--dim)">{{ h.asn_org[:25] if h.asn_org else '' }}</td>
<td style="text-align:right;color:var(--phos)">{{ h.count }}</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody></table></div>
</table> </details>
</div>
{% endif %} {% endif %}
{% if avatar_analysis and avatar_analysis.devices %} {% if dpi_classified and dpi_classified.top_apps %}
<div class="card"> <details>
<h2>{{ avatar_analysis.most_common_emoji }} Avatar / device fingerprint</h2> <summary>🧭 Apps détectées (nDPI)</summary>
<p style="margin-bottom:0.5rem"> <div class="inner"><table><thead><tr><th>App</th><th>Catégorie</th><th style="text-align:right">Conn.</th></tr></thead><tbody>
<span style="font-size:1.2rem">{{ avatar_analysis.most_common_emoji }}</span> {% for a in dpi_classified.top_apps[:20] %}
<b>{{ avatar_analysis.most_common }}</b> (le plus représenté) <tr><td>{{ a.emoji }} <b>{{ a.app }}</b></td><td style="color:var(--dim)">{{ a.category }}</td><td style="text-align:right;color:var(--phos)">{{ a.count }}</td></tr>
</p> {% endfor %}
<div style="display:flex;flex-wrap:wrap;gap:1rem;font-size:0.85rem"> </tbody></table></div>
<div> </details>
<div style="color:var(--dim);font-size:0.78rem;margin-bottom:0.3rem">Devices :</div>
{% for dev, info in avatar_analysis.devices.items() %}
<div>{{ info.emoji }} <b>{{ info.os_label }}</b> <span style="color:var(--dim)">({{ info.count }})</span></div>
{% endfor %}
</div>
<div>
<div style="color:var(--dim);font-size:0.78rem;margin-bottom:0.3rem">Browsers :</div>
{% for br, info in avatar_analysis.browsers.items() %}
<div>{{ info.emoji }} <b>{{ info.label }}</b> <span style="color:var(--dim)">({{ info.count }})</span></div>
{% endfor %}
</div>
</div>
</div>
{% endif %} {% endif %}
{% if cookies_providers %} {% if cookies_providers %}
<div class="card"> <details>
<h2>🍪 Trackers / providers cookies (Phase 2a+)</h2> <summary>🍪 Traceurs / providers cookies ({{ cookies_providers|length }})</summary>
<ul> <div class="inner"><ul>
{% for p in cookies_providers[:10] %} {% for p in cookies_providers[:15] %}<li>{{ p.emoji }} <b>{{ p.provider }}</b> <span style="color:var(--dim)">({{ p.category }})</span> <span style="color:var(--phos)">×{{ p.count }}</span></li>{% endfor %}
<li>{{ p.emoji }} <b>{{ p.provider }}</b> <span style="font-size:0.78rem;color:var(--dim)">({{ p.category }})</span> <span style="color:var(--phos)">×{{ p.count }}</span></li> </ul></div>
{% endfor %} </details>
</ul>
</div>
{% endif %} {% endif %}
<div class="card"> {% if avatar_analysis and avatar_analysis.devices %}
<h2>📱 Apps détectées (vue IP forensics)</h2> <details>
<ul> <summary>{{ _avatar.most_common_emoji }} Empreinte appareil</summary>
{% for app in apps_detected %}<li>{{ app }}</li>{% endfor %} <div class="inner">
</ul> <p style="margin-bottom:.4rem">{{ _avatar.most_common_emoji }} <b>{{ _avatar.most_common }}</b> · {{ _avatar.raw_count|default(0) }} UAs distincts</p>
</div> <div style="display:flex;flex-wrap:wrap;gap:1.2rem;font-size:.84rem">
<div><div style="color:var(--dim);font-size:.75rem">Devices</div>{% for dev,info in _avatar.devices.items() %}<div>{{ info.emoji }} {{ info.os_label }} ({{ info.count }})</div>{% endfor %}</div>
<div class="card"> <div><div style="color:var(--dim);font-size:.75rem">Browsers</div>{% for br,info in (_avatar.browsers or {}).items() %}<div>{{ info.emoji }} {{ info.label }} ({{ info.count }})</div>{% endfor %}</div>
<h2 class="pin">🔒 Apps protégées par cert-pinning</h2>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.5rem">
Ces apps refusent notre certificat — ToolBoX ne peut PAS lire leur contenu. C'est un BON signe sécurité.
</p>
<ul>
{% for app in pinned_apps %}<li>{{ app }}</li>{% endfor %}
</ul>
</div>
{% if dpi and dpi.top_hosts %}
<div class="card">
<h2>🔍 DPI — hôtes les plus contactés</h2>
<table style="width:100%;font-size:0.82rem;border-collapse:collapse">
<thead><tr style="color:var(--dim);text-align:left;border-bottom:1px solid var(--dim)">
<th style="padding:0.2rem 0.4rem">Hôte</th><th style="padding:0.2rem 0.4rem;text-align:right">Requêtes</th>
</tr></thead>
<tbody>
{% for entry in dpi.top_hosts[:10] %}
<tr><td style="padding:0.15rem 0.4rem;font-family:monospace">{{ entry.host[:70] }}</td>
<td style="padding:0.15rem 0.4rem;text-align:right;color:var(--phos);text-shadow:0 0 4px var(--phos)">{{ entry.count }}</td></tr>
{% endfor %}
</tbody>
</table>
{% if dpi.methods %}
<p style="font-size:0.78rem;color:var(--dim);margin-top:0.6rem">
Méthodes : {% for m,c in dpi.methods.items() %}<span style="color:var(--phos)">{{ m }}({{ c }})</span>{% if not loop.last %} · {% endif %}{% endfor %}
</p>
{% endif %}
{% if dpi.user_agents %}
<p style="font-size:0.75rem;color:var(--dim);margin-top:0.4rem">
UA détectés : {{ dpi.user_agents|length }}
{% if dpi.user_agents %}<br><span style="color:var(--text);font-family:monospace;font-size:0.7rem">{{ dpi.user_agents[0][:90] }}</span>{% endif %}
</p>
{% endif %}
</div>
{% endif %}
{% if cookies and (cookies.total_set or cookies.details) %}
<div class="card">
<h2>🍪 Cookies / trackers</h2>
<div class="kv" style="margin-bottom:0.5rem">
<span class="k">Set-Cookie reçus</span> <span class="v">{{ cookies.total_set }}</span>
<span class="k">Cookies envoyés</span> <span class="v">{{ cookies.total_sent }}</span>
</div>
{% if cookies.details %}
<p style="font-size:0.78rem;color:var(--dim);margin-top:0.4rem">URLs avec activité cookies (top {{ cookies.details|length }}) :</p>
{% for d in cookies.details[:10] %}
<div class="url">
<span style="color:var(--amber)">set={{ d.set }}/sent={{ d.sent }}</span> · {{ d.url[:90] }}
</div> </div>
{% endfor %} </div>
{% endif %} </details>
</div>
{% endif %} {% endif %}
{% if soc and soc.indicators %} {% if pinned_apps %}
<div class="card"> <details>
<h2 class="pin">⚠ SOC — indicateurs détectés</h2> <summary>🔒 Apps protégées (cert-pinning) ({{ pinned_apps|length }})</summary>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.5rem"> <div class="inner"><p class="help" style="margin-bottom:.4rem">Ces apps refusent notre certificat — on ne peut PAS lire leur contenu. Bon signe.</p>
Score SOC actuel : <b style="color:var(--amber)">{{ soc.score }}/100</b> <ul>{% for app in pinned_apps %}<li>{{ app }}</li>{% endfor %}</ul></div>
</p> </details>
<ul>
{% for ind in soc.indicators[:10] %}
<li><span style="color:var(--amber)">[poids {{ ind.weight }}]</span> {{ ind.kind }} : <code>{{ ind.host[:60] }}</code></li>
{% endfor %}
</ul>
</div>
{% endif %} {% endif %}
{% if ja4 and ja4.snis_seen %}
<div class="card">
<h2>🔐 JA4 — empreintes TLS</h2>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.4rem">
SNIs vus (top {{ ja4.snis_seen|length }}) :
</p>
{% for sni in ja4.snis_seen[:8] %}
<div class="url">{{ sni }}</div>
{% endfor %}
{% if ja4.alpns_seen %}
<p style="font-size:0.75rem;color:var(--dim);margin-top:0.5rem">
ALPN protocols : <span style="color:var(--phos)">{{ ja4.alpns_seen|join(', ') }}</span>
</p>
{% endif %}
</div>
{% endif %}
{% if inspected_urls and not (cookies and cookies.details) %}
<div class="card">
<h2>👁 Trafic inspecté (R2 consent explicite)</h2>
{% for url in inspected_urls[:15] %}
<div class="url">{{ url }}</div>
{% endfor %}
{% if inspected_urls|length > 15 %}
<p style="font-size:0.75rem;color:var(--dim);margin-top:0.4rem">… et {{ inspected_urls|length - 15 }} autres URLs</p>
{% endif %}
</div>
{% endif %}
<div class="card">
<h2>✅ Recommandations</h2>
<ul>
{% for rec in recommendations %}<li>{{ rec }}</li>{% endfor %}
</ul>
</div>
<div class="actions">
{# Phase 6 (#496) : pass mh= so R3 WG clients can download from kbin too #}
<a href="/report/me?mh={{ mac_hash }}">⬇ Télécharger PDF</a>
<a href="/">↩ Retour splash</a>
<a href="/status">📊 Statut JSON</a>
</div>
{# Phase 3 (#492) : transparency layer — inspection breakdown + per-host quality #}
{% set t = transparency|default({}) %} {% set t = transparency|default({}) %}
{% if t and t.get('total_events', 0) > 0 %} {% if t and t.get('total_events', 0) > 0 %}
<div class="card"> <details>
<h2>🔎 INSPECTION : CE QU'ON A REGARDÉ (et ce qu'on n'a PAS regardé)</h2> <summary>🔎 Transparence : ce qu'on a regardé</summary>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.6rem;font-style:italic"> <div class="inner">
Honnêteté avant magie : la cabine te dit ce qu'elle a inspecté, ce qu'elle a sciemment bypassé, et pourquoi. <p class="help" style="margin-bottom:.5rem">Honnêteté avant magie : ce qu'on a inspecté, bypassé, et pourquoi.</p>
</p> <div class="kv">
<div class="kv"> {% set b = t.get('breakdown_pct', {}) %}
{% set b = t.get('breakdown_pct', {}) %} {% if b.get('inspected') %}<span class="k">🔍 Inspecté</span><span class="v">{{ b.get('inspected') }}%</span>{% endif %}
{% if b.get('inspected') %} {% if b.get('pinned-failed-mitm') %}<span class="k">🔒 Cert-pinning</span><span class="v">{{ b.get('pinned-failed-mitm') }}%</span>{% endif %}
<div class="k">🔍 Inspecté (HTTPS via notre CA)</div> {% if b.get('e2e-opaque') %}<span class="k">🔐 E2E chiffré</span><span class="v">{{ b.get('e2e-opaque') }}%</span>{% endif %}
<div class="v">{{ b.get('inspected', 0) }}% — contenu visible</div> <span class="k">📊 Total events</span><span class="v">{{ t.get('total_events', 0) }}</span>
{% endif %}
{% if b.get('bypassed-whitelist') %}
<div class="k">🛡 Bypass whitelist</div>
<div class="v">{{ b.get('bypassed-whitelist', 0) }}% — décision policy (cert-pinning vendor)</div>
{% endif %}
{% if b.get('pinned-failed-mitm') %}
<div class="k">🔒 Cert-pinning détecté</div>
<div class="v">{{ b.get('pinned-failed-mitm', 0) }}% — l'app refuse notre CA, normal+bon signe</div>
{% endif %}
{% if b.get('e2e-opaque') %}
<div class="k">🔐 E2E messaging</div>
<div class="v">{{ b.get('e2e-opaque', 0) }}% — opaque par design, ton chiffrement marche</div>
{% endif %}
<div class="k">📊 Total events analysés</div>
<div class="v">{{ t.get('total_events', 0) }}</div>
{% if t.get('whitelist_stats') %}
<div class="k">📜 Patterns whitelist actifs</div>
<div class="v">{{ t.get('whitelist_stats', {}).get('count', 0) }} (baseline + override)</div>
{% endif %}
{% if t.get('sensitivity') %}
<div class="k">🎛 Sensibilité active</div>
<div class="v">{{ t.get('sensitivity', {}).get('label', '?') }} — {{ t.get('sensitivity', {}).get('description', '')[:80] }}</div>
{% endif %}
</div>
{# Phase 3 (#492) : tentatives counters — full transparency #}
{% set a = t.get('attempts', {}) %}
{% if a.get('total', 0) > 0 %}
<div style="margin-top:0.8rem;padding-top:0.6rem;border-top:1px solid var(--dim)">
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.4rem"><b>📈 Tentatives observées (toutes catégories)</b></p>
<div class="kv" style="font-size:0.8rem">
<span class="k">Total observé</span><span class="v">{{ a.get('total', 0) }}</span>
<span class="k">🔍 Inspecté</span><span class="v">{{ a.get('inspected', 0) }}</span>
<span class="k">🛡 Bypass</span><span class="v">{{ a.get('bypassed_whitelist', 0) }}</span>
<span class="k">🔒 Cert-pinning</span><span class="v">{{ a.get('pinned_failed', 0) }}</span>
<span class="k">🔐 E2E opaque</span><span class="v">{{ a.get('e2e_opaque', 0) }}</span>
{% if a.get('blocked', 0) > 0 %}
<span class="k">🚫 Bloqué</span><span class="v" style="color:var(--red)">{{ a.get('blocked', 0) }}</span>
{% endif %}
</div> </div>
</div> {% if t.get('per_host') %}
{% endif %} <table style="margin-top:.7rem"><thead><tr><th>Grade</th><th>Destination</th><th>Statut</th></tr></thead><tbody>
{% for h in t.get('per_host', [])[:15] %}
{# Phase 3 (#492) : whitelist hits — accountability per pattern/category #} <tr><td style="font-weight:700;color:{% if h.grade in ['A+','A'] %}var(--phos-hot){% elif h.grade=='B' %}var(--phos){% elif h.grade=='C' %}var(--amber){% else %}var(--red){% endif %}">{{ h.grade }}</td><td><code>{{ h.host[:48] }}</code></td><td style="color:var(--dim);font-size:.72rem">{{ h.status }}</td></tr>
{% set wh = t.get('whitelist_hits', {}) %}
{% if wh.get('total', 0) > 0 %}
<div style="margin-top:0.8rem;padding-top:0.6rem;border-top:1px solid var(--dim)">
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.4rem"><b>📜 Tes bypass whitelist en détail</b> — {{ wh.get('total') }} connexions sciemment non-inspectées</p>
<div class="kv" style="font-size:0.78rem">
{% for cat, cnt in (wh.get('by_category', {}) | dictsort(by='value', reverse=True))[:8] %}
<span class="k">{{ cat }}</span><span class="v">{{ cnt }} hits</span>
{% endfor %} {% endfor %}
</div> </tbody></table>
<ul style="margin-top:0.4rem"> {% endif %}
{% for p in wh.get('top_patterns', [])[:8] %}
<li style="font-size:0.72rem"><code>{{ p.pattern }}</code> · {{ p.count }} hits</li>
{% endfor %}
</ul>
</div> </div>
{% endif %} </details>
</div>
{% if t.get('per_host') %}
<div class="card">
<h2>🎯 QUALITÉ SÉCURITÉ PAR DESTINATION</h2>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.6rem;font-style:italic">
Grade A+/A/B/C/D/F basé sur TLS version + JA4 + headers + cookies. Worst-first :
</p>
<table style="width:100%;font-size:0.78rem;border-collapse:collapse">
<thead>
<tr style="color:var(--phos);text-align:left;border-bottom:1px solid var(--dim)">
<th style="padding:0.3rem">Grade</th>
<th style="padding:0.3rem">Destination</th>
<th style="padding:0.3rem">Statut analyse</th>
</tr>
</thead>
<tbody>
{% for h in t.get('per_host', [])[:15] %}
<tr style="border-bottom:1px solid rgba(0,221,68,0.1)">
<td style="padding:0.25rem;font-weight:bold;color:{% if h.grade in ['A+','A'] %}var(--phos-hot){% elif h.grade == 'B' %}var(--phos){% elif h.grade == 'C' %}var(--amber){% else %}var(--red){% endif %}">{{ h.grade }}</td>
<td style="padding:0.25rem;font-family:monospace;color:var(--text)">{{ h.host[:60] }}</td>
<td style="padding:0.25rem;color:var(--dim);font-size:0.72rem">{{ h.status }} — {{ h.reason[:70] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endif %} {% endif %}
<p class="refresh">↻ Auto-refresh toutes les 15 secondes</p> <details>
<summary>👤 Identité &amp; recommandations</summary>
<div class="inner">
<div class="kv">
<span class="k">Hash session</span><span class="v"><code>{{ mac_hash }}</code></span>
<span class="k">Sandbox IP</span><span class="v">{{ ip }}</span>
<span class="k">Appareil</span><span class="v">{{ device_type }}</span>
</div>
{% if recommendations %}<p style="color:var(--phos-hot);font-size:.85rem;margin:.6rem 0 .2rem">✅ Recommandations</p><ul>{% for rec in recommendations %}<li>{{ rec }}</li>{% endfor %}</ul>{% endif %}
</div>
</details>
<div class="actions">
<a href="/report/me?mh={{ mac_hash }}">⬇ Télécharger le PDF</a>
<a href="/social/me?mh={{ mac_hash }}">🕸️ Ma carto</a>
<a href="/landing">↩ Accueil</a>
</div>
<div class="card"> <div class="card">
<h2>💚 Support &amp; soutien projet</h2> <h2>💚 Soutenir le projet</h2>
<p style="font-size:0.82rem;color:var(--text);line-height:1.55"> <p class="help">Commun numérique open-source, sans pub ni revente — CyberMind / Gérald Kerma (Savoie).</p>
Gondwana ToolBoX est un <b>commun numérique</b> open-source maintenu par <ul style="margin-top:.4rem">
CyberMind / Gérald Kerma (Notre-Dame-du-Cruet, Savoie). Pas de pub, pas de <li>💰 <a href="https://liberapay.com/cybermind" style="color:var(--phos)">liberapay.com/cybermind</a></li>
revente, pas de tracking commercial. Si ce service t'a aidé : <li>🐛 <a href="https://github.com/CyberMind-FR/secubox-deb/issues" style="color:var(--phos)">Signaler un bug</a></li>
</p>
<ul style="margin-top:0.5rem">
<li>💰 <b>Don récurrent</b> : <a href="https://liberapay.com/cybermind" style="color:var(--phos);text-decoration:underline">liberapay.com/cybermind</a></li>
<li>💳 <b>Don ponctuel</b> : <a href="https://cybermind.fr/don" style="color:var(--phos);text-decoration:underline">cybermind.fr/don</a> (carte, virement SEPA)</li>
<li>📧 <b>Support technique</b> : <a href="mailto:support@cybermind.fr" style="color:var(--phos);text-decoration:underline">support@cybermind.fr</a></li>
<li>🐛 <b>Signaler un bug</b> : <a href="https://github.com/CyberMind-FR/secubox-deb/issues" style="color:var(--phos);text-decoration:underline">github.com/CyberMind-FR/secubox-deb/issues</a></li>
<li>📡 <b>Déployer une borne</b> près de chez toi : <a href="mailto:gondwana@cybermind.fr" style="color:var(--phos);text-decoration:underline">gondwana@cybermind.fr</a></li>
</ul> </ul>
<p style="font-size:0.78rem;color:var(--dim);margin-top:0.7rem;border-top:1px solid var(--dim);padding-top:0.5rem">
Audit SecuBox premium / déploiement collectivité / formation cybersécurité :
<a href="mailto:contact@cybermind.fr" style="color:var(--phos);text-decoration:underline">contact@cybermind.fr</a>
</p>
</div> </div>
<div class="footer"> <div class="footer">
Gondwana ToolBoX · LicenseRef-CMSD-1.0 (Source-Disclosed License)<br> Gondwana ToolBoX · LicenseRef-CMSD-1.0 · ↻ rafraîchit toutes les 20 s<br>
Source : <a href="https://github.com/CyberMind-FR/secubox-deb" style="color:var(--dim)">github.com/CyberMind-FR/secubox-deb</a> (#474 #475 #477)<br> <a href="https://github.com/CyberMind-FR/secubox-deb" style="color:var(--dim)">github.com/CyberMind-FR/secubox-deb</a> · <a href="https://cybermind.fr" style="color:var(--dim)">cybermind.fr</a>
CyberMind — Notre-Dame-du-Cruet (73130) · <a href="https://cybermind.fr" style="color:var(--dim)">cybermind.fr</a>
</div> </div>
</body></html> </body></html>

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,137 @@
secubox-toolbox (2.7.11-1~bookworm1) bookworm; urgency=medium
* feat: landing (kbin.gk2) restyled to match the new report — system font,
rounded --panel/--line cards, cleaner accents, softer SVG bars, helper lines,
R3 panel mentions the 🧅 Tor egress option. Dynamic bits unchanged (live KPIs
+ JS, per-OS install panels, cert-probe, ?mh links).
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 20:20:00 +0200
secubox-toolbox (2.7.10-1~bookworm1) bookworm; urgency=medium
* fix: /report/me/html now resolves identity via the shared _client_mac_hash
(?mh → R3 WG peer → captive ARP), so R3 clients reach their report without
?mh (it previously only did captive ARP → 400 "client MAC unknown").
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 19:55:00 +0200
secubox-toolbox (2.7.9-1~bookworm1) bookworm; urgency=medium
* feat: report (/report/me/html) regenerated — verdict-first + graphs + plain
helpers. Top: a score gauge (conic-gradient) + 6 KPIs + 3 graphs (trackers
donut, countries bars, apps bars) computed server-side (_build_report_charts).
All the deep technical cards (threat-intel, DGA, beaconing, hosts, apps,
cookies, avatar, transparency, per-host grades, identity, reco) collapse into
<details> so the page reads instantly. System-font, rounded, mobile-first.
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 19:30:00 +0200
secubox-toolbox (2.7.8-1~bookworm1) bookworm; urgency=medium
* fix: landing "Ma carto" / "Mon rapport" / "Qui me piste ?" links hit
/social/me + /report/me with NO ?mh=, so clicking them re-resolved identity
at click-time and could 400 "client identity unresolved". The landing now
resolves the caller's mac_hash (new _client_mac_hash helper: ?mh → R3 WG peer
→ captive ARP) and bakes ?mh= into those links, so they always open the right
client's view. No change to identity precedence; resolution stays server-side.
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 18:40:00 +0200
secubox-toolbox (2.7.7-1~bookworm1) bookworm; urgency=medium
* fix: injected banner trackers/cookies counts were stuck at 0. They were
computed ONCE at render time — which fires early, before page resources +
cookies have loaded (Resource Timing empty) — and the 2s poll's ensure()
early-returned once the banner existed, so the counts never refreshed. Now
the trackers/cookies spans have ids and updateCounts() re-counts live on the
poll, so they climb to real values within ~2s.
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 18:10:00 +0200
secubox-toolbox (2.7.6-1~bookworm1) bookworm; urgency=medium
* fix(#683): the 🧅 Tor indicator now appears on the ACTUAL injected banner.
The live page banner is the client-side stream-inject bundle (bundle.py:
"SecuBox · LEVEL · 🛰️ trackers · 🍪 cookies · report ▸ · ✕"), NOT the
server-rendered inject_banner chip I'd added earlier. Added `tor_mode` to the
decision bundle + a "🧅 Tor" span in the banner render() (loader + inline
paths) so a consenting client sees their exit is anonymised.
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 17:40:00 +0200
secubox-toolbox (2.7.5-1~bookworm1) bookworm; urgency=medium
* feat(#683): public kbin-safe GET /wg/tor-status ({tor_mode, running,
bootstrap, exit_ip}) so the clients (webext popup + Android app) can show a
🧅 Tor-egress indicator. Mirrors /wg/r3-check (reachable on every vhost incl.
public kbin; the /admin/tor/* controls stay admin-gated).
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 17:00:00 +0200
secubox-toolbox (2.7.4-1~bookworm1) bookworm; urgency=medium
* fix(#683): Tor mode no longer torifies the box's OWN services. kbin/admin
resolve to the WAN IP (reached via hairpin) and the LAN wasn't exempt, so
with Tor armed the portal's self-traffic round-tripped through Tor (~18×
slower) → kbin landing + dashboard graphs/metrics loaded empty for clients
behind the MITM. nft tunnel now has a reconciler-populated `tor_exempt` set:
loopback + board-local subnets (LAN/WG/LXC) + the board's own public IP →
self-traffic stays DIRECT. DNS stays Tor-routed (no leak) and the Tor automap
range (10.192.0.0/10) stays torified (it's never a connected route).
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 15:00:00 +0200
secubox-toolbox (2.7.3-1~bookworm1) bookworm; urgency=medium
* fix: dashboard "Live metrics" MITM trio (connections / hosts / cert-pin
blocks) was permanently 0 — it scraped "server connect" from the journal,
but the workers run at --log-level warning so those INFO lines are never
emitted. Now derived from real data: connections = DPI flow count + unique
hosts from the cumulative top-hosts (same source as the landing page), and
cert-pin blocks = the auto-learned mitm-bypass-dynamic host count.
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 14:00:00 +0200
secubox-toolbox (2.7.2-1~bookworm1) bookworm; urgency=medium
* feat(#683): 🧅 Tor status chip on the injected transparency banner — shown
first (most prominent) when kbin Tor mode is armed, so the client sees their
surf is anonymised. Read from filters (5s-cached) on the banner hot path.
* fix(#683): the Tor tunnel now SURVIVES nftables reloads. The runtime
toolbox_tor table was flushed by any `nft -f /etc/nftables.conf` (triggered
by other postinsts) → flag-on/tunnel-off leak window. Reconciler now persists
it as /etc/nftables.d/zz-secubox-toolbox-tor.nft (zz- = loads after the wg
tables). Added secubox-toolbox-tor.timer (2-min idempotent re-arm) to self-heal
any bare `nft flush ruleset`, and a final postinst reconcile (after the nft
reloads) so upgrades never leave the flag/tunnel mismatched.
* fix: postinst restart loop now also restarts secubox-toolbox-mitm-wg.service
(single-service R3 path), so mitm addon updates take effect without a manual
restart.
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 13:05:00 +0200
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 secubox-toolbox (2.7.0-1~bookworm1) bookworm; urgency=medium
* MIDDLE RELEASE — caps the 2.6.x line (ad-intelligence / Anti-Track v2 / * 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, hostapd,
dnsmasq, dnsmasq,
mitmproxy, mitmproxy,
jq,
openssl, openssl,
adduser, adduser,
fonts-dejavu-core, fonts-dejavu-core,
@ -34,7 +35,9 @@ Depends: ${misc:Depends}, ${python3:Depends},
python3-geoip2 | geoipupdate, python3-geoip2 | geoipupdate,
lxc, lxc,
debian-archive-keyring debian-archive-keyring
Recommends: bridge-utils Recommends: bridge-utils,
tor,
python3-socksio
Description: SecuBox-DEB ToolBoX — Gondwana Cabine Numérique (captive AP + MITM analyzer) Description: SecuBox-DEB ToolBoX — Gondwana Cabine Numérique (captive AP + MITM analyzer)
Phase 1 du parent #474 (ToolBoX Pipeline). Productionize le PoC captive Phase 1 du parent #474 (ToolBoX Pipeline). Productionize le PoC captive
portal en package dédié : splash + consent R2 + CA distribution iOS portal en package dédié : splash + consent R2 + CA distribution iOS

View File

@ -66,9 +66,19 @@ case "$1" in
"ad_ghost_categories": {"ads": true, "consent_nag": true, "newsletter": true, "social_widgets": true} "ad_ghost_categories": {"ads": true, "consent_nag": true, "newsletter": true, "social_widgets": true}
} }
SBXFILTERS SBXFILTERS
chown root:secubox-toolbox /etc/secubox/toolbox/filters.json chown secubox-toolbox:secubox-toolbox /etc/secubox/toolbox/filters.json
chmod 0664 /etc/secubox/toolbox/filters.json chmod 0664 /etc/secubox/toolbox/filters.json
fi fi
# #683 : the portal persists filters.json (Tor switch + mitm toggles) as its
# OWNER (secubox-toolbox). The operator UI is proxied to this same portal, so
# owner-write is sufficient — no aggregator write. Re-applied every install to
# repair the pre-existing root:root drift that made /admin/filters + the Tor
# switch silently no-op. (set_filters writes in-place; the 0750 dir blocks the
# atomic tmp+rename, and the service grants RW on /etc/secubox/toolbox only.)
if [ -f /etc/secubox/toolbox/filters.json ]; then
chown secubox-toolbox:secubox-toolbox /etc/secubox/toolbox/filters.json 2>/dev/null || true
chmod 0664 /etc/secubox/toolbox/filters.json 2>/dev/null || true
fi
# 4. Storage dir (SQLite + future PDF reports) # 4. Storage dir (SQLite + future PDF reports)
# NOTE: `install -d -m 0750 /var/lib/secubox/<leaf>` re-modes the SHARED # NOTE: `install -d -m 0750 /var/lib/secubox/<leaf>` re-modes the SHARED
@ -182,9 +192,25 @@ fi
# Phase 7 (#498) : boot-time peer-restore service for wg-toolbox. # Phase 7 (#498) : boot-time peer-restore service for wg-toolbox.
# Keeps the enrolled WG peers alive across reboots. # Keeps the enrolled WG peers alive across reboots.
systemctl enable secubox-toolbox-wg-restore.service || true 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
systemctl enable secubox-toolbox-tor.timer 2>/dev/null || true
systemctl start secubox-toolbox-tor.timer 2>/dev/null || true
# Pas de start auto — l'opérateur doit configurer mesh.toml et lancer toolbox-up # Pas de start auto — l'opérateur doit configurer mesh.toml et lancer toolbox-up
fi 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 # 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. # 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 # Phase 10 (#501 perf) : ALSO deploy the Phase 9 fanout drop-in as
@ -274,6 +300,7 @@ fi
if [ -n "${2:-}" ] && [ -d /run/systemd/system ]; then if [ -n "${2:-}" ] && [ -d /run/systemd/system ]; then
for unit in secubox-toolbox.service \ for unit in secubox-toolbox.service \
secubox-toolbox-mitm.service \ secubox-toolbox-mitm.service \
secubox-toolbox-mitm-wg.service \
secubox-toolbox-mitm-wg-worker@1.service \ secubox-toolbox-mitm-wg-worker@1.service \
secubox-toolbox-mitm-wg-worker@2.service \ secubox-toolbox-mitm-wg-worker@2.service \
secubox-toolbox-mitm-wg-worker@3.service \ secubox-toolbox-mitm-wg-worker@3.service \
@ -285,6 +312,13 @@ fi
fi fi
done done
fi fi
# #683 : converge the Tor tunnel LAST — after the nft reloads above (which
# would otherwise flush the runtime table) and after the worker restarts.
# Idempotent: re-arms iff tor_mode=true, no-op otherwise. Closes the
# flag-on/tunnel-off leak window an upgrade could open.
if [ -x /usr/sbin/secubox-toolbox-tor-reconcile ]; then
/usr/sbin/secubox-toolbox-tor-reconcile reconcile 2>/dev/null || true
fi
;; ;;
esac esac

View File

@ -9,5 +9,18 @@ case "$1" in
fi fi
;; ;;
esac 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)
for u in secubox-toolbox-tor.path secubox-toolbox-tor.timer; do
systemctl stop "$u" 2>/dev/null || true
systemctl disable "$u" 2>/dev/null || true
done
[ -x /usr/sbin/secubox-toolbox-tor-reconcile ] && \
/usr/sbin/secubox-toolbox-tor-reconcile disarm 2>/dev/null || true
;;
esac
#DEBHELPER# #DEBHELPER#
exit 0 exit 0

View File

@ -52,6 +52,17 @@ override_dh_installsystemd:
# activation + rollback recipe. # activation + rollback recipe.
install -m 0644 systemd/secubox-toolbox-mitm-wg-worker@.service \ install -m 0644 systemd/secubox-toolbox-mitm-wg-worker@.service \
debian/secubox-toolbox/lib/systemd/system/ 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/
install -m 0644 systemd/secubox-toolbox-tor.timer \
debian/secubox-toolbox/lib/systemd/system/
# Primary unit goes via dh_installsystemd which also handles the enable helpers. # Primary unit goes via dh_installsystemd which also handles the enable helpers.
cp systemd/secubox-toolbox.service debian/secubox-toolbox.service cp systemd/secubox-toolbox.service debian/secubox-toolbox.service
dh_installsystemd --no-start --no-enable dh_installsystemd --no-start --no-enable

View File

@ -19,7 +19,10 @@ RestartSec=3
NoNewPrivileges=true NoNewPrivileges=true
ProtectHome=true ProtectHome=true
PrivateTmp=true PrivateTmp=true
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox/toolbox # #683: the portal must persist its own config (filters.json — Tor switch +
# mitm filter toggles). /etc/secubox stays read-only EXCEPT the toolbox subdir;
# the CA key inside stays 0600 root, still unreadable to this user.
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox/toolbox /etc/secubox/toolbox
ReadOnlyPaths=/etc/secubox ReadOnlyPaths=/etc/secubox
# nft + ip neigh require CAP_NET_ADMIN # nft + ip neigh require CAP_NET_ADMIN

View File

@ -314,8 +314,19 @@ def _compute_site_context(flow: http.HTTPFlow) -> dict:
"trackers": 0, "trackers": 0,
"is_tracker_host": False, "is_tracker_host": False,
"utiq_recent_count": 0, "utiq_recent_count": 0,
"tor_mode": False,
} }
# #683 — kbin Tor egress status. When armed, this flow's upstream exits via
# Tor (only the exit IP changes; inspection is still happening — that's how
# this banner exists). Surfaced as a 🧅 chip so the client sees they're
# anonymised. get_filters is 5 s-cached → cheap on the hot path.
try:
from secubox_toolbox.filters import get_filters as _gf
ctx["tor_mode"] = bool(_gf().get("tor_mode", False))
except Exception:
pass
# Host-stable signals — single LRU lookup per host. # Host-stable signals — single LRU lookup per host.
(ctx["app_emoji"], ctx["app"], ctx["flag"], ctx["country"], ctx["asn"], (ctx["app_emoji"], ctx["app"], ctx["flag"], ctx["country"], ctx["asn"],
ctx["status"], ctx["status_icon"]) = _host_signals(host) ctx["status"], ctx["status_icon"]) = _host_signals(host)
@ -463,6 +474,10 @@ def _banner_html_dynamic(sha1: str, ctx: dict, csp_strict: bool,
# #578 — shared broadcast pin first, so every banner shows it. # #578 — shared broadcast pin first, so every banner shows it.
if ctx.get("pin"): if ctx.get("pin"):
right_parts.insert(0, "&#x1F4CC; " + _ncr(ctx["pin"])) # 📌 right_parts.insert(0, "&#x1F4CC; " + _ncr(ctx["pin"])) # 📌
# #683 — Tor status FIRST when kbin Tor mode is armed: this flow exits via
# Tor (anonymised). 🧅 = U+1F9C5. Most prominent chip so the client sees it.
if ctx.get("tor_mode"):
right_parts.insert(0, "&#x1F9C5; Tor") # 🧅
if ctx["flag"]: if ctx["flag"]:
# Phase 6.M (#496) : flags are Unicode "regional indicator" pairs # Phase 6.M (#496) : flags are Unicode "regional indicator" pairs
# (🇫🇷 = U+1F1EB + U+1F1F7). NCR-encoded pairs do NOT join into a # (🇫🇷 = U+1F1EB + U+1F1F7). NCR-encoded pairs do NOT join into a

View File

@ -0,0 +1,114 @@
#!/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
# Persist the table as a drop-in so it SURVIVES `nft -f /etc/nftables.conf`
# reloads (which other postinsts trigger and which would otherwise flush the
# runtime-only table → flag-on/tunnel-off leak). zz- = loads after the wg
# tables (alphabetical glob), per the layered-dropin ordering rule.
NFT_DROPIN=/etc/nftables.d/zz-secubox-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
# Persist as a drop-in (survives nft reloads) then load it NOW. nft FIRST
# (kill-switch active) so there is never a clearnet window.
install -d -m 0755 /etc/nftables.d
install -m 0644 "$NFT_FILE" "$NFT_DROPIN"
nft -f "$NFT_DROPIN"
populate_exempt
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"
}
# Fill the tor_exempt set so the box reaches its OWN services DIRECT (never via
# Tor): loopback, board-local subnets (LAN/WG/LXC), and the board's own public
# IP (kbin/admin resolve to it, reached via hairpin). Without this, self-views
# round-trip Tor and load empty/slow. The Tor automap range (10.192.0.0/10) is
# never a connected route, so it is correctly NOT added → still torified.
populate_exempt() {
local net pub
nft add element inet toolbox_tor tor_exempt "{ 127.0.0.0/8 }" 2>/dev/null || true
# board-local connected RFC1918 subnets
for net in $(ip -4 route show scope link 2>/dev/null | awk '{print $1}' \
| grep -E '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'); do
case "$net" in 10.19[2-9].*|10.2[0-5][0-9].*) continue ;; esac # belt: skip automap
nft add element inet toolbox_tor tor_exempt "{ $net }" 2>/dev/null || true
done
# board's own public IP — detected DIRECT (root egress is not torified).
pub=$(timeout 6 curl -s https://api.ipify.org 2>/dev/null || true)
case "$pub" in
[0-9]*.[0-9]*.[0-9]*.[0-9]*)
nft add element inet toolbox_tor tor_exempt "{ $pub }" 2>/dev/null || true
log "exempt own public IP $pub" ;;
esac
}
disarm() {
log "disarming kbin Tor egress"
rm -f "$NFT_DROPIN"
if table_present; then nft delete table inet toolbox_tor 2>/dev/null || true; fi
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

@ -328,6 +328,36 @@ def _client_ip(request: Request) -> str | None:
return request.client.host if request.client else None return request.client.host if request.client else None
def _client_mac_hash(request: Request, salt: str) -> str | None:
"""Resolve the caller's identity hash, same precedence as /social/me:
explicit ?mh= R3 WG peer (wg-peers.json) captive ARP. Returns None when
unresolvable. Used to bake ?mh= into the landing links so they never hit the
'identity unresolved' 400 (the page already knows who you are)."""
mh_qp = (request.query_params.get("mh") or "").strip().lower()
if mh_qp and all(c in "0123456789abcdef" for c in mh_qp) and 8 <= len(mh_qp) <= 64:
return mh_qp
ip = _client_ip(request)
if ip and ip.startswith("10.99.1."):
try:
import hashlib as _h
import json as _j
from pathlib import Path as _P
_db = _P("/var/lib/secubox/toolbox/wg-peers.json")
if _db.exists():
for pubkey, meta in _j.loads(_db.read_text()).get("peers", {}).items():
if meta.get("ip") == ip:
return _h.sha256(pubkey.encode()).hexdigest()[:16]
except Exception:
pass
try:
_ip, mac = _resolve(request)
if mac:
return macmod.hash_mac(mac, salt)
except Exception:
pass
return None
# ───────────────── Public routes ───────────────── # ───────────────── Public routes ─────────────────
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
@ -500,6 +530,23 @@ async def change_level(request: Request):
status_code=303) status_code=303)
@router.get("/wg/tor-status")
async def wg_tor_status() -> dict:
"""kbin Tor egress status for the clients (#683). Public + read-only, so it
is reachable on the kbin vhost (unlike the admin-gated /admin/tor/*). The
webext popup + Android app poll this to show the 🧅 indicator + exit IP."""
from .filters import get_filters
from . import tor_ctl
f = get_filters(force=False)
st = tor_ctl.status()
return {
"tor_mode": bool(f.get("tor_mode", False)),
"running": bool(st.get("running", False)),
"bootstrap": int(st.get("bootstrap", 0) or 0),
"exit_ip": _tor_exit_ip_cached(),
}
@router.get("/wg/r3-check") @router.get("/wg/r3-check")
async def wg_r3_check(request: Request): async def wg_r3_check(request: Request):
"""Phase 7 (#498) — same-origin HTTPS probe for the R3 verification """Phase 7 (#498) — same-origin HTTPS probe for the R3 verification
@ -684,11 +731,15 @@ async def landing(request: Request) -> HTMLResponse:
stats = _cumulative_stats() stats = _cumulative_stats()
platform = _ua_platform(request.headers.get("user-agent") or "") platform = _ua_platform(request.headers.get("user-agent") or "")
install_panels = _install_panels_html(platform) install_panels = _install_panels_html(platform)
# Resolve identity now (the page knows who you are) so the report/carto links
# carry ?mh= and never hit the /social/me "identity unresolved" 400.
mac_hash = _client_mac_hash(request, _get_salt()) or ""
return HTMLResponse( return HTMLResponse(
_env.get_template("landing.html.j2").render( _env.get_template("landing.html.j2").render(
stats=stats, stats=stats,
install_panels=install_panels, install_panels=install_panels,
install_platform=platform, install_platform=platform,
mac_hash=mac_hash,
), ),
headers={"Cache-Control": "private, max-age=60, no-transform"}, headers={"Cache-Control": "private, max-age=60, no-transform"},
) )
@ -2291,6 +2342,46 @@ def _classify_apps(hosts: set[str]) -> list[str]:
return apps return apps
def _build_report_charts(session: dict) -> dict:
"""Graph-ready aggregates for the simplified report (trackers donut,
countries bars, apps bars). Defensive / fail-empty. Each list item has
{label, emoji/flag, count, pct}; trackers also carry cumulative start/end
for a CSS conic-gradient donut."""
def _top_pct(items: list, n: int = 6) -> list:
items = [it for it in items if it.get("count")]
items.sort(key=lambda x: x["count"], reverse=True)
items = items[:n]
total = sum(x["count"] for x in items) or 1
for it in items:
it["pct"] = round(100 * it["count"] / total)
return items
cp = session.get("cookies_providers") or []
trackers = _top_pct([
{"label": p.get("provider", "?"), "emoji": p.get("emoji", "🍪"),
"count": int(p.get("count", 0) or 0)} for p in cp])
cum = 0
for it in trackers:
it["start"] = cum
cum += it["pct"]
it["end"] = cum
by_country: dict = {}
for h in (session.get("geo_top_hosts") or []):
key = (h.get("flag") or "🏴", h.get("country") or "?")
by_country[key] = by_country.get(key, 0) + int(h.get("count", 0) or 0)
countries = _top_pct([
{"flag": k[0], "label": k[1], "count": v} for k, v in by_country.items()])
dc = session.get("dpi_classified") or {}
apps = _top_pct([
{"label": a.get("app", "?"), "emoji": a.get("emoji", "📦"),
"count": int(a.get("count", 0) or 0)}
for a in (dc.get("top_apps") or []) if a.get("app") not in (None, "", "?")])
return {"trackers": trackers, "countries": countries, "apps": apps}
# NOTE: route order matters in FastAPI — specific routes (/report/me, # NOTE: route order matters in FastAPI — specific routes (/report/me,
# /report/me/html) MUST be declared BEFORE the catch-all /report/{token}, # /report/me/html) MUST be declared BEFORE the catch-all /report/{token},
# otherwise FastAPI matches /report/me with token="me" and returns 404. # otherwise FastAPI matches /report/me with token="me" and returns 404.
@ -2306,17 +2397,16 @@ async def report_me_html(request: Request) -> HTMLResponse:
their own report. The hash for R3 = sha256(wg_pubkey)[:16] derived their own report. The hash for R3 = sha256(wg_pubkey)[:16] derived
by inject_banner.py and embedded in the banner 'Mon rapport' link. by inject_banner.py and embedded in the banner 'Mon rapport' link.
""" """
# Bypass path : explicit mac_hash in query (R3 WG or kbin remote viewer) # Resolve identity the same way everywhere: ?mh → R3 WG peer (wg-peers.json)
mh_qp = (request.query_params.get("mh") or "").strip().lower() # → captive ARP. R3 clients hitting this directly (no ?mh) now resolve too.
if mh_qp and all(c in "0123456789abcdef" for c in mh_qp) and 8 <= len(mh_qp) <= 64: mac_hash = _client_mac_hash(request, _get_salt())
ip = request.client.host if request.client else "?" if not mac_hash:
mac_hash = mh_qp raise HTTPException(
else: 400,
ip, mac = _resolve(request) "client identity unresolved (not on R3 tunnel and not in captive "
if not mac: "subnet) — append ?mh=<hash> from your banner's report link",
raise HTTPException(400, "client MAC unknown (not in captive subnet?) — use ?mh=<hash>") )
salt = _get_salt() ip = _client_ip(request) or (request.client.host if request.client else "?")
mac_hash = macmod.hash_mac(mac, salt)
session = _aggregate_session(mac_hash) session = _aggregate_session(mac_hash)
# Phase 3 (#492) : pass query args + force no-cache so iPhone Safari # Phase 3 (#492) : pass query args + force no-cache so iPhone Safari
# actually fetches the new template. # actually fetches the new template.
@ -2330,6 +2420,7 @@ async def report_me_html(request: Request) -> HTMLResponse:
current_level=store.get_client_level(mac_hash) if mac_hash else "r1", current_level=store.get_client_level(mac_hash) if mac_hash else "r1",
wg_enabled=wg_enabled, wg_enabled=wg_enabled,
cumulative=cumulative, cumulative=cumulative,
charts=_build_report_charts(session),
**session, **session,
) )
return HTMLResponse(html, headers={ return HTMLResponse(html, headers={
@ -2845,6 +2936,116 @@ async def admin_filters_set(request: Request) -> dict:
return set_filters(patch) 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) @router.get("/admin/filters/ui", response_class=HTMLResponse)
async def admin_filters_ui() -> HTMLResponse: async def admin_filters_ui() -> HTMLResponse:
"""#566 — minimal filter toggle panel for the toolbox WebUI.""" """#566 — minimal filter toggle panel for the toolbox WebUI."""
@ -3262,23 +3463,25 @@ async def admin_metrics() -> dict:
).fetchone()[0] ).fetchone()[0]
except Exception as e: except Exception as e:
metrics["sqlite_error"] = str(e) metrics["sqlite_error"] = str(e)
# Mitmproxy live stats (from journal) # Live MITM activity. NOTE: the old journal-scrape for "server connect"
# NEVER worked — the workers run at --log-level warning, so those INFO lines
# are never emitted → the trio was permanently 0. Derive from real data: the
# cumulative stats (same source the landing page uses) + the auto-learned
# cert-pin bypass list.
try: try:
out = _sp.run( cs = cumulative.get_cached() or {}
# #593 — glob matches the LIVE R3 workers (…-mitm-wg-worker@N), ev = cs.get("events", {}) or {}
# not just the (dead) R2 …-mitm unit → real numbers. # "connections analysées" — DPI classifies one flow per upstream connection.
["journalctl", "-u", "secubox-toolbox-mitm*", "--since", "-30min", "--no-pager"], metrics["mitm"]["connections"] = int(ev.get("dpi", 0) or 0)
capture_output=True, text=True, timeout=4, check=False, metrics["mitm"]["unique_hosts"] = len(cs.get("top_hosts_7d", []) or [])
).stdout except Exception:
metrics["mitm"]["connections"] = out.count("server connect") pass
metrics["mitm"]["tls_pinned"] = out.count("Client TLS handshake failed") # Cert-pinned hosts that had to be bypassed (auto-learned by cert_pin_detect).
hosts: set[str] = set() try:
for line in out.splitlines(): if MITM_BYPASS_DYNAMIC_FILE.exists():
if " server connect " in line: metrics["mitm"]["tls_pinned"] = sum(
parts = line.rsplit(" ", 1) 1 for ln in MITM_BYPASS_DYNAMIC_FILE.read_text().splitlines()
if len(parts) == 2: if ln.strip() and not ln.strip().startswith("#"))
hosts.add(parts[1])
metrics["mitm"]["unique_hosts"] = len(hosts)
except Exception: except Exception:
pass pass
return metrics return metrics

View File

@ -72,6 +72,15 @@ def _report_url(client_id: str, is_wg: bool) -> str:
return REPORT_URL_CAPTIVE return REPORT_URL_CAPTIVE
def _tor_mode() -> bool:
"""kbin Tor egress on? (#683) Read from filters; fail-safe to off."""
try:
from .filters import get_filters
return bool(get_filters().get("tor_mode", False))
except Exception:
return False
def build_bundle(client_id: str, is_wg: bool = False) -> dict: def build_bundle(client_id: str, is_wg: bool = False) -> dict:
"""Build the per-client cosmetic decision bundle (pure given inputs + pin file).""" """Build the per-client cosmetic decision bundle (pure given inputs + pin file)."""
return { return {
@ -81,6 +90,7 @@ def build_bundle(client_id: str, is_wg: bool = False) -> dict:
"pin": _read_pin(), "pin": _read_pin(),
"report_url": _report_url(client_id, is_wg), "report_url": _report_url(client_id, is_wg),
"tracker_patterns": TRACKER_PATTERNS, "tracker_patterns": TRACKER_PATTERNS,
"tor_mode": _tor_mode(),
"ts": int(time.time()), "ts": int(time.time()),
} }
@ -141,12 +151,23 @@ _BANNER_CORE = r"""
return Object.keys(seen).length; return Object.keys(seen).length;
} catch (_) { return 0; } } catch (_) { return 0; }
} }
function countCookies(){
try { return document.cookie ? document.cookie.split(";").filter(function(x){return x.indexOf("=")>=0;}).length : 0; } catch (_) { return 0; }
}
// #683 — counts are taken at render time, but resources + cookies keep loading
// AFTER the banner appears (early render stuck at 0). Re-count live on the
// 2s poll so trackers/cookies climb to their real values.
function updateCounts(b){
var t = document.getElementById("sbx-trk");
if (t) t.textContent = "🛰️ " + countTrackers((b || {}).tracker_patterns) + " trackers";
var c = document.getElementById("sbx-ck");
if (c) c.textContent = "🍪 " + countCookies() + " cookies";
}
function render(b){ function render(b){
if (dismissed) return; if (dismissed) return;
if (document.getElementById("sbx-banner")) return; if (document.getElementById("sbx-banner")) return;
var trk = countTrackers(b.tracker_patterns); var trk = countTrackers(b.tracker_patterns);
var ck = 0; var ck = countCookies();
try { ck = document.cookie ? document.cookie.split(";").filter(function(x){return x.indexOf("=")>=0;}).length : 0; } catch (_) {}
var bar = document.createElement("div"); var bar = document.createElement("div");
bar.id = "sbx-banner"; bar.id = "sbx-banner";
bar.setAttribute("style", "position:fixed;left:0;right:0;top:0;z-index:2147483647;" bar.setAttribute("style", "position:fixed;left:0;right:0;top:0;z-index:2147483647;"
@ -157,11 +178,15 @@ _BANNER_CORE = r"""
// #662 — 🔓 proof: the engine relaxed this page's CSP to inject this banner. // #662 — 🔓 proof: the engine relaxed this page's CSP to inject this banner.
var cspProof = (csp === "1") var cspProof = (csp === "1")
? "<span title=\"CSP contourné par SecuBox (démonstration)\">🔓</span>" : ""; ? "<span title=\"CSP contourné par SecuBox (démonstration)\">🔓</span>" : "";
// #683 — 🧅 kbin Tor mode: this session's exit is anonymised via Tor.
var tor = b.tor_mode
? "<span title=\"Sortie anonymisée via Tor\" style=\"color:#9E76FF;font-weight:bold\">🧅 Tor</span>" : "";
bar.innerHTML = "<b style=\"color:#148C66\">SecuBox</b>" bar.innerHTML = "<b style=\"color:#148C66\">SecuBox</b>"
+ cspProof + cspProof
+ tor
+ "<span>" + esc((b.level || "r1").toUpperCase()) + "</span>" + "<span>" + esc((b.level || "r1").toUpperCase()) + "</span>"
+ "<span>🛰️ " + trk + " trackers</span>" + "<span id=\"sbx-trk\">🛰️ " + trk + " trackers</span>"
+ "<span>🍪 " + ck + " cookies</span>" + "<span id=\"sbx-ck\">🍪 " + ck + " cookies</span>"
+ pin + pin
+ "<a href=\"" + esc(b.report_url || "#") + "\" style=\"margin-left:auto;color:#2C70C0;text-decoration:none\">report ▸</a>" + "<a href=\"" + esc(b.report_url || "#") + "\" style=\"margin-left:auto;color:#2C70C0;text-decoration:none\">report ▸</a>"
+ "<button aria-label=\"dismiss\" style=\"background:none;border:0;color:#8A9AA8;cursor:pointer;font-size:14px\">✕</button>"; + "<button aria-label=\"dismiss\" style=\"background:none;border:0;color:#8A9AA8;cursor:pointer;font-size:14px\">✕</button>";
@ -172,7 +197,7 @@ _BANNER_CORE = r"""
} }
// ensure(): (re)render the banner if it's absent and the bundle is loaded and // ensure(): (re)render the banner if it's absent and the bundle is loaded and
// the user hasn't dismissed it. Cheap (a getElementById guard inside render). // the user hasn't dismissed it. Cheap (a getElementById guard inside render).
function ensure(){ if (bundle && !dismissed) ready(function(){ render(bundle); }); } function ensure(){ if (bundle && !dismissed) ready(function(){ if (document.getElementById("sbx-banner")) updateCounts(bundle); else render(bundle); }); }
// SPA re-assert: wrap history nav + popstate (defer so the framework settles), // SPA re-assert: wrap history nav + popstate (defer so the framework settles),
// plus a light 2s poll as a catch-all for DOM re-renders that drop the banner. // plus a light 2s poll as a catch-all for DOM re-renders that drop the banner.
["pushState","replaceState"].forEach(function(m){ ["pushState","replaceState"].forEach(function(m){

View File

@ -27,6 +27,9 @@ DEFAULTS: Dict = {
"autolearn": True, # #589 also block auto-learned bad hosts "autolearn": True, # #589 also block auto-learned bad hosts
"ad_learn": True, # #656 aggressive ad-URL learning toggle "ad_learn": True, # #656 aggressive ad-URL learning toggle
"tls_splice": "observe", # #649 off | observe | on (asset SNI-splice) "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 ── # ── Anti-Track v2 (#633) — ships dark; arm after observe-only soak ──
"privacy_enforce": False, # master switch; off = observe-only "privacy_enforce": False, # master switch; off = observe-only
"privacy_poison": True, # forge stable fake id for loadbearing trackers "privacy_poison": True, # forge stable fake id for loadbearing trackers
@ -44,6 +47,7 @@ DEFAULTS: Dict = {
_VALID_PROTECTIVE = ("off", "alert", "spoof") _VALID_PROTECTIVE = ("off", "alert", "spoof")
_VALID_SPLICE = ("off", "observe", "on") _VALID_SPLICE = ("off", "observe", "on")
_VALID_TOR_PRESET = ("anonymous", "stealth", "minimal")
_cache: Dict = {} _cache: Dict = {}
_cache_ts: float = 0.0 _cache_ts: float = 0.0
@ -71,6 +75,8 @@ def get_filters(force: bool = False) -> Dict:
out["protective"] = DEFAULTS["protective"] out["protective"] = DEFAULTS["protective"]
if out.get("tls_splice") not in _VALID_SPLICE: if out.get("tls_splice") not in _VALID_SPLICE:
out["tls_splice"] = DEFAULTS["tls_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 = out
_cache_ts = now _cache_ts = now
return out return out
@ -90,18 +96,32 @@ def set_filters(patch: Dict) -> Dict:
cur["protective"] = v cur["protective"] = v
elif k == "tls_splice" and v in _VALID_SPLICE: elif k == "tls_splice" and v in _VALID_SPLICE:
cur["tls_splice"] = v 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): elif k == "fortknox_sites" and isinstance(v, list):
cur["fortknox_sites"] = [str(s).strip().lower() for s in v if str(s).strip()] 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", elif k in ("banner", "ad_ghost", "ad_ghost_block", "media_cache", "autolearn",
"privacy_enforce", "privacy_poison", "privacy_anonymize", "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) cur[k] = bool(v)
data = json.dumps(cur, indent=1)
try: try:
os.makedirs(os.path.dirname(FILTERS_PATH), exist_ok=True) # Preferred: atomic tmp + rename (needs write on the parent dir).
tmp = FILTERS_PATH + ".tmp" tmp = FILTERS_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f: with open(tmp, "w", encoding="utf-8") as f:
json.dump(cur, f, indent=1) f.write(data)
os.replace(tmp, FILTERS_PATH) os.replace(tmp, FILTERS_PATH)
except OSError:
# The serving user often can't create a tmp here: the operator UI is
# served by the aggregator (user `secubox`) and /etc/secubox/toolbox is
# 0750 → no dir-write. Fall back to an in-place write, which needs only
# file-write perm (filters.json is group-writable) AND reliably fires
# the secubox-toolbox-tor.path watcher (in-place modify, not a rename).
try:
with open(FILTERS_PATH, "w", encoding="utf-8") as f:
f.write(data)
except Exception:
pass
except Exception: except Exception:
pass pass
_cache_ts = 0.0 # invalidate _cache_ts = 0.0 # invalidate

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,16 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Belt-and-suspenders re-arm (#683). The toolbox_tor table now persists as an
# nftables.d drop-in (survives `nft -f /etc/nftables.conf` reloads), but a bare
# `nft flush ruleset` from elsewhere could still wipe it while tor_mode=on —
# leaving the flag on but egress unprotected. This timer reconciles every 2 min
# so any such drift self-heals (idempotent no-op when desired == current).
[Unit]
Description=SecuBox-Deb ToolBoX — periodic re-arm of kbin Tor egress (#683)
[Timer]
OnBootSec=2min
OnUnitActiveSec=2min
AccuracySec=20s
[Install]
WantedBy=timers.target

View File

@ -19,7 +19,10 @@ RestartSec=3
NoNewPrivileges=true NoNewPrivileges=true
ProtectHome=true ProtectHome=true
PrivateTmp=true PrivateTmp=true
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox/toolbox # #683: the portal must persist its own config (filters.json — Tor switch +
# mitm filter toggles). /etc/secubox stays read-only EXCEPT the toolbox subdir;
# the CA key inside stays 0600 root, still unreadable to this user.
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox/toolbox /etc/secubox/toolbox
ReadOnlyPaths=/etc/secubox ReadOnlyPaths=/etc/secubox
# nft + ip neigh require CAP_NET_ADMIN # nft + ip neigh require CAP_NET_ADMIN

View File

@ -0,0 +1,196 @@
# 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_set_filters_persists_when_dir_not_writable(tmp_path, monkeypatch):
"""Regression (#683): aggregator runs as a user that can't create a tmp file
in the 0750 /etc/secubox/toolbox set_filters must still persist in-place."""
import os
d = tmp_path / "ro"
d.mkdir()
fpath = d / "filters.json"
fpath.write_text('{"banner": true}\n')
monkeypatch.setenv("SECUBOX_FILTERS_PATH", str(fpath))
import secubox_toolbox.filters as f
importlib.reload(f)
os.chmod(d, 0o555) # dir read-only → tmp+rename fails, in-place must work
try:
out = f.set_filters({"tor_mode": True})
assert out["tor_mode"] is True
assert json.loads(fpath.read_text())["tor_mode"] is True # actually persisted
finally:
os.chmod(d, 0o755)
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 _banner_ctx(tor_mode):
return {
"status_icon": "\U0001F50D", "status": "inspected", "flag": "", "app_emoji": "",
"app": "example.com", "asn": "", "grade": "A", "grade_color": "#0f0",
"cookies_set": 0, "cookies_sent": 0, "is_tracker_host": False,
"utiq_recent_count": 0, "ghost_blocked": 0, "ghost_kb": 0, "tor_mode": tor_mode,
}
def test_banner_shows_tor_chip_when_armed():
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "mitmproxy_addons"))
import importlib, inject_banner
importlib.reload(inject_banner)
on = inject_banner._banner_html_dynamic("sha", _banner_ctx(True), True, "https://kbin/r", "R3", "r3")
off = inject_banner._banner_html_dynamic("sha", _banner_ctx(False), True, "https://kbin/r", "R3", "r3")
assert b"&#x1F9C5; Tor" in on # 🧅 chip present when armed
assert b"&#x1F9C5;" not in off # absent when off
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
# own-services exemption: the reconciler-populated set must exist and be
# consulted before the redirect/drop (so the box reaches itself directly)
assert "set tor_exempt" in text
assert text.count("ip daddr @tor_exempt return") >= 2
def test_bundle_banner_has_tor_indicator(tmp_path, monkeypatch):
"""The LIVE injected banner is the stream-inject bundle (bundle.py), not the
server-side inject_banner chip. Its render() must show the 🧅 span and the
decision bundle must carry tor_mode."""
import importlib
monkeypatch.setenv("SECUBOX_FILTERS_PATH", str(tmp_path / "filters.json"))
import secubox_toolbox.filters as f
importlib.reload(f)
f.set_filters({"tor_mode": True})
import secubox_toolbox.bundle as b
importlib.reload(b)
assert b.build_bundle("abc", True)["tor_mode"] is True
assert b.build_bundle("abc", True) is not None
# the banner render() (shared by loader + inline) emits the 🧅 span
assert "b.tor_mode" in b.LOADER_JS
assert "\U0001F9C5" in b.LOADER_JS # 🧅
def test_reconcile_populates_exempt_and_excludes_automap():
"""The reconciler must fill tor_exempt with loopback + own public IP and
must NOT exempt the Tor automap range (10.192/10) or transparent proxy breaks."""
import pathlib
sh = (pathlib.Path(__file__).resolve().parents[1]
/ "sbin" / "secubox-toolbox-tor-reconcile").read_text()
assert "tor_exempt" in sh and "127.0.0.0/8" in sh
assert "api.ipify.org" in sh # own public IP detected direct
assert "scope link" in sh # board-local subnets
assert "10.19" in sh # explicit automap-range guard

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{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)} .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} .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) */ /* Sub-tab nav (#513) */
.tabs{display:flex;gap:0.3rem;margin-bottom:1.2rem;border-bottom:1px solid var(--border);flex-wrap:wrap} .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} .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="filtres" onclick="switchTab('filtres')">🚦 Filtres MITM</button>
<button class="tab" data-tab="social" onclick="switchTab('social')">🕸️ Cartographie sociale</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="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> <button class="tab" data-tab="config" onclick="switchTab('config')">⚙ Config</button>
</nav> </nav>
@ -192,6 +198,37 @@
</div> </div>
</section> </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 --> <!-- Config -->
<section class="panel" id="panel-config"> <section class="panel" id="panel-config">
<div class="toolbar"> <div class="toolbar">
@ -215,6 +252,7 @@ function switchTab(name) {
if (name === 'filtres') loadFilters(); if (name === 'filtres') loadFilters();
if (name === 'social') loadSocial(); if (name === 'social') loadSocial();
if (name === 'ads') loadAds(); if (name === 'ads') loadAds();
if (name === 'tor') loadTor();
location.hash = name; location.hash = name;
} }
@ -424,6 +462,69 @@ async function loadFilters() {
}).join(''); }).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() { async function loadSocial() {
const agg = await J('/admin/social-aggregate?hours=24'); const agg = await J('/admin/social-aggregate?hours=24');
const kpi = document.getElementById('social-kpi'); const kpi = document.getElementById('social-kpi');