mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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
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:
parent
90a323e6e8
commit
ff8ba36ff0
1218
docs/superpowers/plans/2026-07-03-secubox-proxypac.md
Normal file
1218
docs/superpowers/plans/2026-07-03-secubox-proxypac.md
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
45
packages/secubox-annuaire/tests/test_pac_descriptor.py
Normal file
45
packages/secubox-annuaire/tests/test_pac_descriptor.py
Normal 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))
|
||||
216
packages/secubox-proxypac/README.md
Normal file
216
packages/secubox-proxypac/README.md
Normal 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).
|
||||
0
packages/secubox-proxypac/api/__init__.py
Normal file
0
packages/secubox-proxypac/api/__init__.py
Normal file
121
packages/secubox-proxypac/api/main.py
Normal file
121
packages/secubox-proxypac/api/main.py
Normal 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"}
|
||||
2
packages/secubox-proxypac/conf/dnsmasq-wpad.conf
Normal file
2
packages/secubox-proxypac/conf/dnsmasq-wpad.conf
Normal 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"
|
||||
4
packages/secubox-proxypac/conf/rules.d/00-onion.rules
Normal file
4
packages/secubox-proxypac/conf/rules.d/00-onion.rules
Normal 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
|
||||
5
packages/secubox-proxypac/debian/changelog
Normal file
5
packages/secubox-proxypac/debian/changelog
Normal 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
|
||||
13
packages/secubox-proxypac/debian/control
Normal file
13
packages/secubox-proxypac/debian/control
Normal 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.
|
||||
13
packages/secubox-proxypac/debian/postinst
Executable file
13
packages/secubox-proxypac/debian/postinst
Executable 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#
|
||||
6
packages/secubox-proxypac/debian/prerm
Executable file
6
packages/secubox-proxypac/debian/prerm
Executable 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#
|
||||
20
packages/secubox-proxypac/debian/rules
Normal file
20
packages/secubox-proxypac/debian/rules
Normal 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/
|
||||
1
packages/secubox-proxypac/menu.d/580-proxypac.json
Normal file
1
packages/secubox-proxypac/menu.d/580-proxypac.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "id": "proxypac", "name": "ProxyPAC", "path": "/proxypac/", "icon": "🧭", "category": "mesh", "order": 58 }
|
||||
22
packages/secubox-proxypac/nginx/proxypac.conf
Normal file
22
packages/secubox-proxypac/nginx/proxypac.conf
Normal 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;
|
||||
}
|
||||
12
packages/secubox-proxypac/nginx/wpad-vhost.conf
Normal file
12
packages/secubox-proxypac/nginx/wpad-vhost.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
0
packages/secubox-proxypac/proxypac/__init__.py
Normal file
0
packages/secubox-proxypac/proxypac/__init__.py
Normal file
56
packages/secubox-proxypac/proxypac/catalog.py
Normal file
56
packages/secubox-proxypac/proxypac/catalog.py
Normal 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 []
|
||||
45
packages/secubox-proxypac/proxypac/generator.py
Normal file
45
packages/secubox-proxypac/proxypac/generator.py
Normal 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
|
||||
28
packages/secubox-proxypac/proxypac/pac_template.py
Normal file
28
packages/secubox-proxypac/proxypac/pac_template.py
Normal 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)
|
||||
61
packages/secubox-proxypac/proxypac/rules.py
Normal file
61
packages/secubox-proxypac/proxypac/rules.py
Normal 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
|
||||
14
packages/secubox-proxypac/sbin/proxypac-gen
Executable file
14
packages/secubox-proxypac/sbin/proxypac-gen
Executable 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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=SecuBox ProxyPAC — periodic regen fallback
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
22
packages/secubox-proxypac/systemd/secubox-proxypac.service
Normal file
22
packages/secubox-proxypac/systemd/secubox-proxypac.service
Normal 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
|
||||
34
packages/secubox-proxypac/tests/test_api.py
Normal file
34
packages/secubox-proxypac/tests/test_api.py
Normal 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
|
||||
24
packages/secubox-proxypac/tests/test_catalog.py
Normal file
24
packages/secubox-proxypac/tests/test_catalog.py
Normal 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"
|
||||
49
packages/secubox-proxypac/tests/test_generator.py
Normal file
49
packages/secubox-proxypac/tests/test_generator.py
Normal 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
|
||||
24
packages/secubox-proxypac/tests/test_nginx_conf.py
Normal file
24
packages/secubox-proxypac/tests/test_nginx_conf.py
Normal 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
|
||||
19
packages/secubox-proxypac/tests/test_pac_template.py
Normal file
19
packages/secubox-proxypac/tests/test_pac_template.py
Normal 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
|
||||
27
packages/secubox-proxypac/tests/test_packaging.py
Normal file
27
packages/secubox-proxypac/tests/test_packaging.py
Normal 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()
|
||||
29
packages/secubox-proxypac/tests/test_rules.py
Normal file
29
packages/secubox-proxypac/tests/test_rules.py
Normal 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")]
|
||||
33
packages/secubox-proxypac/tests/test_systemd_units.py
Normal file
33
packages/secubox-proxypac/tests/test_systemd_units.py
Normal 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
|
||||
16
packages/secubox-proxypac/tests/test_webui_panel.py
Normal file
16
packages/secubox-proxypac/tests/test_webui_panel.py
Normal 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")
|
||||
27
packages/secubox-proxypac/www/proxypac/index.html
Normal file
27
packages/secubox-proxypac/www/proxypac/index.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
Loading…
Reference in New Issue
Block a user