Files
vacuum-wall/docs/architecture.md
T
mteehan 5ba0f31767 Add state management, WebSocket polling, html.js templating, and refactor pages
- lib/state.py: per-subsystem collectors with versioned state store
- daemon/server.py: state refresh on request, batch routing updates
- webui/static/hoover/html.js: new html tag template helper via htm.js
- webui/static/hoover/websocket.js: real-time state change notifications
- webui/static/hoover/vdom.js: VDOM improvements for keyed diff
- All frontend pages refactored to use html templates
- Add tests for state management and polling
- Update docs and AGENTS.md
2026-06-23 21:12:56 +00:00

16 KiB

Architecture

Request Flow

The following describes the path a request takes from an external client to a backend service and back:

Proxied Service (e.g., app.example.com)

  1. An external client sends an HTTP request to app.example.com.
  2. The request arrives at the Vacuum Wall host's WAN interface, assigned to the external firewalld zone. A firewall rule allows inbound traffic on port 443 (HTTPS).
  3. nginx, listening on port 443, terminates the TLS connection using the domain's certificate.
  4. nginx evaluates the server_name against the configured server blocks. The matching block is generated from the domain entry in config/nginx/config.json.
  5. The request is forwarded to the backend service (e.g., 192.168.2.50:8080) via an proxy_pass directive.
  6. The backend service processes the request and returns an HTTP response.
  7. nginx adds security headers (X-Content-Type-Options, X-Frame-Options, HSTS, etc.) to the response.
  8. nginx encrypts the response with TLS and sends it back to the client through the WAN interface.

For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalent before any proxying occurs.

Management WebUI Access (e.g., <hostname>.local)

  1. A client sends an HTTPS request to the management domain.
  2. nginx terminates TLS and checks for HTTP Basic Authentication credentials against the .htpasswd file.
  3. If authentication succeeds, the request is proxied to 127.0.0.1:9090 where the Flask WebUI is listening.
  4. The Flask application processes the request and communicates with the vacuum-walld daemon via a Unix socket (data/daemon.sock) for any privileged operations.
  5. The daemon executes the privileged commands via the sudo whitelist and returns structured results.
  6. Flask renders an HTML or JSON response, which nginx returns to the client over the encrypted connection.

Because Flask binds only to 127.0.0.1, it is unreachable directly from any external interface. The nginx reverse proxy is the sole entry point.

Subsystem Communication

The following diagram summarizes how the Flask WebUI communicates with each managed subsystem:

External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0.1:9090)
Flask WebUI ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server)
vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ZeroSSL ACME
vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-<name>.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload
vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal

Two-User Model with Shared Group

