Files
vacuum-wall/AGENTS.md
T
mteehan 0ed275835d fix: auth review fixes — token revocation, WS auth, seeding, and hardening
Refresh/logout and token robustness
- drop the post-rotation refresh_tokens row delete in auth_refresh so
  logout blacklists the current (rotated) refresh token; remove the
  dead _clear_refresh_token_after_rotation helper and clear_active_refresh_token
- reject non-object JWT payloads in _extract_unverified_sub so crafted
  Authorization headers return 401 instead of crashing with 500

SQLite user store
- make builtin-admin seeding idempotent: on a concurrent first start the
  losing seeder re-checks, finds the winner, and returns instead of
  raising IntegrityError
- per-thread sqlite connections + busy_timeout so Flask worker threads
  don't hit cross-thread ProgrammingError / SQLITE_BUSY
- add LogsDirectory + /var/log/vacuum-wall to ReadWritePaths in both
  systemd units so the fallback admin password actually lands on disk

Frontend
- skip apiFetch 401-recovery for public auth endpoints so a failed
  login no longer logs out a valid session
- add /passkeys to the nav (passkey registration was unreachable);
  remove the dead checkWebAuthnCapable export
- drop the CSP-blocked inline WS-URL script and the
  __WS_URL_PLACEHOLDER__ plumbing; the WS URL is derived from location

Daemon / WS
- parse Sec-WebSocket-Protocol manually (web.Request.get_subprotocols
  does not exist in aiohttp 3.13); X-Auth-Token is a custom-nginx
  fallback only — docstring and security docs corrected

Install / system
- bootstrap_auth.py is now idempotent: preserves existing auth config
  and syncs the admin password on re-runs (new reset_password helper)
- WebUI server block renders auth_basic off (the UI is JWT-protected)
- install.sh chown/chmod skips .git to avoid git dubious-ownership
  breakage
- tolerate unreadable /etc/wireguard during system import

Contracts / docs
- create_user returns 409 on duplicate username per docs/api.md
- correct docs/api.md response shapes, docs/security.md blacklist
  cleanup wording + one-refresh-per-user caveat, stale WS-URL
  references, and the .htpasswd description

Tests: +7 regression tests (rotation/logout revocation, crafted-token
401, concurrent seeding); placeholder-substitution tests replaced with
serve-as-is SPA root tests.
2026-08-17 01:45:15 +00:00

8.5 KiB

Vacuum Wall — Agent Instructions

What This Is

SSL proxy / firewall appliance. Python 3.13+ Flask SPA behind nginx reverse proxy. Deploys on Debian 13 (trixie). Serves from repo root by default.

Architecture

Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090)
Flask  ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp, daemon.sock)
vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ system service

Blueprints are thin proxies — they never call lib/ directly. All operations flow through the daemon client over a Unix socket.

Two-User Model with Shared Group

  • vacuum-walld (daemon user): runs the privileged background daemon with NOPASSWD sudo whitelist (/etc/sudoers.d/vacuum-walld). Owns socket. Primary group is the WebUI user's primary group. Daemon user name is derived: USER_NAME + d.
  • WebUI user (default: repo owner in --dev mode): runs the Flask process with zero sudo access. Communicates with the daemon via Unix socket.
  • Shared group: both users share the WebUI user's primary group. Socket is vacuum-walld:<group> with mode 0660.

