mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 15:28:24 +00:00
feat(clients): persistent WG identity (APK) + Tor status in webext/APK (ref #683)
APK (0.4.0): - FIX lost-referrer: persist the WG profile in app-internal filesDir and REUSE it. /wg/profile/new mints a fresh keypair each call and onboarding runs every boot, so the device kept re-keying → new sha256(pubkey) identity → stats reset each reboot. Now one stable identity across reboot/reconnect/restart. (Reinstall still wipes filesDir; allowBackup stays off for CSPN.) - Silent root onboarding (CA system-store + native WG) already runs on boot (#538/#558); it now provisions the STABLE profile. - Surfaces 🧅 kbin Tor-egress status after onboarding. webext (0.1.5): - popup shows a 🧅 Tor indicator (exit anonymised) next to the R3 dot, via the new public /wg/tor-status endpoint. toolbox (2.7.5): - public, kbin-safe GET /wg/tor-status {tor_mode,running,bootstrap,exit_ip} (mirrors /wg/r3-check; /admin/tor/* stays admin-gated). Verified live on kbin. 13 toolbox tests green. .xpi 0.1.5 built; .apk builds via build-android-apk.yml. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
55c7d925a6
commit
55955867af
|
|
@ -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,3 +1,12 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -500,6 +500,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
|
||||
|
|
|
|||
|
|
@ -160,5 +160,19 @@ def test_nft_tunnel_failclosed_invariants():
|
|||
assert "meta nfproto ipv6" in text and "drop" in text
|
||||
# only the worker uid is torified (not a blanket rule)
|
||||
assert text.count('meta skuid "secubox-toolbox"') >= 4
|
||||
# loopback + local subnets are exempted (no plumbing breakage)
|
||||
assert "10.99.0.0/16" in text and "10.100.0.0/16" in text
|
||||
# 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_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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user