mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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>
122 lines
3.9 KiB
Python
122 lines
3.9 KiB
Python
# 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"}
|