Compare commits

..

No commits in common. "69d4f0bd5cc1fef118ee95b0eff81f85351028ec" and "4c6777dc68f418294cab39b3c25193b9526927fc" have entirely different histories.

35 changed files with 809 additions and 1690 deletions

View File

@ -3,32 +3,6 @@
--- ---
## 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,13 +3,6 @@
--- ---
## 🔄 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 +
@ -26,28 +19,17 @@ 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.
### ✅ Implémenté DARK — chapitre Tor (#683, ToolBoX 2.7.1, branche feature/683) ### ⬜ Next Up — chapitre Tor (#683)
- ✅ **Transport tranché** : *torify l'egress MITM* (owner-match nft sur l'uid - **Décider le transport** : Option A (dialer SOCKS5 upstream via le cœur Go #662,
`secubox-toolbox`/mitm-wg → Tor TransPort 9040 / DNSPort 5353). Inspection *préféré*) vs Option B (nft mark → Tor TransPort, fallback pré-#662).
préservée. Décision USER (vs dialer SOCKS5 #662 = bloqué, vs torify client = casse - **Profil Tor egress** dans `secubox-exposure` (ou unit `tor-egress` dédié) —
l'inspection). egress-only, pas de relay/hidden-service dans ce profil.
- ✅ **Switch** : flags `tor_mode`/`tor_preset` (filters.json) ; API kbin-gated - **API toolbox** : `POST /admin/tor/{on,off}` (par client, WG-hash), `GET /tor/state`,
`GET/POST /admin/tor/{state,on,off,newnym,check-leaks}` ; onglet 🧅 WebUI (badge, `POST /tor/newnym` + état SQLite + bandeau 🧅 UI.
toggle, NEWNYM, sonde fuite). `tor_ctl.py` réutilise le control-port de secubox-tor. - **Leak-guard nft** + DNS-over-Tor (test : exit IP + resolver ≠ Unbound local).
- ✅ **Tunnel** : `conf/nft-toolbox-tor.nft` (fail-closed kill-switch + drop v6) + - **Caveat** : en mode Tor, forcer `tls_splice` OFF pour ce client (sinon les flux
`conf/torrc-toolbox-egress.conf` + reconciler root path-triggered asset fuient l'IP réelle). Soak DARK (flag présent, UI cachée) avant flip.
(`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 = 4 versionCode = 3
versionName = "0.4.0" versionName = "0.3.0"
} }
buildTypes { buildTypes {

View File

@ -88,23 +88,12 @@ 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, ctx.filesDir) val onb = RootOnboard(api, ctx.cacheDir)
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, filesDir).runSilent { /* headless: no UI log */ } RootOnboard(api, cacheDir).runSilent { /* headless: no UI log */ }
} }
private fun buildNotification(): Notification { private fun buildNotification(): Notification {

View File

@ -9,14 +9,7 @@ import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
class RootOnboard( class RootOnboard(private val api: ToolboxApi, private val cacheDir: File) {
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) }
@ -130,10 +123,8 @@ class RootOnboard(
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("• Profil WireGuard (identité stable)…") log.log("• Génération du profil WireGuard…")
// #683: reuse the persisted keypair so the device keeps ONE identity val conf = api.downloadProfile(cacheDir).readText()
// 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,41 +51,6 @@ 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,17 +65,6 @@ 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) {
@ -144,7 +133,6 @@ 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.5", "version": "0.1.4",
"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,7 +10,6 @@
<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,21 +111,6 @@ 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,17 +1,6 @@
# 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: IMPLEMENTED DARK in secubox-toolbox 2.7.1* *Spec · 2026-06-19 · issue [#683](https://github.com/CyberMind-FR/secubox-deb/issues/683) · status: PLAN (no code yet)*
> **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,14 +32,7 @@ traverse le pipeline de forge MITM SecuBox — sans configuration, sans app obli
--- ---
## La lame suivante : 🧅 Tor quick-switch (#683 — implémenté DARK en 2.7.1) ## La lame suivante : 🧅 Tor quick-switch (plan #683)
> **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,8 +1,7 @@
{# 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 #}
{# #683 restyle: aligned with the new /report look — system font, rounded {# Radical-simplify redesign (#543): animated hero + one CTA + install panel
--panel/--line cards, cleaner accents. Dynamic bits (data-live KPIs + JS, up top ; everything else folded behind "En savoir plus". #}
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>
@ -11,84 +10,108 @@
<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;--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} :root{--bg:#0a0a0f;--bg2:#0e0e15;--phos:#00dd44;--phos-hot:#00ff55;--dim:#006622;--text:#e8e6d9;--purple:#9e76ff;--gold:#c9a84c;--amber:#ffb347;--red:#ff4466;--cyan:#00d4ff}
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);line-height:1.55;padding-bottom:3rem} body{font-family:'Courier New',Menlo,monospace;background:var(--bg);color:var(--text);line-height:1.55;padding-bottom:3rem}
a{color:var(--phos);text-decoration:none}a:hover{text-decoration:underline} a{color:var(--phos);text-decoration:none}
.help{color:var(--dim);font-size:.8rem;font-style:italic} a:hover{text-decoration:underline}
/* ── HERO ── */ /* ── HERO ── */
.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)} .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)}
.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))} .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))}
@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.4rem;color:var(--phos-hot);letter-spacing:.06em;margin-top:.3rem;font-weight:800} .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 .punch{color:var(--text);font-size:1.2rem;margin-top:.6rem;font-weight:700}.hero .punch b{color:var(--gold)} .hero .punch{color:var(--text);font-size:1.25rem;margin-top:0.6rem;font-weight:bold}
.hero .sub{color:var(--dim);font-size:.82rem;margin-top:.5rem;max-width:560px;margin-left:auto;margin-right:auto} .hero .punch b{color:var(--gold)}
.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;animation:float 7s ease-in-out infinite} .dots i{position:absolute;width:7px;height:7px;border-radius:50%;opacity:0.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:3s} .dots i:nth-child(5){left:50%;top:14%;background:var(--cyan);animation-delay:3.0s}
.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 ── */
.ctas{margin-top:1.4rem;display:flex;gap:.6rem;justify-content:center;flex-wrap:wrap} /* ── big CTA row ── */
.cta{display:inline-block;padding:.85rem 1.6rem;font-weight:700;border-radius:10px;font-size:1.02rem;transition:transform .12s} .ctas{margin-top:1.4rem;display:flex;gap:0.6rem;justify-content:center;flex-wrap:wrap}
.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:#06140a;box-shadow:0 4px 18px rgba(0,221,68,.35)} .cta.go{background:var(--phos);color:#0a0a0f;box-shadow:0 4px 18px rgba(0,221,68,0.4)}
.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,.12)} .cta.alt:hover{background:rgba(158,118,255,0.12)}
/* ── quicknav ── */
.quicknav{display:flex;flex-wrap:wrap;justify-content:center;gap:.6rem;margin:1.4rem auto 0;max-width:620px} /* ── quicknav (trimmed) ── */
.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} .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:hover{border-color:var(--purple);transform:translateY(-2px);text-decoration:none} .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-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: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}
/* ── layout ── */ .qi-emoji{font-size:1.5rem;line-height:1}
.container{max-width:760px;margin:auto;padding:1.6rem 1.1rem} .qi-label{font-size:0.62rem;letter-spacing:0.04em;color:var(--phos-hot);font-weight:bold;white-space:nowrap}
.section{margin-bottom:1.7rem}
h2{color:var(--phos-hot);font-size:1.12rem;margin-bottom:.6rem;letter-spacing:.02em} .container{max-width:1080px;margin:auto;padding:2rem 1.5rem}
h3{color:var(--purple);font-size:.95rem;margin-bottom:.4rem} .section{margin-bottom:2.5rem}
.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))} 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}
.card{border:1px solid var(--line);background:var(--panel);padding:1rem 1.1rem;border-radius:12px} h3{color:var(--purple);font-size:1rem;margin-bottom:0.5rem}
.card.purple{border-color:rgba(158,118,255,.4)}.card.amber{border-color:rgba(255,179,71,.4)} .grid{display:grid;gap:1rem}
.kpi{text-align:center;padding:.8rem .4rem;background:var(--soft);border:1px solid var(--line);border-radius:12px} .grid-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
.kpi .v{font-size:1.7rem;font-weight:800;color:var(--phos-hot);display:block}.kpi .l{font-size:.66rem;color:var(--dim)} .grid-4{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))}
.level{display:flex;align-items:start;gap:.8rem;padding:.85rem;border-radius:12px} .card{border:1px solid var(--dim);background:var(--bg2);padding:1rem 1.2rem;border-radius:4px}
.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.purple{border-color:var(--purple);background:rgba(110,64,201,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)} .card.amber{border-color:var(--amber);background:rgba(255,179,71,0.05)}
.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{text-align:center;padding:1rem;background:rgba(0,221,68,0.05);border:1px solid var(--phos);border-radius:4px}
.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 .v{font-size:2rem;font-weight:bold;color:var(--phos-hot);text-shadow:0 0 6px var(--phos);display:block}
.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)}.svg-bar.medium{fill:var(--amber)}.svg-bar.high{fill:var(--red)} .svg-bar{fill:var(--phos);transition:fill 0.3s}
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.medium{fill:var(--amber)}
.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)} .svg-bar.high{fill:var(--red)}
.footer{text-align:center;font-size:.7rem;color:var(--dim);padding:1.4rem;border-top:1px solid var(--line);margin-top:2rem} code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;color:var(--phos-hot)}
.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{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}
/* ── install panel ── */ .cta-sm.outline{background:transparent;color:var(--phos);border:1px solid var(--phos)}
.install-panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:.7rem 1rem;margin:.5rem 0;text-align:left} .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 summary{cursor:pointer;font-size:.95rem;color:var(--phos-hot);list-style:none;outline:none;font-weight:700} .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 (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:.6rem} .install-panel[open] summary{margin-bottom:0.6rem}
.install-panel .emoji{font-size:1.1rem;margin-right:.3rem} .install-panel .emoji{font-size:1.1rem;margin-right:0.3rem}
.install-panel ol{padding-left:1.1rem;line-height:1.5;font-size:.85rem} .install-panel ol{padding-left:1.1rem;line-height:1.5;font-size:0.85rem}
.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{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.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:var(--soft);padding:.1rem .35rem;border-radius:4px;font-size:.8rem;color:var(--phos-hot)} .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 .note{color:var(--dim);font-size:.78rem;margin-top:.6rem;border-left:2px solid var(--amber);padding-left:.6rem} .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 img{max-width:100%;border-radius:8px;margin:.4rem 0} .install-panel img{max-width:100%;border-radius:5px;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} .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}
/* ── "En savoir plus" fold ── */ /* ── "En savoir plus" fold ── */
.more{max-width:760px;margin:0 auto;padding:0 1.1rem} .more{max-width:1080px;margin:0 auto;padding:0 1.5rem}
.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{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::-webkit-details-marker{display:none} .more>summary::-webkit-details-marker{display:none}
.more>summary:hover{background:rgba(158,118,255,.08)} .more>summary:hover{background:rgba(158,118,255,0.1)}
.more[open]>summary{margin-bottom:1.4rem} .more[open]>summary{margin-bottom:1.6rem}
.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}}
.v.tick{animation:flash .6s}@keyframes flash{0%{color:var(--gold);transform:scale(1.15)}100%{color:var(--phos-hot);transform:scale(1)}} @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
.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>
@ -100,17 +123,19 @@ code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82r
<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{{ '?mh=' + mac_hash if mac_hash else '' }}" class="cta alt">🕸️ Qui me piste ?</a> <a href="/social/me" 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{{ '?mh=' + mac_hash if mac_hash else '' }}" class=qi title="Mon rapport live"> <a href="/report/me/html" 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{{ '?mh=' + mac_hash if mac_hash else '' }}" class=qi title="Cartographie sociale — qui me piste, où ?"> <a href="/social/me" 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">
@ -126,12 +151,12 @@ code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82r
<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 class=help style="margin-bottom:.8rem"> <p style="font-size:0.85rem;color:var(--dim);margin-bottom:0.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 class=help style="margin-top:.8rem"> <p style="margin-top:0.8rem;font-size:0.78rem;color:var(--dim)">
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>
@ -145,8 +170,8 @@ code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82r
{# ── 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:.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-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-stamp style="font-size:.7rem;color:var(--dim);margin-left:.4rem">·</span> <span id=live-stamp style="font-size:0.7rem;color:var(--dim);margin-left:0.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>
@ -168,16 +193,16 @@ code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82r
<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:.92rem;line-height:1.5"> <p id=cert-probe-text style="font-size:0.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 class=help style="margin-top:.3rem"> <p style="font-size:0.75rem;color:var(--dim);margin-top:0.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:.7rem">…</code> <code id=cert-fp-r3 style="font-size:0.7rem">…</code>
</p> </p>
</div> </div>
<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> <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>
</div> </div>
</div> </div>
@ -190,7 +215,7 @@ code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82r
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:.6rem"> <p style="margin-top:0.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.
@ -223,10 +248,10 @@ code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82r
</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). Option sortie 🧅 Tor anonymisée. 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). Profile install 1 tap via QR.</p>
</div> </div>
</div> </div>
</div> </div>
@ -244,15 +269,14 @@ code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82r
{% 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 rx=4 class=svg-bar/> <rect x=0 y=20 width="{{ lpct * 3 }}" height=20 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 rx=4 class="svg-bar high"/> <rect x="{{ (lpct + mpct) * 3 }}" y=20 width="{{ hpct * 3 }}" height=20 class="svg-bar high"/>
<text x=10 y=15 fill="#00ff55" font-size=10>🟢 {{ lpct }}% LOW</text> <text x=10 y=15 fill="#00ff55" font-family=monospace font-size=10>🟢 {{ lpct }}% LOW</text>
<text x="{{ lpct * 3 + 10 }}" y=15 fill="#ffb347" font-size=10>🟡 {{ mpct }}% MED</text> <text x="{{ lpct * 3 + 10 }}" y=15 fill="#ffb347" font-family=monospace font-size=10>🟡 {{ mpct }}% MED</text>
<text x="{{ (lpct + mpct) * 3 + 10 }}" y=15 fill="#ff4466" font-size=10>🔴 {{ hpct }}% HI</text> <text x="{{ (lpct + mpct) * 3 + 10 }}" y=15 fill="#ff4466" font-family=monospace font-size=10>🔴 {{ hpct }}% HI</text>
<text x=10 y=55 fill="#5a6b60" font-size=8>{{ total }} sessions analysées</text> <text x=10 y=55 fill="#666" font-family=monospace 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>
@ -260,16 +284,15 @@ code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82r
{% 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 rx=4 fill="#5a6b60"/> <rect x=0 y=10 width="{{ ws[0] }}" height=15 fill="#666"/>
<rect x=0 y=30 width="{{ ws[1] }}" height=15 rx=4 fill="#00dd44"/> <rect x=0 y=30 width="{{ ws[1] }}" height=15 fill="#00dd44"/>
<rect x=0 y=50 width="{{ ws[2] }}" height=15 rx=4 fill="#ffb347"/> <rect x=0 y=50 width="{{ ws[2] }}" height=15 fill="#ffb347"/>
<rect x=0 y=70 width="{{ ws[3] }}" height=15 rx=4 fill="#9e76ff"/> <rect x=0 y=70 width="{{ ws[3] }}" height=15 fill="#9e76ff"/>
<text x="{{ ws[0] + 5 }}" y=22 fill="#888" font-size=10>🌐 R0 ({{ lvl.r0 }})</text> <text x="{{ ws[0] + 5 }}" y=22 fill="#888" font-family=monospace font-size=10>🌐 R0 ({{ lvl.r0 }})</text>
<text x="{{ ws[1] + 5 }}" y=42 fill="#00ff55" font-size=10>🛡 R1 ({{ lvl.r1 }})</text> <text x="{{ ws[1] + 5 }}" y=42 fill="#00ff55" font-family=monospace font-size=10>🛡 R1 ({{ lvl.r1 }})</text>
<text x="{{ ws[2] + 5 }}" y=62 fill="#ffd6a0" font-size=10>🔍 R2 ({{ lvl.r2 }})</text> <text x="{{ ws[2] + 5 }}" y=62 fill="#ffd6a0" font-family=monospace font-size=10>🔍 R2 ({{ lvl.r2 }})</text>
<text x="{{ ws[3] + 5 }}" y=82 fill="#cbb6ff" font-size=10>🧅 R3 ({{ lvl.r3 }})</text> <text x="{{ ws[3] + 5 }}" y=82 fill="#cbb6ff" font-family=monospace 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>
@ -280,7 +303,7 @@ code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82r
<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 (+🧅 Tor) LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
CAs séparées · addons partagés · DB unifiée CAs séparées · addons partagés · DB unifiée
@ -295,7 +318,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard (+🧅 Tor)
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:.6rem"> <div style="margin-top:0.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>
@ -303,11 +326,11 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard (+🧅 Tor)
{# ── Contact ── #} {# ── Contact ── #}
<div class=section> <div class=section>
<h2>📡 Contact &amp; soutiens</h2> <h2>📡 Contact & 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:.85rem"> <ul style="list-style:none;padding-left:0;font-size:0.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>
@ -315,7 +338,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard (+🧅 Tor)
</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:.85rem"> <ul style="list-style:none;padding-left:0;font-size:0.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>
@ -353,6 +376,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard (+🧅 Tor)
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){
@ -379,7 +403,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard (+🧅 Tor)
if (prev !== next) { if (prev !== next) {
el.textContent = next; el.textContent = next;
el.classList.remove('tick'); el.classList.remove('tick');
void el.offsetWidth; void el.offsetWidth; // force reflow
el.classList.add('tick'); el.classList.add('tick');
} }
}); });
@ -425,8 +449,8 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard (+🧅 Tor)
} 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(--amber)">profil CA R3 iPhone</a> ' + 'Installe le <a href=/wg/ca.mobileconfig style="color:var(--orange)">profil CA R3 iPhone</a> ' +
'ou le <a href=/wg/ca.pem style="color:var(--amber)">ca.pem Android/PC</a>.'; 'ou le <a href=/wg/ca.pem style="color:var(--orange)">ca.pem Android/PC</a>.';
} }
} }

View File

@ -1,52 +0,0 @@
# 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,325 +1,662 @@
{# 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="20"> <meta http-equiv="refresh" content="15">
{# 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 — VILLAGE3B</title> <title>Mon rapport Gondwana ToolBoX — live</title>
<style> <style>
:root{--bg:#0a0a0f;--panel:#11131a;--phos:#00dd44;--phos-hot:#00ff55;--dim:#5a6b60;--line:#1e2630;--text:#e8e6d9;--red:#ff4466;--amber:#ffb347;--violet:#9e76ff;--blue:#66bbff} :root{--bg:#0a0a0f;--phos:#00dd44;--phos-hot:#00ff55;--dim:#006622;--text:#e8e6d9;--red:#ff4466;--amber:#ffb347}
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
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} body{font-family:'Courier New',Menlo,monospace;background:var(--bg);color:var(--text);padding:1rem;max-width:760px;margin:auto;line-height:1.5}
h1{color:var(--phos-hot);font-size:1.35rem;letter-spacing:.04em;display:flex;align-items:center;gap:.4rem} h1{color:var(--phos-hot);text-shadow:0 0 6px var(--phos);font-size:1.6rem;margin-bottom:0.3rem;letter-spacing:0.05em}
.sub{color:var(--dim);font-size:.82rem;margin-bottom:1.1rem} .sub{color:var(--dim);font-size:0.85rem;margin-bottom:1rem;letter-spacing:0.05em}
.help{color:var(--dim);font-size:.78rem;font-style:italic;margin-top:.25rem} .card{border:1px solid var(--dim);background:rgba(0,221,68,0.03);padding:0.9rem 1rem;margin-bottom:1rem}
.card{border:1px solid var(--line);background:var(--panel);border-radius:12px;padding:1rem 1.1rem;margin-bottom:1rem} .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 h2{color:var(--phos-hot);font-size:.95rem;margin-bottom:.5rem;letter-spacing:.03em} .kv{display:grid;grid-template-columns:auto 1fr;gap:0.2rem 0.8rem;font-size:0.85rem}
/* ── verdict hero ── */ .kv .k{color:var(--dim)}
.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)} .kv .v{color:var(--phos);text-shadow:0 0 4px var(--phos)}
.gauge{width:170px;height:170px;border-radius:50%;margin:.4rem auto;display:flex;align-items:center;justify-content:center} ul{list-style:none;padding-left:0.6rem}
.gauge-hole{width:124px;height:124px;border-radius:50%;background:var(--bg);display:flex;flex-direction:column;align-items:center;justify-content:center} li{padding:0.15rem 0;font-size:0.85rem}
.gauge-num{font-size:2.6rem;font-weight:800;line-height:1} li::before{content:"▸ ";color:var(--phos);text-shadow:0 0 4px var(--phos)}
.gauge-max{font-size:.8rem;color:var(--dim)} .score{display:inline-block;padding:0.3rem 1rem;font-size:1.2rem;font-weight:bold;border:2px solid;border-radius:4px}
.verdict{font-size:1.15rem;font-weight:700;margin-top:.3rem} .score.low{color:var(--phos-hot);border-color:var(--phos);text-shadow:0 0 6px var(--phos)}
/* ── KPI row ── */ .score.med{color:var(--amber);border-color:var(--amber);text-shadow:0 0 6px var(--amber)}
.kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem;margin-top:.4rem} .score.high{color:var(--red);border-color:var(--red);text-shadow:0 0 6px var(--red)}
.kpi{background:#0d0f15;border:1px solid var(--line);border-radius:10px;padding:.6rem .3rem;text-align:center} .pin{color:var(--amber)}
.kpi .e{font-size:1.15rem} .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 .n{font-size:1.3rem;font-weight:800;color:var(--phos-hot)} .actions{text-align:center;margin:1.5rem 0}
.kpi .l{font-size:.62rem;color:var(--dim);text-transform:uppercase;letter-spacing:.04em} .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}
/* ── graphs ── */ .actions a:hover{background:rgba(0,221,68,0.1)}
.graphs{display:grid;grid-template-columns:1fr 1fr;gap:1rem} .refresh{text-align:center;font-size:0.7rem;color:var(--dim);margin-top:1rem;font-style:italic}
@media(max-width:560px){.graphs{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,1fr)}} .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}
.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|default(0) %} {% set sc = risk_score or 0 %}
{% set rl = risk_label|default('LOW') %} {% set rl = risk_label or 'LOW' %}
{% set ch = charts or {} %} <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 gcol = 'var(--phos-hot)' if sc < 30 else ('var(--amber)' if sc < 70 else 'var(--red)') %} <h2 style="display:flex;justify-content:space-between;align-items:center">
{% set palette = ['#00dd44','#9e76ff','#ff8866','#66bbff','#ffb347','#ff4466'] %} 📊 Ta session VILLAGE3B
<span style="font-size:0.75rem;padding:0.3rem 0.8rem;border-radius:99px;
background:{% if sc < 30 %}#00cc44{% elif sc < 70 %}#ffb347{% else %}#ff4466{% endif %};
color:#0a0a0f;font-weight:bold">
{% if sc < 30 %}🟢{% elif sc < 70 %}🟡{% else %}🔴{% endif %} {{ rl }} {{ sc }}/100
</span>
</h2>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.5rem;margin-top:0.8rem">
{% 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 dpi_cls = dpi_classified or {} %}
{% set cookies_p = cookies_providers or [] %} {% set cookies_p = cookies_providers or [] %}
{% set geo_h = geo_top_hosts or [] %} {% set geo_h = geo_top_hosts or [] %}
{% set n_apps = (dpi_cls.top_apps|default([])|selectattr('app','ne','?')|list|length) %} {% set n_apps = (dpi_cls.top_apps|default([])|selectattr('app','ne','?')|list|length) %}
{% set n_trackers = (cookies_p|map(attribute='count')|sum) %} {% set n_trackers = (cookies_p|map(attribute='count')|sum) %}
{% set n_countries = (geo_h|map(attribute='country')|reject('equalto','')|list|unique|list|length) %} {% 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 _avatar = avatar_analysis or {} %}
{% set _dev_emj = _avatar.most_common_emoji or '❔' %}
<h1>👁️ VILLAGE3B <span style="font-size:.8rem;color:var(--dim);font-weight:400">· mon rapport</span></h1> {% set _dev_label = _avatar.most_common or 'unknown' %}
<p class="sub">Diagnostic live de ce que ton appareil envoie sur le réseau · anonyme · se rafraîchit tout seul</p> {% set _devices = _avatar.devices or {} %}
{% set _dev_info = _devices.get(_dev_label, {}) %}
{% if request_args and (request_args.get('welcome') or request_args.get('switched')) %} {% set _os_label = _dev_info.os_label or _dev_label %}
<div class="card" style="border-color:var(--phos)"> {% set _browsers = _avatar.browsers or {} %}
<b style="color:var(--phos-hot)">{% if request_args.get('switched') %}🔄 Niveau changé{% else %}🎉 Bienvenue !{% endif %}</b> — <div style="margin-top:0.5rem;padding:0.7rem;background:rgba(45,25,60,0.5);border-radius:4px;text-align:center">
tu es en mode <div style="font-size:1.4rem">📱 <b style="color:#cbb6ff">{{ _dev_emj }} Empreinte device</b></div>
{% 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 %}. <div style="font-size:0.92rem;color:var(--phos-hot);margin-top:0.3rem;text-shadow:0 0 3px var(--phos)">
Tu peux surfer normalement. {{ _os_label }}
</div> {% for br, info in _browsers.items() if br != 'unknown' %}
{% endif %} · {{ info.emoji or '' }} {{ info.label or br }}
{# ── VERDICT HERO : score gauge + plain verdict ── #}
<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">
<h2>📊 En un coup d'œil</h2>
<div class="graphs">
{# trackers donut #}
<div>
<div style="font-size:.82rem;color:var(--dim);margin-bottom:.4rem">🍪 Qui te trace</div>
{% if ch.trackers %}
<div class="donut-wrap">
<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 %})">
<div class="donut-hole">{{ n_trackers }}<br>traceurs</div>
</div>
<div class="legend">
{% for t in ch.trackers %}
<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>
{% endfor %} {% endfor %}
</div> </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> </div>
{% else %}<div class="empty">Aucun traceur repéré 🎉</div>{% endif %} {# 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> </div>
{# countries bars #} {# Phase 3 (#492) : filtering compromissions visibility #}
<div>
<div style="font-size:.82rem;color:var(--dim);margin-bottom:.4rem">🌍 Vers quels pays</div>
{% if ch.countries %}
{% for c in ch.countries %}
<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>
{% endfor %}
{% else %}<div class="empty">Pas encore de données géo</div>{% endif %}
</div>
{# apps bars #}
<div style="grid-column:1/-1">
<div style="font-size:.82rem;color:var(--dim);margin-bottom:.4rem">📺 Quelles apps / services</div>
{% if ch.apps %}
{% for a in ch.apps %}
<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>
{% endfor %}
{% 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>
<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>
</div>
{# ════════════ DÉTAILS TECHNIQUES (repliés) ════════════ #}
<details>
<summary>🎯 Analyse de compromission &amp; score</summary>
<div class="inner">
{% if risk_explanation %}<p style="font-size:.85rem;margin-bottom:.6rem">{{ risk_explanation }}</p>{% endif %}
<ul>{% for ind in indicators %}<li>{{ ind }}</li>{% endfor %}</ul>
{% if scoring and scoring.breakdown %}
<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>
{% for b in scoring.breakdown %}
<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 %}
</tbody></table>
{% endif %}
</div>
</details>
{% if threat_intel_matches or dga_candidates or beaconing_candidates %}
<details>
<summary class="pin">🚨 Menaces détectées ({{ (threat_intel_matches|default([]))|length + (dga_candidates|default([]))|length + (beaconing_candidates|default([]))|length }})</summary>
<div class="inner">
{% if threat_intel_matches %}<p style="color:var(--amber);font-size:.82rem;margin:.3rem 0">🚨 Threat-intel (feeds malware)</p><ul>
{% 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 %}
{% if dga_candidates %}<p style="color:var(--amber);font-size:.82rem;margin:.5rem 0 .2rem">🔠 Domaines générés (DGA)</p><ul>
{% for d in dga_candidates[:8] %}<li>{{ d.flag }} [{{ d.score }}] <code>{{ d.host[:50] }}</code></li>{% endfor %}</ul>{% endif %}
{% if beaconing_candidates %}<p style="color:var(--amber);font-size:.82rem;margin:.5rem 0 .2rem">📡 Beaconing (périodique)</p><ul>
{% for b in beaconing_candidates[:8] %}<li>{{ b.flag }} [{{ b.score }}] <code>{{ b.host[:40] }}</code> · {{ b.median_seconds }}s</li>{% endfor %}</ul>{% endif %}
</div>
</details>
{% endif %}
{% if geo_top_hosts %}
<details>
<summary>🌍 Hôtes contactés ({{ geo_top_hosts|length }})</summary>
<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>
{% for h in geo_top_hosts[:20] %}
<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>
{% endfor %}
</tbody></table></div>
</details>
{% endif %}
{% if dpi_classified and dpi_classified.top_apps %}
<details>
<summary>🧭 Apps détectées (nDPI)</summary>
<div class="inner"><table><thead><tr><th>App</th><th>Catégorie</th><th style="text-align:right">Conn.</th></tr></thead><tbody>
{% for a in dpi_classified.top_apps[:20] %}
<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>
{% endfor %}
</tbody></table></div>
</details>
{% endif %}
{% if cookies_providers %}
<details>
<summary>🍪 Traceurs / providers cookies ({{ cookies_providers|length }})</summary>
<div class="inner"><ul>
{% 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 %}
</ul></div>
</details>
{% endif %}
{% if avatar_analysis and avatar_analysis.devices %}
<details>
<summary>{{ _avatar.most_common_emoji }} Empreinte appareil</summary>
<div class="inner">
<p style="margin-bottom:.4rem">{{ _avatar.most_common_emoji }} <b>{{ _avatar.most_common }}</b> · {{ _avatar.raw_count|default(0) }} UAs distincts</p>
<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><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>
</div>
</div>
</details>
{% endif %}
{% if pinned_apps %}
<details>
<summary>🔒 Apps protégées (cert-pinning) ({{ pinned_apps|length }})</summary>
<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>
<ul>{% for app in pinned_apps %}<li>{{ app }}</li>{% endfor %}</ul></div>
</details>
{% endif %}
{% set t = transparency|default({}) %} {% set t = transparency|default({}) %}
{% if t and t.get('total_events', 0) > 0 %} {% set sens = t.get('sensitivity') %}
<details> <div class="card" style="background:rgba(255,68,102,0.06);border-color:var(--red)">
<summary>🔎 Transparence : ce qu'on a regardé</summary> <h2 style="color:var(--red)">🚨 Filtering compromissions actif</h2>
<div class="inner"> <div class="kv" style="font-size:0.82rem">
<p class="help" style="margin-bottom:.5rem">Honnêteté avant magie : ce qu'on a inspecté, bypassé, et pourquoi.</p> <span class="k">Sensibilité actuelle</span>
<div class="kv"> <span class="v">{{ sens.label|default('—') if sens else '—' }}</span>
{% set b = t.get('breakdown_pct', {}) %} <span class="k">Description</span>
{% if b.get('inspected') %}<span class="k">🔍 Inspecté</span><span class="v">{{ b.get('inspected') }}%</span>{% endif %} {% set sens_desc = (sens.description if sens and sens.description else 'Engine pas chargé') %}
{% if b.get('pinned-failed-mitm') %}<span class="k">🔒 Cert-pinning</span><span class="v">{{ b.get('pinned-failed-mitm') }}%</span>{% endif %} <span class="v" style="font-size:0.75rem">{{ sens_desc[:90] }}</span>
{% if b.get('e2e-opaque') %}<span class="k">🔐 E2E chiffré</span><span class="v">{{ b.get('e2e-opaque') }}%</span>{% endif %} <span class="k">🔍 Threat-intel feeds</span>
<span class="k">📊 Total events</span><span class="v">{{ t.get('total_events', 0) }}</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> </div>
{% if t.get('per_host') %} <p style="font-size:0.7rem;color:var(--dim);margin-top:0.5rem;font-style:italic">
<table style="margin-top:.7rem"><thead><tr><th>Grade</th><th>Destination</th><th>Statut</th></tr></thead><tbody> Le moteur détecte mais ne bloque pas encore — passive transparency. La règle engine
{% for h in t.get('per_host', [])[:15] %} décidera blocage actif quand R3 / Phase 4 sera wired.
<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> </p>
{% endfor %} </div>
</tbody></table>
{% endif %} {# 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')) %}
<div class="card" style="background:rgba(0,221,68,0.08);border-color:var(--phos)">
<h2 style="color:var(--phos-hot)">
{% if request_args.get('switched') %}🔄 Niveau changé{% else %}🎉 Bienvenue !{% endif %}
</h2>
<p style="font-size:0.85rem">
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>
</details>
{% endif %} {% endif %}
<details> {# Phase 3 (#492) : level switcher with active highlight from server-side
<summary>👤 Identité &amp; recommandations</summary> current_level. Disables button if user clicks their own level (no-op). #}
<div class="inner"> <div class="card">
<h2>🔀 Mon niveau d'opt-in</h2>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.5rem">
📍 Tu es actuellement en
<b style="color:{% if current_level == 'r0' %}var(--text){% elif current_level == 'r2' %}#ffd6a0{% else %}var(--phos-hot){% endif %}">
{% if current_level == 'r0' %}🌐 R0 — Bypass complet
{% elif current_level == 'r2' %}🔍 R2 — Analyse + bandeau
{% else %}🛡 R1 — Analyse passive{% endif %}
</b>
· clique sur un autre niveau pour switcher
</p>
<form method="POST" action="/change-level" style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.4rem">
<button type="submit" name="level" value="r0"
style="padding:0.5rem;cursor:pointer;font-family:inherit;font-size:0.85rem;
{% if current_level == 'r0' %}
background:rgba(255,255,255,0.08);color:var(--text);border:2px solid var(--text);font-weight:bold;
{% else %}
background:transparent;color:var(--text);border:1px solid var(--dim);
{% endif %}">
{% if current_level == 'r0' %}✓ {% endif %}🌐 R0
</button>
<button type="submit" name="level" value="r1"
style="padding:0.5rem;cursor:pointer;font-family:inherit;font-size:0.85rem;
{% if current_level == 'r1' %}
background:rgba(0,221,68,0.25);color:var(--phos-hot);border:2px solid var(--phos);font-weight:bold;
{% else %}
background:transparent;color:var(--phos);border:1px solid var(--dim);
{% endif %}">
{% if current_level == 'r1' %}✓ {% endif %}🛡 R1
</button>
<button type="submit" name="level" value="r2"
style="padding:0.5rem;cursor:pointer;font-family:inherit;font-size:0.85rem;
{% if current_level == 'r2' %}
background:rgba(255,179,71,0.25);color:#ffd6a0;border:2px solid #ffb347;font-weight:bold;
{% else %}
background:transparent;color:#9d7846;border:1px solid var(--dim);
{% endif %}">
{% if current_level == 'r2' %}✓ {% endif %}🔍 R2
</button>
</form>
{% if wg_enabled|default(false) %}
{# 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 class="card">
<h2>👤 Identifiant anonyme</h2>
<div class="kv"> <div class="kv">
<span class="k">Hash session</span><span class="v"><code>{{ mac_hash }}</code></span> <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">Sandbox IP</span> <span class="v">{{ ip }}</span>
<span class="k">Appareil</span> <span class="v">{{ device_type }}</span> <span class="k">Appareil</span> <span class="v">{{ device_type }}</span>
</div> </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>
<div class="card"> <div class="card">
<h2>💚 Soutenir le projet</h2> <h2>📊 Métriques session</h2>
<p class="help">Commun numérique open-source, sans pub ni revente — CyberMind / Gérald Kerma (Savoie).</p> <div class="kv">
<ul style="margin-top:.4rem"> <span class="k">Connexions</span> <span class="v">{{ metrics.connections }}</span>
<li>💰 <a href="https://liberapay.com/cybermind" style="color:var(--phos)">liberapay.com/cybermind</a></li> <span class="k">Hosts uniques</span> <span class="v">{{ metrics.unique_hosts }}</span>
<li>🐛 <a href="https://github.com/CyberMind-FR/secubox-deb/issues" style="color:var(--phos)">Signaler un bug</a></li> <span class="k">Réussies</span> <span class="v">{{ metrics.successful }}</span>
<span class="k">Cert-pin block</span> <span class="v">{{ metrics.tls_pinned }}</span>
</div>
</div>
<div class="card">
<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 %}
</p>
{% if risk_explanation %}
<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> </ul>
</div> </div>
{% if scoring and scoring.breakdown %}
<div class="card">
<h2>🔬 Breakdown du score (transparent)</h2>
<table style="width:100%;font-size:0.82rem;border-collapse:collapse">
<thead><tr style="color:var(--dim);border-bottom:1px solid var(--dim)">
<th style="padding:0.2rem 0.4rem;text-align:left">Catégorie</th>
<th style="padding:0.2rem 0.4rem;text-align:right">Signaux</th>
<th style="padding:0.2rem 0.4rem;text-align:right">Poids</th>
</tr></thead>
<tbody>
{% for b in scoring.breakdown %}
<tr><td style="padding:0.15rem 0.4rem">{{ b.category }}</td>
<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 %}
{% if geo_top_hosts %}
<div class="card">
<h2>🌍 Hôtes contactés (par pays + ASN + app)</h2>
<table style="width:100%;font-size:0.82rem">
<thead><tr style="color:var(--dim);text-align:left">
<th>🚩</th><th>App</th><th>Hôte</th><th>ASN</th><th style="text-align:right">Hits</th>
</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 %}
</tbody>
</table>
</div>
{% endif %}
{% if avatar_analysis and avatar_analysis.devices %}
<div class="card">
<h2>{{ avatar_analysis.most_common_emoji }} Avatar / device fingerprint</h2>
<p style="margin-bottom:0.5rem">
<span style="font-size:1.2rem">{{ avatar_analysis.most_common_emoji }}</span>
<b>{{ avatar_analysis.most_common }}</b> (le plus représenté)
</p>
<div style="display:flex;flex-wrap:wrap;gap:1rem;font-size:0.85rem">
<div>
<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 %}
{% if cookies_providers %}
<div class="card">
<h2>🍪 Trackers / providers cookies (Phase 2a+)</h2>
<ul>
{% for p in cookies_providers[:10] %}
<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>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="card">
<h2>📱 Apps détectées (vue IP forensics)</h2>
<ul>
{% for app in apps_detected %}<li>{{ app }}</li>{% endfor %}
</ul>
</div>
<div class="card">
<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>
{% endfor %}
{% endif %}
</div>
{% endif %}
{% if soc and soc.indicators %}
<div class="card">
<h2 class="pin">⚠ SOC — indicateurs détectés</h2>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.5rem">
Score SOC actuel : <b style="color:var(--amber)">{{ soc.score }}/100</b>
</p>
<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 %}
{% 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({}) %}
{% if t and t.get('total_events', 0) > 0 %}
<div class="card">
<h2>🔎 INSPECTION : CE QU'ON A REGARDÉ (et ce qu'on n'a PAS regardé)</h2>
<p style="font-size:0.78rem;color:var(--dim);margin-bottom:0.6rem;font-style:italic">
Honnêteté avant magie : la cabine te dit ce qu'elle a inspecté, ce qu'elle a sciemment bypassé, et pourquoi.
</p>
<div class="kv">
{% set b = t.get('breakdown_pct', {}) %}
{% if b.get('inspected') %}
<div class="k">🔍 Inspecté (HTTPS via notre CA)</div>
<div class="v">{{ b.get('inspected', 0) }}% — contenu visible</div>
{% 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>
{% endif %}
{# Phase 3 (#492) : whitelist hits — accountability per pattern/category #}
{% 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 %}
</div>
<ul style="margin-top:0.4rem">
{% 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>
{% endif %}
</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 %}
<p class="refresh">↻ Auto-refresh toutes les 15 secondes</p>
<div class="card">
<h2>💚 Support &amp; soutien projet</h2>
<p style="font-size:0.82rem;color:var(--text);line-height:1.55">
Gondwana ToolBoX est un <b>commun numérique</b> open-source maintenu par
CyberMind / Gérald Kerma (Notre-Dame-du-Cruet, Savoie). Pas de pub, pas de
revente, pas de tracking commercial. Si ce service t'a aidé :
</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>
<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 class="footer"> <div class="footer">
Gondwana ToolBoX · LicenseRef-CMSD-1.0 · ↻ rafraîchit toutes les 20 s<br> Gondwana ToolBoX · LicenseRef-CMSD-1.0 (Source-Disclosed License)<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> 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>
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

@ -1,20 +0,0 @@
# 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,137 +1,3 @@
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,7 +24,6 @@ Depends: ${misc:Depends}, ${python3:Depends},
hostapd, hostapd,
dnsmasq, dnsmasq,
mitmproxy, mitmproxy,
jq,
openssl, openssl,
adduser, adduser,
fonts-dejavu-core, fonts-dejavu-core,
@ -35,9 +34,7 @@ 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,19 +66,9 @@ 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 secubox-toolbox:secubox-toolbox /etc/secubox/toolbox/filters.json chown root: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
@ -192,25 +182,9 @@ 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
@ -300,7 +274,6 @@ 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 \
@ -312,13 +285,6 @@ 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,18 +9,5 @@ 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,17 +52,6 @@ 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,10 +19,7 @@ RestartSec=3
NoNewPrivileges=true NoNewPrivileges=true
ProtectHome=true ProtectHome=true
PrivateTmp=true PrivateTmp=true
# #683: the portal must persist its own config (filters.json — Tor switch + ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox/toolbox
# 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,19 +314,8 @@ 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)
@ -474,10 +463,6 @@ 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

@ -1,114 +0,0 @@
#!/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,36 +328,6 @@ 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)
@ -530,23 +500,6 @@ 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
@ -731,15 +684,11 @@ 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"},
) )
@ -2342,46 +2291,6 @@ 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.
@ -2397,16 +2306,17 @@ 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.
""" """
# Resolve identity the same way everywhere: ?mh → R3 WG peer (wg-peers.json) # Bypass path : explicit mac_hash in query (R3 WG or kbin remote viewer)
# → captive ARP. R3 clients hitting this directly (no ?mh) now resolve too. mh_qp = (request.query_params.get("mh") or "").strip().lower()
mac_hash = _client_mac_hash(request, _get_salt()) if mh_qp and all(c in "0123456789abcdef" for c in mh_qp) and 8 <= len(mh_qp) <= 64:
if not mac_hash: ip = request.client.host if request.client else "?"
raise HTTPException( mac_hash = mh_qp
400, else:
"client identity unresolved (not on R3 tunnel and not in captive " ip, mac = _resolve(request)
"subnet) — append ?mh=<hash> from your banner's report link", if not mac:
) raise HTTPException(400, "client MAC unknown (not in captive subnet?) — use ?mh=<hash>")
ip = _client_ip(request) or (request.client.host if request.client else "?") salt = _get_salt()
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.
@ -2420,7 +2330,6 @@ 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={
@ -2936,116 +2845,6 @@ 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."""
@ -3463,25 +3262,23 @@ 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)
# Live MITM activity. NOTE: the old journal-scrape for "server connect" # Mitmproxy live stats (from journal)
# 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:
cs = cumulative.get_cached() or {} out = _sp.run(
ev = cs.get("events", {}) or {} # #593 — glob matches the LIVE R3 workers (…-mitm-wg-worker@N),
# "connections analysées" — DPI classifies one flow per upstream connection. # not just the (dead) R2 …-mitm unit → real numbers.
metrics["mitm"]["connections"] = int(ev.get("dpi", 0) or 0) ["journalctl", "-u", "secubox-toolbox-mitm*", "--since", "-30min", "--no-pager"],
metrics["mitm"]["unique_hosts"] = len(cs.get("top_hosts_7d", []) or []) capture_output=True, text=True, timeout=4, check=False,
except Exception: ).stdout
pass metrics["mitm"]["connections"] = out.count("server connect")
# Cert-pinned hosts that had to be bypassed (auto-learned by cert_pin_detect). metrics["mitm"]["tls_pinned"] = out.count("Client TLS handshake failed")
try: hosts: set[str] = set()
if MITM_BYPASS_DYNAMIC_FILE.exists(): for line in out.splitlines():
metrics["mitm"]["tls_pinned"] = sum( if " server connect " in line:
1 for ln in MITM_BYPASS_DYNAMIC_FILE.read_text().splitlines() parts = line.rsplit(" ", 1)
if ln.strip() and not ln.strip().startswith("#")) if len(parts) == 2:
hosts.add(parts[1])
metrics["mitm"]["unique_hosts"] = len(hosts)
except Exception: except Exception:
pass pass
return metrics return metrics

View File

@ -72,15 +72,6 @@ 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 {
@ -90,7 +81,6 @@ 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()),
} }
@ -151,23 +141,12 @@ _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 = countCookies(); var ck = 0;
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;"
@ -178,15 +157,11 @@ _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 id=\"sbx-trk\">🛰️ " + trk + " trackers</span>" + "<span>🛰️ " + trk + " trackers</span>"
+ "<span id=\"sbx-ck\">🍪 " + ck + " cookies</span>" + "<span>🍪 " + 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>";
@ -197,7 +172,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(){ if (document.getElementById("sbx-banner")) updateCounts(bundle); else render(bundle); }); } function ensure(){ if (bundle && !dismissed) ready(function(){ 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,9 +27,6 @@ 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
@ -47,7 +44,6 @@ 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
@ -75,8 +71,6 @@ 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
@ -96,32 +90,18 @@ 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", "tor_mode"): "privacy_ip_drop", "privacy_dns_feed", "ad_learn"):
cur[k] = bool(v) cur[k] = bool(v)
data = json.dumps(cur, indent=1)
try: try:
# Preferred: atomic tmp + rename (needs write on the parent dir). os.makedirs(os.path.dirname(FILTERS_PATH), exist_ok=True)
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:
f.write(data) json.dump(cur, f, indent=1)
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

@ -1,102 +0,0 @@
# 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

@ -1,14 +0,0 @@
# 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

@ -1,18 +0,0 @@
# 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

@ -1,16 +0,0 @@
# 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,10 +19,7 @@ RestartSec=3
NoNewPrivileges=true NoNewPrivileges=true
ProtectHome=true ProtectHome=true
PrivateTmp=true PrivateTmp=true
# #683: the portal must persist its own config (filters.json — Tor switch + ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox/toolbox
# 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

@ -1,196 +0,0 @@
# 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,11 +22,6 @@
.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}
@ -83,7 +78,6 @@
<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>
@ -198,37 +192,6 @@
</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">
@ -252,7 +215,6 @@ 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;
} }
@ -462,69 +424,6 @@ 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');