From 2eb3890d5315749383e17ca8905b3e7678560983 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 24 Jul 2026 09:40:56 +0200 Subject: [PATCH] =?UTF-8?q?docs(proxypac):=20plan=20d'impl=C3=A9mentation?= =?UTF-8?q?=20WPAD/DHCP=20autodetect=20+=20transparent=20.onion=20(8=20t?= =?UTF-8?q?=C3=A2ches)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Gerald KERMA --- .../2026-07-24-proxypac-wpad-autodetect.md | 966 ++++++++++++++++++ 1 file changed, 966 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-proxypac-wpad-autodetect.md diff --git a/docs/superpowers/plans/2026-07-24-proxypac-wpad-autodetect.md b/docs/superpowers/plans/2026-07-24-proxypac-wpad-autodetect.md new file mode 100644 index 00000000..77f9cff2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-proxypac-wpad-autodetect.md @@ -0,0 +1,966 @@ +# ProxyPAC WPAD/DHCP autodetect + transparent `.onion` — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Router `.onion` de bout en bout (transparent pour wg-toolbox+LAN, PAC/WPAD en fallback), avec autodétection de rôle DHCP master/slave et distribution best-effort, panneau fini. + +**Architecture:** Deux paquets. `secubox-tor` fournit les endpoints Tor (SocksPort LAN + machinerie transparente TransPort/DNSPort + Unbound onion-forward + nft redirect) pilotés par `torctl`. `secubox-proxypac` (le sous-système PAC déjà mergé/déployé) gagne la détection de rôle, la distribution WPAD, le repoint de la règle onion vers le SOCKS LAN, et un panneau complet qui expose/toggle tout via API déléguant à des ctl root scopés. + +**Tech Stack:** Debian bookworm arm64, Tor 0.4.9, Unbound, nftables (dropins isolés `table inet secubox-*`), FastAPI (servi in-process par l'aggregator), bash ctl + sudoers scopés, pytest + node. + +## Global Constraints + +- **Jamais de `SocksPolicy`** dans un dropin torrc (option GLOBALE → casse le SocksPort mesh). Confinement = bind IP + nft. +- **`TransPort 9040` / `DNSPort 9053` ne se déclarent qu'UNE fois** dans `tor@default` : `torctl transparent on` réutilise un dropin existant (toolbox), ne duplique jamais. `off` ne retire que ce que `on` a posé. +- **Ne JAMAIS chown les parents partagés** `/run/secubox`, `/etc/secubox`, `/var/log/secubox` (chmod only). +- **`#DEBHELPER#` seul sur sa ligne** (jamais en commentaire). +- **Pas de `.bak` dans `sites-enabled/`**. +- nftables **DEFAULT DROP** ; dropins en `table inet secubox-` isolée, préfixe d'ordre correct dans `/etc/nftables.d/`. +- Endpoint SOCKS/LAN-IP **jamais codé en dur** dans le code : détecté + overridable via `/etc/secubox/proxypac/proxypac.toml`. gk2 = `192.168.1.200`. +- API délègue toute action privilégiée à un ctl via **sudo scopé** (jamais d'action root in-process). Jeton webui = `sbx_token` en localStorage. +- Commits : `Co-Authored-By: Gerald KERMA `, **aucune** référence IA. +- Best-effort partout : une étape réseau qui échoue est loggée, n'interrompt pas les autres, ne laisse rien de cassé. + +## File Structure + +**secubox-tor** (endpoints Tor + réseau) +- `conf/torrc.d/50-secubox-socks-lan.conf` — SocksPort LAN (substitué au postinst) +- `conf/torrc.d/60-secubox-transparent.conf` — TransPort/DNSPort/Automap/VirtualAddr (posé par torctl si pas déjà présent) +- `conf/unbound/secubox-onion-forward.conf` — forward-zone `onion.` → 127.0.0.1@9053 + private-domain +- `nft.d/secubox-tor-transparent.nft` — redirect 10.192.0.0/10 → 127.0.0.1:9040 (iif wg-toolbox+LAN) +- `sbin/torctl` — `socks-lan {ensure}` , `transparent {on|off|status}`, `detect-lan-ip` +- `sbin/tor-lan-ip` — helper détection IP LAN (partagé) +- `tests/test_torctl_transparent.py`, `tests/test_socks_lan_dropin.py`, `tests/test_onion_forward.py`, `tests/test_nft_transparent.py` + +**secubox-proxypac** (PAC + rôle + WPAD + panneau) +- `conf/proxypac.toml` — conffile (role/wpad_domain/pac_url/socks_endpoint/transparent) +- `conf/rules.d/00-onion.rules` — `*.onion socks5 __LAN_SOCKS__` (substitué à la génération) +- `proxypac/config.py` — lecture proxypac.toml + résolution socks_endpoint (auto|override) +- `proxypac/role.py` — détection passive master/slave + résolveur DNS + IP LAN +- `sbin/proxypac-wpad` — applique l'échelon (dnsmasq 252 / DNS wpad), idempotent +- `api/main.py` — + /status, /wpad/apply, /transparent, /wpad/state +- `www/proxypac/index.html` — panneau réécrit (navbar + statut + candidats + runbook) +- `debian/secubox-proxypac.sudoers` — sudo scopé (proxypac-wpad, torctl transparent) +- `tests/test_config.py`, `tests/test_role.py`, `tests/test_wpad.py`, `tests/test_api_status.py`, `tests/test_panel.py` + +--- + +### Task 1: secubox-tor — helper détection IP LAN + SocksPort LAN dropin + +**Files:** +- Create: `packages/secubox-tor/sbin/tor-lan-ip` +- Create: `packages/secubox-tor/conf/torrc.d/50-secubox-socks-lan.conf` +- Create: `packages/secubox-tor/sbin/torctl` +- Test: `packages/secubox-tor/tests/test_socks_lan_dropin.py` + +**Interfaces:** +- Produces: `tor-lan-ip` prints one IPv4 (the LAN IP) to stdout; used by torctl and proxypac postinst. `torctl socks-lan ensure` writes `50-secubox-socks-lan.conf` with `SocksPort :9050` (substituting `__LAN_IP__`), no SocksPolicy, then `systemctl reload tor@default`. + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-tor/tests/test_socks_lan_dropin.py +import subprocess, re, os +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] + +def test_socks_lan_template_has_no_socks_policy(): + tpl = (ROOT / "conf/torrc.d/50-secubox-socks-lan.conf").read_text() + assert "SocksPort" in tpl + assert "SocksPolicy" not in tpl, "SocksPolicy est GLOBALE — casserait le port mesh" + assert "__LAN_IP__" in tpl, "l'IP doit être un placeholder substitué au postinst" + assert "0.0.0.0" not in tpl + +def test_lan_ip_helper_prints_a_private_ipv4(): + out = subprocess.run(["bash", str(ROOT / "sbin/tor-lan-ip")], + capture_output=True, text=True) + ip = out.stdout.strip() + assert re.match(r"^(192\.168|10|172)\.\d+\.\d+\.\d+$", ip), f"got {ip!r}" + # ne doit jamais renvoyer une IP wg/docker/mesh + assert not ip.startswith(("10.99.", "10.100.", "10.10.", "172.17.")), ip +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/secubox-tor && python3 -m pytest tests/test_socks_lan_dropin.py -q` +Expected: FAIL (fichiers absents). + +- [ ] **Step 3: Write the LAN-IP helper** + +```bash +# packages/secubox-tor/sbin/tor-lan-ip +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Détecte l'IP LAN du box : override explicite sinon best-effort. +# Override : /etc/secubox/proxypac/proxypac.toml socks_endpoint="ip:port" +# ou variable SECUBOX_LAN_IP. Sinon : première IPv4 globale privée sur une +# interface physique, en excluant wg*/docker*/br-*/lo et les ranges internes. +set -euo pipefail +if [ -n "${SECUBOX_LAN_IP:-}" ]; then echo "$SECUBOX_LAN_IP"; exit 0; fi +TOML=/etc/secubox/proxypac/proxypac.toml +if [ -r "$TOML" ]; then + ip_from_toml=$(sed -n 's/^[[:space:]]*socks_endpoint[[:space:]]*=[[:space:]]*"\{0,1\}\([0-9.]\+\):[0-9]\+"\{0,1\}.*/\1/p' "$TOML" | head -1) + if [ -n "$ip_from_toml" ]; then echo "$ip_from_toml"; exit 0; fi +fi +ip -o -4 addr show scope global 2>/dev/null \ + | grep -vE '\b(wg-|docker|br-|veth|lo)\w*' \ + | awk '{print $2" "$4}' \ + | grep -vE '^(wg-|docker|br-|veth)' \ + | awk '{print $2}' | cut -d/ -f1 \ + | grep -E '^(192\.168|10|172)\.' \ + | grep -vE '^(10\.99\.|10\.100\.|10\.10\.|172\.17\.)' \ + | head -1 +``` + +- [ ] **Step 4: Write the SocksPort LAN template** + +``` +# packages/secubox-tor/conf/torrc.d/50-secubox-socks-lan.conf +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SOCKS LAN pour clients LAN / wg-toolbox (PAC .onion -> Tor). +# PAS de SocksPolicy : l'option est GLOBALE dans Tor et casserait le +# SocksPort mesh (secubox-macro). Confinement = bind IP LAN + nft. +SocksPort __LAN_IP__:9050 +``` + +- [ ] **Step 5: Write torctl (socks-lan ensure + detect-lan-ip stubs used later)** + +```bash +# packages/secubox-tor/sbin/torctl +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SecuBox-Deb :: torctl — pilote les endpoints Tor (SOCKS LAN + transparent). +set -euo pipefail +SHARE=/usr/share/secubox/tor +TORRC_D=/etc/tor/torrc.d +UNBOUND_D=/etc/unbound/unbound.conf.d +NFT_D=/etc/nftables.d +lan_ip() { /usr/sbin/tor-lan-ip; } + +socks_lan_ensure() { + local ip; ip="$(lan_ip)"; [ -n "$ip" ] || { echo "pas d'IP LAN détectée" >&2; return 1; } + sed "s/__LAN_IP__/$ip/g" "$SHARE/50-secubox-socks-lan.conf" > "$TORRC_D/50-secubox-socks-lan.conf" + systemctl reload tor@default 2>/dev/null || systemctl restart tor@default || true + echo "SocksPort LAN $ip:9050" +} + +case "${1:-}" in + detect-lan-ip) lan_ip ;; + socks-lan) [ "${2:-}" = "ensure" ] && socks_lan_ensure ;; + *) echo "usage: torctl {detect-lan-ip|socks-lan ensure|transparent {on|off|status}}" >&2; exit 2 ;; +esac +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd packages/secubox-tor && chmod +x sbin/tor-lan-ip sbin/torctl && python3 -m pytest tests/test_socks_lan_dropin.py -q` +Expected: PASS (2 tests). *(Le test IP tourne sur la machine de dev — s'il n'a pas d'IP LAN privée, il détecte le skip via une IP absente ; documenter si l'env de test n'a pas d'IPv4 privée, auquel cas valider la regex sur une entrée simulée.)* + +- [ ] **Step 7: Commit** + +```bash +git add packages/secubox-tor/sbin packages/secubox-tor/conf/torrc.d/50-secubox-socks-lan.conf packages/secubox-tor/tests/test_socks_lan_dropin.py +git commit -m "feat(tor): SocksPort LAN dropin (no SocksPolicy) + LAN-IP detect helper + torctl" +``` + +--- + +### Task 2: secubox-tor — transparent `.onion` (Unbound forward + tor TransPort + nft redirect + torctl transparent) + +**Files:** +- Create: `packages/secubox-tor/conf/torrc.d/60-secubox-transparent.conf` +- Create: `packages/secubox-tor/conf/unbound/secubox-onion-forward.conf` +- Create: `packages/secubox-tor/nft.d/secubox-tor-transparent.nft` +- Modify: `packages/secubox-tor/sbin/torctl` +- Test: `packages/secubox-tor/tests/test_torctl_transparent.py`, `tests/test_onion_forward.py`, `tests/test_nft_transparent.py` + +**Interfaces:** +- Consumes: `tor-lan-ip` (Task 1). +- Produces: `torctl transparent on|off|status`. `on` = (a) si aucun `TransPort 9040` actif dans `$TORRC_D`, poser `60-secubox-transparent.conf` ; (b) poser le forward Unbound ; (c) poser le nft dropin ; (d) reload tor + unbound + nft ; idempotent. `off` = retirer uniquement les fichiers posés par nous + reload. `status` = imprime `on`/`off` + détail (JSON). + +- [ ] **Step 1: Write the failing tests** + +```python +# packages/secubox-tor/tests/test_onion_forward.py +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] +def test_onion_forward_targets_tor_dnsport_and_keeps_automap_range(): + c = (ROOT / "conf/unbound/secubox-onion-forward.conf").read_text() + assert 'forward-zone:' in c and 'name: "onion."' in c + assert '127.0.0.1@9053' in c + # sinon Unbound strippe le range automap privé 10.192.0.0/10 : + assert 'private-domain: "onion."' in c +``` + +```python +# packages/secubox-tor/tests/test_nft_transparent.py +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] +def test_nft_redirects_only_automap_range_to_transport(): + n = (ROOT / "nft.d/secubox-tor-transparent.nft").read_text() + assert "table inet secubox-tor-transparent" in n + assert "10.192.0.0/10" in n + assert "9040" in n + # portée : wg-toolbox + LAN, hook prerouting dstnat + assert "wg-toolbox" in n + assert "type nat hook prerouting" in n +``` + +```python +# packages/secubox-tor/tests/test_torctl_transparent.py +import subprocess, os, json, stat +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] + +def _run(env, *args): + return subprocess.run(["bash", str(ROOT/"sbin/torctl"), *args], + capture_output=True, text=True, env=env) + +def test_on_skips_transport_dropin_when_one_already_exists(tmp_path): + torrc = tmp_path/"torrc.d"; torrc.mkdir() + (torrc/"torrc-toolbox-egress.conf").write_text("TransPort 127.0.0.1:9040\nDNSPort 127.0.0.1:9053\n") + env = {**os.environ, "TORCTL_TORRC_D": str(torrc), "TORCTL_UNBOUND_D": str(tmp_path/"u"), + "TORCTL_NFT_D": str(tmp_path/"n"), "TORCTL_DRYRUN": "1"} + (tmp_path/"u").mkdir(); (tmp_path/"n").mkdir() + r = _run(env, "transparent", "on") + assert r.returncode == 0 + assert not (torrc/"60-secubox-transparent.conf").exists(), "ne doit pas dupliquer TransPort" + +def test_off_removes_only_our_files(tmp_path): + torrc = tmp_path/"torrc.d"; torrc.mkdir() + ext = torrc/"torrc-toolbox-egress.conf"; ext.write_text("TransPort 127.0.0.1:9040\n") + (torrc/"60-secubox-transparent.conf").write_text("x") + env = {**os.environ, "TORCTL_TORRC_D": str(torrc), "TORCTL_UNBOUND_D": str(tmp_path/"u"), + "TORCTL_NFT_D": str(tmp_path/"n"), "TORCTL_DRYRUN": "1"} + (tmp_path/"u").mkdir(); (tmp_path/"n").mkdir() + _run(env, "transparent", "off") + assert not (torrc/"60-secubox-transparent.conf").exists() + assert ext.exists(), "off ne doit jamais retirer le dropin d'un autre paquet" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/secubox-tor && python3 -m pytest tests/test_torctl_transparent.py tests/test_onion_forward.py tests/test_nft_transparent.py -q` +Expected: FAIL. + +- [ ] **Step 3: Write the tor transparent dropin** + +``` +# packages/secubox-tor/conf/torrc.d/60-secubox-transparent.conf +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Egress transparent .onion : DNSPort automap + TransPort. Posé par torctl +# UNIQUEMENT si aucun TransPort/DNSPort n'est déjà déclaré (coordination avec +# torrc-toolbox-egress.conf du toolbox — mêmes ports, une seule déclaration). +TransPort 127.0.0.1:9040 +DNSPort 127.0.0.1:9053 +AutomapHostsOnResolve 1 +VirtualAddrNetworkIPv4 10.192.0.0/10 +``` + +- [ ] **Step 4: Write the Unbound onion-forward dropin** + +``` +# packages/secubox-tor/conf/unbound/secubox-onion-forward.conf +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Résout *.onion via Tor DNSPort (automap -> 10.192.0.0/10). private-domain +# empêche Unbound de stripper la réponse d'adresse privée (le range automap). +server: + private-domain: "onion." + do-not-query-localhost: no +forward-zone: + name: "onion." + forward-addr: 127.0.0.1@9053 +``` + +- [ ] **Step 5: Write the nft transparent dropin** + +Note: `redirect` cible l'IP locale de l'iface entrante ; comme Tor TransPort est sur 127.0.0.1, on DNAT explicitement vers 127.0.0.1:9040 et le postinst pose `net.ipv4.conf.all.route_localnet=1` (Task 8) pour autoriser le DNAT loopback depuis une iface non-lo. + +``` +# packages/secubox-tor/nft.d/secubox-tor-transparent.nft +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Redirige le trafic vers les IP virtuelles automap Tor (10.192.0.0/10) vers +# le TransPort local. Portée stricte : seul ce range, seules les ifaces +# force-routées (wg-toolbox + LAN eth2). Nécessite route_localnet=1. +table inet secubox-tor-transparent { + chain prerouting { + type nat hook prerouting priority dstnat; policy accept; + iifname { "wg-toolbox", "eth2" } ip daddr 10.192.0.0/10 tcp dport 1-65535 dnat ip to 127.0.0.1:9040 comment "onion-transparent-tcp" + } +} +``` + +- [ ] **Step 6: Extend torctl with transparent {on|off|status}** + +```bash +# append to packages/secubox-tor/sbin/torctl (before the case), and add cases +# Env overrides for tests: TORCTL_TORRC_D / TORCTL_UNBOUND_D / TORCTL_NFT_D / TORCTL_DRYRUN +TORRC_D="${TORCTL_TORRC_D:-/etc/tor/torrc.d}" +UNBOUND_D="${TORCTL_UNBOUND_D:-/etc/unbound/unbound.conf.d}" +NFT_D="${TORCTL_NFT_D:-/etc/nftables.d}" +DRYRUN="${TORCTL_DRYRUN:-0}" +_reload() { [ "$DRYRUN" = "1" ] && return 0; "$@" || true; } + +transport_already_declared() { + grep -rqiE '^[[:space:]]*TransPort[[:space:]]+127\.0\.0\.1:9040' "$TORRC_D"/*.conf 2>/dev/null +} +transparent_on() { + if transport_already_declared; then + echo "TransPort 9040 déjà déclaré (toolbox) — réutilisé, pas de dropin dupliqué" + else + cp "$SHARE/60-secubox-transparent.conf" "$TORRC_D/60-secubox-transparent.conf" + fi + cp "$SHARE/secubox-onion-forward.conf" "$UNBOUND_D/secubox-onion-forward.conf" + cp "$SHARE/secubox-tor-transparent.nft" "$NFT_D/secubox-tor-transparent.nft" + _reload sysctl -q -w net.ipv4.conf.all.route_localnet=1 + _reload systemctl reload tor@default + _reload systemctl restart unbound + _reload nft -f "$NFT_D/secubox-tor-transparent.nft" + echo "transparent on" +} +transparent_off() { + rm -f "$TORRC_D/60-secubox-transparent.conf" "$UNBOUND_D/secubox-onion-forward.conf" "$NFT_D/secubox-tor-transparent.nft" + _reload systemctl reload tor@default + _reload systemctl restart unbound + _reload nft delete table inet secubox-tor-transparent 2>/dev/null + echo "transparent off" +} +transparent_status() { + local on=false + [ -f "$UNBOUND_D/secubox-onion-forward.conf" ] && [ -f "$NFT_D/secubox-tor-transparent.nft" ] && on=true + echo "{\"transparent\": $on}" +} +``` +Add to the `case`: `transparent) case "${2:-}" in on) transparent_on;; off) transparent_off;; status) transparent_status;; esac;;` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cd packages/secubox-tor && python3 -m pytest tests/test_torctl_transparent.py tests/test_onion_forward.py tests/test_nft_transparent.py -q` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add packages/secubox-tor/conf packages/secubox-tor/nft.d packages/secubox-tor/sbin/torctl packages/secubox-tor/tests/test_torctl_transparent.py packages/secubox-tor/tests/test_onion_forward.py packages/secubox-tor/tests/test_nft_transparent.py +git commit -m "feat(tor): transparent .onion — unbound onion-forward + TransPort dropin + nft redirect + torctl on/off (port-coord, route_localnet)" +``` + +--- + +### Task 3: secubox-proxypac — config + repoint règle onion vers SOCKS LAN + +**Files:** +- Create: `packages/secubox-proxypac/conf/proxypac.toml` +- Create: `packages/secubox-proxypac/proxypac/config.py` +- Modify: `packages/secubox-proxypac/conf/rules.d/00-onion.rules` +- Modify: `packages/secubox-proxypac/proxypac/generator.py` +- Test: `packages/secubox-proxypac/tests/test_config.py` + +**Interfaces:** +- Produces: `config.load()` → dict avec `socks_endpoint` (résolu : override toml sinon `:9050`), `role`, `wpad_domain`, `pac_url`, `transparent` (bool). `generator.run_once` substitue `__LAN_SOCKS__` dans les règles par `config.load()['socks_endpoint']`. +- Consumes: `tor-lan-ip` (via subprocess) pour l'IP par défaut. + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_config.py +import sys; from pathlib import Path +ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(ROOT)) +from proxypac import config + +def test_socks_endpoint_prefers_toml_override(tmp_path, monkeypatch): + t = tmp_path/"proxypac.toml" + t.write_text('socks_endpoint = "192.168.5.9:9050"\nrole = "auto"\n') + c = config.load(str(t)) + assert c["socks_endpoint"] == "192.168.5.9:9050" + assert c["role"] == "auto" + +def test_onion_rule_uses_placeholder_not_hardcoded_mesh(): + r = (ROOT/"conf/rules.d/00-onion.rules").read_text() + assert "__LAN_SOCKS__" in r + assert "10.10.0.1:9050" not in r, "plus de SOCKS mesh injoignable en dur" + +def test_defaults_when_no_toml(tmp_path): + c = config.load(str(tmp_path/"absent.toml")) + assert c["role"] == "auto" + assert isinstance(c["transparent"], bool) + assert ":9050" in c["socks_endpoint"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/secubox-proxypac && python3 -m pytest tests/test_config.py -q` +Expected: FAIL. + +- [ ] **Step 3: Write config.py** + +```python +# packages/secubox-proxypac/proxypac/config.py +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +"""SecuBox-Deb :: proxypac.config — lit proxypac.toml, résout le socks_endpoint.""" +import subprocess +try: + import tomllib +except ModuleNotFoundError: # py<3.11 + import tomli as tomllib + +DEFAULTS = {"role": "auto", "wpad_domain": "", "pac_url": "", "transparent": True} + + +def _detect_lan_ip(): + try: + out = subprocess.run(["/usr/sbin/tor-lan-ip"], capture_output=True, text=True, timeout=5) + ip = out.stdout.strip() + return ip or "127.0.0.1" + except Exception: + return "127.0.0.1" + + +def load(path="/etc/secubox/proxypac/proxypac.toml"): + data = dict(DEFAULTS) + try: + with open(path, "rb") as f: + data.update(tomllib.load(f)) + except (OSError, tomllib.TOMLDecodeError): + pass + ep = data.get("socks_endpoint") + if not ep: + ep = f"{_detect_lan_ip()}:9050" + data["socks_endpoint"] = ep + data.setdefault("transparent", True) + return data +``` + +- [ ] **Step 4: Update the onion rule to a placeholder** + +``` +# packages/secubox-proxypac/conf/rules.d/00-onion.rules +# secubox-proxypac seed: route .onion via le SOCKS Tor LAN du box. +# __LAN_SOCKS__ est substitué à la génération par proxypac.config.socks_endpoint +# (override proxypac.toml, sinon IP LAN détectée:9050). Plus de SOCKS mesh en dur. +*.onion socks5 __LAN_SOCKS__ +``` + +- [ ] **Step 5: Substitute the placeholder in generator.run_once** + +In `packages/secubox-proxypac/proxypac/generator.py`, after `overrides = parse_rules_dir(rules_dir)` add substitution of `__LAN_SOCKS__` in each override directive: + +```python + from .config import load as _load_cfg + ep = _load_cfg().get("socks_endpoint", "127.0.0.1:9050") + for r in overrides: + r.directive = r.directive.replace("__LAN_SOCKS__", ep) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd packages/secubox-proxypac && python3 -m pytest tests/test_config.py tests/test_generator.py -q` +Expected: PASS (nouveau + non-régression du générateur existant). + +- [ ] **Step 7: Commit** + +```bash +git add packages/secubox-proxypac/conf/proxypac.toml packages/secubox-proxypac/proxypac/config.py packages/secubox-proxypac/conf/rules.d/00-onion.rules packages/secubox-proxypac/proxypac/generator.py packages/secubox-proxypac/tests/test_config.py +git commit -m "feat(proxypac): onion rule -> LAN SOCKS via proxypac.toml (placeholder subst, plus de mesh en dur)" +``` + +Créer aussi `conf/proxypac.toml` : +```toml +# SecuBox ProxyPAC — configuration (conffile). Tout est optionnel. +role = "auto" # auto | master | slave | off +wpad_domain = "" # ex: gk2.secubox.in (vide = auto depuis le hostname) +pac_url = "" # override de l'URL PAC affichée (vide = auto) +# socks_endpoint = "192.168.1.200:9050" # override ; sinon IP LAN détectée:9050 +transparent = true # .onion transparent pour clients force-routés (wg-toolbox+LAN) +``` + +--- + +### Task 4: secubox-proxypac — détection de rôle passive (`role.py`) + +**Files:** +- Create: `packages/secubox-proxypac/proxypac/role.py` +- Test: `packages/secubox-proxypac/tests/test_role.py` + +**Interfaces:** +- Produces: `role.detect(probe=)` → dict `{"role": "master"|"slave", "dns_resolver": bool, "lan_ip": str, "tier": 1|2|3}`. Détection passive : master si un DHCP écoute UDP/67 sur l'IP LAN ; dns_resolver si UDP/53 lié à l'IP LAN ; tier = 1 si master, 2 si dns_resolver, 3 sinon. Les sondes (listeners) sont injectables pour test (aucune I/O réseau réelle en test). + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_role.py +import sys; from pathlib import Path +ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(ROOT)) +from proxypac import role + +def probe(dhcp=False, dns=False, lan="192.168.1.200"): + return {"lan_ip": lan, + "dhcp_on_lan": dhcp, + "dns_on_lan": dns} + +def test_master_when_dhcp_listens_on_lan(): + r = role.detect(probe(dhcp=True, dns=True)) + assert r["role"] == "master" and r["tier"] == 1 + +def test_slave_with_dns_is_tier2(): + r = role.detect(probe(dhcp=False, dns=True)) + assert r["role"] == "slave" and r["tier"] == 2 and r["dns_resolver"] is True + +def test_slave_without_dns_is_tier3(): + r = role.detect(probe(dhcp=False, dns=False)) + assert r["role"] == "slave" and r["tier"] == 3 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/secubox-proxypac && python3 -m pytest tests/test_role.py -q` +Expected: FAIL. + +- [ ] **Step 3: Write role.py** + +```python +# packages/secubox-proxypac/proxypac/role.py +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +"""SecuBox-Deb :: proxypac.role — détection passive master/slave (read-only).""" +import subprocess + + +def _live_probe(): + """Lecture d'état seulement : aucun paquet DHCP émis.""" + lan_ip = "" + try: + lan_ip = subprocess.run(["/usr/sbin/tor-lan-ip"], capture_output=True, + text=True, timeout=5).stdout.strip() + except Exception: + pass + ss = "" + try: + ss = subprocess.run(["ss", "-ulnp"], capture_output=True, text=True, timeout=5).stdout + except Exception: + pass + dhcp = bool(lan_ip) and (f"{lan_ip}:67" in ss or "0.0.0.0:67" in ss) + dns = bool(lan_ip) and (f"{lan_ip}:53" in ss) + return {"lan_ip": lan_ip, "dhcp_on_lan": dhcp, "dns_on_lan": dns} + + +def detect(probe=None): + p = probe if probe is not None else _live_probe() + if p.get("dhcp_on_lan"): + return {"role": "master", "dns_resolver": p.get("dns_on_lan", False), + "lan_ip": p.get("lan_ip", ""), "tier": 1} + if p.get("dns_on_lan"): + return {"role": "slave", "dns_resolver": True, "lan_ip": p.get("lan_ip", ""), "tier": 2} + return {"role": "slave", "dns_resolver": False, "lan_ip": p.get("lan_ip", ""), "tier": 3} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/secubox-proxypac && python3 -m pytest tests/test_role.py -q` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/proxypac/role.py packages/secubox-proxypac/tests/test_role.py +git commit -m "feat(proxypac): détection passive de rôle master/slave + résolveur DNS (tier 1/2/3)" +``` + +--- + +### Task 5: secubox-proxypac — actuateur WPAD (`proxypac-wpad`) + +**Files:** +- Create: `packages/secubox-proxypac/sbin/proxypac-wpad` +- Test: `packages/secubox-proxypac/tests/test_wpad.py` + +**Interfaces:** +- Consumes: `role.detect()`, `config.load()`. +- Produces: `proxypac-wpad apply` applique l'échelon détecté (respecte l'override `role` du toml) : master → dropin dnsmasq option 252 ; tier2 → local-data wpad dans Unbound ; tier3 → no-op. Idempotent, best-effort. `proxypac-wpad state` imprime le JSON de l'état. + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_wpad.py +import subprocess, os, json +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] + +def _run(env, *args): + return subprocess.run(["bash", str(ROOT/"sbin/proxypac-wpad"), *args], + capture_output=True, text=True, env=env) + +def _env(tmp, role="master"): + return {**os.environ, "WPAD_DRYRUN": "1", "WPAD_ROLE": role, + "WPAD_DNSMASQ_D": str(tmp/"dnsmasq"), "WPAD_UNBOUND_D": str(tmp/"unbound"), + "WPAD_DOMAIN": "gk2.secubox.in", "WPAD_LAN_IP": "192.168.1.200"} + +def test_master_writes_dhcp_option_252(tmp_path): + (tmp_path/"dnsmasq").mkdir(); (tmp_path/"unbound").mkdir() + r = _run(_env(tmp_path, "master"), "apply") + assert r.returncode == 0 + f = tmp_path/"dnsmasq"/"secubox-wpad.conf" + assert f.exists() and "dhcp-option=252" in f.read_text() and "wpad.gk2.secubox.in" in f.read_text() + +def test_slave_tier2_writes_unbound_wpad_record(tmp_path): + (tmp_path/"dnsmasq").mkdir(); (tmp_path/"unbound").mkdir() + r = _run(_env(tmp_path, "slave-dns"), "apply") + assert r.returncode == 0 + f = tmp_path/"unbound"/"secubox-wpad.conf" + assert f.exists() and "wpad.gk2.secubox.in" in f.read_text() and "192.168.1.200" in f.read_text() + assert not (tmp_path/"dnsmasq"/"secubox-wpad.conf").exists() + +def test_idempotent(tmp_path): + (tmp_path/"dnsmasq").mkdir(); (tmp_path/"unbound").mkdir() + e = _env(tmp_path, "master") + _run(e, "apply"); a = (tmp_path/"dnsmasq"/"secubox-wpad.conf").read_text() + _run(e, "apply"); b = (tmp_path/"dnsmasq"/"secubox-wpad.conf").read_text() + assert a == b +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/secubox-proxypac && python3 -m pytest tests/test_wpad.py -q` +Expected: FAIL. + +- [ ] **Step 3: Write proxypac-wpad** + +```bash +# packages/secubox-proxypac/sbin/proxypac-wpad +#!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# SecuBox-Deb :: proxypac-wpad — applique l'échelon WPAD selon le rôle détecté. +set -euo pipefail +DRYRUN="${WPAD_DRYRUN:-0}" +DNSMASQ_D="${WPAD_DNSMASQ_D:-/etc/dnsmasq.d}" +UNBOUND_D="${WPAD_UNBOUND_D:-/etc/unbound/unbound.conf.d}" +DOMAIN="${WPAD_DOMAIN:-$(hostname -d 2>/dev/null || echo local)}" +LAN_IP="${WPAD_LAN_IP:-$(/usr/sbin/tor-lan-ip 2>/dev/null || echo 127.0.0.1)}" +# rôle : override explicite (WPAD_ROLE) sinon détection python +role() { + if [ -n "${WPAD_ROLE:-}" ]; then echo "$WPAD_ROLE"; return; fi + python3 - <<'PY' +import sys; sys.path.insert(0, "/usr/lib/secubox/proxypac") +from proxypac.role import detect +r = detect() +print("master" if r["tier"]==1 else ("slave-dns" if r["tier"]==2 else "slave")) +PY +} +_reload(){ [ "$DRYRUN" = "1" ] && return 0; "$@" || true; } + +apply() { + local r; r="$(role)" + # nettoyage : on retire nos deux dropins puis on repose celui du tier courant + rm -f "$DNSMASQ_D/secubox-wpad.conf" "$UNBOUND_D/secubox-wpad.conf" + case "$r" in + master) + printf '# secubox-proxypac WPAD (auto)\ndhcp-option=252,"http://wpad.%s/wpad.dat"\n' "$DOMAIN" > "$DNSMASQ_D/secubox-wpad.conf" + _reload systemctl reload dnsmasq ;; + slave-dns) + printf '# secubox-proxypac WPAD via DNS (auto)\nserver:\n local-data: "wpad.%s. A %s"\n' "$DOMAIN" "$LAN_IP" > "$UNBOUND_D/secubox-wpad.conf" + _reload unbound-control reload ;; + *) : ;; # tier3 : no-op réseau + esac + echo "wpad applied role=$r domain=$DOMAIN" +} +state() { + local r; r="$(role)" + printf '{"role":"%s","domain":"%s","lan_ip":"%s","pac_url":"http://wpad.%s/wpad.dat"}\n' "$r" "$DOMAIN" "$LAN_IP" "$DOMAIN" +} +case "${1:-}" in apply) apply;; state) state;; *) echo "usage: proxypac-wpad {apply|state}" >&2; exit 2;; esac +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/secubox-proxypac && chmod +x sbin/proxypac-wpad && python3 -m pytest tests/test_wpad.py -q` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/sbin/proxypac-wpad packages/secubox-proxypac/tests/test_wpad.py +git commit -m "feat(proxypac): actuateur WPAD best-effort (dnsmasq 252 / DNS wpad / no-op) idempotent" +``` + +--- + +### Task 6: secubox-proxypac — API `/status` + `/wpad/apply` + `/transparent` + +**Files:** +- Modify: `packages/secubox-proxypac/api/main.py` +- Test: `packages/secubox-proxypac/tests/test_api_status.py` + +**Interfaces:** +- Consumes: `role.detect()`, `config.load()`, ctl via sudo (`proxypac-wpad`, `torctl transparent`). +- Produces: `GET /status` → `{role, tier, lan_ip, socks_endpoint, transparent, pac_url, dns_resolver}`. `POST /wpad/apply` → délègue `sudo -n proxypac-wpad apply`. `POST /transparent` body `{on: bool}` → `sudo -n torctl transparent on|off`. `GET /wpad/state` → `sudo -n proxypac-wpad state` (JSON). Toutes protégées par `require_jwt`. + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_api_status.py +import sys; from pathlib import Path +ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(ROOT)) +from fastapi.testclient import TestClient +import api.main as m + +def test_status_reports_role_and_socks(monkeypatch): + monkeypatch.setattr(m.role, "detect", lambda probe=None: {"role":"master","tier":1,"dns_resolver":True,"lan_ip":"192.168.1.200"}) + monkeypatch.setattr(m.config, "load", lambda *a, **k: {"socks_endpoint":"192.168.1.200:9050","transparent":True,"wpad_domain":"gk2.secubox.in","pac_url":"","role":"auto"}) + c = TestClient(m.app) + r = c.get("/status") + assert r.status_code == 200 + d = r.json() + assert d["role"] == "master" and d["socks_endpoint"] == "192.168.1.200:9050" and d["transparent"] is True + +def test_transparent_toggle_delegates(monkeypatch): + called = {} + monkeypatch.setattr(m, "_ctl", lambda *a, **k: called.setdefault("args", a) or (0, "")) + c = TestClient(m.app) + r = c.post("/transparent", json={"on": True}) + assert r.status_code == 200 and r.json().get("ok") is True + assert "torctl" in " ".join(called["args"][0]) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/secubox-proxypac && python3 -m pytest tests/test_api_status.py -q` +Expected: FAIL. + +- [ ] **Step 3: Extend api/main.py** + +Add imports and helper + routes (keep existing routes untouched): + +```python +from proxypac import role, config as _cfg +import subprocess + +config = _cfg # alias for tests + +def _ctl(args, timeout=25): + try: + p = subprocess.run(["sudo", "-n", *args], capture_output=True, text=True, timeout=timeout) + return p.returncode, p.stdout.strip() + except (OSError, subprocess.SubprocessError): + return 1, "" + +@app.get("/status") +def status(_=Depends(require_jwt)): + r = role.detect() + c = config.load() + dom = c.get("wpad_domain") or "" + return {"role": r["role"], "tier": r["tier"], "dns_resolver": r.get("dns_resolver", False), + "lan_ip": r.get("lan_ip", ""), "socks_endpoint": c["socks_endpoint"], + "transparent": bool(c.get("transparent", True)), + "pac_url": c.get("pac_url") or (f"http://wpad.{dom}/wpad.dat" if dom else "")} + +@app.post("/wpad/apply") +def wpad_apply(_=Depends(require_jwt)): + rc, out = _ctl(["/usr/sbin/proxypac-wpad", "apply"]) + return {"ok": rc == 0, "detail": out} + +@app.get("/wpad/state") +def wpad_state(_=Depends(require_jwt)): + import json as _j + rc, out = _ctl(["/usr/sbin/proxypac-wpad", "state"]) + try: + return _j.loads(out) if rc == 0 else {"error": "ctl indisponible"} + except Exception: + return {"error": "réponse illisible"} + +class Toggle(BaseModel): + on: bool + +@app.post("/transparent") +def transparent(t: Toggle, _=Depends(require_jwt)): + rc, out = _ctl(["/usr/sbin/torctl", "transparent", "on" if t.on else "off"]) + return {"ok": rc == 0, "detail": out} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/secubox-proxypac && python3 -m pytest tests/test_api_status.py tests/test_api.py -q` +Expected: PASS (nouveau + non-régression de l'API existante). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/api/main.py packages/secubox-proxypac/tests/test_api_status.py +git commit -m "feat(proxypac): API /status + /wpad/apply + /wpad/state + /transparent (délègue via sudo scopé)" +``` + +--- + +### Task 7: secubox-proxypac — panneau réécrit (navbar + statut + candidats + runbook) + +**Files:** +- Modify: `packages/secubox-proxypac/www/proxypac/index.html` +- Test: `packages/secubox-proxypac/tests/test_panel.py` + +**Interfaces:** +- Consumes: `/api/v1/proxypac/{status,rules,candidates,override,wpad/state,wpad/apply,transparent}`. + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_panel.py +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] +def test_panel_has_navbar_and_status_and_runbook(): + h = (ROOT/"www/proxypac/index.html").read_text() + assert 'class="sidebar"' in h and '/shared/sidebar.js' in h, "navbar manquante" + assert '/api/v1/proxypac/status' in h, "carte statut manquante" + assert 'socks_remote_dns' in h, "runbook client manquant" + assert 'sbx_token' in h, "doit lire le jeton sbx_token" + assert '/transparent' in h, "toggle transparent manquant" + +def test_menu_entry_valid_json(): + import json + j = json.loads((ROOT/"menu.d/580-proxypac.json").read_text()) + assert j["path"] == "/proxypac/" and j["id"] == "proxypac" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/secubox-proxypac && python3 -m pytest tests/test_panel.py -q` +Expected: FAIL. + +- [ ] **Step 3: Rewrite the panel** + +Réécrire `www/proxypac/index.html` en hybrid-dark avec : sidebar (`` + `