* docs(spec): secubox-proxypac WPAD/PAC mesh-service routing design (ref #784) * docs(plan): secubox-proxypac 11-task TDD implementation plan (ref #784) * feat(annuaire): optional byte-stable pac descriptor on ServiceOffer (ref #784) * feat(proxypac): PAC template + fail-open directive builder (ref #784) * feat(proxypac): rules.d parsing + precedence compose (ref #784) * feat(proxypac): p2p /services catalog reader → routing rules (ref #784) * feat(proxypac): generator + atomic fail-safe write + CLI (ref #784) * fix(proxypac): shadow file is <pac>.shadow not <base>.shadow (ref #784) * feat(proxypac): WebUI API override CRUD + candidates + audit (ref #784) * fix(proxypac): regen honors RULES_DIR + reject whitespace in override host (ref #784) * feat(proxypac): nginx PAC/WPAD serving (LAN-only) + seed rule (ref #784) * fix(proxypac): gate /proxy.pac to LAN/mesh (never public) (ref #784) * feat(proxypac): regen path+timer units + DHCP 252 WPAD advertise (ref #784) * feat(proxypac): WebUI override panel + menu entry (ref #784) * fix(proxypac): correct menu schema (name/category) + escape panel output (ref #784) * feat(proxypac): debian packaging (ref #784) * fix(proxypac): drop duplicate debian/compat + correct secubox user creation (ref #784) * docs(proxypac): README + deploy notes (ref #784) * fix(proxypac): apply final-review follow-ups (ref #784) - compose: overrides now last-file-wins per host, so an operator's later 50-webui.rules override beats a shipped 00-onion.rules seed for the same glob (explicit policy > defaults); cross-source override>service>toolbox unchanged. New test test_compose_override_last_file_wins. - packaging: declare Depends: secubox-hub — the /proxy.pac allow/deny gate matches $remote_addr, which behind HAProxy is 127.0.0.1 unless secubox-hub's real_ip (X-Forwarded-For) rewrite is loaded; without it the PAC would be world-readable. nginx/proxypac.conf comments the dependency; test asserts it in control. - README: document last-file-wins precedence + the real_ip/secubox-hub dependency. --------- Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
45 KiB
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; version1.0.0-1~bookworm1;debian/compat13;Standards-Version: 4.6.2. - Python SPDX header block on every
.py(per.claude/CLAUDE.md); service runs asUser=secubox. - The generated PAC MUST end every non-DIRECT directive with
; DIRECT(fail-open) and MUST have a terminalreturn "DIRECT";. - Generation is fail-safe: on any error the previously-served
proxy.pacis left untouched (never serve empty/broken). wpad.dat//proxy.pacare served ONLY on the LAN +10.10.0.0/24mesh vhost (deny allotherwise) — never public.- The annuaire
pacfield is OPTIONAL and byte-stable: apac-lessServiceOfferMUST produce identicalcanonical_bytesto before this change (None excluded from canonical JSON), mirroringMacroDescriptor(#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: addPacDescriptor+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—Rulemodel, rules.d parsing, precedence compose.packages/secubox-proxypac/proxypac/catalog.py— read p2p/services, map to serviceRules.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-onlywpadserver 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(afterMacroDescriptor, ~line 400, andServiceOffer~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
# 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:
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::
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
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
# 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
# packages/secubox-proxypac/proxypac/pac_template.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""SecuBox-Deb :: 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
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: <host-glob> <proxy_type> [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
# 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
# packages/secubox-proxypac/proxypac/rules.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""SecuBox-Deb :: 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:<id>" | "toolbox"
def parse_rules_dir(path):
"""Parse rules.d/*.rules in sorted filename order.
Line: '<host-glob> <proxy_type> [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
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://<ip>/...); port = macro.params.socks_port for socks5 else empty (gateway/http use the endpoint host).
- Step 1: Write the failing test
# 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
# packages/secubox-proxypac/proxypac/catalog.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""SecuBox-Deb :: 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
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
# 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
# packages/secubox-proxypac/proxypac/generator.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""SecuBox-Deb :: 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
#!/usr/bin/env python3
# packages/secubox-proxypac/sbin/proxypac-gen
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""SecuBox-Deb :: 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
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.logand callsrun_once(). -
Step 1: Write the failing test
# 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
# packages/secubox-proxypac/api/main.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""SecuBox-Deb :: 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
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
# 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):
# 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):
# 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
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
# 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:
[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:
[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):
[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
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
# 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):
<!DOCTYPE html>
<html lang="fr"><head><meta charset="utf-8"><title>ProxyPAC</title></head>
<body>
<h1>ProxyPAC — routage mesh</h1>
<h2>Règles actives</h2><ul id="rules"></ul>
<h2>Ajouter un override</h2>
<input id="host" placeholder="host glob (ex: bank.example)">
<select id="proxy"><option>direct</option><option>socks5</option><option>http</option><option>gateway</option></select>
<input id="address" placeholder="ip:port (socks5/http)">
<button onclick="addOverride()">Ajouter</button>
<script>
const API = '/api/v1/proxypac';
function headers(){return {'Content-Type':'application/json'};}
async function load(){
const d = await (await fetch(API+'/rules',{headers:headers()})).json();
document.getElementById('rules').innerHTML =
d.rules.map(r=>`<li>${r.host} → ${r.directive} <button onclick="del('${r.host}')">x</button></li>`).join('');
}
async function addOverride(){
await fetch(API+'/override',{method:'POST',headers:headers(),
body:JSON.stringify({host:host.value,proxy:proxy.value,address:address.value})});
load();
}
async function del(h){ await fetch(API+'/override/'+encodeURIComponent(h),{method:'DELETE',headers:headers()}); load(); }
load();
</script>
</body></html>
menu.d/580-proxypac.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
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
# 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 <devel@cybermind.fr>
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:
#!/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):
#!/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):
#!/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 <devel@cybermind.fr> 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
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
pacfield, WPAD delivery + DHCP 252, the fail-safe/atomic-swap invariant, and the deploy note (addproxypactoaggregator.toml, installwpad-vhost.confintosites-enabled, deploydnsmasq-wpad.confto 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
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-genis 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/generatesignatures stable T5→T6. - Gap noted (non-blocking): the auto-learn detector that populates
candidates.jsonis out of scope here (fed later by #743); T6 ships the accept/reject/read half so the loop is closable.