style: format docs, fix user_permissions variable scoping in auth middleware
Apply ruff line-wrapping formatting to docs and test files. Clarify auth middleware: extract user_permissions once before subsystem check, removing conditional variable scoping.
This commit is contained in:
+72
-7
@@ -20,9 +20,9 @@ For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalen
|
||||
### 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.
|
||||
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.
|
||||
|
||||
@@ -33,8 +33,10 @@ Because Flask binds only to `127.0.0.1`, it is unreachable directly from any ext
|
||||
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)
|
||||
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
|
||||
@@ -58,6 +60,61 @@ This design isolates privilege escalation entirely within the daemon, so a compr
|
||||
|
||||
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 `localStorage` and injected as `Authorization: Bearer <token>` headers. The API never reads cookies — authentication is header-only.
|
||||
|
||||
| Token | Lifetime | Storage | Purpose |
|
||||
|---|---|---|---|
|
||||
| Access | 15 min | localStorage | API auth, permission checks |
|
||||
| Refresh | 7 days | localStorage | 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:
|
||||
@@ -92,7 +149,7 @@ The daemon runs background polling tasks for subsystems with external runtime st
|
||||
| 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.
|
||||
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
|
||||
@@ -208,6 +265,8 @@ Config files are persistent, user-editable JSON that defines the desired state f
|
||||
|
||||
```
|
||||
config/
|
||||
├── auth/
|
||||
│ └── config.json # JWT settings, WebAuthn RP configuration
|
||||
├── dnsmasq/
|
||||
│ └── config.json # DHCP ranges, static leases, DNS forwarding, custom records
|
||||
├── firewall/
|
||||
@@ -226,6 +285,7 @@ The `data/` directory holds generated files, credentials, and subsystem artifact
|
||||
|
||||
```
|
||||
data/
|
||||
├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds
|
||||
├── nginx/
|
||||
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
|
||||
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
|
||||
@@ -257,6 +317,7 @@ The following file system locations are used for integration with system service
|
||||
| `/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.
|
||||
|
||||
@@ -268,10 +329,14 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh
|
||||
|
||||
```
|
||||
Client requests / ──→ nginx ──→ Flask (server-side __WS_URL_PLACEHOLDER__ substitution)
|
||||
Client loads /static/app.js ──→ Hoover initializes, mounts #sidebar and #main render roots
|
||||
Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091)
|
||||
Client loads /static/app.js ──→ Hoover initializes, checkSession() → 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
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user