feat(proxypac): WPAD/PAC auto-config routing to active mesh services (Closes #784) (#788)
Some checks are pending
License Headers / check (push) Waiting to run

* 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>
This commit is contained in:
CyberMind 2026-07-03 11:57:26 +02:00 committed by GitHub
parent 90a323e6e8
commit ff8ba36ff0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 2432 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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 <gateway-host>` / 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://<node>/proxy.pac` — content-type `application/x-ns-proxy-autoconfig`.
- `http://wpad.<domain>/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 <tool>" 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 <toolbox-mitm-host:port>; DIRECT` — toolbox inspection.
- `PROXY <gateway-host>; 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.

View File

@ -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,

View File

@ -0,0 +1,45 @@
# 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 :: 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))

View File

@ -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:
```
<host-glob> <proxy_type> [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:<id>` (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.<domain>` 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.<domain>` 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.<domain>` 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).

View File

@ -0,0 +1,121 @@
# 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, 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"}

View File

@ -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"

View File

@ -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

View File

@ -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 <devel@cybermind.fr> Fri, 03 Jul 2026 00:00:00 +0200

View File

@ -0,0 +1,13 @@
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, 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.

View File

@ -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#

View File

@ -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#

View File

@ -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/

View File

@ -0,0 +1 @@
{ "id": "proxypac", "name": "ProxyPAC", "path": "/proxypac/", "icon": "🧭", "category": "mesh", "order": 58 }

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -0,0 +1,56 @@
# 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 []

View File

@ -0,0 +1,45 @@
# 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.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

View File

@ -0,0 +1,28 @@
# 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)

View File

@ -0,0 +1,61 @@
# 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):
"""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

View File

@ -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 <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)

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,9 @@
[Unit]
Description=SecuBox ProxyPAC — periodic regen fallback
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target

View File

@ -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

View File

@ -0,0 +1,34 @@
# 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.
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

View File

@ -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"

View File

@ -0,0 +1,49 @@
# 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 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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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")]

View File

@ -0,0 +1,33 @@
# 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-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

View File

@ -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")

View File

@ -0,0 +1,27 @@
<!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>
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function headers(){return {'Content-Type':'application/json'};}
async function load(){
const d = await (await fetch('/api/v1/proxypac/rules',{headers:headers()})).json();
document.getElementById('rules').innerHTML =
d.rules.map(r=>`<li>${esc(r.host)} → ${esc(r.directive)} <button onclick="del('${esc(r.host)}')">x</button></li>`).join('');
}
async function addOverride(){
await fetch('/api/v1/proxypac/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/v1/proxypac/override/'+encodeURIComponent(h),{method:'DELETE',headers:headers()}); load(); }
load();
</script>
</body></html>