diff --git a/docs/superpowers/plans/2026-07-03-secubox-proxypac.md b/docs/superpowers/plans/2026-07-03-secubox-proxypac.md new file mode 100644 index 00000000..9c2f3ebc --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-secubox-proxypac.md @@ -0,0 +1,1218 @@ +# secubox-proxypac Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A per-node package that auto-generates a `proxy.pac`, served via WPAD, routing each host to the right transport (mesh tor-exit SOCKS, toolbox MITM, gateway, or DIRECT) from the active p2p `/services` catalog + operator per-host policy. + +**Architecture:** A pure-Python generator composes an ordered rule list (operator overrides > active mesh-service patterns > toolbox default > DIRECT) into a static PAC file, atomically swapped and fail-safe (last-good preserved, every directive ends `; DIRECT`). nginx serves it at `/proxy.pac` and on a LAN/mesh-only `wpad` vhost; DHCP option 252 makes discovery zero-config. A FastAPI WebUI manages overrides + auto-learn candidates. The annuaire `ServiceOffer` gains an optional, byte-stable `pac` descriptor. + +**Tech Stack:** Python 3.11 (stdlib + pydantic v2), FastAPI/uvicorn on a unix socket, nginx, systemd path+timer units, Debian packaging (debhelper compat 13). + +## Global Constraints + +- Package name `secubox-proxypac`; version `1.0.0-1~bookworm1`; `debian/compat` 13; `Standards-Version: 4.6.2`. +- Python SPDX header block on every `.py` (per `.claude/CLAUDE.md`); service runs as `User=secubox`. +- The generated PAC MUST end every non-DIRECT directive with `; DIRECT` (fail-open) and MUST have a terminal `return "DIRECT";`. +- Generation is fail-safe: on any error the previously-served `proxy.pac` is left untouched (never serve empty/broken). +- `wpad.dat` / `/proxy.pac` are served ONLY on the LAN + `10.10.0.0/24` mesh vhost (`deny all` otherwise) — never public. +- The annuaire `pac` field is OPTIONAL and byte-stable: a `pac`-less `ServiceOffer` MUST produce identical `canonical_bytes` to before this change (None excluded from canonical JSON), mirroring `MacroDescriptor` (#771). +- No secrets in the PAC (hosts + proxy addresses only). Override add/remove + candidate accept/reject append to `/var/log/secubox/audit.log`. +- Paths: state `/var/lib/secubox/proxypac/`, rules `/etc/secubox/proxypac/rules.d/`, PAC output `/var/lib/secubox/proxypac/proxy.pac`. + +--- + +## File Structure + +- `packages/secubox-annuaire/annuaire/model.py` — MODIFY: add `PacDescriptor` + `ServiceOffer.pac`. +- `packages/secubox-proxypac/proxypac/__init__.py` — package marker. +- `packages/secubox-proxypac/proxypac/pac_template.py` — PAC JS rendering + directive builder (pure). +- `packages/secubox-proxypac/proxypac/rules.py` — `Rule` model, rules.d parsing, precedence compose. +- `packages/secubox-proxypac/proxypac/catalog.py` — read p2p `/services`, map to service `Rule`s. +- `packages/secubox-proxypac/proxypac/generator.py` — compose + atomic fail-safe write. +- `packages/secubox-proxypac/sbin/proxypac-gen` — CLI entry (`--once`). +- `packages/secubox-proxypac/api/main.py` — FastAPI WebUI (rules CRUD + candidates). +- `packages/secubox-proxypac/nginx/proxypac.conf` — `/proxy.pac` + WebUI route (secubox.d). +- `packages/secubox-proxypac/nginx/wpad-vhost.conf` — LAN/mesh-only `wpad` server block. +- `packages/secubox-proxypac/systemd/*` — regen path unit + timer + service; dnsmasq 252 dropin. +- `packages/secubox-proxypac/conf/rules.d/00-onion.rules` — seed. +- `packages/secubox-proxypac/www/proxypac/index.html` + `menu.d/` — WebUI panel. +- `packages/secubox-proxypac/debian/*` — control, rules, postinst, prerm, changelog. +- `packages/secubox-proxypac/tests/*` — unit tests per module. + +Each task below runs from the package dir with `PYTHONPATH` including repo `common` + the package, matching sibling packages. Tests use `pytest`. + +--- + +## Task 1: annuaire `PacDescriptor` schema (byte-stable optional field) + +**Files:** +- Modify: `packages/secubox-annuaire/annuaire/model.py` (after `MacroDescriptor`, ~line 400, and `ServiceOffer` ~line 425) +- Test: `packages/secubox-annuaire/tests/test_pac_descriptor.py` + +**Interfaces:** +- Produces: `PacDescriptor(match: list[str], proxy: Literal["socks5","http","gateway","direct"])`; `ServiceOffer.pac: Optional[PacDescriptor]`. + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-annuaire/tests/test_pac_descriptor.py +from annuaire.model import ServiceOffer, PacDescriptor +from annuaire.crypto import canonical_bytes +import pytest + +def _offer(**kw): + return ServiceOffer(service_id="a1", provider="did:plc:"+"0"*32, + name="svc", kind="api", endpoint="http://10.10.0.1/x", **kw) + +def test_pac_parses_and_validates(): + o = _offer(pac=PacDescriptor(match=["*.onion"], proxy="socks5")) + assert o.pac.match == ["*.onion"] and o.pac.proxy == "socks5" + +def test_pac_rejects_bad_proxy(): + with pytest.raises(Exception): + PacDescriptor(match=["x"], proxy="tunnel") + +def test_pac_rejects_empty_match(): + with pytest.raises(Exception): + PacDescriptor(match=[], proxy="socks5") + +def test_pacless_offer_is_byte_stable(): + # An offer with no pac must serialize identically to the pre-field baseline. + o = _offer() + payload = o.model_dump(exclude_none=True) + assert "pac" not in payload + assert canonical_bytes(payload) == canonical_bytes(_offer().model_dump(exclude_none=True)) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-annuaire && PYTHONPATH="$(git rev-parse --show-toplevel)/common:." python -m pytest tests/test_pac_descriptor.py -q` +Expected: FAIL — `ImportError: cannot import name 'PacDescriptor'`. + +- [ ] **Step 3: Implement** + +In `model.py`, add after `MacroDescriptor`: + +```python +class PacDescriptor(BaseModel): + """Optional PAC routing hint federated with a ServiceOffer (#784). + + Declares which hosts this service handles and how a client proxies them. + Absent pac ⇒ the service contributes no client routing rule. + """ + model_config = ConfigDict(extra="forbid") + + match: List[str] = Field(..., min_length=1, description="host globs, e.g. ['*.onion']") + proxy: Literal["socks5", "http", "gateway", "direct"] = Field(...) +``` + +In `ServiceOffer`, add the field right after `macro:`: + +```python + pac: Optional[PacDescriptor] = None +``` + +Confirm `Literal` and `List` are imported at the top of `model.py` (add to the existing `from typing import ...` if missing). + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/secubox-annuaire && PYTHONPATH="$(git rev-parse --show-toplevel)/common:." python -m pytest tests/test_pac_descriptor.py -q` +Expected: PASS (4 tests). If `test_pacless_offer_is_byte_stable` fails, `canonical_bytes` is not excluding None — pass `model_dump(exclude_none=True)` at every call site is already the convention; the test uses it, so this must pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-annuaire/annuaire/model.py packages/secubox-annuaire/tests/test_pac_descriptor.py +git commit -m "feat(annuaire): optional byte-stable pac descriptor on ServiceOffer (ref #784)" +``` + +--- + +## Task 2: PAC template + directive builder (pure) + +**Files:** +- Create: `packages/secubox-proxypac/proxypac/__init__.py` (empty), `packages/secubox-proxypac/proxypac/pac_template.py` +- Test: `packages/secubox-proxypac/tests/test_pac_template.py` + +**Interfaces:** +- Produces: `directive(proxy_type: str, address: str) -> str`; `render(rules: list[tuple[str,str]]) -> str`. + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_pac_template.py +from proxypac.pac_template import directive, render + +def test_directive_socks_has_failopen(): + assert directive("socks5", "10.10.0.1:9050") == "SOCKS5 10.10.0.1:9050; DIRECT" + +def test_directive_http_and_gateway_are_proxy(): + assert directive("http", "127.0.0.1:8081") == "PROXY 127.0.0.1:8081; DIRECT" + assert directive("gateway", "gk2.secubox.in") == "PROXY gk2.secubox.in; DIRECT" + +def test_directive_direct(): + assert directive("direct", "") == "DIRECT" + +def test_render_first_match_wins_and_terminal_direct(): + pac = render([("*.onion", "SOCKS5 10.10.0.1:9050; DIRECT")]) + assert "function FindProxyForURL(url, host)" in pac + assert 'shExpMatch(host, "*.onion")' in pac + assert 'return "SOCKS5 10.10.0.1:9050; DIRECT";' in pac + assert pac.rstrip().endswith('return "DIRECT";\n}'.rstrip()) or 'return "DIRECT";' in pac +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_pac_template.py -q` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```python +# packages/secubox-proxypac/proxypac/pac_template.py +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac.pac_template — PAC JS rendering (pure).""" +import json + +_HEADER = "function FindProxyForURL(url, host) {\n" +_FOOTER = ' return "DIRECT";\n}\n' + + +def directive(proxy_type: str, address: str) -> str: + """Build a PAC return directive with a fail-open DIRECT fallback.""" + if proxy_type == "socks5": + return f"SOCKS5 {address}; DIRECT" + if proxy_type in ("http", "gateway"): + return f"PROXY {address}; DIRECT" + return "DIRECT" + + +def render(rules): + """rules: ordered list of (host_glob, directive_string). First match wins.""" + out = [_HEADER] + for glob, direct in rules: + out.append(f" if (shExpMatch(host, {json.dumps(glob)})) " + f"return {json.dumps(direct)};\n") + out.append(_FOOTER) + return "".join(out) +``` + +Create empty `packages/secubox-proxypac/proxypac/__init__.py`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_pac_template.py -q` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/proxypac/__init__.py packages/secubox-proxypac/proxypac/pac_template.py packages/secubox-proxypac/tests/test_pac_template.py +git commit -m "feat(proxypac): PAC template + fail-open directive builder (ref #784)" +``` + +--- + +## Task 3: Rule model + rules.d parsing + precedence compose + +**Files:** +- Create: `packages/secubox-proxypac/proxypac/rules.py` +- Test: `packages/secubox-proxypac/tests/test_rules.py` + +**Interfaces:** +- Consumes: `directive` (Task 2). +- Produces: `@dataclass Rule(host: str, directive: str, source: str)`; `parse_rules_dir(path) -> list[Rule]`; `compose(overrides, services, toolbox) -> list[tuple[str,str]]`. + +Rules.d line format: ` [address]`, `#` comments, blank lines skipped. Files applied in sorted filename order. Example line: `*.onion socks5 10.10.0.1:9050`. + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_rules.py +from proxypac.rules import Rule, parse_rules_dir, compose + +def test_parse_rules_dir(tmp_path): + (tmp_path / "10-a.rules").write_text("# c\n*.onion socks5 10.10.0.1:9050\n\n") + (tmp_path / "20-b.rules").write_text("bank.example direct\n") + rules = parse_rules_dir(tmp_path) + assert [(r.host, r.directive) for r in rules] == [ + ("*.onion", "SOCKS5 10.10.0.1:9050; DIRECT"), + ("bank.example", "DIRECT"), + ] + assert all(r.source == "override" for r in rules) + +def test_compose_precedence_override_beats_service(): + ov = [Rule("x.com", "DIRECT", "override")] + svc = [Rule("x.com", "PROXY p; DIRECT", "service:1"), Rule("y.com", "PROXY p; DIRECT", "service:1")] + tb = Rule("*", "PROXY t; DIRECT", "toolbox") + out = compose(ov, svc, tb) + # override wins for x.com; y.com from service; toolbox catch-all last + assert out == [("x.com", "DIRECT"), ("y.com", "PROXY p; DIRECT"), ("*", "PROXY t; DIRECT")] + +def test_compose_no_toolbox(): + assert compose([], [], None) == [] +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_rules.py -q` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```python +# packages/secubox-proxypac/proxypac/rules.py +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac.rules — rule model, rules.d parsing, precedence.""" +from dataclasses import dataclass +from pathlib import Path + +from .pac_template import directive + + +@dataclass +class Rule: + host: str + directive: str # full PAC directive string + source: str # "override" | "service:" | "toolbox" + + +def parse_rules_dir(path): + """Parse rules.d/*.rules in sorted filename order. + + Line: ' [address]'. '#' comments and blanks skipped. + """ + path = Path(path) + rules = [] + for f in sorted(path.glob("*.rules")): + for raw in f.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + host, ptype = parts[0], parts[1] + addr = parts[2] if len(parts) > 2 else "" + rules.append(Rule(host, directive(ptype, addr), "override")) + return rules + + +def compose(overrides, services, toolbox): + """Precedence: overrides, then services, then toolbox catch-all. First host wins.""" + seen = set() + out = [] + ordered = list(overrides) + list(services) + ([toolbox] if toolbox else []) + for r in ordered: + if r.host in seen: + continue + seen.add(r.host) + out.append((r.host, r.directive)) + return out +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_rules.py -q` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/proxypac/rules.py packages/secubox-proxypac/tests/test_rules.py +git commit -m "feat(proxypac): rules.d parsing + precedence compose (ref #784)" +``` + +--- + +## Task 4: Catalog reader (p2p /services → service Rules) + +**Files:** +- Create: `packages/secubox-proxypac/proxypac/catalog.py` +- Test: `packages/secubox-proxypac/tests/test_catalog.py` + +**Interfaces:** +- Consumes: `Rule`, `directive`. +- Produces: `service_rules(services: list[dict]) -> list[Rule]`; `read_services(sock=...) -> list[dict]` (thin HTTP-over-unix wrapper, not unit-tested directly). + +A service dict from `/services` has at least `service_id`, `enabled` (bool), `endpoint` (e.g. `http://10.10.0.1/tor`), optional `macro` (`{kind, params:{socks_port}}`), optional `pac` (`{match, proxy}`). Address resolution: host = the mesh IP parsed from `endpoint` (`http:///...`); port = `macro.params.socks_port` for socks5 else empty (gateway/http use the endpoint host). + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_catalog.py +from proxypac.catalog import service_rules + +def test_socks5_service_rule(): + svcs = [{"service_id": "s1", "enabled": True, + "endpoint": "http://10.10.0.1/tor", + "macro": {"kind": "tor-exit", "params": {"socks_port": 9050}}, + "pac": {"match": ["*.onion"], "proxy": "socks5"}}] + rules = service_rules(svcs) + assert (rules[0].host, rules[0].directive) == ("*.onion", "SOCKS5 10.10.0.1:9050; DIRECT") + assert rules[0].source == "service:s1" + +def test_disabled_and_pacless_skipped(): + svcs = [ + {"service_id": "s2", "enabled": False, "endpoint": "http://10.10.0.2/x", + "pac": {"match": ["a"], "proxy": "socks5"}}, + {"service_id": "s3", "enabled": True, "endpoint": "http://10.10.0.3/x"}, # no pac + ] + assert service_rules(svcs) == [] + +def test_gateway_service_uses_endpoint_host(): + svcs = [{"service_id": "s4", "enabled": True, "endpoint": "http://gk2.secubox.in/app", + "pac": {"match": ["app.local"], "proxy": "gateway"}}] + rules = service_rules(svcs) + assert rules[0].directive == "PROXY gk2.secubox.in; DIRECT" +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_catalog.py -q` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```python +# packages/secubox-proxypac/proxypac/catalog.py +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac.catalog — read p2p /services, map to service Rules.""" +import json +import socket +from urllib.parse import urlparse + +from .pac_template import directive +from .rules import Rule + + +def _endpoint_host(endpoint): + return urlparse(endpoint or "").hostname or "" + + +def service_rules(services): + """Map active services carrying a `pac` descriptor to routing Rules.""" + out = [] + for s in services: + if not s.get("enabled"): + continue + pac = s.get("pac") + if not pac: + continue + proxy = pac.get("proxy", "direct") + host = _endpoint_host(s.get("endpoint")) + if proxy == "socks5": + port = (s.get("macro") or {}).get("params", {}).get("socks_port", 9050) + addr = f"{host}:{port}" + else: # http | gateway use the endpoint host as-is + addr = host + d = directive(proxy, addr) + for m in pac.get("match", []): + out.append(Rule(m, d, f"service:{s['service_id']}")) + return out + + +def read_services(sock="/run/secubox/p2p.sock"): + """GET /services over the p2p unix socket. Returns the services list (fail-open: []).""" + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as c: + c.settimeout(5) + c.connect(sock) + c.sendall(b"GET /services HTTP/1.0\r\nHost: x\r\n\r\n") + buf = b"" + while True: + chunk = c.recv(65536) + if not chunk: + break + buf += chunk + body = buf.split(b"\r\n\r\n", 1)[1] + return json.loads(body).get("services", []) + except Exception: + return [] +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_catalog.py -q` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/proxypac/catalog.py packages/secubox-proxypac/tests/test_catalog.py +git commit -m "feat(proxypac): p2p /services catalog reader → routing rules (ref #784)" +``` + +--- + +## Task 5: Generator + atomic fail-safe write + CLI + +**Files:** +- Create: `packages/secubox-proxypac/proxypac/generator.py`, `packages/secubox-proxypac/sbin/proxypac-gen` +- Test: `packages/secubox-proxypac/tests/test_generator.py` + +**Interfaces:** +- Consumes: `parse_rules_dir`, `compose`, `service_rules`, `read_services`, `render`. +- Produces: `generate(rules_dir, services, toolbox_directive=None) -> str`; `write_atomic(pac_str, out) -> None` (raises on invalid); `run_once(...) -> bool`. + +`toolbox_directive` is a pre-built directive string (e.g. `"PROXY 127.0.0.1:8081; DIRECT"`) or None; when present it becomes the `("*", directive)` catch-all. + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_generator.py +import pytest +from proxypac.generator import generate, write_atomic + +def test_generate_composes_override_over_service(tmp_path): + (tmp_path / "10.rules").write_text("x.com direct\n") + svcs = [{"service_id": "s1", "enabled": True, "endpoint": "http://10.10.0.1/tor", + "macro": {"params": {"socks_port": 9050}}, + "pac": {"match": ["x.com", "*.onion"], "proxy": "socks5"}}] + pac = generate(tmp_path, svcs, toolbox_directive="PROXY 127.0.0.1:8081; DIRECT") + # override x.com -> DIRECT wins; *.onion via socks; toolbox catch-all last + assert pac.index('shExpMatch(host, "x.com")') < pac.index('shExpMatch(host, "*.onion")') + assert 'return "DIRECT";' in pac and 'SOCKS5 10.10.0.1:9050; DIRECT' in pac + assert 'shExpMatch(host, "*")' in pac + +def test_write_atomic_swaps_and_validates(tmp_path): + out = tmp_path / "proxy.pac" + write_atomic('function FindProxyForURL(url, host) {\n return "DIRECT";\n}\n', out) + assert out.read_text().startswith("function FindProxyForURL") + +def test_write_atomic_rejects_invalid_keeps_lastgood(tmp_path): + out = tmp_path / "proxy.pac" + write_atomic('function FindProxyForURL(url, host) { return "DIRECT"; }\n', out) + good = out.read_text() + with pytest.raises(ValueError): + write_atomic("garbage not a pac", out) # no FindProxyForURL → rejected + assert out.read_text() == good # last-good preserved + assert not (tmp_path / "proxy.pac.shadow").exists() +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_generator.py -q` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```python +# packages/secubox-proxypac/proxypac/generator.py +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac.generator — compose + atomic fail-safe PAC write.""" +import logging +import os +from pathlib import Path + +from .catalog import read_services, service_rules +from .pac_template import render +from .rules import Rule, compose, parse_rules_dir + +_log = logging.getLogger("secubox.proxypac") +DEFAULT_OUT = Path("/var/lib/secubox/proxypac/proxy.pac") + + +def generate(rules_dir, services, toolbox_directive=None): + overrides = parse_rules_dir(rules_dir) + svc_rules = service_rules(services) + toolbox = Rule("*", toolbox_directive, "toolbox") if toolbox_directive else None + return render(compose(overrides, svc_rules, toolbox)) + + +def write_atomic(pac_str, out=DEFAULT_OUT): + """Validate then atomically swap. Invalid input raises ValueError, last-good kept.""" + out = Path(out) + if "function FindProxyForURL" not in pac_str or 'return "DIRECT"' not in pac_str: + raise ValueError("refusing to write PAC without FindProxyForURL/terminal DIRECT") + out.parent.mkdir(parents=True, exist_ok=True) + shadow = out.with_suffix(".shadow") + shadow.write_text(pac_str) + os.replace(shadow, out) + + +def run_once(rules_dir="/etc/secubox/proxypac/rules.d", + sock="/run/secubox/p2p.sock", out=DEFAULT_OUT, toolbox_directive=None): + """Regenerate the PAC. Fail-safe: on any error the previous file is untouched.""" + try: + pac = generate(rules_dir, read_services(sock), toolbox_directive) + write_atomic(pac, out) + return True + except Exception as exc: # noqa: BLE001 + _log.warning("proxypac regen failed, keeping last-good: %s", exc) + return False +``` + +```python +#!/usr/bin/env python3 +# packages/secubox-proxypac/sbin/proxypac-gen +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac-gen — regenerate proxy.pac once.""" +import sys +sys.path.insert(0, "/usr/lib/secubox/proxypac") +from proxypac.generator import run_once # noqa: E402 + +if __name__ == "__main__": + ok = run_once() + sys.exit(0 if ok else 1) +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_generator.py -q && chmod +x sbin/proxypac-gen` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/proxypac/generator.py packages/secubox-proxypac/sbin/proxypac-gen packages/secubox-proxypac/tests/test_generator.py +git commit -m "feat(proxypac): generator + atomic fail-safe write + CLI (ref #784)" +``` + +--- + +## Task 6: WebUI API — override CRUD + candidates + audit + +**Files:** +- Create: `packages/secubox-proxypac/api/__init__.py` (empty), `packages/secubox-proxypac/api/main.py` +- Test: `packages/secubox-proxypac/tests/test_api.py` + +**Interfaces:** +- Consumes: `parse_rules_dir`, `run_once`. +- Produces endpoints (mounted by the aggregator at `/api/v1/proxypac/`): `GET /rules`, `POST /override` `{host, proxy, address}`, `DELETE /override/{host}`, `GET /candidates`, `POST /candidate/{host}/accept`, `POST /candidate/{host}/reject`. Overrides persist to `/etc/secubox/proxypac/rules.d/50-webui.rules`. Each mutation appends to `/var/log/secubox/audit.log` and calls `run_once()`. + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_api.py +import importlib, json +from fastapi.testclient import TestClient + +def _client(tmp_path, monkeypatch): + monkeypatch.setenv("PROXYPAC_RULES_DIR", str(tmp_path / "rules.d")) + monkeypatch.setenv("PROXYPAC_AUDIT", str(tmp_path / "audit.log")) + import api.main as m + importlib.reload(m) + monkeypatch.setattr(m, "run_once", lambda *a, **k: True) # no live regen in tests + m.app.dependency_overrides[m.require_jwt] = lambda: {"sub": "admin"} + return TestClient(m.app), tmp_path + +def test_add_and_list_override(tmp_path, monkeypatch): + c, tp = _client(tmp_path, monkeypatch) + r = c.post("/override", json={"host": "bank.example", "proxy": "direct", "address": ""}) + assert r.status_code == 200 + rules = c.get("/rules").json()["rules"] + assert {"host": "bank.example", "directive": "DIRECT"} in rules + assert "bank.example" in (tp / "audit.log").read_text() + +def test_delete_override(tmp_path, monkeypatch): + c, tp = _client(tmp_path, monkeypatch) + c.post("/override", json={"host": "x.com", "proxy": "socks5", "address": "10.10.0.1:9050"}) + assert c.delete("/override/x.com").status_code == 200 + assert all(r["host"] != "x.com" for r in c.get("/rules").json()["rules"]) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-proxypac && PYTHONPATH="$(git rev-parse --show-toplevel)/common:." python -m pytest tests/test_api.py -q` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```python +# packages/secubox-proxypac/api/main.py +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac API — override CRUD + candidates.""" +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, "/usr/lib/secubox/proxypac") +from fastapi import FastAPI, Depends, HTTPException +from pydantic import BaseModel + +try: + from secubox_core.auth import require_jwt +except Exception: # test/stub fallback + def require_jwt(): + return {"sub": "anon"} + +from proxypac.pac_template import directive +from proxypac.rules import parse_rules_dir +from proxypac.generator import run_once + +RULES_DIR = Path(os.environ.get("PROXYPAC_RULES_DIR", "/etc/secubox/proxypac/rules.d")) +WEBUI_FILE = RULES_DIR / "50-webui.rules" +CANDIDATES = Path(os.environ.get("PROXYPAC_CANDIDATES", + "/var/lib/secubox/proxypac/candidates.json")) +AUDIT = Path(os.environ.get("PROXYPAC_AUDIT", "/var/log/secubox/audit.log")) + +app = FastAPI(title="SecuBox ProxyPAC") + + +class Override(BaseModel): + host: str + proxy: str + address: str = "" + + +def _audit(action, host, user): + try: + AUDIT.parent.mkdir(parents=True, exist_ok=True) + with AUDIT.open("a") as f: + f.write(f'{datetime.now(timezone.utc).isoformat()} proxypac {action} ' + f'host={host} by={user}\n') + except OSError: + pass + + +def _read_webui_lines(): + if not WEBUI_FILE.exists(): + return [] + return [ln for ln in WEBUI_FILE.read_text().splitlines() if ln.strip() + and not ln.startswith("#")] + + +def _write_webui_lines(lines): + RULES_DIR.mkdir(parents=True, exist_ok=True) + WEBUI_FILE.write_text("\n".join(lines) + ("\n" if lines else "")) + + +@app.get("/rules") +def list_rules(_=Depends(require_jwt)): + return {"rules": [{"host": r.host, "directive": r.directive} + for r in parse_rules_dir(RULES_DIR)]} + + +@app.post("/override") +def add_override(o: Override, user=Depends(require_jwt)): + line = f"{o.host} {o.proxy}" + (f" {o.address}" if o.address else "") + lines = [ln for ln in _read_webui_lines() if ln.split()[0] != o.host] + lines.append(line) + _write_webui_lines(lines) + _audit("override-add", o.host, user.get("sub")) + run_once() + return {"ok": True, "directive": directive(o.proxy, o.address)} + + +@app.delete("/override/{host}") +def del_override(host: str, user=Depends(require_jwt)): + lines = [ln for ln in _read_webui_lines() if ln.split()[0] != host] + _write_webui_lines(lines) + _audit("override-del", host, user.get("sub")) + run_once() + return {"ok": True} + + +@app.get("/candidates") +def list_candidates(_=Depends(require_jwt)): + import json + try: + return {"candidates": json.loads(CANDIDATES.read_text())} + except Exception: + return {"candidates": []} + + +@app.post("/candidate/{host}/accept") +def accept_candidate(host: str, user=Depends(require_jwt)): + # Promote candidate to an override (socks5 to the first active tor-exit is the + # common case; operator can re-edit). Minimal: DIRECT unless already routed. + add_override(Override(host=host, proxy="direct"), user) + _audit("candidate-accept", host, user.get("sub")) + return {"ok": True} + + +@app.post("/candidate/{host}/reject") +def reject_candidate(host: str, user=Depends(require_jwt)): + _audit("candidate-reject", host, user.get("sub")) + return {"ok": True} + + +@app.get("/health") +def health(): + return {"status": "ok", "module": "proxypac"} +``` + +Create empty `api/__init__.py`. + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/secubox-proxypac && PYTHONPATH="$(git rev-parse --show-toplevel)/common:." python -m pytest tests/test_api.py -q` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/api/ packages/secubox-proxypac/tests/test_api.py +git commit -m "feat(proxypac): WebUI API override CRUD + candidates + audit (ref #784)" +``` + +--- + +## Task 7: nginx WPAD/PAC serving (LAN/mesh only) + seed rules + +**Files:** +- Create: `packages/secubox-proxypac/nginx/proxypac.conf`, `packages/secubox-proxypac/nginx/wpad-vhost.conf`, `packages/secubox-proxypac/conf/rules.d/00-onion.rules` +- Test: `packages/secubox-proxypac/tests/test_nginx_conf.py` + +**Interfaces:** none (config files). The WebUI API route mirrors sibling modules (`/api/v1/proxypac/` → aggregator). + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_nginx_conf.py +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +def test_pac_route_sets_content_type_and_serves_state(): + conf = (ROOT / "nginx" / "proxypac.conf").read_text() + assert "location = /proxy.pac" in conf + assert "application/x-ns-proxy-autoconfig" in conf + assert "/var/lib/secubox/proxypac/proxy.pac" in conf + assert "/api/v1/proxypac/" in conf and "aggregator.sock" in conf + +def test_wpad_vhost_is_lan_mesh_only(): + conf = (ROOT / "nginx" / "wpad-vhost.conf").read_text() + assert "server_name wpad." in conf + assert "allow 10.10.0.0/24;" in conf + assert "deny all;" in conf + assert "application/x-ns-proxy-autoconfig" in conf + +def test_seed_rule_present(): + seed = (ROOT / "conf" / "rules.d" / "00-onion.rules").read_text() + assert "*.onion socks5" in seed +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_nginx_conf.py -q` +Expected: FAIL — files missing. + +- [ ] **Step 3: Implement** + +`nginx/proxypac.conf` (secubox.d dropin, added into the main server): + +```nginx +# secubox-proxypac — PAC file + WebUI API +location = /proxy.pac { + default_type application/x-ns-proxy-autoconfig; + alias /var/lib/secubox/proxypac/proxy.pac; + add_header Cache-Control "max-age=300"; +} +location /api/v1/proxypac/ { + proxy_pass http://unix:/run/secubox/aggregator.sock:/api/v1/proxypac/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; +} +``` + +`nginx/wpad-vhost.conf` (dedicated server, LAN/mesh only): + +```nginx +# secubox-proxypac — WPAD auto-discovery vhost (LAN + mesh only, never public) +server { + listen 80; + server_name wpad.gk2.secubox.in; + allow 192.168.0.0/16; + allow 10.10.0.0/24; + deny all; + location = /wpad.dat { + default_type application/x-ns-proxy-autoconfig; + alias /var/lib/secubox/proxypac/proxy.pac; + } +} +``` + +`conf/rules.d/00-onion.rules`: + +``` +# secubox-proxypac seed: route .onion via a mesh tor-exit when one is active. +# The service catalog also emits *.onion; this seed guarantees it even before +# the operator subscribes (address is the local mesh gateway SocksPort). +*.onion socks5 10.10.0.1:9050 +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_nginx_conf.py -q` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/nginx/ packages/secubox-proxypac/conf/ packages/secubox-proxypac/tests/test_nginx_conf.py +git commit -m "feat(proxypac): nginx PAC/WPAD serving (LAN-only) + seed rule (ref #784)" +``` + +--- + +## Task 8: Regeneration units (systemd path + timer + service) + DHCP 252 + +**Files:** +- Create: `packages/secubox-proxypac/systemd/secubox-proxypac-gen.service`, `packages/secubox-proxypac/systemd/secubox-proxypac-gen.path`, `packages/secubox-proxypac/systemd/secubox-proxypac-gen.timer`, `packages/secubox-proxypac/conf/dnsmasq-wpad.conf` +- Test: `packages/secubox-proxypac/tests/test_systemd_units.py` + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_systemd_units.py +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] + +def test_gen_service_runs_cli_as_secubox(): + s = (ROOT / "systemd" / "secubox-proxypac-gen.service").read_text() + assert "ExecStart=/usr/sbin/proxypac-gen" in s + assert "User=secubox" in s and "Type=oneshot" in s + +def test_path_unit_watches_rulesdir(): + p = (ROOT / "systemd" / "secubox-proxypac-gen.path").read_text() + assert "PathModified=/etc/secubox/proxypac/rules.d" in p + assert "Unit=secubox-proxypac-gen.service" in p + +def test_timer_is_fallback(): + t = (ROOT / "systemd" / "secubox-proxypac-gen.timer").read_text() + assert "OnUnitActiveSec=" in t + +def test_dnsmasq_sets_option_252(): + d = (ROOT / "conf" / "dnsmasq-wpad.conf").read_text() + assert "dhcp-option=252" in d +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_systemd_units.py -q` +Expected: FAIL — files missing. + +- [ ] **Step 3: Implement** + +`systemd/secubox-proxypac-gen.service`: + +```ini +[Unit] +Description=SecuBox ProxyPAC — regenerate proxy.pac +After=network.target + +[Service] +Type=oneshot +User=secubox +Group=secubox +ExecStart=/usr/sbin/proxypac-gen +``` + +`systemd/secubox-proxypac-gen.path`: + +```ini +[Unit] +Description=SecuBox ProxyPAC — watch rules.d for changes + +[Path] +PathModified=/etc/secubox/proxypac/rules.d +Unit=secubox-proxypac-gen.service + +[Install] +WantedBy=multi-user.target +``` + +`systemd/secubox-proxypac-gen.timer` (fallback resync, e.g. picks up catalog changes): + +```ini +[Unit] +Description=SecuBox ProxyPAC — periodic regen fallback + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min + +[Install] +WantedBy=timers.target +``` + +`conf/dnsmasq-wpad.conf`: + +``` +# secubox-proxypac — advertise the WPAD URL via DHCP option 252 (WPAD). +dhcp-option=252,"http://wpad.gk2.secubox.in/wpad.dat" +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_systemd_units.py -q` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/systemd/ packages/secubox-proxypac/conf/dnsmasq-wpad.conf packages/secubox-proxypac/tests/test_systemd_units.py +git commit -m "feat(proxypac): regen path+timer units + DHCP 252 WPAD advertise (ref #784)" +``` + +--- + +## Task 9: WebUI panel + menu entry + +**Files:** +- Create: `packages/secubox-proxypac/www/proxypac/index.html`, `packages/secubox-proxypac/menu.d/580-proxypac.json` +- Test: `packages/secubox-proxypac/tests/test_webui_panel.py` + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_webui_panel.py +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] + +def test_panel_calls_api_and_has_override_form(): + html = (ROOT / "www" / "proxypac" / "index.html").read_text() + assert "/api/v1/proxypac/rules" in html + assert "/api/v1/proxypac/override" in html + assert 'id="host"' in html and 'id="proxy"' in html + +def test_menu_entry_points_to_panel(): + import json + m = json.loads((ROOT / "menu.d" / "580-proxypac.json").read_text()) + assert m.get("path") == "/proxypac/" or m.get("url") == "/proxypac/" +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_webui_panel.py -q` +Expected: FAIL — files missing. + +- [ ] **Step 3: Implement** + +`www/proxypac/index.html` (minimal functional panel; match sibling panels' fetch+headers convention): + +```html + +ProxyPAC + +

