Files
vacuum-wall/AGENTS.md
T
mteehan bc72db903c feat: add networkd subsystem and fix code review issues
Phase 1-4: Networkd subsystem
- lib/network.py: systemd-networkd config renderer (.network INI files)
  with full schema support: [Match], [Link], [Network], [Address], [Route],
  [DHCPv4], [DHCPv6] sections. One Address/=DNS= line per value per spec.
  Route sections use #N suffix per systemd.syntax(7).
- lib/network.py: generate_network_files() with 50-<name>.network prefix
  and stale file cleanup
- lib/network.py: collect_upstream_dns() filters local/private DNS
- lib/network.py: infer_dhcp_ranges() and infer_zones() helpers
- daemon/handlers/network.py: routes for GET/POST /network/interfaces
  and full apply with DNS upstream sync to dnsmasq
- webui/api/network.py: Flask blueprint for /api/network/* endpoints
- webui/api: interfaces page updated with IP config inline editing
- lib/state.py: networkd collector using parse_networkctl_status()
- system/sudoers.d/vacuum-walld: networkctl + systemd-network rules
- system/systemd/vacuum-walld.service: ReadWritePaths for /etc/systemd/network
- install.sh: ACME email now optional, configured from WebUI
- lib/acme.py: get_email() falls back to declarative config

Phase 5: Code review fixes
- daemon/server.py: path params now win over JSON body and query params
  in request body merge (prevents config save name override)
- daemon/server.py: remove dead 'import re'
- daemon/handlers/network.py: replace Path.mkdir() with sudo mkdir
  for /etc/systemd/network (ProtectSystem=strict compatibility)
- system/sudoers.d/vacuum-walld: pin systemctl to specific commands
  (reload/is-active dnsmasq instead of wildcard)
- system/sudoers.d/vacuum-walld: restore !requiretty and section comment
- lib/network.py: remove unused _MANAGEMENT_PORTS constant
- webui/api/network.py: remove redundant body[\name\] = name in save_interface

Tests: 332 passing (110 new/updated), ruff clean
2026-06-01 03:15:50 +00:00

8.0 KiB

Vacuum Wall — Agent Instructions

What This Is

SSL proxy / firewall appliance. Python 3 Flask WebUI 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

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 project directory and socket. Primary group is the WebUI user's primary group.
  • 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. Project dir is owned by the WebUI user with group-read+execute.

Code Layout

  • webui/server.py — Flask app entry point. Only file that creates the app.
  • 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, cache engine, batch routing, handler registry.
  • daemon/client.py — Sync HTTP client over Unix socket using requests_unixsocket.Session.
  • daemon/handlers/*.py — Privileged operation handlers (all sudo calls live here).
  • lib/common.py — Shared utilities: run(), run_proc(), load_json(), save_json(), deep_merge(), ensure_dirs(). All lib/ modules use these instead of defining local helpers.
  • lib/*.py — Backend modules (parsing, config, shared logic). All have full type hints and __all__ exports. No sudo calls — privilege escalation is handled by daemon/handlers/*.py.
  • data/ — Runtime artifacts (generated .confs, .htpasswd, ACME certs, firewall backup, dnsmasq fragments).
  • config/<subsystem>/config.json — Declarative JSON configs (source of truth). Generated .conf in data/nginx/sites-enabled/. Certs in data/acme/.
  • system/ — System file templates. systemd/ (service units installed to /etc/systemd/system/), sudoers.d/, nginx/.
  • webui/static/ — Vendored frontend libraries (JS + CSS). Flask auto-serves at /static/.

Project uses .venv. Install deps with pip install -e . (from pyproject.toml). __init__.py files in webui/ and lib/ are intentionally empty — no sys.path boilerplate needed.

No CDN packages. All frontend libraries (JS and CSS) must be vendored in webui/static/. Never reference unpkg.com, cdn.jsdelivr.net, or similar. To add/update a library, edit the version in scripts/update-vendor.sh and run it.

Library Version Vendor file Symlink (active) CDN source
htmx 2.0.4 vendor/htmx-2.0.4.min.js webui/static/htmx.min.js npm:htmx.org@2.0.4
htmx-ext-json-enc 2.0.0 vendor/json-enc-2.0.0.js webui/static/json-enc.js npm:htmx-ext-json-enc@2.0.0

Deployment

install.sh installs only system components and configures them; the project serves from the repo root by default. All options can be set via env vars or CLI flags (CLI takes precedence). Set INSTALL_DIR or --path to override install directory. Use --dev to auto-detect repo owner as service user (non-dev mode requires --user).

All Python modules use Path(__file__).resolve().parent.parent for PROJECT_DIR — no hardcoded paths. ACME certs live at PROJECT_DIR/data/acme/.

Local Dev

.venv/bin/python webui/server.py          # binds 127.0.0.1:9090

In production the systemd unit runs as the configured service user (NoNewPrivileges, ProtectSystem=strict, loopback-only networking).

When install.sh --dev is used, the repo owner gets NOPASSWD sudo for system service commands (nginx -t, nginx -s reload, firewall-cmd, wg, systemctl reload dnsmasq, etc.). This allows invoking those commands directly in bash to inspect or test live system state during debugging, without relying on the mocked test suite.

Blueprint ↔ lib Mapping (Naming Is Not 1:1)

Blueprint URL prefix Backend module
webui/api/firewall /api/firewall/ lib.firewall
webui/api/dhcp /api/dhcp/ lib.dnsmasq
webui/api/proxy /api/proxy/ lib.nginx
webui/api/certs /api/certs/ lib.acme
webui/api/wireguard /api/wireguard/ lib.wireguard
webui/api/network /api/network/ lib.network

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>} — helper _ok(data) from webui.api.common
  • Error: {"ok": false, "error": "msg"} — helper _error(msg, code=400) from webui.api.common
  • acme.issue() / acme.renew() raise RuntimeError on failure — API layer wraps in try/except
  • HTTP codes: 400 bad request, 404 not found, 500 internal failure

Page Routes vs API

server.py serves HTML pages with Jinja templates. All data is wrapped in _safely(fn, default) so page routes never 500 — they render with fallback values instead.

Deploy

install.sh is the single deploy script. Run as root, requires MGMT_DOMAIN, MGMT_PASS, ACME_EMAIL env vars.

Lint and Tests

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

Tests: pytest in tests/. Run with python -m pytest. Tests mock out subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — 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 (212 tests)

Install dev tooling with pip install -e ".[dev]".

Docs

docs/ contains the authoritative reference for each 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 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/overview.md Subsystem summaries, tech stack, complete project directory tree

Important Rules

  1. Ask, don't assume. If something is unclear, ask before writing a single line. Never make silent assumptions about intent, architecture, or requirements.
  2. Simplest solution first. Always implement the simplest thing that could work. Do not add abstractions or flexibility that weren't explicitly requested.
  3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it, even if you think it could be improved.
  4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so before proceeding. Confidence without certainty causes more damage than admitting a gap.