183904faad
Auth seeding (last-resort guard) - `_seed_builtin_admin()` in get_db() now skips when VACUUM_WALL_SEED_BUILTIN_ADMIN=0 or when the users table already contains any user — previously a fresh service start after a non-default bootstrap (e.g. --mgmt-user alice) seeded a hard-coded `admin` with an unrecoverable random password, shadowing the operator's account - bootstrap_auth.py sets VACUUM_WALL_SEED_BUILTIN_ADMIN=0: bootstrap creates the operator user itself on a fresh install, so exactly one account exists and no seeded admin can appear Frontend (session recovery) - on page load/restore the in-memory TTL timer is gone, so a valid 7-day refresh token could sit in sessionStorage while the access token is already expired server-side: the session `check` now attempts exactly one refresh (POST /api/auth/refresh with the stored refresh token) on 401 before treating the session as dead - extract shared `_doRefresh()` used by both the `check` 401 fallback and the `refresh` action (removes the duplicated rotation logic) Tests - update seeding tests to the new any-user-present check; add test_seed_skipped_when_users_exist, test_seed_skipped_via_env, test_bootstrap_flow_creates_exactly_one_user, and the auth-model JS test suite (tests/test-auth-model.js) Docs - AGENTS.md: document VACUUM_WALL_SEED_BUILTIN_ADMIN - architecture.md / hoover.md / security.md: describe the bootstrap check 401 → one-refresh fallback path
376 lines
27 KiB
Markdown
376 lines
27 KiB
Markdown
# 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 proxies the request to `127.0.0.1:9090` where the Flask WebUI is listening. No nginx-level authentication is applied.
|
|
3. Flask validates the JWT from the `Authorization: Bearer <token>` header, checks the token against the SQLite blacklist (`data/auth.db`), and verifies per-subsystem permissions before processing the request. Public endpoints (login, WebAuthn authenticate) are exempt from validation.
|
|
4. The Flask application 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, NO auth) ──→ Flask WebUI (127.0.0.1:9090, JWT + permission check)
|
|
Flask WebUI ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server)
|
|
Flask WebUI ──→ lib/db.py (abstract DB interface) ──→ SQLite (data/auth.db)
|
|
vacuum-walld ──→ daemon/handlers/auth.py ──→ lib/auth.py ──→ JWT operations
|
|
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 cp /tmp/... /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl restart dnsmasq
|
|
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ACME provider
|
|
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 `scripts/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 `scripts/install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
|
|
|
|
## JWT Token Model
|
|
|
|
Vacuum Wall uses JWT-based authentication with access/refresh token rotation. Tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API never reads cookies — authentication is header-only.
|
|
|
|
| Token | Lifetime | Storage | Purpose |
|
|
|---|---|---|---|
|
|
| Access | 15 min | sessionStorage / memory | API auth, permission checks |
|
|
| Refresh | 7 days | sessionStorage | Token rotation, new access tokens |
|
|
|
|
JWT payload contains `sub` (username), `exp` (expiry), `iat` (issued at), `jti` (unique identifier), `type` (`"access"` or `"refresh"`), `permissions` (per-subsystem permissions), and `session_id` (session binding). Access tokens additionally contain `permissions` and `session_id`.
|
|
|
|
Each user has a unique signing secret stored in the `users.jwt_secret` database column (generated as a 32-byte base64url token via `secrets.token_urlsafe(32)`). This per-user secret model means tokens signed for one user cannot be validated as another user's tokens. Both Flask and daemon processes validate tokens by extracting `sub` from the unverified payload, looking up the user's secret, and verifying the signature with that secret. Expired and blacklisted tokens are rejected against the SQLite `token_blacklist` table (via `data/auth.db`).
|
|
|
|
Token auto-refresh occurs before expiry. On logout or password change, tokens are blacklisted in the SQLite `token_blacklist` table to prevent reuse. The blacklist is cleaned of expired entries on every refresh operation.
|
|
|
|
## Permission Model
|
|
|
|
Each user has per-subsystem permissions with two levels:
|
|
|
|
- **`"read"`** — `GET /api/<subsystem>/*` allowed; `POST`/`PATCH`/`DELETE` rejected with 403
|
|
- **`"rw"`** — all HTTP methods allowed for the subsystem
|
|
|
|
Flask `before_request` middleware enforces permissions by extracting the subsystem name from the blueprint route prefix (e.g., `/api/firewall/` → `"firewall"`). The middleware checks `request.user.permissions[subsystem]`. If the permission level doesn't match the required level, a 403 response is returned.
|
|
|
|
The `auth` subsystem controls user management. User CRUD endpoints (`/api/auth/users/*`) require `auth: "rw"` ("admin required").
|
|
|
|
Login-related endpoints are public (no JWT required): `POST /api/auth/login`, `POST /api/auth/webauthn/authenticate-begin`, `POST /api/auth/webauthn/authenticate-finish`.
|
|
|
|
## Database Layer
|
|
|
|
Vacuum Wall uses SQLite for authentication and user management data. Subsystem configuration remains as JSON in `config/*/`.
|
|
|
|
**Architecture:**
|
|
|
|
- `lib/db.py` — Query ID constants + abstract `Database` baseclass (no SQL strings)
|
|
- `lib/db_sqlite.py` — `QUERY_MAP` (query_id → SQLite SQL) + concrete implementation
|
|
- Subsystems call by **query ID only** — never write SQL
|
|
|
|
The abstract `Database` baseclass provides:
|
|
- Connection caching via `self.conn` property (lazy initialization)
|
|
- Prepared statement auto-cache (cached on first use, reused subsequently)
|
|
- `query(query_id, params)` — returns row dicts
|
|
- `run(query_id, params)` — returns rowcount
|
|
- `run_one(query_id, params)` — returns last_insert_id
|
|
- `in_transaction()` context manager — provides `BEGIN`/`COMMIT`/`ROLLBACK` with auto-commit suppressed inside
|
|
|
|
Environment variables (not config files) control database access:
|
|
|
|
| Env Var | Default | Description |
|
|
|---|---|---|
|
|
| `VACUUM_WALL_DB_BACKEND` | `sqlite` | Database backend selection |
|
|
| `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path |
|
|
|
|
Both Flask (`webui/server.py`) and daemon (`daemon/server.py`) call `get_db()` at startup. Each process opens its own connection to the same DB file. SQLite WAL mode enables concurrent reads; writes are serialized by SQLite.
|
|
|
|
## Install-Time Templating
|
|
|
|
System configuration files in `system/` are Jinja2 templates rendered by `scripts/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 | `config/acme/config.json` | `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. Account registration (email, CA provider) is stored in the declarative config. |
|
|
|
|
#### 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, acme, and auth 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`.
|
|
|
|
## System Config Import
|
|
|
|
On daemon startup, `lib/system_import.py` reconciles live system configurations
|
|
with the declarative JSON configs. This ensures that configurations created
|
|
by `scripts/install.sh` or edited manually in system files are imported into
|
|
the JSON source of truth, preventing drift.
|
|
|
|
When `vacuum-walld` starts, it calls `import_all()` which runs each subsystem
|
|
import function:
|
|
|
|
- **`import_dnsmasq`**: Parses `/etc/dnsmasq.d/vacuum-wall.conf` (managed
|
|
block between comment markers) → `config/dnsmasq/config.json`. Only writes
|
|
if config doesn't exist or differs.
|
|
- **`import_wireguard`**: Parses `/etc/wireguard/wg0.conf` →
|
|
`config/wireguard/config.json`. Skips if configs match.
|
|
- **`import_networkd`**: Parses `/etc/systemd/network/99-*.network` files
|
|
(install-time files) → `config/network/config.json`. Only adds/updates
|
|
interfaces; doesn't remove interfaces without a file (they may be pending apply).
|
|
- **`import_nginx`**: Parses `data/nginx/sites-enabled/*.conf` →
|
|
`config/nginx/config.json`. Only touches vacuum-wall-managed files
|
|
(identified by `# Auto-generated by Vacuum Wall` header). Skips `_acme-challenge.conf`.
|
|
- **`import_firewall`**: Runs `sudo firewall-cmd --list-all-zones` →
|
|
`config/firewall/config.json`. Only writes if no config file exists
|
|
(firewalld state always takes precedence).
|
|
|
|
Import failures are silently logged as warnings — they never abort daemon startup.
|
|
The returned list of updated subsystems is logged for debugging.
|
|
|
|
## Cross-Subsystem Sync Event Bus
|
|
|
|
When a subsystem's configuration changes, related subsystems are automatically
|
|
updated to stay consistent. An in-process event bus (`lib/sync.py`) decouples
|
|
subsystems — no handler calls into another handler's logic directly.
|
|
|
|
### How It Works
|
|
|
|
1. A mutation handler saves its config (e.g., adding a DHCP range).
|
|
2. The handler emits a `SyncEvent` on the event bus.
|
|
3. Subscribers react by updating related subsystem configs:
|
|
- **DnsToFirewallSync**: Adds `dhcp`, `dns` services to the firewall zone
|
|
for each interface serving a DHCP range. Back-propagates gateway (interface
|
|
IP) into DHCP ranges so clients receive their default route.
|
|
- **WgToFirewallSync**: Creates or updates a `vpn` firewall zone with
|
|
WireGuard interface, masquerade, UDP 51820 rich rule, and inter-zone
|
|
accept rules for each peer's allowed_ips subnets. Cleans up WireGuard-created
|
|
entries when no active peers exist.
|
|
- **FirewallToDhcpSync**: Removes stale DHCP ranges for interfaces no longer
|
|
in any zone. Ensures DHCP ranges on masquerade-enabled zones carry the
|
|
gateway (interface IP). Logs warnings for zones with dhcp service but no range.
|
|
- **NetworkToAllSync**: Suggests DHCP ranges for static-IP interfaces without
|
|
ranges. Syncs firewall zone interface assignments — adding new interfaces
|
|
and removing stale ones no longer in network config.
|
|
4. The handler refreshes state for the originating subsystem plus all
|
|
transitively affected subsystems.
|
|
|
|
### Guard Rails
|
|
|
|
- **Idempotency**: Each subscriber reads current state, computes desired state,
|
|
writes the diff. Running twice is safe.
|
|
- **No loops**: The event bus tracks `(subsystem, action)` per dispatch cycle.
|
|
Re-entrant emits for the same key are silently dropped.
|
|
- **Firewall-cmd separation**: Sync subscribers only write JSON config. They
|
|
do NOT call `firewall-cmd`. The user clicks "Apply" on the firewall page to
|
|
push to firewalld.
|
|
- **Error handling**: Subscriber exceptions are caught, logged as warnings,
|
|
and do NOT abort the originating handler.
|
|
|
|
### Frontend Impact
|
|
|
|
Minimal. The sync happens transparently in the backend. The "pending changes"
|
|
indicator on the firewall page will show pending when DHCP or WireGuard saves
|
|
(since sync writes JSON but does not call firewall-cmd).
|
|
|
|
## System Config Import
|
|
|
|
On daemon startup, `vacuum-walld` runs `import_all()` from `lib/system_import.py`
|
|
to reconcile any drift between system configuration files and the declarative
|
|
JSON configs. This is invoked from `daemon/server.py` during initialization.
|
|
|
|
Each subsystem import function parses the corresponding live system config and
|
|
updates the JSON config if they differ:
|
|
|
|
| Subsystem | Source | Condition |
|
|
|---|---|---|
|
|
| dnsmasq | `/etc/dnsmasq.d/vacuum-wall.conf` | Always — parses managed block between markers |
|
|
| firewall | `firewall-cmd --list-all-zones` | Only if no JSON config exists yet |
|
|
| WireGuard | `/etc/wireguard/wg0.conf` | Always — parses INI format |
|
|
| networkd | `/etc/systemd/network/99-*.network` | Always — parses INI files |
|
|
| nginx | `data/nginx/sites-enabled/*.conf` | Always — parses generated server blocks |
|
|
|
|
All imports are **idempotent** and **non-destructive**: they only write when
|
|
configs differ, skip on failure (logged as warnings), and never abort daemon
|
|
startup. This ensures that manual edits to system files (e.g., during install
|
|
or troubleshooting) are reconciled into the declarative JSON source of truth.
|
|
|
|
## Directory Structure
|
|
|
|
### Config — Declarative Settings
|
|
|
|
Config files are persistent, user-editable JSON that defines the desired state for each subsystem:
|
|
|
|
```
|
|
config/
|
|
├── auth/
|
|
│ └── config.json # JWT settings, WebAuthn RP configuration
|
|
├── 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/
|
|
├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds
|
|
├── nginx/
|
|
│ ├── .htpasswd # HTTP Basic credentials for basic-authed proxy domains (created on demand; the management UI itself uses JWT only)
|
|
│ └── 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 processes write access to these directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
|
|
|
|
The daemon uses a **runtime directory** at `/run/vacuum-wall` (created by systemd `RuntimeDirectory=`) for secure temporary files during config apply. `tempfile.NamedTemporaryFile` writes to this directory before `sudo cp` moves files to their final destination, eliminating TOCTOU symlink races that would exist with `/tmp`. The directory is automatically removed on service stop.
|
|
|
|
## 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) |
|
|
| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) |
|
|
| `data/auth.db` | SQLite database: users, permissions, token_blacklist, webauthn_creds. Created on first access via `get_db()`. | Auth layer (lib/db.py) |
|
|
|
|
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](hoover.md) for the complete API.
|
|
|
|
### Request Flow (Frontend)
|
|
|
|
```
|
|
Client requests / ──→ nginx ──→ Flask (serves index.html)
|
|
Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login
|
|
Authenticated ──→ mounts #sidebar and #main render roots
|
|
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
|
|
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
|
|
Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091?token=<access_token>)
|
|
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
|
|
Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ──→ new tokens
|
|
WebSocket message (versions) ──→ topic match ──→ page load() re-executed ──→ state updated ──→ render engine patches DOM
|
|
```
|
|
|
|
The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/<path>` route serves vendored JS libraries.
|
|
|
|
### 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. Cache invalidation is handled via HTTP cache-control headers. 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. |