- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
10 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 (TLS; basic auth on basic-authed proxy domains only) ──→ 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.
Authentication: the management UI gets no nginx-level auth_basic — the mgmt
server block's location / is a bare proxy and /ws is auth_basic off. Management
auth is the Flask-layer JWT middleware (POST /api/auth/login →
Authorization: Bearer <token>; public paths: static files, /vendor/, auth endpoints)
plus the daemon WS handshake (raw JWT as the Sec-WebSocket-Protocol subprotocol).
Basic auth (.htpasswd) renders only for proxy domains whose
config/nginx/config.json has an auth block — never for the management domain
(see docs/security.md, "Management Interface").
Two-User Model with Shared Group
vacuum-walld(daemon user): runs the privileged background daemon withNOPASSWD sudowhitelist (/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
--devmode): 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 mode0660.
Code Layout
webui/server.py— Flask app entry point. Only file that creates theapp. SPA root route (/) servesindex.html(no templating). All other paths return 404.webui/api/*.py— Flask blueprints, one per subsystem. Routes prefix/api/<subsystem>/. All calldaemon.clientinstead oflib/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 usingrequests_unixsocket.Session.daemon/iface.py— Single 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. Allsudocalls live here.lib/state.py— In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on mutation/poll. Backs the WebSocket push stream:get_snapshot()(full state on WS connect),poll()two-layer diff (structuralversionsbroadcast vs volatile-onlytickbroadcast, per-subsystem, each carrying the full subsystem data),register_volatile(subsystem, keys)to mark volatile fields,get_versions()/bump(). Per-subsystem poll intervals via_DEFAULT_POLL_INTERVALS(system 1s, firewall 30s, wireguard/dnsmasq/networkd 10s, nginx 60s, acme 300s).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. ReadsVACUUM_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/exportdefinePage({ init, subscribe, load, render }). - Bootstrap:
webui/static/app.jsmounts two render roots (#sidebar,#main), thenconnect()for WS. h()builds VNodes withon:clickprefix.htmltag (htm) templates use camelCaseonClick(adapter translates).- State always has
loading,refreshing,errorplus data.load()receives(state, abortController, entry). openModal+formModalfor 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 viaVACUUM_WALLD_SOCKET) - WebSocket at
127.0.0.1:9091(configurable viaVACUUM_WALLD_WS_PORT) for real-time state streaming: fullsnapshoton connect, then per-subsystem data-carryingversions(structural) /tick(volatile-only) deltas. The client patches reactive models in place viamodelSet()— no HTTP round-trip for auto-refresh. - Periodic polling per subsystem via
lib.state._DEFAULT_POLL_INTERVALS, overridable withVACUUM_WALL_POLL_INTERVALSenv var (formatsubsystem:seconds,subsystem:seconds) - Start as
python -m daemon.serveror via thevacuum-walldconsole script
Environment Variables
VACUUM_WALL_DEV— dev mode flag; disables aggressive static asset cachingVACUUM_WALL_LOG_LEVEL— log level (defaultINFO)VACUUM_WALLD_SOCKET— override daemon socket path (defaultdata/daemon.sock)VACUUM_WALLD_WS_PORT— override WebSocket port (default9091)VACUUM_WALL_POLL_INTERVALS— override poll intervals, e.g.firewall:60,wireguard:5VACUUM_WALL_EXTERNAL_IP_URL— custom URL for external IP detection (acme handler)VACUUM_WALL_SEED_BUILTIN_ADMIN— set to0to skip the last-resort builtin admin seed inget_db(). The seed only runs on a completely empty DB (no users);scripts/bootstrap_auth.pyalways sets this since bootstrap creates the operator user itself.
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) orok(data)(aiohttp) - Error:
{"ok": false, "error": "msg"}—_error(msg, code)(Flask) orerror(msg, code)(aiohttp) acme.issue()/acme.renew()raiseRuntimeErroron failure — API layer wraps in try/except- HTTP codes:
400bad request,404not found,409conflict,500internal 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 — it is the SQLite DB password for the initial admin
user (full rw on all subsystems; default username admin), not an nginx htpasswd.
MGMT_DOMAIN auto-detected from hostname.
Service Start Order
firewalld → avahi-daemon → dnsmasq → vacuum-walld → vacuum-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 |