mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 18:06:21 +00:00
Compare commits
12 Commits
4c6777dc68
...
69d4f0bd5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69d4f0bd5c | ||
| 47eae4a774 | |||
| db5d5dbcf1 | |||
| 41dbdadaa2 | |||
| e1b2e6ccbb | |||
| 79c6166181 | |||
| 55955867af | |||
| 55c7d925a6 | |||
| 982a27ce38 | |||
| aef5b00b85 | |||
| 03346907e4 | |||
| a870eb380e |
|
|
@ -3,6 +3,32 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-06-19 — kbin Tor egress quick-switch implemented DARK (#683, ToolBoX 2.7.1)
|
||||
|
||||
- **Switch + tunnel** for routing kbin surfing through Tor, shipped **default-OFF /
|
||||
fail-closed** on `feature/683`. Reuses existing secubox components per the user ask.
|
||||
- **Transport decision (USER): torify the MITM egress.** nft owner-match on the
|
||||
`secubox-toolbox` (mitm-wg) uid → Tor TransPort 9040 / DNSPort 5353. Clients →
|
||||
TPROXY → mitm decrypts/ad-blocks/poisons/banners/re-encrypts → exits via Tor.
|
||||
**Inspection fully preserved**; only the exit IP + network identity change. (Rejected:
|
||||
SOCKS5 Go-core dialer = blocked on #662; transparent client torify = breaks inspection.)
|
||||
- **Switch**: `filters.json` flags `tor_mode`/`tor_preset`; API (kbin-gated, admin.gk2
|
||||
only for actions) `GET/POST /admin/tor/{state,on,off,newnym,check-leaks}`; 🧅 WebUI tab
|
||||
(badge bootstrap/circuits/exit-IP, toggle, NEWNYM, SOCKS leak probe). `tor_ctl.py`
|
||||
reuses secubox-tor's control-port code — no cross-service JWT.
|
||||
- **Tunnel arms via reconciler**: root, path-triggered (`secubox-toolbox-tor.path`
|
||||
watches filters.json) → portal stays `NoNewPrivileges=true`, no sudo. nft loaded
|
||||
BEFORE tor (no clearnet window); IPv6 worker egress dropped (no v6 leak); prerm
|
||||
disarms on real removal (not upgrade). Depends jq; Recommends tor + python3-socksio;
|
||||
postinst adds secubox-toolbox to debian-tor group.
|
||||
- **Verified**: 166 toolbox tests green (10 new), nft syntax valid (user-resolve only),
|
||||
maintainer scripts `sh -n` clean, license headers OK, changelog parses 2.7.1.
|
||||
- **Granularity = global kbin Tor mode** (owner-match can't be per-client). Per-client
|
||||
(WG-hash) Tor tracked under #662 (Go-core SOCKS5 dialer). NOT yet flipped/deployed —
|
||||
needs soak + off-board leak test + tls_splice(#649)-OFF before arming.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-19 — kbin milestone: ToolBoX 2.7.0 (middle release) + Tor chapter staged (#683)
|
||||
|
||||
- **End-of-session checkpoint** — docs + positioning + version, no runtime behaviour change.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@
|
|||
|
||||
---
|
||||
|
||||
## 🔄 2026-06-19 : kbin Tor egress (#683) — ToolBoX 2.7.1, implémenté DARK
|
||||
|
||||
Switch + tunnel Tor quick-switch livrés sur `feature/683`, **défaut OFF / fail-closed**.
|
||||
Détail dans la section "Implémenté DARK" ci-dessous + HISTORY 2026-06-19.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 2026-06-19 : kbin milestone — ToolBoX 2.7.0 + chapitre Tor (plan)
|
||||
|
||||
Checkpoint de fin de session. Pas de changement de comportement runtime — docs +
|
||||
|
|
@ -19,17 +26,28 @@ positionnement + version + plan de la lame suivante.
|
|||
endpoint Tor quick-switch (egress sortant, fail-closed, opt-in, no DNS leak,
|
||||
inspection préservée). Dépend du cœur Go #662.
|
||||
|
||||
### ⬜ Next Up — chapitre Tor (#683)
|
||||
### ✅ Implémenté DARK — chapitre Tor (#683, ToolBoX 2.7.1, branche feature/683)
|
||||
|
||||
- **Décider le transport** : Option A (dialer SOCKS5 upstream via le cœur Go #662,
|
||||
*préféré*) vs Option B (nft mark → Tor TransPort, fallback pré-#662).
|
||||
- **Profil Tor egress** dans `secubox-exposure` (ou unit `tor-egress` dédié) —
|
||||
egress-only, pas de relay/hidden-service dans ce profil.
|
||||
- **API toolbox** : `POST /admin/tor/{on,off}` (par client, WG-hash), `GET /tor/state`,
|
||||
`POST /tor/newnym` + état SQLite + bandeau 🧅 UI.
|
||||
- **Leak-guard nft** + DNS-over-Tor (test : exit IP + resolver ≠ Unbound local).
|
||||
- **Caveat** : en mode Tor, forcer `tls_splice` OFF pour ce client (sinon les flux
|
||||
asset fuient l'IP réelle). Soak DARK (flag présent, UI cachée) avant flip.
|
||||
- ✅ **Transport tranché** : *torify l'egress MITM* (owner-match nft sur l'uid
|
||||
`secubox-toolbox`/mitm-wg → Tor TransPort 9040 / DNSPort 5353). Inspection
|
||||
préservée. Décision USER (vs dialer SOCKS5 #662 = bloqué, vs torify client = casse
|
||||
l'inspection).
|
||||
- ✅ **Switch** : flags `tor_mode`/`tor_preset` (filters.json) ; API kbin-gated
|
||||
`GET/POST /admin/tor/{state,on,off,newnym,check-leaks}` ; onglet 🧅 WebUI (badge,
|
||||
toggle, NEWNYM, sonde fuite). `tor_ctl.py` réutilise le control-port de secubox-tor.
|
||||
- ✅ **Tunnel** : `conf/nft-toolbox-tor.nft` (fail-closed kill-switch + drop v6) +
|
||||
`conf/torrc-toolbox-egress.conf` + reconciler root path-triggered
|
||||
(`secubox-toolbox-tor.path` surveille filters.json → portail reste
|
||||
NoNewPrivileges=true). nft chargé AVANT tor (pas de fenêtre clearnet).
|
||||
- ✅ 166 tests verts ; license headers OK ; changelog 2.7.1.
|
||||
|
||||
#### ⬜ Avant flip ON (USER)
|
||||
|
||||
- Soak DARK puis `tor_mode=true` via l'onglet (admin.gk2).
|
||||
- Test de fuite **hors-board** : l'IP réelle de la box ne doit jamais apparaître.
|
||||
- Forcer `tls_splice` (#649) OFF quand armé (sinon flux asset fuient l'IP réelle).
|
||||
- **Per-client (WG-hash)** : nécessite le dialer SOCKS5 du cœur Go #662 (l'owner-match
|
||||
est global). Suivi sous #662.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ android {
|
|||
applicationId = "in.secubox.toolbox"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 3
|
||||
versionName = "0.3.0"
|
||||
versionCode = 4
|
||||
versionName = "0.4.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
|
|
|||
|
|
@ -88,12 +88,23 @@ fun OnboardApp() {
|
|||
busy = false; status = "Borne injoignable — vérifie le réseau."
|
||||
} else {
|
||||
step = Step.RootAuto
|
||||
val onb = RootOnboard(api, ctx.cacheDir)
|
||||
val onb = RootOnboard(api, ctx.cacheDir, ctx.filesDir)
|
||||
val out = withContext(Dispatchers.IO) {
|
||||
onb.runSilent { line -> scope.launch(Dispatchers.Main) { rootLog.add(line) } }
|
||||
}
|
||||
busy = false
|
||||
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 {
|
||||
out.verified -> step = Step.Done
|
||||
out.wgViaApp -> { step = Step.ImportProfile
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class OnboardService : Service() {
|
|||
kotlinx.coroutines.delay(2000)
|
||||
}
|
||||
if (!ok) return
|
||||
RootOnboard(api, cacheDir).runSilent { /* headless: no UI log */ }
|
||||
RootOnboard(api, cacheDir, filesDir).runSilent { /* headless: no UI log */ }
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,14 @@ import java.io.File
|
|||
import java.security.MessageDigest
|
||||
import java.security.cert.CertificateFactory
|
||||
|
||||
class RootOnboard(private val api: ToolboxApi, private val cacheDir: File) {
|
||||
class RootOnboard(
|
||||
private val api: ToolboxApi,
|
||||
private val cacheDir: File,
|
||||
// #683: app-internal storage for the STABLE WG identity (survives reboot).
|
||||
// Defaults to cacheDir so older call sites still compile, but real callers
|
||||
// pass filesDir so the identity persists instead of churning each boot.
|
||||
private val filesDir: File = cacheDir,
|
||||
) {
|
||||
|
||||
/** A line appended to the on-screen log during the silent run. */
|
||||
fun interface Logger { fun log(line: String) }
|
||||
|
|
@ -123,8 +130,10 @@ class RootOnboard(private val api: ToolboxApi, private val cacheDir: File) {
|
|||
log.log("• Noyau sans module WireGuard — bascule sur l'app WireGuard")
|
||||
return false
|
||||
}
|
||||
log.log("• Génération du profil WireGuard…")
|
||||
val conf = api.downloadProfile(cacheDir).readText()
|
||||
log.log("• Profil WireGuard (identité stable)…")
|
||||
// #683: reuse the persisted keypair so the device keeps ONE identity
|
||||
// across reboots (no more stats reset to a fresh empty hash each boot).
|
||||
val conf = api.persistentProfile(filesDir).readText()
|
||||
val wg = parse(conf) ?: run { log.log("✗ profil illisible"); return false }
|
||||
val iface = "wg-village3b"
|
||||
val r = RootShell.runScript(
|
||||
|
|
|
|||
|
|
@ -51,6 +51,41 @@ class ToolboxApi(rawHost: String) {
|
|||
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)
|
||||
|
||||
/**
|
||||
* 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?). */
|
||||
fun r3Check(): Pair<Boolean, String?> {
|
||||
val c = open("/wg/r3-check")
|
||||
|
|
|
|||
|
|
@ -65,6 +65,17 @@ async function r3Check(host) {
|
|||
}
|
||||
}
|
||||
|
||||
// #683 — kbin Tor egress status (public, kbin-safe endpoint).
|
||||
async function torStatus(host) {
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl(host)}/wg/tor-status`, { credentials: "omit" });
|
||||
if (!resp.ok) return { tor_mode: false };
|
||||
return await resp.json();
|
||||
} catch (_) {
|
||||
return { tor_mode: false };
|
||||
}
|
||||
}
|
||||
|
||||
// graph: the per-session cartographie JSON. Throws on HTTP error so the
|
||||
// caller can show "token expired — re-pair".
|
||||
async function graph(host, token, since) {
|
||||
|
|
@ -133,6 +144,7 @@ const SbxApi = {
|
|||
setConfig,
|
||||
pair,
|
||||
r3Check,
|
||||
torStatus,
|
||||
graph,
|
||||
wipe,
|
||||
ghost,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "SecuBox ToolBoX — Cartographie sociale",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"description": "Surface the SecuBox R3 toolbox live tracker analysis (cartographie sociale) in your browser: live badge, per-session trackers, mini Round-Eye graph, RGPD wipe + PDF report.",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<body>
|
||||
<header>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -111,6 +111,21 @@ async function load() {
|
|||
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) {
|
||||
$("host").value = cfg.host;
|
||||
show("pair");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
# Design — kbin Tor endpoint: quick-switch anonymized web surfing
|
||||
|
||||
*Spec · 2026-06-19 · issue [#683](https://github.com/CyberMind-FR/secubox-deb/issues/683) · status: PLAN (no code yet)*
|
||||
*Spec · 2026-06-19 · issue [#683](https://github.com/CyberMind-FR/secubox-deb/issues/683) · status: IMPLEMENTED DARK in secubox-toolbox 2.7.1*
|
||||
|
||||
> **Implemented (Option A-variant: torify MITM egress).** Switch + tunnel shipped
|
||||
> default-OFF / fail-closed. Tunnel = nft owner-match on the `secubox-toolbox`
|
||||
> (mitm-wg) uid → Tor TransPort 9040 / DNSPort 5353; loaded by a root,
|
||||
> path-triggered reconciler (`secubox-toolbox-tor.path`) so the portal stays
|
||||
> `NoNewPrivileges=true`. API `GET/POST /admin/tor/*` (kbin-gated) + 🧅 WebUI tab.
|
||||
> Control/status/NEWNYM reuse secubox-tor's control-port code (`tor_ctl.py`).
|
||||
> **Granularity is global kbin Tor mode** (owner-match can't be per-client);
|
||||
> per-client (WG-hash) Tor needs the #662 Go-core SOCKS5 dialer — tracked as a
|
||||
> follow-up. Before flipping ON: soak + off-board leak test (real board IP must
|
||||
> never appear); `tls_splice` (#649) should be OFF for torified flows.
|
||||
|
||||
## Problem
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,14 @@ traverse le pipeline de forge MITM SecuBox — sans configuration, sans app obli
|
|||
|
||||
---
|
||||
|
||||
## La lame suivante : 🧅 Tor quick-switch (plan #683)
|
||||
## La lame suivante : 🧅 Tor quick-switch (#683 — implémenté DARK en 2.7.1)
|
||||
|
||||
> **Statut** : switch + tunnel livrés dans `secubox-toolbox` 2.7.1, **défaut OFF /
|
||||
> fail-closed**. Onglet 🧅 Tor dans la WebUI opérateur (badge bootstrap/circuits/IP
|
||||
> de sortie, toggle arm/désarm, nouvelle identité NEWNYM, sonde de fuite SOCKS).
|
||||
> Granularité = mode Tor **global** (l'owner-match nft ne peut pas être par-client ;
|
||||
> le per-client viendra avec le dialer SOCKS5 du cœur Go #662). Avant de basculer
|
||||
> ON : soak + test de fuite hors-board (l'IP réelle ne doit jamais apparaître).
|
||||
|
||||
C'est la **pointe manquante** : l'anonymat de la sortie.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
{# SPDX-License-Identifier: LicenseRef-CMSD-1.0 #}
|
||||
{# Public landing page — kbin.gk2.secubox.in #}
|
||||
{# Radical-simplify redesign (#543): animated hero + one CTA + install panel
|
||||
up top ; everything else folded behind "En savoir plus". #}
|
||||
{# #683 restyle: aligned with the new /report look — system font, rounded
|
||||
--panel/--line cards, cleaner accents. Dynamic bits (data-live KPIs + JS,
|
||||
install panels, cert-probe, ?mh links) unchanged. #}
|
||||
<!DOCTYPE html>
|
||||
<html lang=fr><head>
|
||||
<meta charset=UTF-8>
|
||||
|
|
@ -10,108 +11,84 @@
|
|||
<title>👁️ VILLAGE3B — Qui te piste ?</title>
|
||||
<link rel=manifest href=/manifest.json>
|
||||
<style>
|
||||
:root{--bg:#0a0a0f;--bg2:#0e0e15;--phos:#00dd44;--phos-hot:#00ff55;--dim:#006622;--text:#e8e6d9;--purple:#9e76ff;--gold:#c9a84c;--amber:#ffb347;--red:#ff4466;--cyan:#00d4ff}
|
||||
:root{--bg:#0a0a0f;--panel:#11131a;--soft:#0d0f15;--phos:#00dd44;--phos-hot:#00ff55;--dim:#5a6b60;--line:#1e2630;--text:#e8e6d9;--purple:#9e76ff;--gold:#c9a84c;--amber:#ffb347;--red:#ff4466;--cyan:#66bbff}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
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}
|
||||
|
||||
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);line-height:1.55;padding-bottom:3rem}
|
||||
a{color:var(--phos);text-decoration:none}a:hover{text-decoration:underline}
|
||||
.help{color:var(--dim);font-size:.8rem;font-style:italic}
|
||||
/* ── HERO ── */
|
||||
.hero{position:relative;overflow:hidden;background:radial-gradient(120% 120% at 50% -10%,#221041 0%,#0a0a0f 60%);padding:3rem 1.5rem 2.4rem;text-align:center;border-bottom:2px solid var(--phos)}
|
||||
.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))}
|
||||
.hero{position:relative;overflow:hidden;background:radial-gradient(120% 120% at 50% -10%,#1a1030 0%,#0a0a0f 62%);padding:3rem 1.5rem 2.4rem;text-align:center;border-bottom:1px solid var(--line)}
|
||||
.eye{font-size:3.4rem;line-height:1;display:inline-block;animation:gaze 5s ease-in-out infinite;filter:drop-shadow(0 0 14px rgba(0,255,85,.5))}
|
||||
@keyframes gaze{0%,100%{transform:translateX(0) scale(1)}25%{transform:translateX(-6px) scale(1.04)}60%{transform:translateX(7px) scale(1.04)}}
|
||||
.hero h1{font-size:2.6rem;color:var(--phos-hot);text-shadow:0 0 10px var(--phos);letter-spacing:0.08em;margin-top:0.3rem}
|
||||
.hero .punch{color:var(--text);font-size:1.25rem;margin-top:0.6rem;font-weight:bold}
|
||||
.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" */
|
||||
.hero h1{font-size:2.4rem;color:var(--phos-hot);letter-spacing:.06em;margin-top:.3rem;font-weight:800}
|
||||
.hero .punch{color:var(--text);font-size:1.2rem;margin-top:.6rem;font-weight:700}.hero .punch b{color:var(--gold)}
|
||||
.hero .sub{color:var(--dim);font-size:.82rem;margin-top:.5rem;max-width:560px;margin-left:auto;margin-right:auto}
|
||||
.dots{position:absolute;inset:0;pointer-events:none;z-index:0}
|
||||
.dots i{position:absolute;width:7px;height:7px;border-radius:50%;opacity:0.0;animation:float 7s ease-in-out infinite}
|
||||
.dots i:nth-child(1){left:12%;top:30%;background:var(--cyan);animation-delay:.0s}
|
||||
.dots i{position:absolute;width:7px;height:7px;border-radius:50%;opacity:0;animation:float 7s ease-in-out infinite}
|
||||
.dots i:nth-child(1){left:12%;top:30%;background:var(--cyan);animation-delay:0s}
|
||||
.dots i:nth-child(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(4){left:70%;top:64%;background:var(--purple);animation-delay:.7s}
|
||||
.dots i:nth-child(5){left:50%;top:14%;background:var(--cyan);animation-delay:3.0s}
|
||||
.dots i:nth-child(5){left:50%;top:14%;background:var(--cyan);animation-delay:3s}
|
||||
.dots i:nth-child(6){left:90%;top:54%;background:var(--red);animation-delay:1.8s}
|
||||
@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}
|
||||
|
||||
/* ── big CTA row ── */
|
||||
.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 row ── */
|
||||
.ctas{margin-top:1.4rem;display:flex;gap:.6rem;justify-content:center;flex-wrap:wrap}
|
||||
.cta{display:inline-block;padding:.85rem 1.6rem;font-weight:700;border-radius:10px;font-size:1.02rem;transition:transform .12s}
|
||||
.cta:hover{text-decoration:none;transform:translateY(-2px)}
|
||||
.cta.go{background:var(--phos);color:#0a0a0f;box-shadow:0 4px 18px rgba(0,221,68,0.4)}
|
||||
.cta.go:hover{box-shadow:0 6px 24px rgba(0,221,68,0.6)}
|
||||
.cta.go{background:var(--phos);color:#06140a;box-shadow:0 4px 18px rgba(0,221,68,.35)}
|
||||
.cta.alt{background:transparent;color:var(--purple);border:1px solid var(--purple)}
|
||||
.cta.alt:hover{background:rgba(158,118,255,0.12)}
|
||||
|
||||
/* ── quicknav (trimmed) ── */
|
||||
.quicknav{display:flex;flex-wrap:wrap;justify-content:center;gap:0.6rem;margin-top:1.4rem;max-width:620px;margin-left:auto;margin-right:auto}
|
||||
.qi{display:flex;flex-direction:column;align-items:center;gap:4px;padding:0.5rem 0.4rem;min-width:74px;background:rgba(110,64,201,0.08);border:1px solid var(--purple);border-radius:8px;text-decoration:none;color:var(--text);transition:all 0.12s;font-family:inherit}
|
||||
.qi:hover{background:rgba(110,64,201,0.22);transform:translateY(-2px);box-shadow:0 4px 14px rgba(158,118,255,0.35);text-decoration:none}
|
||||
.qi-emoji{font-size:1.5rem;line-height:1}
|
||||
.qi-label{font-size:0.62rem;letter-spacing:0.04em;color:var(--phos-hot);font-weight:bold;white-space:nowrap}
|
||||
|
||||
.container{max-width:1080px;margin:auto;padding:2rem 1.5rem}
|
||||
.section{margin-bottom:2.5rem}
|
||||
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}
|
||||
h3{color:var(--purple);font-size:1rem;margin-bottom:0.5rem}
|
||||
.grid{display:grid;gap:1rem}
|
||||
.grid-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
|
||||
.grid-4{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))}
|
||||
.card{border:1px solid var(--dim);background:var(--bg2);padding:1rem 1.2rem;border-radius:4px}
|
||||
.card.purple{border-color:var(--purple);background:rgba(110,64,201,0.05)}
|
||||
.card.amber{border-color:var(--amber);background:rgba(255,179,71,0.05)}
|
||||
.kpi{text-align:center;padding:1rem;background:rgba(0,221,68,0.05);border:1px solid var(--phos);border-radius:4px}
|
||||
.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}
|
||||
.cta.alt:hover{background:rgba(158,118,255,.12)}
|
||||
/* ── quicknav ── */
|
||||
.quicknav{display:flex;flex-wrap:wrap;justify-content:center;gap:.6rem;margin:1.4rem auto 0;max-width:620px}
|
||||
.qi{display:flex;flex-direction:column;align-items:center;gap:4px;padding:.55rem .45rem;min-width:74px;background:var(--soft);border:1px solid var(--line);border-radius:12px;color:var(--text);transition:.12s;font-family:inherit}
|
||||
.qi:hover{border-color:var(--purple);transform:translateY(-2px);text-decoration:none}
|
||||
.qi-emoji{font-size:1.5rem;line-height:1}.qi-label{font-size:.62rem;letter-spacing:.04em;color:var(--phos-hot);font-weight:700;white-space:nowrap}
|
||||
/* ── layout ── */
|
||||
.container{max-width:760px;margin:auto;padding:1.6rem 1.1rem}
|
||||
.section{margin-bottom:1.7rem}
|
||||
h2{color:var(--phos-hot);font-size:1.12rem;margin-bottom:.6rem;letter-spacing:.02em}
|
||||
h3{color:var(--purple);font-size:.95rem;margin-bottom:.4rem}
|
||||
.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))}
|
||||
.card{border:1px solid var(--line);background:var(--panel);padding:1rem 1.1rem;border-radius:12px}
|
||||
.card.purple{border-color:rgba(158,118,255,.4)}.card.amber{border-color:rgba(255,179,71,.4)}
|
||||
.kpi{text-align:center;padding:.8rem .4rem;background:var(--soft);border:1px solid var(--line);border-radius:12px}
|
||||
.kpi .v{font-size:1.7rem;font-weight:800;color:var(--phos-hot);display:block}.kpi .l{font-size:.66rem;color:var(--dim)}
|
||||
.level{display:flex;align-items:start;gap:.8rem;padding:.85rem;border-radius:12px}
|
||||
.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}
|
||||
.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)}
|
||||
.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}
|
||||
.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}
|
||||
svg.chart{width:100%;max-width:400px;height:auto}
|
||||
.svg-bar{fill:var(--phos);transition:fill 0.3s}
|
||||
.svg-bar.medium{fill:var(--amber)}
|
||||
.svg-bar.high{fill:var(--red)}
|
||||
code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;color:var(--phos-hot)}
|
||||
.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}
|
||||
.cta-sm.outline{background:transparent;color:var(--phos);border:1px solid var(--phos)}
|
||||
.footer{text-align:center;font-size:0.78rem;color:var(--dim);padding:1.5rem;border-top:1px solid var(--dim);margin-top:2rem}
|
||||
.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}
|
||||
.svg-bar{fill:var(--phos)}.svg-bar.medium{fill:var(--amber)}.svg-bar.high{fill:var(--red)}
|
||||
code{background:var(--soft);padding:.1rem .4rem;border-radius:4px;font-size:.82rem;color:var(--phos-hot);font-family:ui-monospace,Menlo,monospace}
|
||||
.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)}
|
||||
.footer{text-align:center;font-size:.7rem;color:var(--dim);padding:1.4rem;border-top:1px solid var(--line);margin-top:2rem}
|
||||
.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}
|
||||
/* ── install panel ── */
|
||||
.install-panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:.7rem 1rem;margin:.5rem 0;text-align:left}
|
||||
.install-panel summary{cursor:pointer;font-size:.95rem;color:var(--phos-hot);list-style:none;outline:none;font-weight:700}
|
||||
.install-panel summary::-webkit-details-marker{display:none}
|
||||
.install-panel[open] summary{margin-bottom:0.6rem}
|
||||
.install-panel .emoji{font-size:1.1rem;margin-right:0.3rem}
|
||||
.install-panel ol{padding-left:1.1rem;line-height:1.5;font-size:0.85rem}
|
||||
.install-panel .btn{display:inline-block;padding:0.45rem 0.75rem;margin:0.25rem 0.2rem 0.25rem 0;background:var(--purple);color:#fff;text-decoration:none;border-radius:5px;font-weight:bold;font-size:0.82rem}
|
||||
.install-panel[open] summary{margin-bottom:.6rem}
|
||||
.install-panel .emoji{font-size:1.1rem;margin-right:.3rem}
|
||||
.install-panel ol{padding-left:1.1rem;line-height:1.5;font-size:.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.alt{background:transparent;border:1px solid var(--purple);color:var(--purple)}
|
||||
.install-panel code{background:rgba(0,0,0,0.4);padding:0.1rem 0.35rem;border-radius:3px;font-size:0.8rem;color:var(--phos-hot)}
|
||||
.install-panel .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:5px;margin:0.4rem 0}
|
||||
.install-panel pre{background:rgba(0,0,0,0.4);padding:0.5rem 0.7rem;border-radius:4px;overflow-x:auto;font-size:0.78rem;margin:0.4rem 0}
|
||||
|
||||
.install-panel code{background:var(--soft);padding:.1rem .35rem;border-radius:4px;font-size:.8rem;color:var(--phos-hot)}
|
||||
.install-panel .note{color:var(--dim);font-size:.78rem;margin-top:.6rem;border-left:2px solid var(--amber);padding-left:.6rem}
|
||||
.install-panel img{max-width:100%;border-radius:8px;margin:.4rem 0}
|
||||
.install-panel pre{background:var(--soft);padding:.5rem .7rem;border-radius:8px;overflow-x:auto;font-size:.76rem;margin:.4rem 0}
|
||||
/* ── "En savoir plus" fold ── */
|
||||
.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:0.95rem;letter-spacing:0.05em;padding:0.9rem;border:1px dashed var(--purple);border-radius:8px;margin-bottom:1rem;transition:background .12s}
|
||||
.more{max-width:760px;margin:0 auto;padding:0 1.1rem}
|
||||
.more>summary{cursor:pointer;list-style:none;text-align:center;color:var(--purple);font-size:.92rem;letter-spacing:.04em;padding:.85rem;border:1px solid var(--line);border-radius:12px;margin-bottom:1rem;transition:background .12s}
|
||||
.more>summary::-webkit-details-marker{display:none}
|
||||
.more>summary:hover{background:rgba(158,118,255,0.1)}
|
||||
.more[open]>summary{margin-bottom:1.6rem}
|
||||
.more>summary:hover{background:rgba(158,118,255,.08)}
|
||||
.more[open]>summary{margin-bottom:1.4rem}
|
||||
.more>summary .chev{display:inline-block;transition:transform .2s}
|
||||
.more[open]>summary .chev{transform:rotate(90deg)}
|
||||
|
||||
@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)}}
|
||||
@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)}}
|
||||
</style></head><body>
|
||||
|
||||
<div class=hero>
|
||||
|
|
@ -123,19 +100,17 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
|
|||
|
||||
<div class=ctas>
|
||||
<a href="/wg/r3-install" class="cta go">✨ Protège-moi (R3)</a>
|
||||
<a href="/social/me" class="cta alt">🕸️ Qui me piste ?</a>
|
||||
<a href="/social/me{{ '?mh=' + mac_hash if mac_hash else '' }}" class="cta alt">🕸️ Qui me piste ?</a>
|
||||
</div>
|
||||
|
||||
{# trimmed quick-nav — CA iPhone / CA Android / QR profil moved into the
|
||||
per-platform install panel below (#543) #}
|
||||
<div class=quicknav>
|
||||
<a href="/wg/r3-install" class=qi title="Installer R3 WireGuard">
|
||||
<span class=qi-emoji>🌐</span><span class=qi-label>R3 Install</span>
|
||||
</a>
|
||||
<a href="/report/me/html" class=qi title="Mon rapport live">
|
||||
<a href="/report/me/html{{ '?mh=' + mac_hash if mac_hash else '' }}" class=qi title="Mon rapport live">
|
||||
<span class=qi-emoji>📊</span><span class=qi-label>Mon rapport</span>
|
||||
</a>
|
||||
<a href="/social/me" class=qi title="Cartographie sociale — qui me piste, où ?">
|
||||
<a href="/social/me{{ '?mh=' + mac_hash if mac_hash else '' }}" class=qi title="Cartographie sociale — qui me piste, où ?">
|
||||
<span class=qi-emoji>🕸️</span><span class=qi-label>Ma carto</span>
|
||||
</a>
|
||||
<a href="https://github.com/CyberMind-FR/secubox-deb/wiki/R3-WireGuard-install" class=qi title="Wiki R3 multi-OS">
|
||||
|
|
@ -151,12 +126,12 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
|
|||
<div class=container>
|
||||
<div class=section style="margin-bottom:1.5rem">
|
||||
<h2>📥 Installe en 1 tap</h2>
|
||||
<p style="font-size:0.85rem;color:var(--dim);margin-bottom:0.8rem">
|
||||
<p class=help style="margin-bottom:.8rem">
|
||||
On a détecté <code>{{ install_platform }}</code> — le panneau adapté est ouvert.
|
||||
Le CA, le QR et le profil sont dedans. Autre appareil ? Déplie le bon panneau.
|
||||
</p>
|
||||
{{ install_panels | safe }}
|
||||
<p style="margin-top:0.8rem;font-size:0.78rem;color:var(--dim)">
|
||||
<p class=help style="margin-top:.8rem">
|
||||
R3 marche hors-cabine (4G/5G, autre WiFi), couvre tout le HTTPS, et se révoque
|
||||
à tout moment. Page standalone : <a href=/wg/onboard>/wg/onboard</a>.
|
||||
</p>
|
||||
|
|
@ -170,8 +145,8 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
|
|||
{# ── KPI live (auto-refresh 5s via /cumulative-stats.json) ── #}
|
||||
<div class=section>
|
||||
<h2>📊 Cabine en chiffres
|
||||
<span id=live-badge style="font-size:0.7rem;background:var(--red);color:#fff;padding:0.15rem 0.5rem;border-radius:99px;margin-left:0.5rem;letter-spacing:0.05em;animation:pulse 1.5s infinite">🔴 LIVE</span>
|
||||
<span id=live-stamp style="font-size:0.7rem;color:var(--dim);margin-left:0.4rem">·</span>
|
||||
<span id=live-badge style="font-size:.7rem;background:var(--red);color:#fff;padding:.15rem .5rem;border-radius:99px;margin-left:.5rem;letter-spacing:.04em;animation:pulse 1.5s infinite">🔴 LIVE</span>
|
||||
<span id=live-stamp style="font-size:.7rem;color:var(--dim);margin-left:.4rem">·</span>
|
||||
</h2>
|
||||
<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>
|
||||
|
|
@ -193,16 +168,16 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
|
|||
<div class="card purple" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
|
||||
<div style="font-size:2.4rem;flex-shrink:0" id=cert-probe-emoji>❔</div>
|
||||
<div style="flex:1;min-width:220px">
|
||||
<p id=cert-probe-text style="font-size:0.92rem;line-height:1.5">
|
||||
<p id=cert-probe-text style="font-size:.92rem;line-height:1.5">
|
||||
Clique sur <b>Tester</b> pour vérifier que ton CA R3 est bien installé sur ton device
|
||||
(depuis le tunnel WireGuard). Le test télécharge une image HTTPS et vérifie le succès.
|
||||
</p>
|
||||
<p style="font-size:0.75rem;color:var(--dim);margin-top:0.3rem">
|
||||
<p class=help style="margin-top:.3rem">
|
||||
Empreinte SHA1 CA R3 (à vérifier dans Réglages iPhone) :
|
||||
<code id=cert-fp-r3 style="font-size:0.7rem">…</code>
|
||||
<code id=cert-fp-r3 style="font-size:.7rem">…</code>
|
||||
</p>
|
||||
</div>
|
||||
<button id=cert-probe-btn style="background:var(--purple);color:#0a0a0f;border:0;padding:0.7rem 1.2rem;font-weight:bold;border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9rem">🔬 Tester</button>
|
||||
<button id=cert-probe-btn style="background:var(--purple);color:#06140a;border:0;padding:.7rem 1.2rem;font-weight:700;border-radius:10px;cursor:pointer;font-family:inherit;font-size:.9rem">🔬 Tester</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -215,7 +190,7 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
|
|||
niveau d'analyse, tu obtiens un rapport détaillé sur les apps, trackers, certificats,
|
||||
et risques observés pendant ta session.
|
||||
</p>
|
||||
<p style="margin-top:0.6rem">
|
||||
<p style="margin-top:.6rem">
|
||||
<b>Conçu par CyberMind / Gérald Kerma</b> à Notre-Dame-du-Cruet (Savoie). Conforme
|
||||
<b>CSPN ANSSI</b> + <b>LCEN</b> : consentement explicite, hash MAC quotidien rotatif,
|
||||
données effacées après 24h, aucun envoi externe.
|
||||
|
|
@ -248,10 +223,10 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
|
|||
</div>
|
||||
</div>
|
||||
<div class="level r3">
|
||||
<span class=emj>🌐</span>
|
||||
<span class=emj>🧅</span>
|
||||
<div class=body>
|
||||
<b>R3 — WireGuard portable<span class=tag-new>NOUVEAU</span></b>
|
||||
<p class=desc>VPN tunnel mitm. Bandeau sur TOUT (HTTPS + QUIC). <b>Marche hors VILLAGE3B</b> (4G/5G, autre WiFi). Profile install 1 tap via QR.</p>
|
||||
<p class=desc>VPN tunnel mitm. Bandeau sur TOUT (HTTPS + QUIC). <b>Marche hors VILLAGE3B</b> (4G/5G, autre WiFi). Option sortie 🧅 Tor anonymisée. Profile install 1 tap via QR.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -269,14 +244,15 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
|
|||
{% set mpct = (risk.medium * 100 / total) | round(0) | int %}
|
||||
{% set hpct = (risk.high * 100 / total) | round(0) | int %}
|
||||
<svg class=chart viewBox="0 0 300 60" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x=0 y=20 width="{{ lpct * 3 }}" height=20 class=svg-bar/>
|
||||
<rect x=0 y=20 width="{{ lpct * 3 }}" height=20 rx=4 class=svg-bar/>
|
||||
<rect x="{{ lpct * 3 }}" y=20 width="{{ mpct * 3 }}" height=20 class="svg-bar medium"/>
|
||||
<rect x="{{ (lpct + mpct) * 3 }}" y=20 width="{{ hpct * 3 }}" height=20 class="svg-bar high"/>
|
||||
<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-family=monospace font-size=10>🟡 {{ mpct }}% MED</text>
|
||||
<text x="{{ (lpct + mpct) * 3 + 10 }}" y=15 fill="#ff4466" font-family=monospace font-size=10>🔴 {{ hpct }}% HI</text>
|
||||
<text x=10 y=55 fill="#666" font-family=monospace font-size=8>{{ total }} sessions analysées</text>
|
||||
<rect x="{{ (lpct + mpct) * 3 }}" y=20 width="{{ hpct * 3 }}" height=20 rx=4 class="svg-bar high"/>
|
||||
<text x=10 y=15 fill="#00ff55" font-size=10>🟢 {{ lpct }}% LOW</text>
|
||||
<text x="{{ lpct * 3 + 10 }}" y=15 fill="#ffb347" font-size=10>🟡 {{ mpct }}% MED</text>
|
||||
<text x="{{ (lpct + mpct) * 3 + 10 }}" y=15 fill="#ff4466" font-size=10>🔴 {{ hpct }}% HI</text>
|
||||
<text x=10 y=55 fill="#5a6b60" font-size=8>{{ total }} sessions analysées</text>
|
||||
</svg>
|
||||
<p class=help>La plupart des sessions sont à faible risque.</p>
|
||||
</div>
|
||||
<div class=card>
|
||||
<h3>🛡 Niveau d'opt-in choisi par les visiteurs</h3>
|
||||
|
|
@ -284,15 +260,16 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
|
|||
{% set ltotal = (lvl.r0 + lvl.r1 + lvl.r2 + lvl.r3) or 1 %}
|
||||
<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] %}
|
||||
<rect x=0 y=10 width="{{ ws[0] }}" height=15 fill="#666"/>
|
||||
<rect x=0 y=30 width="{{ ws[1] }}" height=15 fill="#00dd44"/>
|
||||
<rect x=0 y=50 width="{{ ws[2] }}" height=15 fill="#ffb347"/>
|
||||
<rect x=0 y=70 width="{{ ws[3] }}" height=15 fill="#9e76ff"/>
|
||||
<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-family=monospace font-size=10>🛡 R1 ({{ lvl.r1 }})</text>
|
||||
<text x="{{ ws[2] + 5 }}" y=62 fill="#ffd6a0" font-family=monospace font-size=10>🔍 R2 ({{ lvl.r2 }})</text>
|
||||
<text x="{{ ws[3] + 5 }}" y=82 fill="#cbb6ff" font-family=monospace font-size=10>🌐 R3 ({{ lvl.r3 }})</text>
|
||||
<rect x=0 y=10 width="{{ ws[0] }}" height=15 rx=4 fill="#5a6b60"/>
|
||||
<rect x=0 y=30 width="{{ ws[1] }}" height=15 rx=4 fill="#00dd44"/>
|
||||
<rect x=0 y=50 width="{{ ws[2] }}" height=15 rx=4 fill="#ffb347"/>
|
||||
<rect x=0 y=70 width="{{ ws[3] }}" height=15 rx=4 fill="#9e76ff"/>
|
||||
<text x="{{ ws[0] + 5 }}" y=22 fill="#888" font-size=10>🌐 R0 ({{ lvl.r0 }})</text>
|
||||
<text x="{{ ws[1] + 5 }}" y=42 fill="#00ff55" font-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[3] + 5 }}" y=82 fill="#cbb6ff" font-size=10>🧅 R3 ({{ lvl.r3 }})</text>
|
||||
</svg>
|
||||
<p class=help>R1 (analyse passive) est le choix le plus courant.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -303,7 +280,7 @@ code{background:#222;padding:0.1rem 0.4rem;border-radius:2px;font-size:0.85rem;c
|
|||
<div class=arch>
|
||||
LXC mitmproxy 10.100.0.60 WAF (vhosts CyberMind)
|
||||
LXC toolbox-mitm 10.100.0.61 R1/R2 transparent
|
||||
LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
|
||||
LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard (+🧅 Tor)
|
||||
↑
|
||||
CAs séparées · addons partagés · DB unifiée
|
||||
↑
|
||||
|
|
@ -318,7 +295,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
|
|||
Le code est <b>publié intégralement</b> sous licence <b>CyberMind Source-Disclosed v1.0</b>
|
||||
(audit citoyen possible, droits d'usage régis par licence CMSD). Pas de boîte noire.
|
||||
</p>
|
||||
<div style="margin-top:0.6rem">
|
||||
<div style="margin-top:.6rem">
|
||||
<a href="https://github.com/CyberMind-FR/secubox-deb" class=cta-sm>📂 Code source GitHub</a>
|
||||
<a href="https://github.com/CyberMind-FR/secubox-deb/blob/master/LICENCE-CMSD-1.0.md" class="cta-sm outline">📜 Licence CMSD-1.0</a>
|
||||
</div>
|
||||
|
|
@ -326,11 +303,11 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
|
|||
|
||||
{# ── Contact ── #}
|
||||
<div class=section>
|
||||
<h2>📡 Contact & soutiens</h2>
|
||||
<h2>📡 Contact & soutiens</h2>
|
||||
<div class="grid grid-2">
|
||||
<div class=card>
|
||||
<h3>💚 Soutenir le projet</h3>
|
||||
<ul style="list-style:none;padding-left:0;font-size:0.85rem">
|
||||
<ul style="list-style:none;padding-left:0;font-size:.85rem">
|
||||
<li>💰 Don récurrent : <a href="https://liberapay.com/cybermind">liberapay.com/cybermind</a></li>
|
||||
<li>💳 Don 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>
|
||||
|
|
@ -338,7 +315,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
|
|||
</div>
|
||||
<div class="card purple">
|
||||
<h3>🏢 Déploiement collectivité</h3>
|
||||
<ul style="list-style:none;padding-left:0;font-size:0.85rem">
|
||||
<ul style="list-style:none;padding-left:0;font-size:.85rem">
|
||||
<li>📡 Borne grand public : <a href="mailto:gondwana@cybermind.fr">gondwana@cybermind.fr</a></li>
|
||||
<li>🎓 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>
|
||||
|
|
@ -376,7 +353,6 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
|
|||
var ss = String(d.getSeconds()).padStart(2,'0');
|
||||
return 'maj ' + hh+':'+mm+':'+ss;
|
||||
}
|
||||
// count-up: animate each KPI from 0 → its server-rendered value, once.
|
||||
function countUp(el, target){
|
||||
var start = 0, dur = 900, t0 = null;
|
||||
function step(ts){
|
||||
|
|
@ -403,7 +379,7 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
|
|||
if (prev !== next) {
|
||||
el.textContent = next;
|
||||
el.classList.remove('tick');
|
||||
void el.offsetWidth; // force reflow
|
||||
void el.offsetWidth;
|
||||
el.classList.add('tick');
|
||||
}
|
||||
});
|
||||
|
|
@ -449,8 +425,8 @@ LXC toolbox-mitm-wg 10.100.0.62 R3 WireGuard
|
|||
} else {
|
||||
emj.textContent = '🔴';
|
||||
txt.innerHTML = '<b>Tunnel R3 actif mais CA R3 NON trusté</b> — HTTPS casse. ' +
|
||||
'Installe le <a href=/wg/ca.mobileconfig style="color:var(--orange)">profil CA R3 iPhone</a> ' +
|
||||
'ou le <a href=/wg/ca.pem style="color:var(--orange)">ca.pem Android/PC</a>.';
|
||||
'Installe le <a href=/wg/ca.mobileconfig style="color:var(--amber)">profil CA R3 iPhone</a> ' +
|
||||
'ou le <a href=/wg/ca.pem style="color:var(--amber)">ca.pem Android/PC</a>.';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
52
packages/secubox-toolbox/conf/nft-toolbox-tor.nft
Normal file
52
packages/secubox-toolbox/conf/nft-toolbox-tor.nft
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# kbin Tor egress tunnel (#683) — torify ONLY the MITM worker uid's upstream.
|
||||
#
|
||||
# The R3 mitm-wg workers run as user `secubox-toolbox` and dial the *real*
|
||||
# upstream AFTER decrypting + inspecting + poisoning + bannering. We redirect
|
||||
# only that uid's internet egress (80/443) and ALL its DNS through Tor's
|
||||
# TransPort/DNSPort, so inspection is fully preserved and only the exit IP +
|
||||
# network identity change. Loaded/flushed by secubox-toolbox-tor-reconcile,
|
||||
# which also POPULATES the `tor_exempt` set (see below).
|
||||
#
|
||||
# Invariants: inspection preserved · no DNS leak · fail-closed (kill-switch
|
||||
# drops any worker egress that escaped the redirect — never leaks the real IP).
|
||||
#
|
||||
# `tor_exempt` = destinations that must NEVER be torified: loopback, the board's
|
||||
# own local subnets (LAN/WG/LXC), and the board's own PUBLIC IP. Without this the
|
||||
# box torifies traffic to its OWN services (kbin/admin resolve to the WAN IP,
|
||||
# reached via hairpin) → self-views round-trip Tor and load empty/slow. The
|
||||
# reconciler fills the set at arm time (it is intentionally empty here). The Tor
|
||||
# automap range (10.192.0.0/10) is deliberately NOT exempt so worker-initiated
|
||||
# hostname connections still egress via Tor.
|
||||
table inet toolbox_tor {
|
||||
set tor_exempt {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
}
|
||||
|
||||
# ── NAT: redirect the worker uid's egress into Tor (TransPort/DNSPort) ──
|
||||
chain output_nat {
|
||||
type nat hook output priority -100; policy accept;
|
||||
oifname "lo" return
|
||||
# DNS always via Tor (no leak), even to local resolvers — before the
|
||||
# tor_exempt return so own-subnet resolvers don't bypass it.
|
||||
ip daddr != 127.0.0.0/8 meta skuid "secubox-toolbox" meta l4proto { tcp, udp } th dport 53 redirect to :5353
|
||||
# Own services / LAN / loopback → DIRECT (never torified).
|
||||
ip daddr @tor_exempt return
|
||||
# Everything else from the worker uid → Tor TransPort.
|
||||
meta skuid "secubox-toolbox" tcp dport { 80, 443 } redirect to :9040
|
||||
}
|
||||
|
||||
# ── Kill-switch: fail-closed safety net (runs after the NAT redirect) ──
|
||||
chain output_killswitch {
|
||||
type filter hook output priority 0; policy accept;
|
||||
oifname "lo" return
|
||||
ip daddr @tor_exempt return
|
||||
# IPv4 that escaped the redirect (Tor down / rule gap) → drop, no leak
|
||||
meta skuid "secubox-toolbox" tcp dport { 80, 443 } ip daddr != 127.0.0.0/8 drop
|
||||
meta skuid "secubox-toolbox" meta l4proto { tcp, udp } th dport 53 ip daddr != 127.0.0.0/8 drop
|
||||
# IPv6 is not carried over Tor here → drop worker v6 egress (no v6 leak)
|
||||
meta skuid "secubox-toolbox" meta nfproto ipv6 tcp dport { 80, 443 } drop
|
||||
meta skuid "secubox-toolbox" meta nfproto ipv6 meta l4proto { tcp, udp } th dport 53 drop
|
||||
}
|
||||
}
|
||||
|
|
@ -1,662 +1,325 @@
|
|||
{# SPDX-License-Identifier: LicenseRef-CMSD-1.0 #}
|
||||
{# 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>
|
||||
<html lang="fr"><head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="refresh" content="15">
|
||||
{# Phase 3 (#492) : PWA tags for iOS Add-to-Home-Screen webclip experience #}
|
||||
<meta http-equiv="refresh" content="20">
|
||||
<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-title" content="ToolBoX Cabine">
|
||||
<meta name="theme-color" content="#0a0a0f">
|
||||
<title>Mon rapport Gondwana ToolBoX — live</title>
|
||||
<title>Mon rapport — VILLAGE3B</title>
|
||||
<style>
|
||||
:root{--bg:#0a0a0f;--phos:#00dd44;--phos-hot:#00ff55;--dim:#006622;--text:#e8e6d9;--red:#ff4466;--amber:#ffb347}
|
||||
:root{--bg:#0a0a0f;--panel:#11131a;--phos:#00dd44;--phos-hot:#00ff55;--dim:#5a6b60;--line:#1e2630;--text:#e8e6d9;--red:#ff4466;--amber:#ffb347;--violet:#9e76ff;--blue:#66bbff}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
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);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:0.85rem;margin-bottom:1rem;letter-spacing:0.05em}
|
||||
.card{border:1px solid var(--dim);background:rgba(0,221,68,0.03);padding:0.9rem 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}
|
||||
.kv{display:grid;grid-template-columns:auto 1fr;gap:0.2rem 0.8rem;font-size:0.85rem}
|
||||
.kv .k{color:var(--dim)}
|
||||
.kv .v{color:var(--phos);text-shadow:0 0 4px var(--phos)}
|
||||
ul{list-style:none;padding-left:0.6rem}
|
||||
li{padding:0.15rem 0;font-size:0.85rem}
|
||||
li::before{content:"▸ ";color:var(--phos);text-shadow:0 0 4px var(--phos)}
|
||||
.score{display:inline-block;padding:0.3rem 1rem;font-size:1.2rem;font-weight:bold;border:2px solid;border-radius:4px}
|
||||
.score.low{color:var(--phos-hot);border-color:var(--phos);text-shadow:0 0 6px var(--phos)}
|
||||
.score.med{color:var(--amber);border-color:var(--amber);text-shadow:0 0 6px var(--amber)}
|
||||
.score.high{color:var(--red);border-color:var(--red);text-shadow:0 0 6px var(--red)}
|
||||
.pin{color:var(--amber)}
|
||||
.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}
|
||||
.actions{text-align:center;margin:1.5rem 0}
|
||||
.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}
|
||||
.actions a:hover{background:rgba(0,221,68,0.1)}
|
||||
.refresh{text-align:center;font-size:0.7rem;color:var(--dim);margin-top:1rem;font-style:italic}
|
||||
.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}
|
||||
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);padding:1rem;max-width:740px;margin:auto;line-height:1.5}
|
||||
h1{color:var(--phos-hot);font-size:1.35rem;letter-spacing:.04em;display:flex;align-items:center;gap:.4rem}
|
||||
.sub{color:var(--dim);font-size:.82rem;margin-bottom:1.1rem}
|
||||
.help{color:var(--dim);font-size:.78rem;font-style:italic;margin-top:.25rem}
|
||||
.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);font-size:.95rem;margin-bottom:.5rem;letter-spacing:.03em}
|
||||
/* ── verdict hero ── */
|
||||
.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)}
|
||||
.gauge{width:170px;height:170px;border-radius:50%;margin:.4rem auto;display:flex;align-items:center;justify-content:center}
|
||||
.gauge-hole{width:124px;height:124px;border-radius:50%;background:var(--bg);display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||
.gauge-num{font-size:2.6rem;font-weight:800;line-height:1}
|
||||
.gauge-max{font-size:.8rem;color:var(--dim)}
|
||||
.verdict{font-size:1.15rem;font-weight:700;margin-top:.3rem}
|
||||
/* ── KPI row ── */
|
||||
.kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:.5rem;margin-top:.4rem}
|
||||
.kpi{background:#0d0f15;border:1px solid var(--line);border-radius:10px;padding:.6rem .3rem;text-align:center}
|
||||
.kpi .e{font-size:1.15rem}
|
||||
.kpi .n{font-size:1.3rem;font-weight:800;color:var(--phos-hot)}
|
||||
.kpi .l{font-size:.62rem;color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
||||
/* ── graphs ── */
|
||||
.graphs{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
|
||||
@media(max-width:560px){.graphs{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,1fr)}}
|
||||
.donut-wrap{display:flex;gap:.8rem;align-items:center}
|
||||
.donut{width:96px;height:96px;border-radius:50%;flex:0 0 auto}
|
||||
.donut-hole{width:54px;height:54px;border-radius:50%;background:var(--panel);margin:21px;display:flex;align-items:center;justify-content:center;font-size:.7rem;color:var(--dim);text-align:center}
|
||||
.legend{font-size:.78rem;display:flex;flex-direction:column;gap:.2rem;min-width:0}
|
||||
.legend .row{display:flex;align-items:center;gap:.35rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.dot{width:.6rem;height:.6rem;border-radius:50%;flex:0 0 auto}
|
||||
.bar-row{display:grid;grid-template-columns:5.5rem 1fr 2rem;gap:.45rem;align-items:center;font-size:.78rem;margin:.2rem 0}
|
||||
.bar-lbl{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.bar-track{background:#0d0f15;border-radius:99px;height:.7rem;overflow:hidden}
|
||||
.bar-fill{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,var(--phos),var(--phos-hot))}
|
||||
.bar-val{text-align:right;color:var(--phos);font-weight:700}
|
||||
.empty{color:var(--dim);font-size:.8rem;font-style:italic;padding:.4rem 0}
|
||||
/* ── details ── */
|
||||
details{border:1px solid var(--line);background:var(--panel);border-radius:12px;margin-bottom:1rem;overflow:hidden}
|
||||
details>summary{cursor:pointer;padding:.85rem 1.1rem;font-weight:700;color:var(--phos-hot);font-size:.92rem;list-style:none}
|
||||
details>summary::-webkit-details-marker{display:none}
|
||||
details>summary::before{content:"▸ ";color:var(--phos)}
|
||||
details[open]>summary::before{content:"▾ "}
|
||||
details .inner{padding:0 1.1rem 1rem}
|
||||
.kv{display:grid;grid-template-columns:auto 1fr;gap:.2rem .8rem;font-size:.84rem}
|
||||
.kv .k{color:var(--dim)}.kv .v{color:var(--phos)}
|
||||
table{width:100%;font-size:.8rem;border-collapse:collapse}
|
||||
th{color:var(--dim);text-align:left;font-weight:600;border-bottom:1px solid var(--line);padding:.25rem .3rem}
|
||||
td{padding:.2rem .3rem;border-bottom:1px solid rgba(255,255,255,.03)}
|
||||
code{font-family:ui-monospace,Menlo,monospace;font-size:.74rem;color:var(--text);word-break:break-all}
|
||||
ul{list-style:none;padding-left:.2rem}li{padding:.12rem 0;font-size:.82rem}li::before{content:"▸ ";color:var(--phos)}
|
||||
.pin h2,.pin{color:var(--amber)}
|
||||
.lvl{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem;margin-top:.4rem}
|
||||
.lvl button{padding:.55rem;cursor:pointer;font-family:inherit;font-size:.85rem;border-radius:8px;background:transparent;color:var(--text);border:1px solid var(--line)}
|
||||
.lvl button.on{border-width:2px;font-weight:700}
|
||||
.actions{display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center;margin:1.2rem 0}
|
||||
.actions a{padding:.55rem 1rem;border:1px solid var(--phos);color:var(--phos-hot);text-decoration:none;border-radius:8px;font-size:.85rem}
|
||||
.footer{text-align:center;font-size:.66rem;color:var(--dim);margin-top:1.4rem;border-top:1px solid var(--line);padding-top:.7rem}
|
||||
.url{font-family:ui-monospace,monospace;font-size:.72rem;background:#0d0f15;padding:.12rem .35rem;border-radius:4px;margin:.1rem 0;display:block;word-break:break-all}
|
||||
</style></head>
|
||||
<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 sc = risk_score or 0 %}
|
||||
{% set rl = risk_label or 'LOW' %}
|
||||
<div class="card" style="background:linear-gradient(135deg,rgba(0,221,68,0.08),rgba(110,64,201,0.05));border-color:var(--phos)">
|
||||
<h2 style="display:flex;justify-content:space-between;align-items:center">
|
||||
📊 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 cookies_p = cookies_providers or [] %}
|
||||
{% set geo_h = geo_top_hosts or [] %}
|
||||
{% set n_apps = (dpi_cls.top_apps|default([])|selectattr('app','ne','?')|list|length) %}
|
||||
{% set n_trackers = (cookies_p|map(attribute='count')|sum) %}
|
||||
{% set countries = geo_h|map(attribute='country')|reject('equalto','')|list %}
|
||||
{% set n_countries = countries|unique|list|length %}
|
||||
<div style="padding:0.6rem;background:rgba(25,25,45,0.5);border-radius:4px;text-align:center">
|
||||
<div style="font-size:1.2rem">📺</div>
|
||||
<div style="font-size:1.3rem;font-weight:bold;color:#9e76ff">{{ n_apps }}</div>
|
||||
<div style="font-size:0.65rem;color:var(--dim)">apps détectées</div>
|
||||
</div>
|
||||
<div style="padding:0.6rem;background:rgba(45,25,35,0.5);border-radius:4px;text-align:center">
|
||||
<div style="font-size:1.2rem">🍪</div>
|
||||
<div style="font-size:1.3rem;font-weight:bold;color:#ff8866">{{ n_trackers }}</div>
|
||||
<div style="font-size:0.65rem;color:var(--dim)">trackers</div>
|
||||
</div>
|
||||
<div style="padding:0.6rem;background:rgba(25,40,35,0.5);border-radius:4px;text-align:center">
|
||||
<div style="font-size:1.2rem">🌍</div>
|
||||
<div style="font-size:1.3rem;font-weight:bold;color:#66bbff">{{ n_countries }}</div>
|
||||
<div style="font-size:0.65rem;color:var(--dim)">pays/ASN</div>
|
||||
</div>
|
||||
<div style="padding:0.6rem;background:rgba(60,50,15,0.5);border-radius:4px;text-align:center">
|
||||
<div style="font-size:1.2rem">{% if sc < 30 %}🟢{% elif sc < 70 %}🟡{% else %}🔴{% endif %}</div>
|
||||
<div style="font-size:1.3rem;font-weight:bold;color:{% if sc < 30 %}var(--phos-hot){% elif sc < 70 %}#ffb347{% else %}#ff4466{% endif %}">{{ sc }}</div>
|
||||
<div style="font-size:0.65rem;color:var(--dim)">risque /100</div>
|
||||
</div>
|
||||
</div>
|
||||
{# 9th widget (poster sync #497) : 📱 empreinte device — full-width below #}
|
||||
{% set _avatar = avatar_analysis or {} %}
|
||||
{% set _dev_emj = _avatar.most_common_emoji or '❔' %}
|
||||
{% set _dev_label = _avatar.most_common or 'unknown' %}
|
||||
{% set _devices = _avatar.devices or {} %}
|
||||
{% set _dev_info = _devices.get(_dev_label, {}) %}
|
||||
{% set _os_label = _dev_info.os_label or _dev_label %}
|
||||
{% set _browsers = _avatar.browsers or {} %}
|
||||
<div style="margin-top:0.5rem;padding:0.7rem;background:rgba(45,25,60,0.5);border-radius:4px;text-align:center">
|
||||
<div style="font-size:1.4rem">📱 <b style="color:#cbb6ff">{{ _dev_emj }} Empreinte device</b></div>
|
||||
<div style="font-size:0.92rem;color:var(--phos-hot);margin-top:0.3rem;text-shadow:0 0 3px var(--phos)">
|
||||
{{ _os_label }}
|
||||
{% for br, info in _browsers.items() if br != 'unknown' %}
|
||||
· {{ info.emoji or '' }} {{ info.label or br }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="font-size:0.65rem;color:var(--dim);margin-top:0.2rem">{{ _avatar.raw_count or 0 }} UAs distincts observés</div>
|
||||
</div>
|
||||
{# Top device + app + ASN line #}
|
||||
{% set avatar = avatar_analysis or {} %}
|
||||
{% set top_dev = avatar.most_common or '?' %}
|
||||
{% set top_dev_emj = avatar.most_common_emoji or '' %}
|
||||
{% set top_app = (dpi_cls.top_apps and dpi_cls.top_apps[0]) or {} %}
|
||||
{% set top_geo = (geo_h and geo_h[0]) or {} %}
|
||||
{% set top_asn_raw = top_geo.asn_org if top_geo.asn_org else '?' %}
|
||||
<p style="font-size:0.78rem;color:var(--dim);margin-top:0.6rem;text-align:center;padding-top:0.5rem;border-top:1px solid var(--dim)">
|
||||
Top device : {{ top_dev_emj }} {{ top_dev }} ·
|
||||
Top app : {{ top_app.emoji|default('') }} {{ top_app.app|default('?') }} ·
|
||||
Top ASN : {{ top_geo.flag|default('') }} {{ top_asn_raw[:30] }}
|
||||
</p>
|
||||
</div>
|
||||
{% set sc = risk_score|default(0) %}
|
||||
{% set rl = risk_label|default('LOW') %}
|
||||
{% set ch = charts or {} %}
|
||||
{% set gcol = 'var(--phos-hot)' if sc < 30 else ('var(--amber)' if sc < 70 else 'var(--red)') %}
|
||||
{% set palette = ['#00dd44','#9e76ff','#ff8866','#66bbff','#ffb347','#ff4466'] %}
|
||||
{% set dpi_cls = dpi_classified or {} %}
|
||||
{% set cookies_p = cookies_providers or [] %}
|
||||
{% set geo_h = geo_top_hosts or [] %}
|
||||
{% set n_apps = (dpi_cls.top_apps|default([])|selectattr('app','ne','?')|list|length) %}
|
||||
{% set n_trackers = (cookies_p|map(attribute='count')|sum) %}
|
||||
{% set n_countries = (geo_h|map(attribute='country')|reject('equalto','')|list|unique|list|length) %}
|
||||
{% set _avatar = avatar_analysis or {} %}
|
||||
|
||||
{# Phase 3 (#492) : filtering compromissions visibility #}
|
||||
{% set t = transparency|default({}) %}
|
||||
{% set sens = t.get('sensitivity') %}
|
||||
<div class="card" style="background:rgba(255,68,102,0.06);border-color:var(--red)">
|
||||
<h2 style="color:var(--red)">🚨 Filtering compromissions actif</h2>
|
||||
<div class="kv" style="font-size:0.82rem">
|
||||
<span class="k">Sensibilité actuelle</span>
|
||||
<span class="v">{{ sens.label|default('—') if sens else '—' }}</span>
|
||||
<span class="k">Description</span>
|
||||
{% set sens_desc = (sens.description if sens and sens.description else 'Engine pas chargé') %}
|
||||
<span class="v" style="font-size:0.75rem">{{ sens_desc[:90] }}</span>
|
||||
<span class="k">🔍 Threat-intel feeds</span>
|
||||
<span class="v">{{ (threat_intel_matches|default([]))|length }} matches actifs</span>
|
||||
<span class="k">🎲 DGA candidates</span>
|
||||
<span class="v">{{ (dga_candidates|default([]))|length }} hôtes suspects</span>
|
||||
<span class="k">📡 Beaconing patterns</span>
|
||||
<span class="v">{{ (beaconing_candidates|default([]))|length }} détectés</span>
|
||||
<span class="k">🚫 Connexions bloquées</span>
|
||||
<span class="v">{{ t.get('attempts', {}).get('blocked', 0) }} <span style="font-size:0.7rem;color:var(--dim)">(Phase 4 : observation only)</span></span>
|
||||
</div>
|
||||
<p style="font-size:0.7rem;color:var(--dim);margin-top:0.5rem;font-style:italic">
|
||||
Le moteur détecte mais ne bloque pas encore — passive transparency. La règle engine
|
||||
décidera blocage actif quand R3 / Phase 4 sera wired.
|
||||
</p>
|
||||
</div>
|
||||
<h1>👁️ VILLAGE3B <span style="font-size:.8rem;color:var(--dim);font-weight:400">· mon rapport</span></h1>
|
||||
<p class="sub">Diagnostic live de ce que ton appareil envoie sur le réseau · anonyme · se rafraîchit tout seul</p>
|
||||
|
||||
{# 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 class="card" style="border-color:var(--phos)">
|
||||
<b style="color:var(--phos-hot)">{% if request_args.get('switched') %}🔄 Niveau changé{% else %}🎉 Bienvenue !{% endif %}</b> —
|
||||
tu es en mode
|
||||
{% 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 %}.
|
||||
Tu peux surfer normalement.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Phase 3 (#492) : level switcher with active highlight from server-side
|
||||
current_level. Disables button if user clicks their own level (no-op). #}
|
||||
{# ── 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>🔀 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>
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}<div class="empty">Aucun traceur repéré 🎉</div>{% endif %}
|
||||
</div>
|
||||
|
||||
{# countries bars #}
|
||||
<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>
|
||||
{% 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 %}
|
||||
<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>
|
||||
|
||||
<div class="card">
|
||||
<h2>👤 Identifiant anonyme</h2>
|
||||
<div class="kv">
|
||||
<span class="k">Hash session</span> <span class="v">{{ mac_hash }}</span>
|
||||
<span class="k">Sandbox IP</span> <span class="v">{{ ip }}</span>
|
||||
<span class="k">Appareil</span> <span class="v">{{ device_type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{# ════════════ DÉTAILS TECHNIQUES (repliés) ════════════ #}
|
||||
|
||||
<div class="card">
|
||||
<h2>📊 Métriques session</h2>
|
||||
<div class="kv">
|
||||
<span class="k">Connexions</span> <span class="v">{{ metrics.connections }}</span>
|
||||
<span class="k">Hosts uniques</span> <span class="v">{{ metrics.unique_hosts }}</span>
|
||||
<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>
|
||||
<details>
|
||||
<summary>🎯 Analyse de compromission & 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 %}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
<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>
|
||||
<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>
|
||||
</tbody></table></div>
|
||||
</details>
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
<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>
|
||||
<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 %}
|
||||
|
||||
<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] }}
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
|
||||
{% 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 %}
|
||||
<details>
|
||||
<summary>🔎 Transparence : ce qu'on a regardé</summary>
|
||||
<div class="inner">
|
||||
<p class="help" style="margin-bottom:.5rem">Honnêteté avant magie : ce qu'on a inspecté, bypassé, et pourquoi.</p>
|
||||
<div class="kv">
|
||||
{% set b = t.get('breakdown_pct', {}) %}
|
||||
{% if b.get('inspected') %}<span class="k">🔍 Inspecté</span><span class="v">{{ b.get('inspected') }}%</span>{% endif %}
|
||||
{% if b.get('pinned-failed-mitm') %}<span class="k">🔒 Cert-pinning</span><span class="v">{{ b.get('pinned-failed-mitm') }}%</span>{% endif %}
|
||||
{% if b.get('e2e-opaque') %}<span class="k">🔐 E2E chiffré</span><span class="v">{{ b.get('e2e-opaque') }}%</span>{% endif %}
|
||||
<span class="k">📊 Total events</span><span class="v">{{ t.get('total_events', 0) }}</span>
|
||||
</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>
|
||||
{% if t.get('per_host') %}
|
||||
<table style="margin-top:.7rem"><thead><tr><th>Grade</th><th>Destination</th><th>Statut</th></tr></thead><tbody>
|
||||
{% for h in t.get('per_host', [])[:15] %}
|
||||
<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>
|
||||
{% 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>
|
||||
</tbody></table>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<p class="refresh">↻ Auto-refresh toutes les 15 secondes</p>
|
||||
<details>
|
||||
<summary>👤 Identité & recommandations</summary>
|
||||
<div class="inner">
|
||||
<div class="kv">
|
||||
<span class="k">Hash session</span><span class="v"><code>{{ mac_hash }}</code></span>
|
||||
<span class="k">Sandbox IP</span><span class="v">{{ ip }}</span>
|
||||
<span class="k">Appareil</span><span class="v">{{ device_type }}</span>
|
||||
</div>
|
||||
{% if recommendations %}<p style="color:var(--phos-hot);font-size:.85rem;margin:.6rem 0 .2rem">✅ Recommandations</p><ul>{% for rec in recommendations %}<li>{{ rec }}</li>{% endfor %}</ul>{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/report/me?mh={{ mac_hash }}">⬇ Télécharger le PDF</a>
|
||||
<a href="/social/me?mh={{ mac_hash }}">🕸️ Ma carto</a>
|
||||
<a href="/landing">↩ Accueil</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>💚 Support & 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>
|
||||
<h2>💚 Soutenir le projet</h2>
|
||||
<p class="help">Commun numérique open-source, sans pub ni revente — CyberMind / Gérald Kerma (Savoie).</p>
|
||||
<ul style="margin-top:.4rem">
|
||||
<li>💰 <a href="https://liberapay.com/cybermind" style="color:var(--phos)">liberapay.com/cybermind</a></li>
|
||||
<li>🐛 <a href="https://github.com/CyberMind-FR/secubox-deb/issues" style="color:var(--phos)">Signaler un bug</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">
|
||||
Gondwana ToolBoX · LicenseRef-CMSD-1.0 (Source-Disclosed License)<br>
|
||||
Source : <a href="https://github.com/CyberMind-FR/secubox-deb" style="color:var(--dim)">github.com/CyberMind-FR/secubox-deb</a> (#474 #475 #477)<br>
|
||||
CyberMind — Notre-Dame-du-Cruet (73130) · <a href="https://cybermind.fr" style="color:var(--dim)">cybermind.fr</a>
|
||||
Gondwana ToolBoX · LicenseRef-CMSD-1.0 · ↻ rafraîchit toutes les 20 s<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>
|
||||
</div>
|
||||
|
||||
</body></html>
|
||||
|
|
|
|||
20
packages/secubox-toolbox/conf/torrc-toolbox-egress.conf
Normal file
20
packages/secubox-toolbox/conf/torrc-toolbox-egress.conf
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# kbin Tor egress drop-in (#683) — installed to /etc/tor/torrc.d/ by the
|
||||
# reconciler when kbin Tor mode is armed. Provides the transparent egress
|
||||
# ports the nft tunnel redirects into, plus a control port for status/NEWNYM.
|
||||
#
|
||||
# SOCKS (9050) stays default for the leak-check probe and any future Go-core
|
||||
# (#662) SOCKS5 dialer. TransPort/DNSPort give the transparent egress path.
|
||||
|
||||
SocksPort 127.0.0.1:9050
|
||||
TransPort 127.0.0.1:9040
|
||||
DNSPort 127.0.0.1:5353
|
||||
AutomapHostsOnResolve 1
|
||||
VirtualAddrNetworkIPv4 10.192.0.0/10
|
||||
|
||||
# Control port for bootstrap/circuit status + SIGNAL NEWNYM (cookie auth,
|
||||
# group-readable so the secubox-toolbox portal — added to the debian-tor
|
||||
# group by postinst — can authenticate without a password or secret on disk).
|
||||
ControlPort 9051
|
||||
CookieAuthentication 1
|
||||
CookieAuthFileGroupReadable 1
|
||||
|
|
@ -1,3 +1,137 @@
|
|||
secubox-toolbox (2.7.11-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* feat: landing (kbin.gk2) restyled to match the new report — system font,
|
||||
rounded --panel/--line cards, cleaner accents, softer SVG bars, helper lines,
|
||||
R3 panel mentions the 🧅 Tor egress option. Dynamic bits unchanged (live KPIs
|
||||
+ JS, per-OS install panels, cert-probe, ?mh links).
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 20:20:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.10-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix: /report/me/html now resolves identity via the shared _client_mac_hash
|
||||
(?mh → R3 WG peer → captive ARP), so R3 clients reach their report without
|
||||
?mh (it previously only did captive ARP → 400 "client MAC unknown").
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 19:55:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.9-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* feat: report (/report/me/html) regenerated — verdict-first + graphs + plain
|
||||
helpers. Top: a score gauge (conic-gradient) + 6 KPIs + 3 graphs (trackers
|
||||
donut, countries bars, apps bars) computed server-side (_build_report_charts).
|
||||
All the deep technical cards (threat-intel, DGA, beaconing, hosts, apps,
|
||||
cookies, avatar, transparency, per-host grades, identity, reco) collapse into
|
||||
<details> so the page reads instantly. System-font, rounded, mobile-first.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 19:30:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.8-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix: landing "Ma carto" / "Mon rapport" / "Qui me piste ?" links hit
|
||||
/social/me + /report/me with NO ?mh=, so clicking them re-resolved identity
|
||||
at click-time and could 400 "client identity unresolved". The landing now
|
||||
resolves the caller's mac_hash (new _client_mac_hash helper: ?mh → R3 WG peer
|
||||
→ captive ARP) and bakes ?mh= into those links, so they always open the right
|
||||
client's view. No change to identity precedence; resolution stays server-side.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 18:40:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.7-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix: injected banner trackers/cookies counts were stuck at 0. They were
|
||||
computed ONCE at render time — which fires early, before page resources +
|
||||
cookies have loaded (Resource Timing empty) — and the 2s poll's ensure()
|
||||
early-returned once the banner existed, so the counts never refreshed. Now
|
||||
the trackers/cookies spans have ids and updateCounts() re-counts live on the
|
||||
poll, so they climb to real values within ~2s.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 18:10:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.6-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(#683): the 🧅 Tor indicator now appears on the ACTUAL injected banner.
|
||||
The live page banner is the client-side stream-inject bundle (bundle.py:
|
||||
"SecuBox · LEVEL · 🛰️ trackers · 🍪 cookies · report ▸ · ✕"), NOT the
|
||||
server-rendered inject_banner chip I'd added earlier. Added `tor_mode` to the
|
||||
decision bundle + a "🧅 Tor" span in the banner render() (loader + inline
|
||||
paths) so a consenting client sees their exit is anonymised.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 17:40:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.5-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* feat(#683): public kbin-safe GET /wg/tor-status ({tor_mode, running,
|
||||
bootstrap, exit_ip}) so the clients (webext popup + Android app) can show a
|
||||
🧅 Tor-egress indicator. Mirrors /wg/r3-check (reachable on every vhost incl.
|
||||
public kbin; the /admin/tor/* controls stay admin-gated).
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 17:00:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.4-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(#683): Tor mode no longer torifies the box's OWN services. kbin/admin
|
||||
resolve to the WAN IP (reached via hairpin) and the LAN wasn't exempt, so
|
||||
with Tor armed the portal's self-traffic round-tripped through Tor (~18×
|
||||
slower) → kbin landing + dashboard graphs/metrics loaded empty for clients
|
||||
behind the MITM. nft tunnel now has a reconciler-populated `tor_exempt` set:
|
||||
loopback + board-local subnets (LAN/WG/LXC) + the board's own public IP →
|
||||
self-traffic stays DIRECT. DNS stays Tor-routed (no leak) and the Tor automap
|
||||
range (10.192.0.0/10) stays torified (it's never a connected route).
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 15:00:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.3-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix: dashboard "Live metrics" MITM trio (connections / hosts / cert-pin
|
||||
blocks) was permanently 0 — it scraped "server connect" from the journal,
|
||||
but the workers run at --log-level warning so those INFO lines are never
|
||||
emitted. Now derived from real data: connections = DPI flow count + unique
|
||||
hosts from the cumulative top-hosts (same source as the landing page), and
|
||||
cert-pin blocks = the auto-learned mitm-bypass-dynamic host count.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 14:00:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.2-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* feat(#683): 🧅 Tor status chip on the injected transparency banner — shown
|
||||
first (most prominent) when kbin Tor mode is armed, so the client sees their
|
||||
surf is anonymised. Read from filters (5s-cached) on the banner hot path.
|
||||
* fix(#683): the Tor tunnel now SURVIVES nftables reloads. The runtime
|
||||
toolbox_tor table was flushed by any `nft -f /etc/nftables.conf` (triggered
|
||||
by other postinsts) → flag-on/tunnel-off leak window. Reconciler now persists
|
||||
it as /etc/nftables.d/zz-secubox-toolbox-tor.nft (zz- = loads after the wg
|
||||
tables). Added secubox-toolbox-tor.timer (2-min idempotent re-arm) to self-heal
|
||||
any bare `nft flush ruleset`, and a final postinst reconcile (after the nft
|
||||
reloads) so upgrades never leave the flag/tunnel mismatched.
|
||||
* fix: postinst restart loop now also restarts secubox-toolbox-mitm-wg.service
|
||||
(single-service R3 path), so mitm addon updates take effect without a manual
|
||||
restart.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 13:05:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.1-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* feat(#683): kbin Tor egress quick-switch — switch + tunnel, ships DARK
|
||||
(tor_mode default OFF, fail-closed). Routes ONLY the R3 mitm-wg worker uid's
|
||||
upstream egress + DNS through Tor's TransPort(9040)/DNSPort(5353) via an nft
|
||||
owner-match table, so inspection (ad-block/poison/banner/safe-browsing) is
|
||||
fully preserved — only the exit IP + network identity change.
|
||||
* New filters flags `tor_mode` / `tor_preset` (anonymous|stealth|minimal).
|
||||
* API (kbin-gated, admin.gk2 only for actions): GET /admin/tor/state,
|
||||
POST /admin/tor/{on,off,newnym,check-leaks}. State read-only-safe on kbin.
|
||||
* WebUI: 🧅 Tor tab — status badge (bootstrap/circuits/exit IP), arm/disarm
|
||||
toggle, preset selector, New-Identity (NEWNYM), SOCKS leak probe. Reuses the
|
||||
secubox-tor control-port logic (new tor_ctl.py) — no cross-service JWT.
|
||||
* Tunnel arms via a root, path-triggered reconciler (secubox-toolbox-tor.path
|
||||
watches filters.json) — portal stays NoNewPrivileges=true, no sudo. nft is
|
||||
loaded BEFORE tor starts (fail-closed: no clearnet window). IPv6 worker
|
||||
egress dropped (no v6 leak). prerm disarms on real removal (not upgrade).
|
||||
* Depends: jq; Recommends: tor, python3-socksio. postinst adds secubox-toolbox
|
||||
to the debian-tor group for control-cookie access.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 19 Jun 2026 16:00:00 +0200
|
||||
|
||||
secubox-toolbox (2.7.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* MIDDLE RELEASE — caps the 2.6.x line (ad-intelligence / Anti-Track v2 /
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ Depends: ${misc:Depends}, ${python3:Depends},
|
|||
hostapd,
|
||||
dnsmasq,
|
||||
mitmproxy,
|
||||
jq,
|
||||
openssl,
|
||||
adduser,
|
||||
fonts-dejavu-core,
|
||||
|
|
@ -34,7 +35,9 @@ Depends: ${misc:Depends}, ${python3:Depends},
|
|||
python3-geoip2 | geoipupdate,
|
||||
lxc,
|
||||
debian-archive-keyring
|
||||
Recommends: bridge-utils
|
||||
Recommends: bridge-utils,
|
||||
tor,
|
||||
python3-socksio
|
||||
Description: SecuBox-DEB ToolBoX — Gondwana Cabine Numérique (captive AP + MITM analyzer)
|
||||
Phase 1 du parent #474 (ToolBoX Pipeline). Productionize le PoC captive
|
||||
portal en package dédié : splash + consent R2 + CA distribution iOS
|
||||
|
|
|
|||
|
|
@ -66,9 +66,19 @@ case "$1" in
|
|||
"ad_ghost_categories": {"ads": true, "consent_nag": true, "newsletter": true, "social_widgets": true}
|
||||
}
|
||||
SBXFILTERS
|
||||
chown root:secubox-toolbox /etc/secubox/toolbox/filters.json
|
||||
chown secubox-toolbox:secubox-toolbox /etc/secubox/toolbox/filters.json
|
||||
chmod 0664 /etc/secubox/toolbox/filters.json
|
||||
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)
|
||||
# NOTE: `install -d -m 0750 /var/lib/secubox/<leaf>` re-modes the SHARED
|
||||
|
|
@ -182,9 +192,25 @@ fi
|
|||
# Phase 7 (#498) : boot-time peer-restore service for wg-toolbox.
|
||||
# Keeps the enrolled WG peers alive across reboots.
|
||||
systemctl enable secubox-toolbox-wg-restore.service || true
|
||||
# #683 : kbin Tor egress — enable the path watcher so flipping tor_mode
|
||||
# in filters.json arms/disarms the tunnel (reconciler runs as root; the
|
||||
# portal stays NoNewPrivileges=true). Default tor_mode=false → reconcile
|
||||
# is a no-op until the operator flips it. Enable+start the .path; run one
|
||||
# reconcile so a fresh/false flag leaves no tunnel behind.
|
||||
systemctl enable secubox-toolbox-tor.path 2>/dev/null || true
|
||||
systemctl start secubox-toolbox-tor.path 2>/dev/null || true
|
||||
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
|
||||
fi
|
||||
|
||||
# #683 : let the portal authenticate to Tor's control port (cookie is
|
||||
# group-readable by debian-tor — see torrc-toolbox-egress.conf) so the
|
||||
# Tor card can read bootstrap/circuits + SIGNAL NEWNYM without a secret.
|
||||
if getent group debian-tor >/dev/null 2>&1; then
|
||||
usermod -aG debian-tor secubox-toolbox 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Phase 7 (#498) : install nft drop-in for boot-survival + reload nft so
|
||||
# the running ruleset matches what nftables.service will load at next boot.
|
||||
# Phase 10 (#501 perf) : ALSO deploy the Phase 9 fanout drop-in as
|
||||
|
|
@ -274,6 +300,7 @@ fi
|
|||
if [ -n "${2:-}" ] && [ -d /run/systemd/system ]; then
|
||||
for unit in secubox-toolbox.service \
|
||||
secubox-toolbox-mitm.service \
|
||||
secubox-toolbox-mitm-wg.service \
|
||||
secubox-toolbox-mitm-wg-worker@1.service \
|
||||
secubox-toolbox-mitm-wg-worker@2.service \
|
||||
secubox-toolbox-mitm-wg-worker@3.service \
|
||||
|
|
@ -285,6 +312,13 @@ fi
|
|||
fi
|
||||
done
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -9,5 +9,18 @@ case "$1" in
|
|||
fi
|
||||
;;
|
||||
esac
|
||||
# #683 : on real removal, tear the kbin Tor tunnel down (flush nft table +
|
||||
# torrc drop-in + stop tor) so the box never stays torified post-uninstall.
|
||||
# NOT on upgrade — that must preserve runtime state.
|
||||
case "$1" in
|
||||
remove)
|
||||
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#
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@ override_dh_installsystemd:
|
|||
# activation + rollback recipe.
|
||||
install -m 0644 systemd/secubox-toolbox-mitm-wg-worker@.service \
|
||||
debian/secubox-toolbox/lib/systemd/system/
|
||||
# #683 : kbin Tor egress reconciler (root, oneshot) + path watcher on
|
||||
# filters.json. conf/nft-toolbox-tor.nft + conf/torrc-toolbox-egress.conf
|
||||
# ship via the `cp -r conf` above (→ /usr/lib/secubox/toolbox/conf/).
|
||||
install -m 0755 sbin/secubox-toolbox-tor-reconcile \
|
||||
debian/secubox-toolbox/usr/sbin/
|
||||
install -m 0644 systemd/secubox-toolbox-tor.service \
|
||||
debian/secubox-toolbox/lib/systemd/system/
|
||||
install -m 0644 systemd/secubox-toolbox-tor.path \
|
||||
debian/secubox-toolbox/lib/systemd/system/
|
||||
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.
|
||||
cp systemd/secubox-toolbox.service debian/secubox-toolbox.service
|
||||
dh_installsystemd --no-start --no-enable
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ RestartSec=3
|
|||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox/toolbox
|
||||
# #683: the portal must persist its own config (filters.json — Tor switch +
|
||||
# mitm filter toggles). /etc/secubox stays read-only EXCEPT the toolbox subdir;
|
||||
# the CA key inside stays 0600 root, still unreadable to this user.
|
||||
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox/toolbox /etc/secubox/toolbox
|
||||
ReadOnlyPaths=/etc/secubox
|
||||
|
||||
# nft + ip neigh require CAP_NET_ADMIN
|
||||
|
|
|
|||
|
|
@ -314,8 +314,19 @@ def _compute_site_context(flow: http.HTTPFlow) -> dict:
|
|||
"trackers": 0,
|
||||
"is_tracker_host": False,
|
||||
"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.
|
||||
(ctx["app_emoji"], ctx["app"], ctx["flag"], ctx["country"], ctx["asn"],
|
||||
ctx["status"], ctx["status_icon"]) = _host_signals(host)
|
||||
|
|
@ -463,6 +474,10 @@ def _banner_html_dynamic(sha1: str, ctx: dict, csp_strict: bool,
|
|||
# #578 — shared broadcast pin first, so every banner shows it.
|
||||
if ctx.get("pin"):
|
||||
right_parts.insert(0, "📌 " + _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, "🧅 Tor") # 🧅
|
||||
if ctx["flag"]:
|
||||
# Phase 6.M (#496) : flags are Unicode "regional indicator" pairs
|
||||
# (🇫🇷 = U+1F1EB + U+1F1F7). NCR-encoded pairs do NOT join into a
|
||||
|
|
|
|||
114
packages/secubox-toolbox/sbin/secubox-toolbox-tor-reconcile
Executable file
114
packages/secubox-toolbox/sbin/secubox-toolbox-tor-reconcile
Executable file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
#
|
||||
# SecuBox-Deb :: toolbox :: kbin Tor egress reconciler (#683)
|
||||
#
|
||||
# Reconciles the kbin Tor egress tunnel to match filters.json `tor_mode`.
|
||||
# Runs as root (path-triggered by secubox-toolbox-tor.path on filters.json,
|
||||
# or invoked directly with `arm`/`disarm`). The portal API never escalates —
|
||||
# it only flips the flag; this privileged, hardening-preserving step does the
|
||||
# nft + tor work asynchronously.
|
||||
#
|
||||
# Fail-closed: nft (incl. the kill-switch) is loaded BEFORE tor starts, so if
|
||||
# tor is down the redirect target is simply unreachable — egress is refused,
|
||||
# never leaked in the clear.
|
||||
set -euo pipefail
|
||||
readonly MODULE="toolbox-tor"
|
||||
|
||||
FILTERS="${SECUBOX_FILTERS_PATH:-/etc/secubox/toolbox/filters.json}"
|
||||
NFT_FILE=/usr/lib/secubox/toolbox/conf/nft-toolbox-tor.nft
|
||||
# Persist the table as a drop-in so it SURVIVES `nft -f /etc/nftables.conf`
|
||||
# reloads (which other postinsts trigger and which would otherwise flush the
|
||||
# runtime-only table → flag-on/tunnel-off leak). zz- = loads after the wg
|
||||
# tables (alphabetical glob), per the layered-dropin ordering rule.
|
||||
NFT_DROPIN=/etc/nftables.d/zz-secubox-toolbox-tor.nft
|
||||
TORRC_DROPIN_SRC=/usr/lib/secubox/toolbox/conf/torrc-toolbox-egress.conf
|
||||
TORRC_DROPIN=/etc/tor/torrc.d/10-secubox-toolbox-egress.conf
|
||||
TORRC_MAIN=/etc/tor/torrc
|
||||
|
||||
log() { logger -t "secubox-${MODULE}" -- "$*" 2>/dev/null || true; echo "[$MODULE] $*"; }
|
||||
|
||||
table_present() { nft list table inet toolbox_tor >/dev/null 2>&1; }
|
||||
|
||||
want_from_flag() {
|
||||
# default false on any parse error (fail-safe = off)
|
||||
jq -r '.tor_mode // false' "$FILTERS" 2>/dev/null || echo false
|
||||
}
|
||||
|
||||
arm() {
|
||||
log "arming kbin Tor egress (TransPort 9040 / DNSPort 5353)"
|
||||
mkdir -p /etc/tor/torrc.d
|
||||
install -m 0644 "$TORRC_DROPIN_SRC" "$TORRC_DROPIN"
|
||||
# Debian's torrc does not %include torrc.d by default — add it idempotently.
|
||||
if ! grep -qE '^\s*%include\s+/etc/tor/torrc\.d/' "$TORRC_MAIN" 2>/dev/null; then
|
||||
echo '%include /etc/tor/torrc.d/*.conf' >> "$TORRC_MAIN"
|
||||
fi
|
||||
# Persist as a drop-in (survives nft reloads) then load it NOW. nft FIRST
|
||||
# (kill-switch active) so there is never a clearnet window.
|
||||
install -d -m 0755 /etc/nftables.d
|
||||
install -m 0644 "$NFT_FILE" "$NFT_DROPIN"
|
||||
nft -f "$NFT_DROPIN"
|
||||
populate_exempt
|
||||
systemctl restart tor 2>/dev/null || systemctl start tor 2>/dev/null || \
|
||||
log "WARN tor failed to (re)start — egress fail-closed until it does"
|
||||
log "ARMED"
|
||||
}
|
||||
|
||||
# Fill the tor_exempt set so the box reaches its OWN services DIRECT (never via
|
||||
# Tor): loopback, board-local subnets (LAN/WG/LXC), and the board's own public
|
||||
# IP (kbin/admin resolve to it, reached via hairpin). Without this, self-views
|
||||
# round-trip Tor and load empty/slow. The Tor automap range (10.192.0.0/10) is
|
||||
# never a connected route, so it is correctly NOT added → still torified.
|
||||
populate_exempt() {
|
||||
local net pub
|
||||
nft add element inet toolbox_tor tor_exempt "{ 127.0.0.0/8 }" 2>/dev/null || true
|
||||
# board-local connected RFC1918 subnets
|
||||
for net in $(ip -4 route show scope link 2>/dev/null | awk '{print $1}' \
|
||||
| grep -E '^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'); do
|
||||
case "$net" in 10.19[2-9].*|10.2[0-5][0-9].*) continue ;; esac # belt: skip automap
|
||||
nft add element inet toolbox_tor tor_exempt "{ $net }" 2>/dev/null || true
|
||||
done
|
||||
# board's own public IP — detected DIRECT (root egress is not torified).
|
||||
pub=$(timeout 6 curl -s https://api.ipify.org 2>/dev/null || true)
|
||||
case "$pub" in
|
||||
[0-9]*.[0-9]*.[0-9]*.[0-9]*)
|
||||
nft add element inet toolbox_tor tor_exempt "{ $pub }" 2>/dev/null || true
|
||||
log "exempt own public IP $pub" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
disarm() {
|
||||
log "disarming kbin Tor egress"
|
||||
rm -f "$NFT_DROPIN"
|
||||
if table_present; then nft delete table inet toolbox_tor 2>/dev/null || true; fi
|
||||
rm -f "$TORRC_DROPIN"
|
||||
# Stop tor to minimise attack surface when not in use (no hidden services
|
||||
# are owned by this drop-in; secubox-tor manages those separately).
|
||||
systemctl stop tor 2>/dev/null || true
|
||||
log "DISARMED"
|
||||
}
|
||||
|
||||
main() {
|
||||
local arg="${1:-reconcile}" want
|
||||
case "$arg" in
|
||||
arm) want=true ;;
|
||||
disarm) want=false ;;
|
||||
reconcile) want="$(want_from_flag)" ;;
|
||||
*) echo "usage: $0 {arm|disarm|reconcile}" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ "$want" = "true" ]; then
|
||||
table_present && { log "already armed — no-op"; exit 0; }
|
||||
arm
|
||||
else
|
||||
if ! table_present && [ ! -f "$TORRC_DROPIN" ]; then
|
||||
log "already disarmed — no-op"; exit 0
|
||||
fi
|
||||
disarm
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
@ -328,6 +328,36 @@ def _client_ip(request: Request) -> str | 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 ─────────────────
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
|
|
@ -500,6 +530,23 @@ async def change_level(request: Request):
|
|||
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")
|
||||
async def wg_r3_check(request: Request):
|
||||
"""Phase 7 (#498) — same-origin HTTPS probe for the R3 verification
|
||||
|
|
@ -684,11 +731,15 @@ async def landing(request: Request) -> HTMLResponse:
|
|||
stats = _cumulative_stats()
|
||||
platform = _ua_platform(request.headers.get("user-agent") or "")
|
||||
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(
|
||||
_env.get_template("landing.html.j2").render(
|
||||
stats=stats,
|
||||
install_panels=install_panels,
|
||||
install_platform=platform,
|
||||
mac_hash=mac_hash,
|
||||
),
|
||||
headers={"Cache-Control": "private, max-age=60, no-transform"},
|
||||
)
|
||||
|
|
@ -2291,6 +2342,46 @@ def _classify_apps(hosts: set[str]) -> list[str]:
|
|||
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,
|
||||
# /report/me/html) MUST be declared BEFORE the catch-all /report/{token},
|
||||
# otherwise FastAPI matches /report/me with token="me" and returns 404.
|
||||
|
|
@ -2306,17 +2397,16 @@ async def report_me_html(request: Request) -> HTMLResponse:
|
|||
their own report. The hash for R3 = sha256(wg_pubkey)[:16] derived
|
||||
by inject_banner.py and embedded in the banner 'Mon rapport' link.
|
||||
"""
|
||||
# Bypass path : explicit mac_hash in query (R3 WG or kbin remote viewer)
|
||||
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:
|
||||
ip = request.client.host if request.client else "?"
|
||||
mac_hash = mh_qp
|
||||
else:
|
||||
ip, mac = _resolve(request)
|
||||
if not mac:
|
||||
raise HTTPException(400, "client MAC unknown (not in captive subnet?) — use ?mh=<hash>")
|
||||
salt = _get_salt()
|
||||
mac_hash = macmod.hash_mac(mac, salt)
|
||||
# Resolve identity the same way everywhere: ?mh → R3 WG peer (wg-peers.json)
|
||||
# → captive ARP. R3 clients hitting this directly (no ?mh) now resolve too.
|
||||
mac_hash = _client_mac_hash(request, _get_salt())
|
||||
if not mac_hash:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"client identity unresolved (not on R3 tunnel and not in captive "
|
||||
"subnet) — append ?mh=<hash> from your banner's report link",
|
||||
)
|
||||
ip = _client_ip(request) or (request.client.host if request.client else "?")
|
||||
session = _aggregate_session(mac_hash)
|
||||
# Phase 3 (#492) : pass query args + force no-cache so iPhone Safari
|
||||
# actually fetches the new template.
|
||||
|
|
@ -2330,6 +2420,7 @@ async def report_me_html(request: Request) -> HTMLResponse:
|
|||
current_level=store.get_client_level(mac_hash) if mac_hash else "r1",
|
||||
wg_enabled=wg_enabled,
|
||||
cumulative=cumulative,
|
||||
charts=_build_report_charts(session),
|
||||
**session,
|
||||
)
|
||||
return HTMLResponse(html, headers={
|
||||
|
|
@ -2845,6 +2936,116 @@ async def admin_filters_set(request: Request) -> dict:
|
|||
return set_filters(patch)
|
||||
|
||||
|
||||
# ── kbin Tor egress switch (#683) — reuses the secubox-tor control plane ──
|
||||
_TOR_EXIT_IP_CACHE = "/tmp/tor_exit_ip" # shared with secubox-tor
|
||||
|
||||
|
||||
def _tor_exit_ip_cached():
|
||||
try:
|
||||
if os.path.exists(_TOR_EXIT_IP_CACHE):
|
||||
return (open(_TOR_EXIT_IP_CACHE).read().strip() or None)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _require_tor_admin(request: Request) -> None:
|
||||
"""Tor on/off/newnym/leak-check are operator actions — blocked on the
|
||||
public kbin vhost (defense-in-depth, mirrors /admin/filter-control)."""
|
||||
if _is_public_kbin(request):
|
||||
raise HTTPException(status_code=403,
|
||||
detail="Tor controls are admin-only (use admin.gk2.secubox.in)")
|
||||
|
||||
|
||||
@router.get("/admin/tor/state")
|
||||
async def admin_tor_state() -> dict:
|
||||
"""kbin Tor mode status: the flag + live daemon bootstrap/circuits/exit IP.
|
||||
Read-only — safe to expose on the public kbin vhost (no secrets)."""
|
||||
from .filters import get_filters
|
||||
from . import tor_ctl
|
||||
f = get_filters(force=True)
|
||||
st = tor_ctl.status()
|
||||
st.update({
|
||||
"tor_mode": bool(f.get("tor_mode", False)),
|
||||
"tor_preset": f.get("tor_preset", "anonymous"),
|
||||
"exit_ip": _tor_exit_ip_cached(),
|
||||
})
|
||||
return st
|
||||
|
||||
|
||||
@router.post("/admin/tor/on")
|
||||
async def admin_tor_on(request: Request) -> dict:
|
||||
"""Arm kbin Tor mode. Flips the flag only; the privileged tunnel arm runs
|
||||
async via secubox-toolbox-tor.path → reconcile (portal stays unprivileged)."""
|
||||
_require_tor_admin(request)
|
||||
from .filters import set_filters
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
patch = {"tor_mode": True}
|
||||
if isinstance(body, dict) and body.get("tor_preset"):
|
||||
patch["tor_preset"] = body["tor_preset"]
|
||||
out = set_filters(patch)
|
||||
return {"tor_mode": out["tor_mode"], "tor_preset": out["tor_preset"], "armed_async": True}
|
||||
|
||||
|
||||
@router.post("/admin/tor/off")
|
||||
async def admin_tor_off(request: Request) -> dict:
|
||||
"""Disarm kbin Tor mode (flag flip; reconciler tears the tunnel down)."""
|
||||
_require_tor_admin(request)
|
||||
from .filters import set_filters
|
||||
out = set_filters({"tor_mode": False})
|
||||
return {"tor_mode": out["tor_mode"], "disarmed_async": True}
|
||||
|
||||
|
||||
@router.post("/admin/tor/newnym")
|
||||
async def admin_tor_newnym(request: Request) -> dict:
|
||||
"""Request a fresh Tor circuit (new exit IP) — SIGNAL NEWNYM via control port."""
|
||||
_require_tor_admin(request)
|
||||
from . import tor_ctl
|
||||
ok = tor_ctl.new_identity()
|
||||
try:
|
||||
if os.path.exists(_TOR_EXIT_IP_CACHE):
|
||||
os.unlink(_TOR_EXIT_IP_CACHE)
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": ok}
|
||||
|
||||
|
||||
@router.post("/admin/tor/check-leaks")
|
||||
async def admin_tor_check_leaks(request: Request) -> dict:
|
||||
"""Probe the egress IP via Tor SOCKS (9050) and cache it for the badge.
|
||||
A real off-board leak test is still the source of truth; this confirms the
|
||||
SOCKS path resolves and surfaces the current exit IP."""
|
||||
_require_tor_admin(request)
|
||||
from . import tor_ctl
|
||||
if not tor_ctl.tor_running():
|
||||
return {"ok": False, "error": "tor not running"}
|
||||
import httpx
|
||||
result: dict = {"ok": True}
|
||||
# httpx renamed proxies= → proxy= (0.26+, removed proxies in 0.28); board
|
||||
# ships bookworm's 0.23 (proxies=). Support both.
|
||||
try:
|
||||
_client = httpx.AsyncClient(timeout=15.0, proxy="socks5://127.0.0.1:9050")
|
||||
except TypeError:
|
||||
_client = httpx.AsyncClient(timeout=15.0, proxies="socks5://127.0.0.1:9050")
|
||||
try:
|
||||
async with _client as c:
|
||||
tor_ip = (await c.get("https://api.ipify.org")).text.strip()
|
||||
result["tor_ip"] = tor_ip
|
||||
try:
|
||||
with open(_TOR_EXIT_IP_CACHE, "w") as fh:
|
||||
fh.write(tor_ip)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
result["ok"] = False
|
||||
result["tor_ip"] = None
|
||||
result["error"] = "SOCKS probe failed (python3-socksio installed? tor bootstrapped?)"
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/admin/filters/ui", response_class=HTMLResponse)
|
||||
async def admin_filters_ui() -> HTMLResponse:
|
||||
"""#566 — minimal filter toggle panel for the toolbox WebUI."""
|
||||
|
|
@ -3262,23 +3463,25 @@ async def admin_metrics() -> dict:
|
|||
).fetchone()[0]
|
||||
except Exception as e:
|
||||
metrics["sqlite_error"] = str(e)
|
||||
# Mitmproxy live stats (from journal)
|
||||
# Live MITM activity. NOTE: the old journal-scrape for "server connect"
|
||||
# NEVER worked — the workers run at --log-level warning, so those INFO lines
|
||||
# are never emitted → the trio was permanently 0. Derive from real data: the
|
||||
# cumulative stats (same source the landing page uses) + the auto-learned
|
||||
# cert-pin bypass list.
|
||||
try:
|
||||
out = _sp.run(
|
||||
# #593 — glob matches the LIVE R3 workers (…-mitm-wg-worker@N),
|
||||
# not just the (dead) R2 …-mitm unit → real numbers.
|
||||
["journalctl", "-u", "secubox-toolbox-mitm*", "--since", "-30min", "--no-pager"],
|
||||
capture_output=True, text=True, timeout=4, check=False,
|
||||
).stdout
|
||||
metrics["mitm"]["connections"] = out.count("server connect")
|
||||
metrics["mitm"]["tls_pinned"] = out.count("Client TLS handshake failed")
|
||||
hosts: set[str] = set()
|
||||
for line in out.splitlines():
|
||||
if " server connect " in line:
|
||||
parts = line.rsplit(" ", 1)
|
||||
if len(parts) == 2:
|
||||
hosts.add(parts[1])
|
||||
metrics["mitm"]["unique_hosts"] = len(hosts)
|
||||
cs = cumulative.get_cached() or {}
|
||||
ev = cs.get("events", {}) or {}
|
||||
# "connections analysées" — DPI classifies one flow per upstream connection.
|
||||
metrics["mitm"]["connections"] = int(ev.get("dpi", 0) or 0)
|
||||
metrics["mitm"]["unique_hosts"] = len(cs.get("top_hosts_7d", []) or [])
|
||||
except Exception:
|
||||
pass
|
||||
# Cert-pinned hosts that had to be bypassed (auto-learned by cert_pin_detect).
|
||||
try:
|
||||
if MITM_BYPASS_DYNAMIC_FILE.exists():
|
||||
metrics["mitm"]["tls_pinned"] = sum(
|
||||
1 for ln in MITM_BYPASS_DYNAMIC_FILE.read_text().splitlines()
|
||||
if ln.strip() and not ln.strip().startswith("#"))
|
||||
except Exception:
|
||||
pass
|
||||
return metrics
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@ def _report_url(client_id: str, is_wg: bool) -> str:
|
|||
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:
|
||||
"""Build the per-client cosmetic decision bundle (pure given inputs + pin file)."""
|
||||
return {
|
||||
|
|
@ -81,6 +90,7 @@ def build_bundle(client_id: str, is_wg: bool = False) -> dict:
|
|||
"pin": _read_pin(),
|
||||
"report_url": _report_url(client_id, is_wg),
|
||||
"tracker_patterns": TRACKER_PATTERNS,
|
||||
"tor_mode": _tor_mode(),
|
||||
"ts": int(time.time()),
|
||||
}
|
||||
|
||||
|
|
@ -141,12 +151,23 @@ _BANNER_CORE = r"""
|
|||
return Object.keys(seen).length;
|
||||
} 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){
|
||||
if (dismissed) return;
|
||||
if (document.getElementById("sbx-banner")) return;
|
||||
var trk = countTrackers(b.tracker_patterns);
|
||||
var ck = 0;
|
||||
try { ck = document.cookie ? document.cookie.split(";").filter(function(x){return x.indexOf("=")>=0;}).length : 0; } catch (_) {}
|
||||
var ck = countCookies();
|
||||
var bar = document.createElement("div");
|
||||
bar.id = "sbx-banner";
|
||||
bar.setAttribute("style", "position:fixed;left:0;right:0;top:0;z-index:2147483647;"
|
||||
|
|
@ -157,11 +178,15 @@ _BANNER_CORE = r"""
|
|||
// #662 — 🔓 proof: the engine relaxed this page's CSP to inject this banner.
|
||||
var cspProof = (csp === "1")
|
||||
? "<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>"
|
||||
+ cspProof
|
||||
+ tor
|
||||
+ "<span>" + esc((b.level || "r1").toUpperCase()) + "</span>"
|
||||
+ "<span>🛰️ " + trk + " trackers</span>"
|
||||
+ "<span>🍪 " + ck + " cookies</span>"
|
||||
+ "<span id=\"sbx-trk\">🛰️ " + trk + " trackers</span>"
|
||||
+ "<span id=\"sbx-ck\">🍪 " + ck + " cookies</span>"
|
||||
+ pin
|
||||
+ "<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>";
|
||||
|
|
@ -172,7 +197,7 @@ _BANNER_CORE = r"""
|
|||
}
|
||||
// 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).
|
||||
function ensure(){ if (bundle && !dismissed) ready(function(){ render(bundle); }); }
|
||||
function ensure(){ if (bundle && !dismissed) ready(function(){ if (document.getElementById("sbx-banner")) updateCounts(bundle); else render(bundle); }); }
|
||||
// SPA re-assert: wrap history nav + popstate (defer so the framework settles),
|
||||
// plus a light 2s poll as a catch-all for DOM re-renders that drop the banner.
|
||||
["pushState","replaceState"].forEach(function(m){
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ DEFAULTS: Dict = {
|
|||
"autolearn": True, # #589 also block auto-learned bad hosts
|
||||
"ad_learn": True, # #656 aggressive ad-URL learning toggle
|
||||
"tls_splice": "observe", # #649 off | observe | on (asset SNI-splice)
|
||||
# ── kbin Tor egress (#683) — ships dark; arm via reconciler after soak ──
|
||||
"tor_mode": False, # route MITM upstream egress through Tor (global kbin Tor mode)
|
||||
"tor_preset": "anonymous", # anonymous | stealth | minimal (secubox-tor preset)
|
||||
# ── Anti-Track v2 (#633) — ships dark; arm after observe-only soak ──
|
||||
"privacy_enforce": False, # master switch; off = observe-only
|
||||
"privacy_poison": True, # forge stable fake id for loadbearing trackers
|
||||
|
|
@ -44,6 +47,7 @@ DEFAULTS: Dict = {
|
|||
|
||||
_VALID_PROTECTIVE = ("off", "alert", "spoof")
|
||||
_VALID_SPLICE = ("off", "observe", "on")
|
||||
_VALID_TOR_PRESET = ("anonymous", "stealth", "minimal")
|
||||
|
||||
_cache: Dict = {}
|
||||
_cache_ts: float = 0.0
|
||||
|
|
@ -71,6 +75,8 @@ def get_filters(force: bool = False) -> Dict:
|
|||
out["protective"] = DEFAULTS["protective"]
|
||||
if out.get("tls_splice") not in _VALID_SPLICE:
|
||||
out["tls_splice"] = DEFAULTS["tls_splice"]
|
||||
if out.get("tor_preset") not in _VALID_TOR_PRESET:
|
||||
out["tor_preset"] = DEFAULTS["tor_preset"]
|
||||
_cache = out
|
||||
_cache_ts = now
|
||||
return out
|
||||
|
|
@ -90,18 +96,32 @@ def set_filters(patch: Dict) -> Dict:
|
|||
cur["protective"] = v
|
||||
elif k == "tls_splice" and v in _VALID_SPLICE:
|
||||
cur["tls_splice"] = v
|
||||
elif k == "tor_preset" and v in _VALID_TOR_PRESET:
|
||||
cur["tor_preset"] = v
|
||||
elif k == "fortknox_sites" and isinstance(v, list):
|
||||
cur["fortknox_sites"] = [str(s).strip().lower() for s in v if str(s).strip()]
|
||||
elif k in ("banner", "ad_ghost", "ad_ghost_block", "media_cache", "autolearn",
|
||||
"privacy_enforce", "privacy_poison", "privacy_anonymize",
|
||||
"privacy_ip_drop", "privacy_dns_feed", "ad_learn"):
|
||||
"privacy_ip_drop", "privacy_dns_feed", "ad_learn", "tor_mode"):
|
||||
cur[k] = bool(v)
|
||||
data = json.dumps(cur, indent=1)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(FILTERS_PATH), exist_ok=True)
|
||||
# Preferred: atomic tmp + rename (needs write on the parent dir).
|
||||
tmp = FILTERS_PATH + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(cur, f, indent=1)
|
||||
f.write(data)
|
||||
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:
|
||||
pass
|
||||
_cache_ts = 0.0 # invalidate
|
||||
|
|
|
|||
102
packages/secubox-toolbox/secubox_toolbox/tor_ctl.py
Normal file
102
packages/secubox-toolbox/secubox_toolbox/tor_ctl.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
|
||||
"""
|
||||
SecuBox-Deb :: toolbox :: Tor control-port helpers (#683)
|
||||
CyberMind — https://cybermind.fr
|
||||
|
||||
Thin, dependency-free Tor control-port client, mirrored from the proven
|
||||
``secubox-tor`` package (api/main.py ``tor_control``). The kbin Tor switch
|
||||
reads status (bootstrap / circuits / exit IP) and requests a new identity
|
||||
(NEWNYM) directly over the control port — no cross-service JWT, no root.
|
||||
|
||||
The control cookie is group-readable by ``debian-tor``; the toolbox postinst
|
||||
adds ``secubox-toolbox`` to that group so the FastAPI portal can authenticate.
|
||||
All functions are best-effort and never raise — a dead/absent Tor daemon
|
||||
yields ``running=False`` rather than a 500, so the UI degrades gracefully.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
CONTROL_PORT = 9051
|
||||
SOCKS_PORT = 9050
|
||||
CONTROL_SOCKET = Path("/run/tor/control")
|
||||
COOKIE_FILE = Path("/run/tor/control.authcookie")
|
||||
|
||||
|
||||
def _connect() -> socket.socket:
|
||||
if CONTROL_SOCKET.exists():
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.connect(str(CONTROL_SOCKET))
|
||||
else:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.connect(("127.0.0.1", CONTROL_PORT))
|
||||
return sock
|
||||
|
||||
|
||||
def tor_control(command: str) -> str:
|
||||
"""Authenticate (cookie if present) and run one control-port command.
|
||||
Returns the raw reply, or "" on any failure."""
|
||||
try:
|
||||
sock = _connect()
|
||||
try:
|
||||
if COOKIE_FILE.exists():
|
||||
cookie = COOKIE_FILE.read_bytes().hex()
|
||||
sock.send(f"AUTHENTICATE {cookie}\r\n".encode())
|
||||
else:
|
||||
sock.send(b"AUTHENTICATE\r\n")
|
||||
if "250 OK" not in sock.recv(1024).decode(errors="replace"):
|
||||
return ""
|
||||
sock.send(f"{command}\r\n".encode())
|
||||
return sock.recv(4096).decode(errors="replace")
|
||||
finally:
|
||||
sock.close()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def tor_running() -> bool:
|
||||
try:
|
||||
r = subprocess.run(["pgrep", "-x", "tor"], capture_output=True, text=True, timeout=5)
|
||||
return r.returncode == 0 and r.stdout.strip() != ""
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def bootstrap_progress(reply: str | None = None) -> int:
|
||||
"""Parse ``GETINFO status/bootstrap-phase`` PROGRESS=NN (0..100)."""
|
||||
resp = reply if reply is not None else tor_control("GETINFO status/bootstrap-phase")
|
||||
if "PROGRESS=" in resp:
|
||||
try:
|
||||
return int(resp.split("PROGRESS=")[1].split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def circuit_count(reply: str | None = None) -> int:
|
||||
"""Count BUILT circuits from ``GETINFO circuit-status``."""
|
||||
resp = reply if reply is not None else tor_control("GETINFO circuit-status")
|
||||
return sum(1 for line in resp.splitlines() if " BUILT " in f" {line} ")
|
||||
|
||||
|
||||
def new_identity() -> bool:
|
||||
"""Request a fresh Tor circuit (SIGNAL NEWNYM). True on 250 OK."""
|
||||
return "250 OK" in tor_control("SIGNAL NEWNYM")
|
||||
|
||||
|
||||
def status() -> Dict:
|
||||
"""Best-effort consolidated status for the kbin Tor card."""
|
||||
if not tor_running():
|
||||
return {"running": False, "bootstrap": 0, "circuits": 0}
|
||||
boot = bootstrap_progress()
|
||||
circ = circuit_count()
|
||||
return {"running": True, "bootstrap": boot, "circuits": circ}
|
||||
14
packages/secubox-toolbox/systemd/secubox-toolbox-tor.path
Normal file
14
packages/secubox-toolbox/systemd/secubox-toolbox-tor.path
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Watches the toolbox filter config; any change re-runs the Tor reconciler so
|
||||
# flipping `tor_mode` from the WebUI arms/disarms the tunnel within seconds —
|
||||
# no portal privilege escalation, no restart. The reconciler is idempotent
|
||||
# (no-op when desired == current) so unrelated filter toggles are cheap.
|
||||
[Unit]
|
||||
Description=SecuBox-Deb ToolBoX — watch filters.json for kbin Tor mode (#683)
|
||||
|
||||
[Path]
|
||||
PathModified=/etc/secubox/toolbox/filters.json
|
||||
Unit=secubox-toolbox-tor.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
18
packages/secubox-toolbox/systemd/secubox-toolbox-tor.service
Normal file
18
packages/secubox-toolbox/systemd/secubox-toolbox-tor.service
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# kbin Tor egress reconciler (#683) — oneshot, root. Triggered by
|
||||
# secubox-toolbox-tor.path on every filters.json change, and once at boot,
|
||||
# to make the nft+tor tunnel match `tor_mode`. The portal API only flips the
|
||||
# flag (NoNewPrivileges=true); this unit does the privileged work.
|
||||
[Unit]
|
||||
Description=SecuBox-Deb ToolBoX — reconcile kbin Tor egress tunnel (#683)
|
||||
After=network-online.target nftables.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/sbin/secubox-toolbox-tor-reconcile reconcile
|
||||
# Keep failures visible but never block the trigger chain.
|
||||
SuccessExitStatus=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
16
packages/secubox-toolbox/systemd/secubox-toolbox-tor.timer
Normal file
16
packages/secubox-toolbox/systemd/secubox-toolbox-tor.timer
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Belt-and-suspenders re-arm (#683). The toolbox_tor table now persists as an
|
||||
# nftables.d drop-in (survives `nft -f /etc/nftables.conf` reloads), but a bare
|
||||
# `nft flush ruleset` from elsewhere could still wipe it while tor_mode=on —
|
||||
# leaving the flag on but egress unprotected. This timer reconciles every 2 min
|
||||
# so any such drift self-heals (idempotent no-op when desired == current).
|
||||
[Unit]
|
||||
Description=SecuBox-Deb ToolBoX — periodic re-arm of kbin Tor egress (#683)
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=2min
|
||||
AccuracySec=20s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
|
@ -19,7 +19,10 @@ RestartSec=3
|
|||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox/toolbox
|
||||
# #683: the portal must persist its own config (filters.json — Tor switch +
|
||||
# mitm filter toggles). /etc/secubox stays read-only EXCEPT the toolbox subdir;
|
||||
# the CA key inside stays 0600 root, still unreadable to this user.
|
||||
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox/toolbox /etc/secubox/toolbox
|
||||
ReadOnlyPaths=/etc/secubox
|
||||
|
||||
# nft + ip neigh require CAP_NET_ADMIN
|
||||
|
|
|
|||
196
packages/secubox-toolbox/tests/test_tor_switch.py
Normal file
196
packages/secubox-toolbox/tests/test_tor_switch.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
"""kbin Tor switch (#683): filters flags + control-port parsing."""
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def filters_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("SECUBOX_FILTERS_PATH", str(tmp_path / "filters.json"))
|
||||
import secubox_toolbox.filters as f
|
||||
importlib.reload(f)
|
||||
return f
|
||||
|
||||
|
||||
def test_tor_defaults_off(filters_mod):
|
||||
cur = filters_mod.get_filters(force=True)
|
||||
assert cur["tor_mode"] is False
|
||||
assert cur["tor_preset"] == "anonymous"
|
||||
|
||||
|
||||
def test_tor_mode_is_bool_coerced(filters_mod):
|
||||
out = filters_mod.set_filters({"tor_mode": 1})
|
||||
assert out["tor_mode"] is True
|
||||
on_disk = json.loads((filters_mod.FILTERS_PATH and open(filters_mod.FILTERS_PATH).read()))
|
||||
assert on_disk["tor_mode"] is True
|
||||
|
||||
|
||||
def test_tor_preset_enum_guarded(filters_mod):
|
||||
out = filters_mod.set_filters({"tor_preset": "bogus"})
|
||||
assert out["tor_preset"] == "anonymous" # rejected, default kept
|
||||
out = filters_mod.set_filters({"tor_preset": "stealth"})
|
||||
assert out["tor_preset"] == "stealth"
|
||||
|
||||
|
||||
def test_set_filters_persists_when_dir_not_writable(tmp_path, monkeypatch):
|
||||
"""Regression (#683): aggregator runs as a user that can't create a tmp file
|
||||
in the 0750 /etc/secubox/toolbox — set_filters must still persist in-place."""
|
||||
import os
|
||||
d = tmp_path / "ro"
|
||||
d.mkdir()
|
||||
fpath = d / "filters.json"
|
||||
fpath.write_text('{"banner": true}\n')
|
||||
monkeypatch.setenv("SECUBOX_FILTERS_PATH", str(fpath))
|
||||
import secubox_toolbox.filters as f
|
||||
importlib.reload(f)
|
||||
os.chmod(d, 0o555) # dir read-only → tmp+rename fails, in-place must work
|
||||
try:
|
||||
out = f.set_filters({"tor_mode": True})
|
||||
assert out["tor_mode"] is True
|
||||
assert json.loads(fpath.read_text())["tor_mode"] is True # actually persisted
|
||||
finally:
|
||||
os.chmod(d, 0o755)
|
||||
|
||||
|
||||
def test_get_filters_clamps_bad_preset_on_disk(filters_mod):
|
||||
with open(filters_mod.FILTERS_PATH, "w") as fh:
|
||||
json.dump({"tor_preset": "evil"}, fh)
|
||||
assert filters_mod.get_filters(force=True)["tor_preset"] == "anonymous"
|
||||
|
||||
|
||||
def test_bootstrap_progress_parse():
|
||||
from secubox_toolbox import tor_ctl
|
||||
reply = "250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=100 TAG=done SUMMARY=\"Done\"\r\n250 OK\r\n"
|
||||
assert tor_ctl.bootstrap_progress(reply) == 100
|
||||
assert tor_ctl.bootstrap_progress("garbage") == 0
|
||||
|
||||
|
||||
def test_circuit_count_parse():
|
||||
from secubox_toolbox import tor_ctl
|
||||
reply = (
|
||||
"250+circuit-status=\r\n"
|
||||
"1 BUILT $AAA~a,$BBB~b PURPOSE=GENERAL\r\n"
|
||||
"2 BUILT $CCC~c PURPOSE=GENERAL\r\n"
|
||||
"3 LAUNCHED PURPOSE=GENERAL\r\n"
|
||||
".\r\n250 OK\r\n"
|
||||
)
|
||||
assert tor_ctl.circuit_count(reply) == 2
|
||||
|
||||
|
||||
class _FakeReq:
|
||||
def __init__(self, host="admin.gk2.secubox.in", body=None):
|
||||
self.headers = {"host": host}
|
||||
self._body = body or {}
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("SECUBOX_FILTERS_PATH", str(tmp_path / "filters.json"))
|
||||
import secubox_toolbox.filters as f
|
||||
importlib.reload(f)
|
||||
import secubox_toolbox.api as api
|
||||
return api
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tor_on_off_flips_flag(api_mod):
|
||||
out = await api_mod.admin_tor_on(_FakeReq(body={"tor_preset": "stealth"}))
|
||||
assert out["tor_mode"] is True and out["tor_preset"] == "stealth"
|
||||
out = await api_mod.admin_tor_off(_FakeReq())
|
||||
assert out["tor_mode"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tor_actions_blocked_on_public_kbin(api_mod):
|
||||
from fastapi import HTTPException
|
||||
for fn in (api_mod.admin_tor_on, api_mod.admin_tor_off,
|
||||
api_mod.admin_tor_newnym, api_mod.admin_tor_check_leaks):
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
await fn(_FakeReq(host="kbin.gk2.secubox.in"))
|
||||
assert ei.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tor_state_shape_offline(api_mod, monkeypatch):
|
||||
from secubox_toolbox import tor_ctl
|
||||
monkeypatch.setattr(tor_ctl, "tor_running", lambda: False)
|
||||
st = await api_mod.admin_tor_state()
|
||||
assert st["tor_mode"] is False
|
||||
assert st["running"] is False
|
||||
assert "bootstrap" in st and "circuits" in st and "exit_ip" in st
|
||||
|
||||
|
||||
def _banner_ctx(tor_mode):
|
||||
return {
|
||||
"status_icon": "\U0001F50D", "status": "inspected", "flag": "", "app_emoji": "",
|
||||
"app": "example.com", "asn": "", "grade": "A", "grade_color": "#0f0",
|
||||
"cookies_set": 0, "cookies_sent": 0, "is_tracker_host": False,
|
||||
"utiq_recent_count": 0, "ghost_blocked": 0, "ghost_kb": 0, "tor_mode": tor_mode,
|
||||
}
|
||||
|
||||
|
||||
def test_banner_shows_tor_chip_when_armed():
|
||||
import sys, pathlib
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "mitmproxy_addons"))
|
||||
import importlib, inject_banner
|
||||
importlib.reload(inject_banner)
|
||||
on = inject_banner._banner_html_dynamic("sha", _banner_ctx(True), True, "https://kbin/r", "R3", "r3")
|
||||
off = inject_banner._banner_html_dynamic("sha", _banner_ctx(False), True, "https://kbin/r", "R3", "r3")
|
||||
assert b"🧅 Tor" in on # 🧅 chip present when armed
|
||||
assert b"🧅" 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
|
||||
|
|
@ -22,6 +22,11 @@
|
|||
.header{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.5rem;border:1px solid var(--border);background:var(--bg-card);margin-bottom:1rem}
|
||||
.header h1{font-size:1.4rem;color:var(--p31-hot);text-shadow:var(--bloom-text)}
|
||||
.badge{font-size:0.85rem;color:var(--p31-dim);padding:0.2rem 0.6rem;border:1px solid var(--border);border-radius:3px}
|
||||
/* Tor switch status badges (#683) */
|
||||
.badge-green{color:var(--p31-hot);border-color:var(--p31-peak);text-shadow:var(--bloom-soft)}
|
||||
.badge-yellow{color:var(--p31-decay);border-color:var(--p31-decay)}
|
||||
.badge-red{color:var(--red);border-color:var(--red)}
|
||||
.btn-green{border-color:var(--p31-peak)!important;color:var(--p31-hot)!important}
|
||||
/* Sub-tab nav (#513) */
|
||||
.tabs{display:flex;gap:0.3rem;margin-bottom:1.2rem;border-bottom:1px solid var(--border);flex-wrap:wrap}
|
||||
.tab{font-family:inherit;font-size:0.9rem;padding:0.5rem 1rem;background:transparent;color:var(--p31-dim);border:1px solid var(--border);border-bottom:none;cursor:pointer;border-radius:3px 3px 0 0}
|
||||
|
|
@ -78,6 +83,7 @@
|
|||
<button class="tab" data-tab="filtres" onclick="switchTab('filtres')">🚦 Filtres MITM</button>
|
||||
<button class="tab" data-tab="social" onclick="switchTab('social')">🕸️ Cartographie sociale</button>
|
||||
<button class="tab" data-tab="ads" onclick="switchTab('ads')">🛑 Pubs</button>
|
||||
<button class="tab" data-tab="tor" onclick="switchTab('tor')">🧅 Tor</button>
|
||||
<button class="tab" data-tab="config" onclick="switchTab('config')">⚙ Config</button>
|
||||
</nav>
|
||||
|
||||
|
|
@ -192,6 +198,37 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tor egress quick-switch (#683) -->
|
||||
<section class="panel" id="panel-tor">
|
||||
<div class="toolbar">
|
||||
<button onclick="loadTor()">🔁 Refresh</button>
|
||||
<button type="button" onclick="torNewnym()">🔄 Nouvelle identité</button>
|
||||
<button type="button" onclick="torLeaks()">🔍 Vérifier l'IP de sortie</button>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:1rem">
|
||||
<h2>🧅 Mode Tor <span class="badge" id="tor-badge">…</span></h2>
|
||||
<p style="font-size:0.82rem;color:var(--p31-dim,#888);margin-bottom:0.6rem">
|
||||
Quick-switch : route l'egress des workers MITM par le réseau Tor. L'inspection
|
||||
(ad-block, poison, bannière, safe-browsing) reste intacte — seule l'IP de sortie
|
||||
change. Fail-closed (pas de repli clearnet), sans fuite DNS. Armement asynchrone
|
||||
(quelques secondes via le reconciler).
|
||||
</p>
|
||||
<div class="kv" id="tor-stats"><span class="k">loading…</span><span class="v"></span></div>
|
||||
<div style="margin-top:0.8rem;display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn-green" id="tor-on-btn" onclick="torSet(true)">🛡️ Activer le mode Tor</button>
|
||||
<button type="button" id="tor-off-btn" onclick="torSet(false)">⏹ Désactiver</button>
|
||||
<label style="font-size:0.8rem;color:var(--p31-dim,#888)">Preset
|
||||
<select id="tor-preset">
|
||||
<option value="anonymous">anonymous</option>
|
||||
<option value="stealth">stealth (bridges)</option>
|
||||
<option value="minimal">minimal (SOCKS)</option>
|
||||
</select>
|
||||
</label>
|
||||
<span id="tor-msg" style="font-size:0.8rem;color:var(--p31-peak,#0a0)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Config -->
|
||||
<section class="panel" id="panel-config">
|
||||
<div class="toolbar">
|
||||
|
|
@ -215,6 +252,7 @@ function switchTab(name) {
|
|||
if (name === 'filtres') loadFilters();
|
||||
if (name === 'social') loadSocial();
|
||||
if (name === 'ads') loadAds();
|
||||
if (name === 'tor') loadTor();
|
||||
location.hash = name;
|
||||
}
|
||||
|
||||
|
|
@ -424,6 +462,69 @@ async function loadFilters() {
|
|||
}).join('');
|
||||
}
|
||||
|
||||
// ── kbin Tor egress quick-switch (#683) ──
|
||||
async function loadTor() {
|
||||
const st = await J('/admin/tor/state');
|
||||
const badge = document.getElementById('tor-badge');
|
||||
const stats = document.getElementById('tor-stats');
|
||||
if (st.__error) {
|
||||
badge.textContent = 'err'; badge.className = 'badge badge-red';
|
||||
stats.innerHTML = `<span class="k">err</span><span class="v">${st.__error}</span>`;
|
||||
return;
|
||||
}
|
||||
const on = !!st.tor_mode;
|
||||
badge.textContent = on ? (st.running ? `ON · ${st.bootstrap||0}%` : 'ON (tor down)') : 'OFF';
|
||||
badge.className = 'badge ' + (on ? (st.running && st.bootstrap >= 100 ? 'badge-green' : 'badge-yellow') : '');
|
||||
const psel = document.getElementById('tor-preset');
|
||||
if (psel && st.tor_preset) psel.value = st.tor_preset;
|
||||
stats.innerHTML = `
|
||||
<span class="k">Mode Tor</span> <span class="v">${on ? '🧅 activé' : '⚪ désactivé'}</span>
|
||||
<span class="k">Daemon</span> <span class="v">${st.running ? '🟢 running' : '⚪ stopped'}</span>
|
||||
<span class="k">Bootstrap</span> <span class="v">${st.bootstrap || 0}%</span>
|
||||
<span class="k">Circuits</span> <span class="v">${st.circuits || 0}</span>
|
||||
<span class="k">IP de sortie</span><span class="v">${st.exit_ip || '— (cliquez « Vérifier »)'}</span>
|
||||
<span class="k">Preset</span> <span class="v">${st.tor_preset || 'anonymous'}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
async function torSet(on) {
|
||||
const msg = document.getElementById('tor-msg');
|
||||
const preset = (document.getElementById('tor-preset') || {}).value || 'anonymous';
|
||||
msg.textContent = on ? 'armement…' : 'désactivation…';
|
||||
try {
|
||||
const body = on ? JSON.stringify({tor_preset: preset}) : '{}';
|
||||
const r = await fetch(`${API}/admin/tor/${on ? 'on' : 'off'}`, {
|
||||
method: 'POST', headers: {'content-type': 'application/json'},
|
||||
body, credentials: 'same-origin'
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status + (r.status === 403 ? ' (admin.gk2 only)' : ''));
|
||||
msg.textContent = on ? 'armé ✓ (tunnel dans quelques secondes)' : 'désarmé ✓';
|
||||
setTimeout(() => { msg.textContent = ''; loadTor(); }, 1500);
|
||||
} catch (e) { msg.textContent = 'échec : ' + e.message; }
|
||||
}
|
||||
|
||||
async function torNewnym() {
|
||||
const msg = document.getElementById('tor-msg');
|
||||
msg.textContent = 'nouveau circuit…';
|
||||
try {
|
||||
const r = await fetch(`${API}/admin/tor/newnym`, {method: 'POST', credentials: 'same-origin'});
|
||||
const d = await r.json();
|
||||
msg.textContent = (r.ok && d.success) ? 'nouvelle identité ✓' : 'échec NEWNYM';
|
||||
setTimeout(() => { msg.textContent = ''; loadTor(); }, 1500);
|
||||
} catch (e) { msg.textContent = 'échec : ' + e.message; }
|
||||
}
|
||||
|
||||
async function torLeaks() {
|
||||
const msg = document.getElementById('tor-msg');
|
||||
msg.textContent = 'sonde SOCKS…';
|
||||
try {
|
||||
const r = await fetch(`${API}/admin/tor/check-leaks`, {method: 'POST', credentials: 'same-origin'});
|
||||
const d = await r.json();
|
||||
msg.textContent = d.ok ? `IP de sortie : ${d.tor_ip}` : ('échec : ' + (d.error || 'tor down'));
|
||||
setTimeout(() => loadTor(), 800);
|
||||
} catch (e) { msg.textContent = 'échec : ' + e.message; }
|
||||
}
|
||||
|
||||
async function loadSocial() {
|
||||
const agg = await J('/admin/social-aggregate?hours=24');
|
||||
const kpi = document.getElementById('social-kpi');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user