Code Layout

  • webui/server.py — Flask app entry point. Only file that creates the app. SPA root route (/) serves index.html (no templating). All other paths return 404.
  • webui/api/*.py — Flask blueprints, one per subsystem. Routes prefix /api/<subsystem>/. All call daemon.client instead of lib/ directly.
  • webui/api/common.py — Shared _ok() / _error() response helpers used by all blueprints.
  • daemon/server.py — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
  • daemon/client.py — Sync HTTP client over Unix socket using requests_unixsocket.Session.
  • daemon/iface.pySingle source of truth for all daemon API endpoints. Every endpoint is a frozen (method, path) tuple. Renaming here auto-updates both server registry and client calls.
  • daemon/handlers/*.py — Privileged operation handlers. All sudo calls live here.
  • lib/state.py — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on request. Backs WebSocket versioning/broadcast.
  • lib/common.py — Shared utilities: run(), run_proc(), load_json(), save_json(), deep_merge(), ensure_dirs(), config_hash(), validate_interface_name().
  • lib/logging.py — Logging setup used by both webui and daemon. Reads VACUUM_WALL_LOG_LEVEL.
  • lib/*.py — Backend modules (parsing, config, shared logic). Full type hints and __all__ exports. No sudo calls.
  • vendor/ — Vendored scripts and JS libraries (acme.sh, htm).
  • data/ — Runtime artifacts (generated .confs, .htpasswd, ACME certs, firewall backup, dnsmasq fragments).
  • config/<subsystem>/config.json — Declarative JSON configs (source of truth).
  • system/ — System file templates. systemd/ (units installed to /etc/systemd/system/), sudoers.d/, nginx/.

Project uses .venv. Install deps with pip install -e . (from pyproject.toml). __init__.py files in webui/, lib/, and daemon/ are intentionally empty.

Frontend (hoover)

Custom reactive SPA framework at webui/static/hoover/. See docs/hoover.md for full API reference.

Conventions:

  • All imports from /static/hoover/index.js (barrel export).
  • Pages in webui/static/pages/ export definePage({ init, subscribe, load, render }).
  • Bootstrap: webui/static/app.js mounts two render roots (#sidebar, #main), then connect() for WS.
  • h() builds VNodes with on:click prefix. html tag (htm) templates use camelCase onClick (adapter translates).
  • State always has loading, refreshing, error plus data. load() receives (state, abortController, entry).
  • openModal + formModal for dialogs; apiSubmit() for form submission.
  • No build step — ES modules served raw. Cache controlled via HTTP headers.

Daemon Endpoints

  • Unix socket at data/daemon.sock (configurable via VACUUM_WALLD_SOCKET)
  • WebSocket at 127.0.0.1:9091 (configurable via VACUUM_WALLD_WS_PORT) for real-time state notifications
  • Periodic polling per subsystem via lib.state._DEFAULT_POLL_INTERVALS, overridable with VACUUM_WALL_POLL_INTERVALS env var (format subsystem:seconds,subsystem:seconds)
  • Start as python -m daemon.server or via the vacuum-walld console script

Environment Variables

  • VACUUM_WALL_DEV — dev mode flag; disables aggressive static asset caching
  • VACUUM_WALL_LOG_LEVEL — log level (default INFO)
  • VACUUM_WALLD_SOCKET — override daemon socket path (default data/daemon.sock)
  • VACUUM_WALLD_WS_PORT — override WebSocket port (default 9091)
  • VACUUM_WALL_POLL_INTERVALS — override poll intervals, e.g. firewall:60,wireguard:5
  • VACUUM_WALL_EXTERNAL_IP_URL — custom URL for external IP detection (acme handler)

Local Dev

# Setup
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
bash scripts/update-vendor.sh        # fetches acme.sh + htm.js

# Start (Flask only, binds 127.0.0.1:9090)
.venv/bin/python webui/server.py

Reload running Flask via SIGHUP (auto-reloads webui.* and lib.* modules, then SIGTERM restart).

Blueprint / Handler / lib Mapping

Blueprint URL Prefix Handler lib Module
webui/api/firewall /api/firewall/ daemon/handlers/firewall lib.firewall
webui/api/dhcp /api/dhcp/ daemon/handlers/dnsmasq lib.dnsmasq
webui/api/proxy /api/proxy/ daemon/handlers/nginx lib.nginx
webui/api/certs /api/certs/ daemon/handlers/acme lib.acme
webui/api/wireguard /api/wireguard/ daemon/handlers/wireguard lib.wireguard
webui/api/network /api/network/ daemon/handlers/network lib.network
webui/api/logs /api/logs/ daemon/handlers/logs
webui/api/status /api/status/ daemon/handlers/status

Privileged Operations

daemon/handlers/*.py call sudo for everything that touches system services. Whitelist is system/sudoers.d/vacuum-walld.

acme.sh must never run as root — always as the service user via sudo -u.

Pattern for mutations: write JSON → render native config → sudo <cmd> to apply. Adding a new privileged command requires a sudoers entry and the daemon/handlers/ code.

API Response Contract

  • Success: {"ok": true, "data": <value>}_ok(data) (Flask) or ok(data) (aiohttp)
  • Error: {"ok": false, "error": "msg"}_error(msg, code) (Flask) or error(msg, code) (aiohttp)
  • acme.issue() / acme.renew() raise RuntimeError on failure — API layer wraps in try/except
  • HTTP codes: 400 bad request, 404 not found, 409 conflict, 500 internal failure

Deploy

scripts/install.sh is the single deploy script. Run as root. CLI flags take precedence over env vars. MGMT_PASS is strictly required; MGMT_DOMAIN auto-detected from hostname.

Service Start Order

firewalldavahi-daemondnsmasqvacuum-walldvacuum-wall

Lint and Tests

Linter / formatter: Ruff (ruff check + ruff format). Config in pyproject.toml.

Tests: pytest in tests/ (17 modules). All subprocess calls are mocked — no system services required.

.venv/bin/ruff check lib/ webui/ tests/   # lint
.venv/bin/ruff format lib/ webui/ tests/   # format
.venv/bin/python -m pytest tests/ -v       # test

Docs

docs/ contains the authoritative reference for each subsystem. Read the relevant doc before reasoning about a subsystem.

Doc Contents
docs/architecture.md Request flow, subsystem communication, two-user model, zone model, state management
docs/security.md Privilege model, sudo whitelist, systemd hardening, TLS config, zone trust levels
docs/deployment.md Install script options, what scripts/install.sh does, post-install setup, troubleshooting
docs/config.md JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types)
docs/api.md REST API endpoint reference, request/response contracts, route patterns
docs/hoover.md Custom frontend framework API reference
docs/overview.md Subsystem summaries, tech stack, complete project directory tree