- dashboard.html: Fix zones, leases, wg_status, cert key names, add services var
- server.py: Pass services to dashboard template via _get_service_status()
- lib/acme.py: Fix dead third date format (%Y%m%d%H%M%z) using astimezone(UTC)
- lib/wireguard.py: Add -- separator to cp command to match sudoers rule
- lib/nginx.py: Replace shallow dict.copy() with {**...} for DEFAULT_SSL
- AGENTS.md: Update test count 149 -> 154
- docs/api.md: Rename cert field expiry -> expires_at
8.8 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)
- An external client sends an HTTP request to
app.example.com. - The request arrives at the Vacuum Wall host's WAN interface, assigned to the
externalfirewalld zone. A firewall rule allows inbound traffic on port 443 (HTTPS). - nginx, listening on port 443, terminates the TLS connection using the domain's certificate.
- nginx evaluates the
server_nameagainst the configured server blocks. The matching block is generated from the domain entry indata/nginx/config.json. - The request is forwarded to the backend service (e.g.,
192.168.2.50:8080) via anproxy_passdirective. - The backend service processes the request and returns an HTTP response.
- nginx adds security headers (
X-Content-Type-Options,X-Frame-Options, HSTS, etc.) to the response. - 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., wall.lan)
- A client sends an HTTPS request to the management domain.
- nginx terminates TLS and checks for HTTP Basic Authentication credentials against the
.htpasswdfile. - If authentication succeeds, the request is proxied to
127.0.0.1:9090where the Flask WebUI is listening. - The Flask application processes the request, performs any necessary privileged operations through the sudo whitelist, and returns an HTML or JSON response.
- nginx returns the response 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 ──→ lib/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
Flask WebUI ──→ lib/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
Flask WebUI ──→ lib/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
Flask WebUI ──→ lib/acme.py ──→ acme.sh (no sudo, runs as vacuum-wall user) ──→ Let's Encrypt ACME
Flask WebUI ──→ lib/wireguard.py ──→ render /home/wall/vacuum-wall/data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
Each lib/ module encapsulates command construction, privilege escalation (via sudo where needed), and error handling for its subsystem. The modules read declarative configuration from data/, render the appropriate system configuration files, and invoke the corresponding privileged operation. Note that lib/acme.py runs acme.sh without sudo — it executes as the unprivileged vacuum-wall user using webroot validation rather than standalone/TLS-ALPN modes that would require elevated privileges.
State Management
Vacuum Wall uses a declarative configuration model. The source of truth for each subsystem is a JSON file in the data/ directory. The application renders these declarations into the format expected by the underlying system service.
| Subsystem | Declarative Config | Rendered Target | State Persistence |
|---|---|---|---|
| firewalld | data/firewall/rules.json |
N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in /etc/firewalld/. rules.json serves as a declarative backup and can be used to restore firewall rules. |
| dnsmasq | data/dnsmasq/config.json |
/etc/dnsmasq.d/vacuum-wall.conf |
The JSON file is the source of truth. The rendered .conf file is overwritten on each apply. |
| nginx | data/nginx/config.json |
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 | data/wireguard/config.json |
/etc/wireguard/wg0.conf |
The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
| ACME | ~/.acme.sh/ (managed by acme.sh) |
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. |
Data Directory Structure
data/
├── nginx/
│ ├── config.json # Proxy domain definitions, management domain, SSL settings
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
├── dnsmasq/
│ ├── config.json # DHCP ranges, static leases, DNS forwarding, custom records
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
├── firewall/
│ └── rules.json # Declarative firewall rule state backup
└── wireguard/
└── config.json # WireGuard interface and peer configuration
The data/ directory resides within the vacuum-wall user's project directory (/home/wall/vacuum-wall/data/). The systemd service unit's ReadWritePaths directive grants the Flask process write access to this directory, while keeping the rest of the filesystem read-only.
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 data/dnsmasq/config.json. |
Vacuum Wall (lib/dnsmasq.py) |
/etc/wireguard/wg0.conf |
Generated WireGuard interface configuration. Written from data/wireguard/config.json. |
Vacuum Wall (lib/wireguard.py) |
/etc/sudoers.d/vacuum-wall |
Sudo whitelist for the vacuum-wall user. Defines all permitted privilege escalations. |
Install script (manual edits not required) |
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.
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
internalzone 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
internalorvpnzones. - 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.