ProxyPAC — routage mesh

+

Règles actives

    +

    Ajouter un override

    + + + + + + +``` + +`menu.d/580-proxypac.json`: + +```json +{ "id": "proxypac", "title": "ProxyPAC", "path": "/proxypac/", "icon": "🧭", "category": "network" } +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_webui_panel.py -q` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/www/ packages/secubox-proxypac/menu.d/ packages/secubox-proxypac/tests/test_webui_panel.py +git commit -m "feat(proxypac): WebUI override panel + menu entry (ref #784)" +``` + +--- + +## Task 10: Debian packaging (control/rules/postinst/prerm/changelog) + +**Files:** +- Create: `packages/secubox-proxypac/debian/{control,rules,compat,changelog,postinst,prerm,secubox-proxypac.service}` +- Test: `packages/secubox-proxypac/tests/test_packaging.py` + +The aggregator serves `api/main.py` in-process (like sibling modules); `secubox-proxypac.service` is a thin standalone fallback that runs the same app on `/run/secubox/proxypac.sock` (mirrors other modules — copy `secubox-threats.service` shape). Add `proxypac` to the aggregator module list is a live-config step handled at deploy, noted in the README (not this package's file). + +- [ ] **Step 1: Write the failing test** + +```python +# packages/secubox-proxypac/tests/test_packaging.py +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] + +def test_control_metadata(): + c = (ROOT / "debian" / "control").read_text() + assert "Package: secubox-proxypac" in c + assert "Standards-Version: 4.6.2" in c + assert "Depends:" in c and "secubox-core" in c + +def test_rules_installs_all_artifacts(): + r = (ROOT / "debian" / "rules").read_text() + for frag in ["proxypac", "sbin/proxypac-gen", "nginx/proxypac.conf", + "systemd/secubox-proxypac-gen.path", "conf/rules.d", "www"]: + assert frag in r + +def test_postinst_enables_regen_and_seeds_rules(): + p = (ROOT / "debian" / "postinst").read_text() + assert "systemctl enable --now secubox-proxypac-gen.path" in p + assert "systemctl enable --now secubox-proxypac-gen.timer" in p + assert "proxypac-gen" in p # initial generation +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_packaging.py -q` +Expected: FAIL — files missing. + +- [ ] **Step 3: Implement** + +`debian/compat`: `13` + +`debian/control`: + +``` +Source: secubox-proxypac +Section: net +Priority: optional +Maintainer: Gerald KERMA +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 + +Package: secubox-proxypac +Architecture: all +Depends: ${misc:Depends}, python3, python3-fastapi, python3-uvicorn, secubox-core, nginx +Description: SecuBox WPAD/PAC auto-config routing to mesh services + Generates a proxy.pac from the active p2p /services catalog + operator policy, + served via WPAD for zero-config client routing to mesh tools. +``` + +`debian/rules`: + +```makefile +#!/usr/bin/make -f +%: + dh $@ + +override_dh_auto_install: + install -d debian/secubox-proxypac/usr/lib/secubox/proxypac + cp -r proxypac api debian/secubox-proxypac/usr/lib/secubox/proxypac/ + install -D -m 755 sbin/proxypac-gen debian/secubox-proxypac/usr/sbin/proxypac-gen + install -d debian/secubox-proxypac/etc/nginx/secubox.d + cp nginx/proxypac.conf debian/secubox-proxypac/etc/nginx/secubox.d/ + install -d debian/secubox-proxypac/etc/nginx/sites-available + cp nginx/wpad-vhost.conf debian/secubox-proxypac/etc/nginx/sites-available/ + install -d debian/secubox-proxypac/usr/lib/systemd/system + cp systemd/*.service systemd/*.path systemd/*.timer debian/secubox-proxypac/usr/lib/systemd/system/ + install -d debian/secubox-proxypac/etc/secubox/proxypac/rules.d + cp conf/rules.d/*.rules debian/secubox-proxypac/etc/secubox/proxypac/rules.d/ + install -d debian/secubox-proxypac/usr/share/secubox/www + cp -r www/. debian/secubox-proxypac/usr/share/secubox/www/ + install -d debian/secubox-proxypac/usr/share/secubox/menu.d + cp menu.d/*.json debian/secubox-proxypac/usr/share/secubox/menu.d/ +``` + +`debian/postinst` (`#!/bin/bash`, `set -e`): + +```bash +#!/bin/bash +set -e +if [ "$1" = "configure" ]; then + getent passwd secubox >/dev/null || useradd --system --group secubox || true + install -d -o secubox -g secubox /var/lib/secubox/proxypac + /usr/sbin/proxypac-gen || true # initial generation (fail-safe) + systemctl daemon-reload || true + systemctl enable --now secubox-proxypac-gen.path || true + systemctl enable --now secubox-proxypac-gen.timer || true + nginx -t && systemctl reload nginx || true +fi +#DEBHELPER# +``` + +`debian/prerm` (`#!/bin/bash`, `set -e`): + +```bash +#!/bin/bash +set -e +if [ "$1" = "remove" ]; then + systemctl disable --now secubox-proxypac-gen.path secubox-proxypac-gen.timer || true +fi +#DEBHELPER# +``` + +`debian/changelog`: + +``` +secubox-proxypac (1.0.0-1~bookworm1) bookworm; urgency=medium + + * Initial release: WPAD/PAC auto-config routing to active mesh services (#784). + + -- Gerald KERMA Fri, 03 Jul 2026 00:00:00 +0200 +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd packages/secubox-proxypac && PYTHONPATH=. python -m pytest tests/test_packaging.py -q && chmod +x debian/postinst debian/prerm` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/secubox-proxypac/debian/ packages/secubox-proxypac/tests/test_packaging.py +git commit -m "feat(proxypac): debian packaging (ref #784)" +``` + +--- + +## Task 11: README + full-suite green + +**Files:** +- Create: `packages/secubox-proxypac/README.md` +- Test: run the whole package suite. + +- [ ] **Step 1: Write README** documenting: what it does, the rules.d format, the annuaire `pac` field, WPAD delivery + DHCP 252, the fail-safe/atomic-swap invariant, and the deploy note (add `proxypac` to `aggregator.toml`, install `wpad-vhost.conf` into `sites-enabled`, deploy `dnsmasq-wpad.conf` to the AP). + +- [ ] **Step 2: Run the whole suite** + +Run: `cd packages/secubox-proxypac && PYTHONPATH="$(git rev-parse --show-toplevel)/common:." python -m pytest tests/ -q` +Expected: PASS (all tasks' tests). + +- [ ] **Step 3: Commit** + +```bash +git add packages/secubox-proxypac/README.md +git commit -m "docs(proxypac): README + deploy notes (ref #784)" +``` + +--- + +## Self-Review (plan vs spec) + +- **Spec coverage:** generator+atomic+fail-safe (T5), WPAD/PAC serving LAN-only (T7), regen trigger path+timer+hook (T8, hook = the p2p activate calling `proxypac-gen` is a deploy wiring noted in README/T11), hybrid source: annuaire pac (T1) + service rules (T4) + operator override+precedence (T3,T6) + auto-learn candidates (T6), WebUI panel (T9), webext complement (deferred — spec marks it optional; not a task here, tracked on #655), CSPN audit + atomic swap (T5,T6), tests (every task). Gateway/off-mesh explicitly out of scope (B). +- **Placeholder scan:** none — every code step has full code; config files have full content. +- **Type consistency:** `Rule(host,directive,source)` used identically in T3/T4/T5; `directive(proxy_type,address)` signature stable T2→T3→T4→T6; `run_once`/`write_atomic`/`generate` signatures stable T5→T6. +- **Gap noted (non-blocking):** the auto-learn *detector* that populates `candidates.json` is out of scope here (fed later by #743); T6 ships the accept/reject/read half so the loop is closable. diff --git a/docs/superpowers/specs/2026-07-03-secubox-proxypac-mesh-routing-design.md b/docs/superpowers/specs/2026-07-03-secubox-proxypac-mesh-routing-design.md new file mode 100644 index 00000000..2c82ace9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-secubox-proxypac-mesh-routing-design.md @@ -0,0 +1,182 @@ +# secubox-proxypac — WPAD/PAC auto-config for mesh-service routing (design) + +**Issue:** #784 · **Date:** 2026-07-03 · **Status:** design (sub-project A of 2) + +## Goal + +Give end-users **zero-config** access to the tools and services distributed across the +SecuBox p2p mesh. A locally-served, auto-generated **proxy.pac** — discovered by clients +via **WPAD** — routes each host/URL to the right transport: + +- a mesh **tor-exit** SOCKS (SOCKS-over-mesh, macro #771/#773), +- the **toolbox MITM** inspection proxy, +- a **remote HTTP service** on another node (via the future mesh gateway, sub-project B), +- or **DIRECT**. + +The routing map is composed automatically from the **active/subscribed** p2p `/services` +catalog, refined by an **operator per-host policy** (seed + WebUI + auto-learn), exactly +mirroring the #740 splice-whitelist / #743 tor-egress override pattern. + +## Scope + +**In scope (A):** the PAC generator, WPAD/PAC serving, the hybrid routing source (annuaire +service patterns + operator override + auto-learn), the WebUI override panel, and a +browser-extension complement. Routing targets are **on-mesh** transports (mesh IPs) plus +placeholder emission for gateway URLs. + +**Out of scope (B, separate spec):** the mesh **gateway with generative URLs** that lets +**off-mesh** clients (a plain LAN/internet browser not on the wg-mesh) reach services on +another node — e.g. gk2 reverse-proxying to a c3box service via a minted URL. A's PAC will +emit `PROXY ` / the generated URL for such services **once B exists**; until +then those services simply are not routed for off-mesh clients. + +## Non-goals / YAGNI + +- No per-client/per-identity dynamic PAC (static file only; browsers cache PAC hard). +- No replacement of the toolbox webext — it is an optional complement, not the engine. +- No new transport implementation — A only *routes to* transports that already exist + (tor-exit SOCKS grant, toolbox MITM, direct). + +## Architecture + +New Debian package **`secubox-proxypac`**, deployed **per node**. Each node serves a PAC +reflecting *its own* active subscriptions + operator policy. Five components: + +### 1. Generator (`proxypac-gen`) +A small script/service that reads the inputs, composes the PAC JavaScript, and writes a +**static** file `/var/lib/secubox/proxypac/proxy.pac`. Runs on a change trigger (below), +never in the client's resolution path. + +**Inputs:** +- **Mesh service catalog** — p2p `GET /services` (active + subscribed). Each service may + carry an optional `pac` descriptor (new annuaire field, below) declaring the host + patterns it serves and the proxy directive to use. +- **Toolbox tool state** — whether MITM inspection is active (and its proxy address), read + from the toolbox filters/state already exposed by secubox-toolbox. +- **Operator override rules** — `/etc/secubox/proxypac/rules.d/*.rules` (per-host → + directive), seed + WebUI-managed. Same shape/precedence as #740/#743 lists. +- **Auto-learn candidates** — `/var/lib/secubox/proxypac/candidates.json`; *proposed only*, + never applied until an operator accepts (which promotes them into `rules.d`). + +**Output:** `/var/lib/secubox/proxypac/proxy.pac` (+ a `.shadow` staged copy, atomically +swapped — see Safety). + +### 2. WPAD / PAC serving (nginx) +- `http:///proxy.pac` — content-type `application/x-ns-proxy-autoconfig`. +- `http://wpad./wpad.dat` — same file, on a dedicated `wpad` vhost, **LAN/mesh + only** (`allow` the LAN + `10.10.0.0/24`, `deny all`). +- **DHCP option 252** (WPAD URL) added to the toolbox AP's dnsmasq and, where applicable, + the mesh-facing DHCP, so browsers auto-discover with no user action. + +### 3. Regeneration trigger +A debounced watcher that regenerates on any input change: +- mtime of `rules.d/` and the toolbox state file (a lightweight systemd path unit / timer); +- an explicit hook `proxypac-gen --once` called by p2p **service activate/revoke** and by + the toolbox inspection toggle, so routing updates immediately on a service change. + +### 4. WebUI override panel +A dashboard panel (served like other secubox modules) to: view the current PAC + effective +rules; add/remove a per-host override with its target directive; accept/reject auto-learn +candidates. Reuses the #740/#743 UX and **dual-engine write** (write the rule, trigger +regen). Live list. + +### 5. Browser-extension complement (optional, #655) +The existing toolbox webext reads the same effective rules (or the served PAC) to offer +per-request UI and a one-click "route this host via " toggle that writes back into +`rules.d/` (through the WebUI API). Not required for the PAC to function; purely additive. + +## Routing source & PAC composition + +`FindProxyForURL(url, host)` is generated with a fixed **precedence**: + +1. **Operator override** (explicit per-host / pattern) — highest, always wins. +2. **Active mesh-service patterns** (from the catalog `pac.match`). +3. **Toolbox default** — if "inspect all" is on, route the rest through the toolbox MITM. +4. **`DIRECT`** — terminal default. + +Each matched rule yields one PAC directive: +- `SOCKS5 10.10.0.1:9050; DIRECT` — a mesh tor-exit (provider mesh IP + granted SocksPort). +- `PROXY ; DIRECT` — toolbox inspection. +- `PROXY ; DIRECT` — a remote HTTP service via gateway **B** (emitted only + when a `pac.proxy == "gateway"` service is active *and* B is present; otherwise skipped). +- `DIRECT` — no proxy. + +Every non-DIRECT directive ends with `; DIRECT` fallback so a dead proxy fails **open to +direct**, never blackholes the client. + +### Annuaire schema addition +Add an **optional** `pac` object to a service offer: + +```json +"pac": { "match": ["*.onion", "check.torproject.org"], "proxy": "socks5" } +``` + +- `proxy ∈ {"socks5", "http", "gateway", "direct"}`. +- The provider's mesh IP + granted port fill the concrete address at generation time. +- **Backward-compatible**: absent `pac` ⇒ the service contributes no routing rule. A + **byte-stability guard** keeps `pac`-less offers signature-compatible with pre-schema + nodes (same pattern as the macro field #771). + +## Data flow + +``` +service activate/revoke (p2p) ─┐ +operator edits override (WebUI) ─┼─▶ regen trigger ─▶ proxypac-gen +toolbox inspection toggle ─┘ reads: /services + rules.d + toolbox state + writes: proxy.pac (atomic swap) + │ +client (WPAD-discovered, short cache) ◀── nginx /proxy.pac + wpad.dat +``` + +## Safety, security, CSPN + +- **Fail-safe PAC:** if generation errors, keep the **last-good** file (never serve an + empty/broken PAC). The terminal `DIRECT` + per-directive `; DIRECT` fallback guarantee a + malformed or stale rule cannot blackhole traffic. +- **Atomic swap:** generate to `proxy.pac.shadow`, validate (parse-check the JS), then + rename over `proxy.pac` — active/shadow double-buffer (PARAMETERS 4R pattern). +- **Authorization coupling:** the PAC references only transports the node is *currently + authorized* for. A revoked mesh grant ⇒ its rule disappears on the next regen (bounded by + the debounce). No secrets ever appear in the PAC (it carries hosts + proxy addresses + only). +- **WPAD hijack containment:** `wpad.dat` and `/proxy.pac` are served **only** on the + trusted LAN/mesh vhost (`deny all` otherwise); never public. DHCP 252 is set only on the + operator-controlled AP/mesh DHCP. +- **Audit (CSPN):** every override add/remove and every auto-learn accept/reject is written + append-only to `/var/log/secubox/audit.log`. + +## Auto-learn (candidate proposal) + +A detector proposes per-host candidates but **never** applies them: +- signal source is deliberately generic in A — a hook other components can feed (e.g. the + toolbox observing a Tor-hostile 403/captcha for #743, or a "service X advertises host Y" + event). A writes candidates to `candidates.json`; the operator promotes via WebUI. +- This keeps A's auto-learn mechanism decoupled from any single signal, and lets #743 plug + its Tor-hostile detector in later without changing A. + +## Testing + +- **Generator (golden):** given a fixed catalog + `rules.d` + toolbox state, assert the + exact generated PAC JS. +- **Routing correctness:** evaluate `FindProxyForURL` for representative hosts (onion, + cloudflare host, a mesh-service host, an unlisted host) via a PAC evaluator + (pacparser / a node harness) → expected directive incl. the `; DIRECT` fallback. +- **Precedence:** an operator override beats a service pattern for the same host. +- **Fail-safe:** a generation error preserves the previous `proxy.pac`; a malformed rule is + rejected at the shadow-validate step, not swapped in. +- **Backward-compat:** a `pac`-less service offer round-trips signature-stable. +- **WPAD serving:** correct content-type; reachable on the LAN/mesh vhost only, `deny` + elsewhere. + +## Open questions (to resolve in planning, non-blocking) + +1. Exact toolbox-MITM proxy address to emit (per-node toolbox listener) — read from the + toolbox state file; confirm the field during planning. +2. Whether to ship a starter `rules.d` seed (e.g. `*.onion → socks5` when a tor-exit is + active) — proposed yes, minimal. + +## Follow-ons + +- **Sub-project B** — mesh gateway + generative URLs for off-mesh clients (own spec). +- **#743** — Tor-hostile auto-learn detector feeding A's `candidates.json`. +- **#655** — webext wired to the override API. diff --git a/packages/secubox-annuaire/annuaire/model.py b/packages/secubox-annuaire/annuaire/model.py index 536340a8..af3a971a 100644 --- a/packages/secubox-annuaire/annuaire/model.py +++ b/packages/secubox-annuaire/annuaire/model.py @@ -398,6 +398,22 @@ class MacroDescriptor(BaseModel): params: Dict[str, Union[str, int, bool]] = Field(default_factory=dict) +# --------------------------------------------------------------------------- +# PacDescriptor — optional PAC routing hint for a ServiceOffer +# --------------------------------------------------------------------------- + +class PacDescriptor(BaseModel): + """Optional PAC routing hint federated with a ServiceOffer (#784). + + Declares which hosts this service handles and how a client proxies them. + Absent pac ⇒ the service contributes no client routing rule. + """ + model_config = ConfigDict(extra="forbid") + + match: List[str] = Field(..., min_length=1, description="host globs, e.g. ['*.onion']") + proxy: Literal["socks5", "http", "gateway", "direct"] = Field(...) + + # --------------------------------------------------------------------------- # ServiceOffer — a provider advertising a service to the trust graph # --------------------------------------------------------------------------- @@ -421,6 +437,7 @@ class ServiceOffer(BaseModel): approval_mode: ApprovalMode = ApprovalMode.AUTO description: str = "" macro: Optional[MacroDescriptor] = None + pac: Optional[PacDescriptor] = None created_at: str = Field(default_factory=now_rfc3339) sig: Optional[str] = Field( default=None, diff --git a/packages/secubox-annuaire/tests/test_pac_descriptor.py b/packages/secubox-annuaire/tests/test_pac_descriptor.py new file mode 100644 index 00000000..4c39fb2b --- /dev/null +++ b/packages/secubox-annuaire/tests/test_pac_descriptor.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +""" +SecuBox-Deb :: secubox-annuaire :: test_pac_descriptor +CyberMind — https://cybermind.fr +""" + +from annuaire.model import ServiceOffer, PacDescriptor +from annuaire.crypto import canonical_bytes +import pytest + + +def _offer(**kw): + return ServiceOffer(service_id="a1", provider="did:plc:"+"0"*32, + name="svc", kind="api", endpoint="http://10.10.0.1/x", **kw) + + +def test_pac_parses_and_validates(): + o = _offer(pac=PacDescriptor(match=["*.onion"], proxy="socks5")) + assert o.pac.match == ["*.onion"] and o.pac.proxy == "socks5" + + +def test_pac_rejects_bad_proxy(): + with pytest.raises(Exception): + PacDescriptor(match=["x"], proxy="tunnel") + + +def test_pac_rejects_empty_match(): + with pytest.raises(Exception): + PacDescriptor(match=[], proxy="socks5") + + +def test_pacless_offer_is_byte_stable(): + # An offer with no pac must serialize identically to the pre-field baseline. + # created_at is pinned so the two independent instances are byte-comparable + # (ServiceOffer.created_at otherwise defaults to datetime.now(), which would + # make this assertion flaky regardless of the pac field under test). + fixed_ts = "2026-01-01T00:00:00+00:00" + o = _offer(created_at=fixed_ts) + payload = o.model_dump(exclude_none=True) + assert "pac" not in payload + assert canonical_bytes(payload) == canonical_bytes(_offer(created_at=fixed_ts).model_dump(exclude_none=True)) diff --git a/packages/secubox-proxypac/README.md b/packages/secubox-proxypac/README.md new file mode 100644 index 00000000..a7198137 --- /dev/null +++ b/packages/secubox-proxypac/README.md @@ -0,0 +1,216 @@ +# secubox-proxypac + +SecuBox WPAD/PAC auto-config: zero-config client-side routing to mesh +services (mesh tor-exit SOCKS, toolbox MITM, gateway, or `DIRECT`). + +## What it does + +`secubox-proxypac` generates `/var/lib/secubox/proxypac/proxy.pac`, a +standard [PAC](https://en.wikipedia.org/wiki/Proxy_auto-config) (Proxy +Auto-Config) file, and serves it via [WPAD](https://en.wikipedia.org/wiki/Web_Proxy_Autodiscovery_Protocol) +so LAN/mesh clients pick up the right proxy for each host automatically — +no manual browser proxy configuration. + +The PAC content is composed from two sources, in this precedence order: + +1. **Operator overrides** — `/etc/secubox/proxypac/rules.d/*.rules` (see + below), highest precedence. +2. **Active mesh services** — the p2p `/services` catalog (via the p2p + Unix socket), for any `ServiceOffer` carrying an optional `pac` + descriptor (see "annuaire `pac` field" below). +3. **Toolbox default** — a catch-all directive (e.g. route everything + else through the local MITM/toolbox), if configured. +4. **`DIRECT`** — the unconditional fallback if nothing else matched. + +The first host-glob match wins (rules are evaluated top-to-bottom in the +composed order above). Every generated PAC ends with a terminal +`return "DIRECT";`, and every non-`DIRECT` directive is itself +fail-open (`...; DIRECT`) — see "Fail-safe / atomic-swap invariant" +below. + +## rules.d format + +Operator policy lives in `/etc/secubox/proxypac/rules.d/*.rules`. Files +are parsed in **sorted filename order** (hence the numeric prefixes, +e.g. `00-onion.rules`, `50-webui.rules`), and each non-comment, +non-blank line is: + +``` + [address] +``` + +- `host-glob` — a PAC `shExpMatch` host pattern, e.g. `*.onion`, + `*.example.com`. +- `proxy_type` — one of `socks5`, `http`, `gateway`, `direct`. +- `address` — `host:port` (or bare host for `http`/`gateway`); omitted + for `direct`. +- Lines starting with `#` and blank lines are ignored (comments). + +Example (`conf/rules.d/00-onion.rules`, shipped as a seed): + +``` +# secubox-proxypac seed: route .onion via a mesh tor-exit when one is active. +*.onion socks5 10.10.0.1:9050 +``` + +The WebUI (`POST /override`, `DELETE /override/{host}`) persists +operator-added rules to `/etc/secubox/proxypac/rules.d/50-webui.rules` +— a file the API owns exclusively; hand-edit any other numbered file +for static seeds instead. + +**Precedence recap**: `override` (rules.d, including `50-webui.rules`) +> `service:` (active catalog entry) > `toolbox` (catch-all) > +implicit `DIRECT`. Within overrides, the **last** definition of a host wins, +so a later file (e.g. the WebUI's `50-webui.rules`) overrides an earlier seed +(e.g. `00-onion.rules`) for the same host glob — explicit operator policy beats +shipped defaults. The `override` source as a whole still wins over anything with +the same host from `services`/`toolbox`. + +**Dependency note**: the `/proxy.pac` nginx `allow`/`deny` gate matches on +`$remote_addr`; behind HAProxy that is `127.0.0.1` unless the `real_ip` rewrite +(`set_real_ip_from` / `real_ip_header X-Forwarded-For`) shipped by **secubox-hub** +is loaded — hence `Depends: secubox-hub`. + +## Annuaire `pac` field + +A `ServiceOffer` in the p2p annuaire may carry an **optional** +`pac` descriptor: + +```json +{ + "pac": { + "match": ["*.onion"], + "proxy": "socks5" + } +} +``` + +- `match` — non-empty list of host globs this service should be routed + for. +- `proxy` — one of `socks5`, `http`, `gateway`, `direct`. + +This field is byte-stable and backward-compatible: a `ServiceOffer` +without `pac` serializes to identical `canonical_bytes` as before the +field existed (it's excluded from canonical JSON when absent), so +existing signed offers and mesh sync are unaffected. Services declaring +`pac` are cross-referenced by their `endpoint` host — for `socks5` +the port defaults to the service's `macro.params.socks_port` (falling +back to `9050`) unless overridden; for `http`/`gateway` the endpoint +host is used as-is. + +Only `enabled` catalog entries with a `pac` descriptor contribute +routing rules; anything else is ignored by the generator. + +## WPAD delivery + +Two artifacts are served, both **LAN/mesh-gated only — never public**: + +- `/proxy.pac` — served by the main SecuBox vhost + (`nginx/proxypac.conf`), `allow 127.0.0.1; allow 192.168.0.0/16; allow + 10.10.0.0/24; deny all;`, `Content-Type: + application/x-ns-proxy-autoconfig`, short cache (`max-age=300`). +- `wpad.dat` — served on a dedicated `wpad.` vhost + (`nginx/wpad-vhost.conf`), same LAN/mesh `allow`/`deny` gate, aliasing + the identical `proxy.pac` file so both discovery paths always agree. + +Zero-config discovery is completed via **DHCP option 252** +(`conf/dnsmasq-wpad.conf`): + +``` +dhcp-option=252,"http://wpad.gk2.secubox.in/wpad.dat" +``` + +Browsers/OSes that support WPAD auto-detect this DHCP option (or the +`wpad.` DNS convention) and fetch `wpad.dat` without any client +configuration. + +## Fail-safe / atomic-swap invariant + +Generation (`proxypac.generator.run_once` / `generate` / +`write_atomic`) never leaves `proxy.pac` broken or empty: + +1. `generate()` composes overrides + service rules + toolbox default + into PAC JavaScript source. +2. `write_atomic()` validates the rendered text contains + `function FindProxyForURL` and a terminal `return "DIRECT";`, + writes it to `proxy.pac.shadow`, then atomically renames + (`os.replace`) the shadow over the live `proxy.pac`. +3. If **anything** fails during composition, validation, or the + p2p socket read (which fails open to an empty service list rather + than raising), the exception is caught, logged, and the + **last-good `proxy.pac` is left untouched** — clients never see a + half-written or invalid PAC file. +4. Every non-`DIRECT` directive the generator ever emits + (`SOCKS5 ...; DIRECT`, `PROXY ...; DIRECT`) ends in `; DIRECT`, so a + dead proxy fails open to direct connection rather than blocking + traffic. + +This mirrors the double-buffer/4R discipline used elsewhere in +SecuBox (shadow write → validate → atomic swap; no rollback history is +kept here since the previous file is never touched on failure). + +## Regeneration triggers + +- `secubox-proxypac-gen.path` — watches + `/etc/secubox/proxypac/rules.d` and fires `secubox-proxypac-gen.service` + (which runs `/usr/sbin/proxypac-gen`, a thin CLI wrapping + `generator.run_once()`) on any change. +- `secubox-proxypac-gen.timer` — periodic fallback regen (`OnBootSec=2min`, + `OnUnitActiveSec=5min`) to pick up p2p catalog changes even without a + rules.d edit. +- The WebUI API also calls `run_once()` synchronously after every + `POST /override` / `DELETE /override/{host}` mutation, so operator + changes take effect immediately without waiting for the timer. + +## API (mounted at `/api/v1/proxypac/`) + +| Method | Path | Description | +|--------|------|--------------| +| GET | `/rules` | List the composed rules.d overrides | +| POST | `/override` | Add/replace an override `{host, proxy, address}` | +| DELETE | `/override/{host}` | Remove an override | +| GET | `/candidates` | List auto-learn candidate hosts | +| POST | `/candidate/{host}/accept` | Promote a candidate to an override | +| POST | `/candidate/{host}/reject` | Dismiss a candidate | +| GET | `/health` | Health check | + +All routes except `/health` require JWT (`Depends(require_jwt)`). +Override add/remove and candidate accept/reject append an entry to the +CSPN audit trail (`/var/log/secubox/audit.log`). + +## Deploy notes + +- **Aggregator wiring**: add `proxypac` to the `aggregator.toml` module + list so `/api/v1/proxypac/` is served in-process by the shared + aggregator (like sibling modules). `secubox-proxypac.service` ships + as a thin standalone fallback (own `proxypac.sock`) for when the + aggregator isn't wired yet or a dedicated socket is preferred. +- **nginx**: enable `nginx/proxypac.conf` (the `/proxy.pac` + + `/api/v1/proxypac/` location block) and `nginx/wpad-vhost.conf` (the + dedicated `wpad.` vhost) into `sites-enabled`, then + `nginx -t && systemctl reload nginx` (done automatically by + `debian/postinst`, which also assumes the vhost/site drop is + performed at deploy — the packaged files live under `nginx/` and are + not auto-symlinked by the `.deb`). +- **dnsmasq**: deploy `conf/dnsmasq-wpad.conf` to the access point's + dnsmasq config directory (e.g. + `/etc/dnsmasq.d/dnsmasq-wpad.conf`) and restart `dnsmasq` so DHCP + option 252 is advertised. +- **Regen units**: `debian/postinst` runs an initial `proxypac-gen` + (fail-safe — ignored if it errors before the p2p socket exists) and + enables `secubox-proxypac-gen.path` + `secubox-proxypac-gen.timer`. +- Package runs as `secubox:secubox` (created in `postinst` if absent); + state under `/var/lib/secubox/proxypac/`, rules under + `/etc/secubox/proxypac/rules.d/`, audit log under + `/var/log/secubox/audit.log`. + +## Out of scope + +- **Mesh gateway with generative URLs for off-mesh clients** — a + separate follow-on sub-project (tracked apart from this package); + this package only routes clients that are already on the LAN/mesh. +- **Auto-learn detector** that populates `candidates.json` — deferred; + this package ships the accept/reject/read half of that loop + (`GET /candidates`, `POST /candidate/{host}/accept|reject`), but the + detector that discovers and writes candidates is fed by a later, + separate effort (#743). diff --git a/packages/secubox-proxypac/api/__init__.py b/packages/secubox-proxypac/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/secubox-proxypac/api/main.py b/packages/secubox-proxypac/api/main.py new file mode 100644 index 00000000..364cd623 --- /dev/null +++ b/packages/secubox-proxypac/api/main.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac API — override CRUD + candidates.""" +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, "/usr/lib/secubox/proxypac") +from fastapi import FastAPI, Depends, HTTPException +from pydantic import BaseModel, field_validator + +try: + from secubox_core.auth import require_jwt +except Exception: # test/stub fallback + def require_jwt(): + return {"sub": "anon"} + +from proxypac.pac_template import directive +from proxypac.rules import parse_rules_dir +from proxypac.generator import run_once + +RULES_DIR = Path(os.environ.get("PROXYPAC_RULES_DIR", "/etc/secubox/proxypac/rules.d")) +WEBUI_FILE = RULES_DIR / "50-webui.rules" +CANDIDATES = Path(os.environ.get("PROXYPAC_CANDIDATES", + "/var/lib/secubox/proxypac/candidates.json")) +AUDIT = Path(os.environ.get("PROXYPAC_AUDIT", "/var/log/secubox/audit.log")) + +app = FastAPI(title="SecuBox ProxyPAC") + + +class Override(BaseModel): + host: str + proxy: str + address: str = "" + + @field_validator("host") + @classmethod + def _no_whitespace(cls, v): + if not v or any(c.isspace() for c in v): + raise ValueError("host must be non-empty and contain no whitespace") + return v + + +def _audit(action, host, user): + try: + AUDIT.parent.mkdir(parents=True, exist_ok=True) + with AUDIT.open("a") as f: + f.write(f'{datetime.now(timezone.utc).isoformat()} proxypac {action} ' + f'host={host} by={user}\n') + except OSError: + pass + + +def _read_webui_lines(): + if not WEBUI_FILE.exists(): + return [] + return [ln for ln in WEBUI_FILE.read_text().splitlines() if ln.strip() + and not ln.startswith("#")] + + +def _write_webui_lines(lines): + RULES_DIR.mkdir(parents=True, exist_ok=True) + WEBUI_FILE.write_text("\n".join(lines) + ("\n" if lines else "")) + + +@app.get("/rules") +def list_rules(_=Depends(require_jwt)): + return {"rules": [{"host": r.host, "directive": r.directive} + for r in parse_rules_dir(RULES_DIR)]} + + +@app.post("/override") +def add_override(o: Override, user=Depends(require_jwt)): + line = f"{o.host} {o.proxy}" + (f" {o.address}" if o.address else "") + lines = [ln for ln in _read_webui_lines() if ln.split()[0] != o.host] + lines.append(line) + _write_webui_lines(lines) + _audit("override-add", o.host, user.get("sub")) + run_once(rules_dir=str(RULES_DIR)) + return {"ok": True, "directive": directive(o.proxy, o.address)} + + +@app.delete("/override/{host}") +def del_override(host: str, user=Depends(require_jwt)): + lines = [ln for ln in _read_webui_lines() if ln.split()[0] != host] + _write_webui_lines(lines) + _audit("override-del", host, user.get("sub")) + run_once(rules_dir=str(RULES_DIR)) + return {"ok": True} + + +@app.get("/candidates") +def list_candidates(_=Depends(require_jwt)): + import json + try: + return {"candidates": json.loads(CANDIDATES.read_text())} + except Exception: + return {"candidates": []} + + +@app.post("/candidate/{host}/accept") +def accept_candidate(host: str, user=Depends(require_jwt)): + # Promote candidate to an override (socks5 to the first active tor-exit is the + # common case; operator can re-edit). Minimal: DIRECT unless already routed. + add_override(Override(host=host, proxy="direct"), user) + _audit("candidate-accept", host, user.get("sub")) + return {"ok": True} + + +@app.post("/candidate/{host}/reject") +def reject_candidate(host: str, user=Depends(require_jwt)): + _audit("candidate-reject", host, user.get("sub")) + return {"ok": True} + + +@app.get("/health") +def health(): + return {"status": "ok", "module": "proxypac"} diff --git a/packages/secubox-proxypac/conf/dnsmasq-wpad.conf b/packages/secubox-proxypac/conf/dnsmasq-wpad.conf new file mode 100644 index 00000000..b290c664 --- /dev/null +++ b/packages/secubox-proxypac/conf/dnsmasq-wpad.conf @@ -0,0 +1,2 @@ +# secubox-proxypac — advertise the WPAD URL via DHCP option 252 (WPAD). +dhcp-option=252,"http://wpad.gk2.secubox.in/wpad.dat" diff --git a/packages/secubox-proxypac/conf/rules.d/00-onion.rules b/packages/secubox-proxypac/conf/rules.d/00-onion.rules new file mode 100644 index 00000000..3f011547 --- /dev/null +++ b/packages/secubox-proxypac/conf/rules.d/00-onion.rules @@ -0,0 +1,4 @@ +# secubox-proxypac seed: route .onion via a mesh tor-exit when one is active. +# The service catalog also emits *.onion; this seed guarantees it even before +# the operator subscribes (address is the local mesh gateway SocksPort). +*.onion socks5 10.10.0.1:9050 diff --git a/packages/secubox-proxypac/debian/changelog b/packages/secubox-proxypac/debian/changelog new file mode 100644 index 00000000..7eceb56a --- /dev/null +++ b/packages/secubox-proxypac/debian/changelog @@ -0,0 +1,5 @@ +secubox-proxypac (1.0.0-1~bookworm1) bookworm; urgency=medium + + * Initial release: WPAD/PAC auto-config routing to active mesh services (#784). + + -- Gerald KERMA Fri, 03 Jul 2026 00:00:00 +0200 diff --git a/packages/secubox-proxypac/debian/control b/packages/secubox-proxypac/debian/control new file mode 100644 index 00000000..ac243e07 --- /dev/null +++ b/packages/secubox-proxypac/debian/control @@ -0,0 +1,13 @@ +Source: secubox-proxypac +Section: net +Priority: optional +Maintainer: Gerald KERMA +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 + +Package: secubox-proxypac +Architecture: all +Depends: ${misc:Depends}, python3, python3-fastapi, python3-uvicorn, secubox-core, secubox-hub, nginx +Description: SecuBox WPAD/PAC auto-config routing to mesh services + Generates a proxy.pac from the active p2p /services catalog + operator policy, + served via WPAD for zero-config client routing to mesh tools. diff --git a/packages/secubox-proxypac/debian/postinst b/packages/secubox-proxypac/debian/postinst new file mode 100755 index 00000000..d125af06 --- /dev/null +++ b/packages/secubox-proxypac/debian/postinst @@ -0,0 +1,13 @@ +#!/bin/bash +set -e +if [ "$1" = "configure" ]; then + getent group secubox >/dev/null || groupadd --system secubox + getent passwd secubox >/dev/null || useradd --system --gid secubox --no-create-home --shell /usr/sbin/nologin secubox + install -d -o secubox -g secubox /var/lib/secubox/proxypac + /usr/sbin/proxypac-gen || true # initial generation (fail-safe) + systemctl daemon-reload || true + systemctl enable --now secubox-proxypac-gen.path || true + systemctl enable --now secubox-proxypac-gen.timer || true + nginx -t && systemctl reload nginx || true +fi +#DEBHELPER# diff --git a/packages/secubox-proxypac/debian/prerm b/packages/secubox-proxypac/debian/prerm new file mode 100755 index 00000000..1a035c86 --- /dev/null +++ b/packages/secubox-proxypac/debian/prerm @@ -0,0 +1,6 @@ +#!/bin/bash +set -e +if [ "$1" = "remove" ]; then + systemctl disable --now secubox-proxypac-gen.path secubox-proxypac-gen.timer || true +fi +#DEBHELPER# diff --git a/packages/secubox-proxypac/debian/rules b/packages/secubox-proxypac/debian/rules new file mode 100644 index 00000000..2013cc2f --- /dev/null +++ b/packages/secubox-proxypac/debian/rules @@ -0,0 +1,20 @@ +#!/usr/bin/make -f +%: + dh $@ + +override_dh_auto_install: + install -d debian/secubox-proxypac/usr/lib/secubox/proxypac + cp -r proxypac api debian/secubox-proxypac/usr/lib/secubox/proxypac/ + install -D -m 755 sbin/proxypac-gen debian/secubox-proxypac/usr/sbin/proxypac-gen + install -d debian/secubox-proxypac/etc/nginx/secubox.d + cp nginx/proxypac.conf debian/secubox-proxypac/etc/nginx/secubox.d/ + install -d debian/secubox-proxypac/etc/nginx/sites-available + cp nginx/wpad-vhost.conf debian/secubox-proxypac/etc/nginx/sites-available/ + install -d debian/secubox-proxypac/usr/lib/systemd/system + cp systemd/secubox-proxypac.service systemd/secubox-proxypac-gen.service systemd/secubox-proxypac-gen.path systemd/secubox-proxypac-gen.timer debian/secubox-proxypac/usr/lib/systemd/system/ + install -d debian/secubox-proxypac/etc/secubox/proxypac/rules.d + cp conf/rules.d/*.rules debian/secubox-proxypac/etc/secubox/proxypac/rules.d/ + install -d debian/secubox-proxypac/usr/share/secubox/www + cp -r www/. debian/secubox-proxypac/usr/share/secubox/www/ + install -d debian/secubox-proxypac/usr/share/secubox/menu.d + cp menu.d/*.json debian/secubox-proxypac/usr/share/secubox/menu.d/ diff --git a/packages/secubox-proxypac/menu.d/580-proxypac.json b/packages/secubox-proxypac/menu.d/580-proxypac.json new file mode 100644 index 00000000..cb35f7eb --- /dev/null +++ b/packages/secubox-proxypac/menu.d/580-proxypac.json @@ -0,0 +1 @@ +{ "id": "proxypac", "name": "ProxyPAC", "path": "/proxypac/", "icon": "🧭", "category": "mesh", "order": 58 } diff --git a/packages/secubox-proxypac/nginx/proxypac.conf b/packages/secubox-proxypac/nginx/proxypac.conf new file mode 100644 index 00000000..3b774885 --- /dev/null +++ b/packages/secubox-proxypac/nginx/proxypac.conf @@ -0,0 +1,22 @@ +# secubox-proxypac — PAC file + WebUI API +# +# The /proxy.pac allow/deny below matches on $remote_addr. Behind the board's +# HAProxy -> sbxwaf -> nginx chain, $remote_addr is 127.0.0.1 UNLESS the real_ip +# module rewrites it from X-Forwarded-For. That rewrite (set_real_ip_from / +# real_ip_header) is shipped by secubox-hub (nginx/secubox-lan-geo.conf, http +# context) — hence the Depends: secubox-hub. Without it, `allow 127.0.0.1` would +# whitelist every proxied request and expose the PAC (internal mesh topology). +location = /proxy.pac { + default_type application/x-ns-proxy-autoconfig; + alias /var/lib/secubox/proxypac/proxy.pac; + add_header Cache-Control "max-age=300"; + allow 127.0.0.1; + allow 192.168.0.0/16; + allow 10.10.0.0/24; + deny all; +} +location /api/v1/proxypac/ { + proxy_pass http://unix:/run/secubox/aggregator.sock:/api/v1/proxypac/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; +} diff --git a/packages/secubox-proxypac/nginx/wpad-vhost.conf b/packages/secubox-proxypac/nginx/wpad-vhost.conf new file mode 100644 index 00000000..f5b9d65f --- /dev/null +++ b/packages/secubox-proxypac/nginx/wpad-vhost.conf @@ -0,0 +1,12 @@ +# secubox-proxypac — WPAD auto-discovery vhost (LAN + mesh only, never public) +server { + listen 80; + server_name wpad.gk2.secubox.in; + allow 192.168.0.0/16; + allow 10.10.0.0/24; + deny all; + location = /wpad.dat { + default_type application/x-ns-proxy-autoconfig; + alias /var/lib/secubox/proxypac/proxy.pac; + } +} diff --git a/packages/secubox-proxypac/proxypac/__init__.py b/packages/secubox-proxypac/proxypac/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/secubox-proxypac/proxypac/catalog.py b/packages/secubox-proxypac/proxypac/catalog.py new file mode 100644 index 00000000..13b5fd16 --- /dev/null +++ b/packages/secubox-proxypac/proxypac/catalog.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac.catalog — read p2p /services, map to service Rules.""" +import json +import socket +from urllib.parse import urlparse + +from .pac_template import directive +from .rules import Rule + + +def _endpoint_host(endpoint): + return urlparse(endpoint or "").hostname or "" + + +def service_rules(services): + """Map active services carrying a `pac` descriptor to routing Rules.""" + out = [] + for s in services: + if not s.get("enabled"): + continue + pac = s.get("pac") + if not pac: + continue + proxy = pac.get("proxy", "direct") + host = _endpoint_host(s.get("endpoint")) + if proxy == "socks5": + port = (s.get("macro") or {}).get("params", {}).get("socks_port", 9050) + addr = f"{host}:{port}" + else: # http | gateway use the endpoint host as-is + addr = host + d = directive(proxy, addr) + for m in pac.get("match", []): + out.append(Rule(m, d, f"service:{s['service_id']}")) + return out + + +def read_services(sock="/run/secubox/p2p.sock"): + """GET /services over the p2p unix socket. Returns the services list (fail-open: []).""" + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as c: + c.settimeout(5) + c.connect(sock) + c.sendall(b"GET /services HTTP/1.0\r\nHost: x\r\n\r\n") + buf = b"" + while True: + chunk = c.recv(65536) + if not chunk: + break + buf += chunk + body = buf.split(b"\r\n\r\n", 1)[1] + return json.loads(body).get("services", []) + except Exception: + return [] diff --git a/packages/secubox-proxypac/proxypac/generator.py b/packages/secubox-proxypac/proxypac/generator.py new file mode 100644 index 00000000..333a1c6a --- /dev/null +++ b/packages/secubox-proxypac/proxypac/generator.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac.generator — compose + atomic fail-safe PAC write.""" +import logging +import os +from pathlib import Path + +from .catalog import read_services, service_rules +from .pac_template import render +from .rules import Rule, compose, parse_rules_dir + +_log = logging.getLogger("secubox.proxypac") +DEFAULT_OUT = Path("/var/lib/secubox/proxypac/proxy.pac") + + +def generate(rules_dir, services, toolbox_directive=None): + overrides = parse_rules_dir(rules_dir) + svc_rules = service_rules(services) + toolbox = Rule("*", toolbox_directive, "toolbox") if toolbox_directive else None + return render(compose(overrides, svc_rules, toolbox)) + + +def write_atomic(pac_str, out=DEFAULT_OUT): + """Validate then atomically swap. Invalid input raises ValueError, last-good kept.""" + out = Path(out) + if "function FindProxyForURL" not in pac_str or 'return "DIRECT"' not in pac_str: + raise ValueError("refusing to write PAC without FindProxyForURL/terminal DIRECT") + out.parent.mkdir(parents=True, exist_ok=True) + shadow = out.parent / (out.name + ".shadow") + shadow.write_text(pac_str) + os.replace(shadow, out) + + +def run_once(rules_dir="/etc/secubox/proxypac/rules.d", + sock="/run/secubox/p2p.sock", out=DEFAULT_OUT, toolbox_directive=None): + """Regenerate the PAC. Fail-safe: on any error the previous file is untouched.""" + try: + pac = generate(rules_dir, read_services(sock), toolbox_directive) + write_atomic(pac, out) + return True + except Exception as exc: # noqa: BLE001 + _log.warning("proxypac regen failed, keeping last-good: %s", exc) + return False diff --git a/packages/secubox-proxypac/proxypac/pac_template.py b/packages/secubox-proxypac/proxypac/pac_template.py new file mode 100644 index 00000000..e665cce3 --- /dev/null +++ b/packages/secubox-proxypac/proxypac/pac_template.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac.pac_template — PAC JS rendering (pure).""" +import json + +_HEADER = "function FindProxyForURL(url, host) {\n" +_FOOTER = ' return "DIRECT";\n}\n' + + +def directive(proxy_type: str, address: str) -> str: + """Build a PAC return directive with a fail-open DIRECT fallback.""" + if proxy_type == "socks5": + return f"SOCKS5 {address}; DIRECT" + if proxy_type in ("http", "gateway"): + return f"PROXY {address}; DIRECT" + return "DIRECT" + + +def render(rules): + """rules: ordered list of (host_glob, directive_string). First match wins.""" + out = [_HEADER] + for glob, direct in rules: + out.append(f" if (shExpMatch(host, {json.dumps(glob)})) " + f"return {json.dumps(direct)};\n") + out.append(_FOOTER) + return "".join(out) diff --git a/packages/secubox-proxypac/proxypac/rules.py b/packages/secubox-proxypac/proxypac/rules.py new file mode 100644 index 00000000..0c7097fa --- /dev/null +++ b/packages/secubox-proxypac/proxypac/rules.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac.rules — rule model, rules.d parsing, precedence.""" +from dataclasses import dataclass +from pathlib import Path + +from .pac_template import directive + + +@dataclass +class Rule: + host: str + directive: str # full PAC directive string + source: str # "override" | "service:" | "toolbox" + + +def parse_rules_dir(path): + """Parse rules.d/*.rules in sorted filename order. + + Line: ' [address]'. '#' comments and blanks skipped. + """ + path = Path(path) + rules = [] + for f in sorted(path.glob("*.rules")): + for raw in f.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + host, ptype = parts[0], parts[1] + addr = parts[2] if len(parts) > 2 else "" + rules.append(Rule(host, directive(ptype, addr), "override")) + return rules + + +def compose(overrides, services, toolbox): + """Compose the final ordered (host, directive) rule list. + + Cross-source precedence: overrides beat services beat the toolbox catch-all + (they are emitted in that order, and PAC matches first-listed host first). + + Within the overrides, the LAST definition of a host wins, so an operator's + later file (e.g. 50-webui.rules written by the WebUI) overrides a shipped seed + (e.g. 00-onion.rules) for the same host glob — explicit policy beats defaults. + Each host keeps the position of its first appearance so glob ordering is stable. + """ + # Overrides: last definition per host wins (dict keeps last value, first-seen order). + ov = {} + for r in overrides: + ov[r.host] = r + seen = set() + out = [] + ordered = list(ov.values()) + list(services) + ([toolbox] if toolbox else []) + for r in ordered: + if r.host in seen: + continue + seen.add(r.host) + out.append((r.host, r.directive)) + return out diff --git a/packages/secubox-proxypac/sbin/proxypac-gen b/packages/secubox-proxypac/sbin/proxypac-gen new file mode 100755 index 00000000..a9594cd8 --- /dev/null +++ b/packages/secubox-proxypac/sbin/proxypac-gen @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +# packages/secubox-proxypac/sbin/proxypac-gen +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac-gen — regenerate proxy.pac once.""" +import sys +sys.path.insert(0, "/usr/lib/secubox/proxypac") +from proxypac.generator import run_once # noqa: E402 + +if __name__ == "__main__": + ok = run_once() + sys.exit(0 if ok else 1) diff --git a/packages/secubox-proxypac/systemd/secubox-proxypac-gen.path b/packages/secubox-proxypac/systemd/secubox-proxypac-gen.path new file mode 100644 index 00000000..58bf8e15 --- /dev/null +++ b/packages/secubox-proxypac/systemd/secubox-proxypac-gen.path @@ -0,0 +1,9 @@ +[Unit] +Description=SecuBox ProxyPAC — watch rules.d for changes + +[Path] +PathModified=/etc/secubox/proxypac/rules.d +Unit=secubox-proxypac-gen.service + +[Install] +WantedBy=multi-user.target diff --git a/packages/secubox-proxypac/systemd/secubox-proxypac-gen.service b/packages/secubox-proxypac/systemd/secubox-proxypac-gen.service new file mode 100644 index 00000000..4d633b1b --- /dev/null +++ b/packages/secubox-proxypac/systemd/secubox-proxypac-gen.service @@ -0,0 +1,9 @@ +[Unit] +Description=SecuBox ProxyPAC — regenerate proxy.pac +After=network.target + +[Service] +Type=oneshot +User=secubox +Group=secubox +ExecStart=/usr/sbin/proxypac-gen diff --git a/packages/secubox-proxypac/systemd/secubox-proxypac-gen.timer b/packages/secubox-proxypac/systemd/secubox-proxypac-gen.timer new file mode 100644 index 00000000..7fe66a95 --- /dev/null +++ b/packages/secubox-proxypac/systemd/secubox-proxypac-gen.timer @@ -0,0 +1,9 @@ +[Unit] +Description=SecuBox ProxyPAC — periodic regen fallback + +[Timer] +OnBootSec=2min +OnUnitActiveSec=5min + +[Install] +WantedBy=timers.target diff --git a/packages/secubox-proxypac/systemd/secubox-proxypac.service b/packages/secubox-proxypac/systemd/secubox-proxypac.service new file mode 100644 index 00000000..e24a7cb7 --- /dev/null +++ b/packages/secubox-proxypac/systemd/secubox-proxypac.service @@ -0,0 +1,22 @@ +[Unit] +Description=SecuBox ProxyPAC API (standalone fallback) +After=network.target secubox-core.service +Requires=secubox-core.service + +[Service] +Type=simple +User=secubox +Group=secubox +WorkingDirectory=/usr/lib/secubox/proxypac +ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/proxypac.sock --log-level warning +Restart=on-failure +RestartSec=5 +UMask=0000 +NoNewPrivileges=true +RuntimeDirectory=secubox +RuntimeDirectoryPreserve=yes +RuntimeDirectoryMode=0775 +ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox /var/log/secubox + +[Install] +WantedBy=multi-user.target diff --git a/packages/secubox-proxypac/tests/test_api.py b/packages/secubox-proxypac/tests/test_api.py new file mode 100644 index 00000000..44b6cf3f --- /dev/null +++ b/packages/secubox-proxypac/tests/test_api.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +import importlib, json +from fastapi.testclient import TestClient + +def _client(tmp_path, monkeypatch): + monkeypatch.setenv("PROXYPAC_RULES_DIR", str(tmp_path / "rules.d")) + monkeypatch.setenv("PROXYPAC_AUDIT", str(tmp_path / "audit.log")) + import api.main as m + importlib.reload(m) + monkeypatch.setattr(m, "run_once", lambda *a, **k: True) # no live regen in tests + m.app.dependency_overrides[m.require_jwt] = lambda: {"sub": "admin"} + return TestClient(m.app), tmp_path + +def test_add_and_list_override(tmp_path, monkeypatch): + c, tp = _client(tmp_path, monkeypatch) + r = c.post("/override", json={"host": "bank.example", "proxy": "direct", "address": ""}) + assert r.status_code == 200 + rules = c.get("/rules").json()["rules"] + assert {"host": "bank.example", "directive": "DIRECT"} in rules + assert "bank.example" in (tp / "audit.log").read_text() + +def test_delete_override(tmp_path, monkeypatch): + c, tp = _client(tmp_path, monkeypatch) + c.post("/override", json={"host": "x.com", "proxy": "socks5", "address": "10.10.0.1:9050"}) + assert c.delete("/override/x.com").status_code == 200 + assert all(r["host"] != "x.com" for r in c.get("/rules").json()["rules"]) + +def test_reject_host_with_whitespace(tmp_path, monkeypatch): + c, _ = _client(tmp_path, monkeypatch) + r = c.post("/override", json={"host": "bad host\ninject", "proxy": "direct", "address": ""}) + assert r.status_code == 422 diff --git a/packages/secubox-proxypac/tests/test_catalog.py b/packages/secubox-proxypac/tests/test_catalog.py new file mode 100644 index 00000000..e68fee47 --- /dev/null +++ b/packages/secubox-proxypac/tests/test_catalog.py @@ -0,0 +1,24 @@ +from proxypac.catalog import service_rules + +def test_socks5_service_rule(): + svcs = [{"service_id": "s1", "enabled": True, + "endpoint": "http://10.10.0.1/tor", + "macro": {"kind": "tor-exit", "params": {"socks_port": 9050}}, + "pac": {"match": ["*.onion"], "proxy": "socks5"}}] + rules = service_rules(svcs) + assert (rules[0].host, rules[0].directive) == ("*.onion", "SOCKS5 10.10.0.1:9050; DIRECT") + assert rules[0].source == "service:s1" + +def test_disabled_and_pacless_skipped(): + svcs = [ + {"service_id": "s2", "enabled": False, "endpoint": "http://10.10.0.2/x", + "pac": {"match": ["a"], "proxy": "socks5"}}, + {"service_id": "s3", "enabled": True, "endpoint": "http://10.10.0.3/x"}, # no pac + ] + assert service_rules(svcs) == [] + +def test_gateway_service_uses_endpoint_host(): + svcs = [{"service_id": "s4", "enabled": True, "endpoint": "http://gk2.secubox.in/app", + "pac": {"match": ["app.local"], "proxy": "gateway"}}] + rules = service_rules(svcs) + assert rules[0].directive == "PROXY gk2.secubox.in; DIRECT" diff --git a/packages/secubox-proxypac/tests/test_generator.py b/packages/secubox-proxypac/tests/test_generator.py new file mode 100644 index 00000000..3e94ca5d --- /dev/null +++ b/packages/secubox-proxypac/tests/test_generator.py @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +"""SecuBox-Deb :: proxypac.generator tests.""" +import pytest + +from proxypac.generator import generate, write_atomic + + +def test_generate_composes_override_over_service(tmp_path): + (tmp_path / "10.rules").write_text("x.com direct\n") + svcs = [{"service_id": "s1", "enabled": True, "endpoint": "http://10.10.0.1/tor", + "macro": {"params": {"socks_port": 9050}}, + "pac": {"match": ["x.com", "*.onion"], "proxy": "socks5"}}] + pac = generate(tmp_path, svcs, toolbox_directive="PROXY 127.0.0.1:8081; DIRECT") + # override x.com -> DIRECT wins; *.onion via socks; toolbox catch-all last + assert pac.index('shExpMatch(host, "x.com")') < pac.index('shExpMatch(host, "*.onion")') + assert 'return "DIRECT";' in pac and 'SOCKS5 10.10.0.1:9050; DIRECT' in pac + assert 'shExpMatch(host, "*")' in pac + + +def test_write_atomic_swaps_and_validates(tmp_path): + out = tmp_path / "proxy.pac" + write_atomic('function FindProxyForURL(url, host) {\n return "DIRECT";\n}\n', out) + assert out.read_text().startswith("function FindProxyForURL") + assert not (tmp_path / "proxy.pac.shadow").exists() + + +def test_write_atomic_rejects_invalid_keeps_lastgood(tmp_path): + out = tmp_path / "proxy.pac" + write_atomic('function FindProxyForURL(url, host) { return "DIRECT"; }\n', out) + good = out.read_text() + with pytest.raises(ValueError): + write_atomic("garbage not a pac", out) # no FindProxyForURL → rejected + assert out.read_text() == good # last-good preserved + assert not (tmp_path / "proxy.pac.shadow").exists() + + +def test_run_once_failsafe_on_malformed_rule(tmp_path): + from proxypac.generator import run_once, write_atomic + rules = tmp_path / "rules.d"; rules.mkdir() + (rules / "10.rules").write_text("onlyonetoken\n") # missing proxy_type -> IndexError in parse + out = tmp_path / "proxy.pac" + write_atomic('function FindProxyForURL(url, host) {\n return "DIRECT";\n}\n', out) + good = out.read_text() + ok = run_once(rules_dir=rules, sock="/nonexistent.sock", out=out) + assert ok is False # generation failed + assert out.read_text() == good # last-good preserved, not clobbered diff --git a/packages/secubox-proxypac/tests/test_nginx_conf.py b/packages/secubox-proxypac/tests/test_nginx_conf.py new file mode 100644 index 00000000..b1fb45bc --- /dev/null +++ b/packages/secubox-proxypac/tests/test_nginx_conf.py @@ -0,0 +1,24 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +def test_pac_route_sets_content_type_and_serves_state(): + conf = (ROOT / "nginx" / "proxypac.conf").read_text() + assert "location = /proxy.pac" in conf + assert "application/x-ns-proxy-autoconfig" in conf + assert "/var/lib/secubox/proxypac/proxy.pac" in conf + assert "/api/v1/proxypac/" in conf and "aggregator.sock" in conf + # /proxy.pac must be LAN/mesh-gated even in the shared (public) server + assert "deny all;" in conf + assert "allow 10.10.0.0/24;" in conf + +def test_wpad_vhost_is_lan_mesh_only(): + conf = (ROOT / "nginx" / "wpad-vhost.conf").read_text() + assert "server_name wpad." in conf + assert "allow 10.10.0.0/24;" in conf + assert "deny all;" in conf + assert "application/x-ns-proxy-autoconfig" in conf + +def test_seed_rule_present(): + seed = (ROOT / "conf" / "rules.d" / "00-onion.rules").read_text() + assert "*.onion socks5" in seed diff --git a/packages/secubox-proxypac/tests/test_pac_template.py b/packages/secubox-proxypac/tests/test_pac_template.py new file mode 100644 index 00000000..40c7e81c --- /dev/null +++ b/packages/secubox-proxypac/tests/test_pac_template.py @@ -0,0 +1,19 @@ +from proxypac.pac_template import directive, render + +def test_directive_socks_has_failopen(): + assert directive("socks5", "10.10.0.1:9050") == "SOCKS5 10.10.0.1:9050; DIRECT" + +def test_directive_http_and_gateway_are_proxy(): + assert directive("http", "127.0.0.1:8081") == "PROXY 127.0.0.1:8081; DIRECT" + assert directive("gateway", "gk2.secubox.in") == "PROXY gk2.secubox.in; DIRECT" + +def test_directive_direct(): + assert directive("direct", "") == "DIRECT" + +def test_render_first_match_wins_and_terminal_direct(): + pac = render([("*.onion", "SOCKS5 10.10.0.1:9050; DIRECT")]) + assert "function FindProxyForURL(url, host)" in pac + assert 'shExpMatch(host, "*.onion")' in pac + assert 'return "SOCKS5 10.10.0.1:9050; DIRECT";' in pac + assert pac.rstrip().endswith("}") + assert 'return "DIRECT";' in pac diff --git a/packages/secubox-proxypac/tests/test_packaging.py b/packages/secubox-proxypac/tests/test_packaging.py new file mode 100644 index 00000000..04c84149 --- /dev/null +++ b/packages/secubox-proxypac/tests/test_packaging.py @@ -0,0 +1,27 @@ +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] + +def test_control_metadata(): + c = (ROOT / "debian" / "control").read_text() + assert "Package: secubox-proxypac" in c + assert "Standards-Version: 4.6.2" in c + assert "Depends:" in c and "secubox-core" in c + # /proxy.pac LAN gate relies on secubox-hub's real_ip rewrite behind HAProxy + assert "secubox-hub" in c + +def test_rules_installs_all_artifacts(): + r = (ROOT / "debian" / "rules").read_text() + for frag in ["proxypac", "sbin/proxypac-gen", "nginx/proxypac.conf", + "systemd/secubox-proxypac-gen.path", "conf/rules.d", "www"]: + assert frag in r + +def test_postinst_enables_regen_and_seeds_rules(): + p = (ROOT / "debian" / "postinst").read_text() + assert "systemctl enable --now secubox-proxypac-gen.path" in p + assert "systemctl enable --now secubox-proxypac-gen.timer" in p + assert "proxypac-gen" in p # initial generation + +def test_no_conflicting_compat_file(): + # debhelper forbids both debian/compat AND Build-Depends: debhelper-compat + assert not (ROOT / "debian" / "compat").exists() + assert "debhelper-compat (= 13)" in (ROOT / "debian" / "control").read_text() diff --git a/packages/secubox-proxypac/tests/test_rules.py b/packages/secubox-proxypac/tests/test_rules.py new file mode 100644 index 00000000..7ee20df4 --- /dev/null +++ b/packages/secubox-proxypac/tests/test_rules.py @@ -0,0 +1,29 @@ +from proxypac.rules import Rule, parse_rules_dir, compose + +def test_parse_rules_dir(tmp_path): + (tmp_path / "10-a.rules").write_text("# c\n*.onion socks5 10.10.0.1:9050\n\n") + (tmp_path / "20-b.rules").write_text("bank.example direct\n") + rules = parse_rules_dir(tmp_path) + assert [(r.host, r.directive) for r in rules] == [ + ("*.onion", "SOCKS5 10.10.0.1:9050; DIRECT"), + ("bank.example", "DIRECT"), + ] + assert all(r.source == "override" for r in rules) + +def test_compose_precedence_override_beats_service(): + ov = [Rule("x.com", "DIRECT", "override")] + svc = [Rule("x.com", "PROXY p; DIRECT", "service:1"), Rule("y.com", "PROXY p; DIRECT", "service:1")] + tb = Rule("*", "PROXY t; DIRECT", "toolbox") + out = compose(ov, svc, tb) + # override wins for x.com; y.com from service; toolbox catch-all last + assert out == [("x.com", "DIRECT"), ("y.com", "PROXY p; DIRECT"), ("*", "PROXY t; DIRECT")] + +def test_compose_no_toolbox(): + assert compose([], [], None) == [] + +def test_compose_override_last_file_wins(): + # A later operator override (e.g. 50-webui.rules) must beat an earlier seed + # (e.g. 00-onion.rules) for the same host glob. + ov = [Rule("*.onion", "SOCKS5 10.10.0.1:9050; DIRECT", "override"), # 00-onion seed + Rule("*.onion", "DIRECT", "override")] # 50-webui (later) + assert compose(ov, [], None) == [("*.onion", "DIRECT")] diff --git a/packages/secubox-proxypac/tests/test_systemd_units.py b/packages/secubox-proxypac/tests/test_systemd_units.py new file mode 100644 index 00000000..3067cedb --- /dev/null +++ b/packages/secubox-proxypac/tests/test_systemd_units.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +""" +SecuBox-ProxyPAC :: systemd units and config verification +""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_gen_service_runs_cli_as_secubox(): + s = (ROOT / "systemd" / "secubox-proxypac-gen.service").read_text() + assert "ExecStart=/usr/sbin/proxypac-gen" in s + assert "User=secubox" in s and "Type=oneshot" in s + + +def test_path_unit_watches_rulesdir(): + p = (ROOT / "systemd" / "secubox-proxypac-gen.path").read_text() + assert "PathModified=/etc/secubox/proxypac/rules.d" in p + assert "Unit=secubox-proxypac-gen.service" in p + + +def test_timer_is_fallback(): + t = (ROOT / "systemd" / "secubox-proxypac-gen.timer").read_text() + assert "OnUnitActiveSec=" in t + + +def test_dnsmasq_sets_option_252(): + d = (ROOT / "conf" / "dnsmasq-wpad.conf").read_text() + assert "dhcp-option=252" in d diff --git a/packages/secubox-proxypac/tests/test_webui_panel.py b/packages/secubox-proxypac/tests/test_webui_panel.py new file mode 100644 index 00000000..462530e6 --- /dev/null +++ b/packages/secubox-proxypac/tests/test_webui_panel.py @@ -0,0 +1,16 @@ +# packages/secubox-proxypac/tests/test_webui_panel.py +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] + +def test_panel_calls_api_and_has_override_form(): + html = (ROOT / "www" / "proxypac" / "index.html").read_text() + assert "/api/v1/proxypac/rules" in html + assert "/api/v1/proxypac/override" in html + assert 'id="host"' in html and 'id="proxy"' in html + +def test_menu_entry_points_to_panel(): + import json + m = json.loads((ROOT / "menu.d" / "580-proxypac.json").read_text()) + assert m.get("path") == "/proxypac/" or m.get("url") == "/proxypac/" + assert m.get("name") == "ProxyPAC" + assert m.get("category") in ("auth","wall","boot","mind","root","mesh") diff --git a/packages/secubox-proxypac/www/proxypac/index.html b/packages/secubox-proxypac/www/proxypac/index.html new file mode 100644 index 00000000..b65eb2a4 --- /dev/null +++ b/packages/secubox-proxypac/www/proxypac/index.html @@ -0,0 +1,27 @@ + +ProxyPAC + +

    ProxyPAC — routage mesh

    +

    Règles actives

      +

      Ajouter un override

      + + + + + +