Vacuum Wall uses two distinct system users bridged by a shared group:

  • vacuum-walld (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. Runs with NoNewPrivileges=yes (satisfiable since sudo is called directly by the daemon process).
  • WebUI user (default: repo owner in --dev mode): Runs the Flask web serving process. Has zero sudo access. Communicates with the daemon via a Unix socket at data/daemon.sock. Runs with NoNewPrivileges=yes.
  • Shared group: Both users share the WebUI user's primary group. The daemon socket is owned by vacuum-walld:<group> with mode 0660, allowing the web UI user to connect via group permission. The project directory is owned by the WebUI user with group-read+execute, giving the daemon read access to configs and shared files.

This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. The lib/ modules no longer contain sudo calls; all privileged command execution lives in daemon/handlers/*.py.

Dev mode variant: When install.sh --dev is used, the repo owner (e.g., wall) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (vacuum-walld) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (g+s) so new files inherit the group regardless of the creator's primary group.

The lib/ modules auto-discover the project root at runtime via Path(__file__).resolve().parent.parent. This works because install.sh performs an editable pip install (pip install -e .), keeping module files in the project directory rather than copying them to site-packages/.

Install-Time Templating

System configuration files in system/ are Jinja2 templates rendered by install.sh at install time:

  • systemd/vacuum-wall.service, systemd/vacuum-walld.service, systemd/vacuum-wall-acme.service{{ USER_NAME }}, {{ USER_DAEMON_NAME }}, {{ USER_GROUP }}, {{ PROJECT_DIR }}, {{ ACME_HOME }} are substituted to produce the final systemd unit files installed to /etc/systemd/system/. The PROJECT_DIR template variable is set from the INSTALL_DIR environment variable (defaults to the repo root).
  • sudoers.d/vacuum-walld{{ USER_DAEMON_NAME }} is substituted to produce the sudoers whitelist for the daemon user.
  • The timer file (vacuum-wall-acme.timer) contains no variable paths and is installed as-is.

Runtime templates (system/nginx/*.conf, system/dnsmasq.conf, system/wireguard*.conf) are rendered at runtime by lib/ modules via Jinja2 with Python data.

State Management

Vacuum Wall uses a declarative configuration model. Persistent user-facing configuration lives in config/<subsystem>/config.json. Runtime artifacts and generated files live in data/<subsystem>/. The application renders these declarations into the format expected by the underlying system service.

Subsystem Declarative Config Runtime Data Rendered Target State Persistence
firewalld config/firewall/config.json data/firewall/rules.json N/A (commands issued directly to firewalld via D-Bus) firewalld manages its own persistent state in /etc/firewalld/. config.json is the declarative source of truth. rules.json serves as an automated backup snapshot.
dnsmasq config/dnsmasq/config.json data/dnsmasq/fragments/ /etc/dnsmasq.d/vacuum-wall.conf The JSON file is the source of truth. The rendered .conf file is overwritten on each apply.
nginx config/nginx/config.json data/nginx/.htpasswd, data/nginx/sites-enabled/ data/nginx/sites-enabled/<domain>.conf + /etc/nginx/conf.d/vacuum-wall.conf All proxy and management domain definitions are derived from the JSON config. Generated .conf files are overwritten on each apply.
WireGuard config/wireguard/config.json data/wireguard/ /etc/wireguard/wg0.conf The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply.
networkd config/network/config.json data/networkd/ /etc/systemd/network/50-<name>.network The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a 50-<name>.network INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams.
ACME N/A (~/.acme.sh/ managed by acme.sh) data/acme/ Certificate and key files acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state.

Background Polling

The daemon runs background polling tasks for subsystems with external runtime state. Each subsystem has a configurable interval and a two-layer diff (structural vs volatile) to minimize unnecessary broadcasts.

Subsystem Interval Rationale
firewall 30s Most expensive collector (6+ subprocess calls)
wireguard 10s Peer connections/handshakes change frequently
dnsmasq 10s Lease file + service status
networkd 10s Interface up/down, DHCP address changes

nginx and acme are not polled — they have no external runtime state.

Two-layer diff: Each poll cycle classifies changes as:

  • Structural change (zones added, peers removed, config changed): triggers bump() + broadcast {"type": "versions", ...} → full UI re-load
  • Volatile change only (transfer counters, DHCP-assigned IPs): sends {"type": "tick", "subsystems": [...]} → lightweight per-subsystem re-fetch
  • No change: silence

Volatile fields per subsystem: wireguard (peer transfer/handshake stats), firewall (DHCP-assigned IPs), networkd (DHCP addresses, link metrics). Defined per collector via register_volatile().

Poll intervals are configurable via VACUUM_WALL_POLL_INTERVALS env var (firewall:30,wireguard:10,...).

On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to None.

Directory Structure

Config — Declarative Settings

Config files are persistent, user-editable JSON that defines the desired state for each subsystem:

config/
├── dnsmasq/
│   └── config.json                   # DHCP ranges, static leases, DNS forwarding, custom records
├── firewall/
│   └── config.json                   # Firewall zones, rich rules, forward ports
├── nginx/
│   └── config.json                   # Proxy domain definitions, management domain, SSL settings
└── wireguard/
    └── config.json                   # WireGuard interface and peer configuration
├── network/
│   └── config.json                   # Per-interface static IP, routes, DNS, DHCP settings

Data — Runtime Artifacts

The data/ directory holds generated files, credentials, and subsystem artifacts:

data/
├── nginx/
│   ├── .htpasswd                     # HTTP Basic Authentication credentials for management UI
│   └── sites-enabled/                # Generated nginx server block .conf files (one per domain)
├── dnsmasq/
│   └── fragments/                    # User-defined dnsmasq config fragments (appended verbatim)
├── firewall/
│   └── rules.json                    # Auto-generated firewall rule state backup
├── acme/                             # ACME certificate files (acme.sh home)
├── logs/
│   └── vacuum-wall.log              # Application log file
└── wireguard/                        # WireGuard runtime artifacts
├── networkd/                         # Generated 50-<name>.network files

Both config/ and data/ reside within the project directory. The systemd service unit's ReadWritePaths directive grants the Flask process write access to both directories, while keeping the rest of the filesystem read-only. The INSTALL_DIR value is templated into the service unit at install time.

File System Layout

The following file system locations are used for integration with system services:

Path Purpose Managed By
/etc/nginx/conf.d/vacuum-wall.conf Include directive that pulls in data/nginx/sites-enabled/*.conf. Vacuum Wall (lib/nginx.py)
/etc/nginx/snippets/vacuum-wall-ssl.conf Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. Vacuum Wall (lib/nginx.py)
/etc/dnsmasq.d/vacuum-wall.conf Generated dnsmasq configuration file. Written from config/dnsmasq/config.json. Vacuum Wall (lib/dnsmasq.py)
/etc/wireguard/wg0.conf Generated WireGuard interface configuration. Written from config/wireguard/config.json. Vacuum Wall (lib/wireguard.py)
/etc/systemd/network/50-<name>.network Generated systemd-networkd drop-in files. Written from config/network/config.json, one per interface. Vacuum Wall (lib/network.py)
/etc/sudoers.d/vacuum-walld Sudo whitelist for the daemon user. Defines all permitted privilege escalations. Install script (rendered from Jinja2 template)

The /etc/nginx/conf.d/vacuum-wall.conf include file ensures that all domain-specific configurations in sites-enabled/ are loaded by nginx without modifying the main nginx.conf. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.

Frontend Architecture

The web UI is a single-page application built on Hoover, a custom lightweight VDOM framework. See Hoover Framework Reference for the complete API.

Request Flow (Frontend)

Client requests index.html ──→ nginx ──→ Flask (server-side __WS_URL_PLACEHOLDER__ substitution)
Client loads app.js ──→ Hoover initializes, mounts #sidebar and #main render roots
Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091)
Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM
User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld
WebSocket message (versions) ──→ topic match ──→ page load() re-executed ──→ state updated ──→ render engine patches DOM

Component Model

Each route is a definePage() component with reactive state, async data loading, and WebSocket auto-refresh. Pages are mounted using hComp(page, key) in the router, where the key determines lifecycle boundaries. The same key reuses the component instance (preserving state); a different key unmounts the old page and mounts the new one.

No Build Step

All JavaScript is served as ES modules. The ?v=N query string param version-pins asset imports for cache invalidation. Dev mode (VACUUM_WALL_DEV) disables aggressive static asset caching.

WebSocket Broadcast

The daemon broadcasts state-change notifications via WebSocket. Hoover's subscribe mechanism maps page-level topic subscriptions to automatic load() re-executions. Messages are debounced (300ms) and in-flight loads are aborted before re-loading, ensuring the UI always displays the latest available data.

Zone Model

The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level:

Zone Interfaces Trust Level Description
public / external WAN (e.g., eth0) Untrusted Internet-facing. Only explicitly allowed inbound services (HTTPS/443, WireGuard/51820, ICMP echo rate-limited) are accessible. All other inbound traffic is dropped.
internal LAN (e.g., eth1) Trusted Local area network. DHCP (UDP 67/68) and DNS (UDP/TCP 53) are served. Masquerade (NAT) is enabled for outbound Internet access from LAN clients. Inbound from WAN to this zone is not directly accessible.
vpn WireGuard (wg0) Semi-trusted WireGuard tunnel interface. Firewall rules determine which internal services and subnets VPN peers can reach. By default, VPN peers can access the Internet but may be restricted from accessing management interfaces or sensitive LAN services.
trusted Management interface Administrative Used for management traffic. The loopback zone covers localhost communication, enabling the Flask WebUI to receive proxied requests from nginx on 127.0.0.1:9090.

Custom Zones

Additional zones can be created for specialized network segments:

  • DMZ zone: For hosting public-facing services that need to be isolated from the internal LAN. Traffic from the DMZ to the internal zone is denied by default.
  • Guest zone: For visitor Wi-Fi or untrusted devices. Access is limited to outbound Internet traffic only, with no access to internal or vpn zones.
  • IoT zone: For devices requiring restricted outbound access (e.g., blocking telemetry domains).

Each custom zone can define its own source rules, port forwardings, and inter-zone traffic policies. The Flask WebUI provides interfaces to create, modify, and assign interfaces to zones at runtime.