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:
2026-07-27 18:37:11 +00:00
parent d4213fb93b
commit ca110c321d
9 changed files with 640 additions and 26 deletions
+355 -1
View File
@@ -1,9 +1,24 @@
# REST API Reference
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination and HTTP basic authentication. Requests target the management domain (e.g., `https://<hostname>.local/api/...`).
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination. Authentication is handled at the Flask layer via JWT — the `Authorization: Bearer <token>` header. Public endpoints (login, WebAuthn authenticate) do not require a token.
Every request and response uses `Content-Type: application/json`.
## Authentication
Most endpoints require a valid JWT access token. The token is obtained by logging in via `POST /api/auth/login` or completing a WebAuthn authentication ceremony.
### Obtaining a Token
1. Call `POST /api/auth/login` with credentials
2. Store the returned `access_token`
3. Include `Authorization: Bearer <access_token>` on all subsequent requests
4. Refresh before expiry via `POST /api/auth/refresh`
### Permission Checks
Each request is checked against per-subsystem permissions. `GET` requires `"read"` or `"rw"` on the subsystem. `POST`/`PATCH`/`DELETE` requires `"rw"`. User management endpoints (`/api/auth/users/*`) require `auth: "rw"`.
## Conventions
### Success Responses
@@ -41,6 +56,345 @@ Resource identification uses **path parameters** whenever possible. Exceptions o
---
## Auth API
Endpoints prefixed with `/api/auth/...`. Manage authentication, session, tokens, and user accounts.
### Login
#### Password Login
```
POST /api/auth/login
```
Authenticate with username and password. Returns access and refresh tokens.
**Auth:** Public — no JWT required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `password` | `string` | Yes | Plain-text password (hashed for verification) |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` |
| `access_ttl` | `integer` | Access token lifetime in seconds (default: `900`) |
| `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `401` if credentials are invalid.
---
### Session
#### Get Current Session
```
GET /api/auth/session
```
Return the current authenticated user and permissions.
**Auth:** Access token required.
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `401` if token is invalid, expired, or blacklisted.
#### Logout
```
POST /api/auth/logout
```
Invalidate the current session by blacklisting the access token.
**Auth:** Access token required.
**Response:** `data` is `null` on success.
#### Refresh Tokens
```
POST /api/auth/refresh
```
Rotate token pair. Validates the refresh token, blacklists the old pair, and issues new access and refresh tokens.
**Auth:** Refresh token required.
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` |
| `access_ttl` | `integer` | Access token lifetime in seconds |
| `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `401` if refresh token is invalid, expired, or blacklisted.
#### Change Password
```
POST /api/auth/password
```
Change the current user's password.
**Auth:** Access token required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | No | Auto-injected from JWT context |
| `oldPassword` | `string` | Yes | Current password |
| `newPassword` | `string` | Yes | New password |
**Response:** `data` is `null` on success.
Returns HTTP `400` if old password is incorrect.
---
### User Management
#### List Users
```
GET /api/auth/users
```
List all users. Requires admin permission (`auth: "rw"`).
**Auth:** `auth: "rw"` required.
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `users` | `[object, ...]` | Array of user summaries (`id`, `username`, `permissions`) |
#### Create User
```
POST /api/auth/users
```
Create a new user with password and per-subsystem permissions.
**Auth:** `auth: "rw"` required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `password` | `string` | Yes | Plain-text password |
| `permissions` | `object` | Yes | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
**Response:** `data` is `null` on success.
Returns HTTP `409` if username already exists.
#### Update User
```
POST /api/auth/users/<username>
```
Update user's permissions. (To change a password, use `POST /api/auth/password`.)
**Auth:** `auth: "rw"` required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `permissions` | `object` | No | New per-subsystem permissions |
**Response:** `data` is `null` on success.
Returns HTTP `404` if user not found.
#### Delete User
```
DELETE /api/auth/users/<username>
```
Delete a user and all associated permissions and WebAuthn credentials (CASCADE).
**Auth:** `auth: "rw"` required. Cannot delete self.
**Response:** `data` is `null` on success.
Returns HTTP `404` if user not found.
---
### WebAuthn
#### Begin Registration
```
POST /api/auth/webauthn/register-begin
```
Start WebAuthn credential registration. Returns options for `navigator.credentials.create()`.
**Auth:** Access token required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username to register for |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `challenge` | `string` | Base64url challenge |
| `rp` | `object` | Relying party config (`id`, `name`) |
| `user` | `object` | User info for registration |
| `excludeCredentials` | `[object, ...]` | Credentials to exclude |
#### Finish Registration
```
POST /api/auth/webauthn/register-finish
```
Complete WebAuthn credential registration. Verifies the attestation response and stores the credential in the database.
**Auth:** Access token required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `response` | `object` | Yes | WebAuthn authenticator attestation response |
| `name` | `string` | No | Display name for this credential |
**Response:** `data` is `null` on success.
Returns HTTP `400` if verification fails.
#### Begin Authentication
```
POST /api/auth/webauthn/authenticate-begin
```
Start WebAuthn authentication. Returns options for `navigator.credentials.get()`.
**Auth:** Public — no JWT required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username to authenticate |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `challenge` | `string` | Base64url challenge |
| `allowCredentials` | `[object, ...]` | Registered credentials for this user |
Returns `{"ok": true, "data": {"no_webauthn": true}}` if user has no WebAuthn credentials (use password instead).
#### Finish Authentication
```
POST /api/auth/webauthn/authenticate-finish
```
Complete WebAuthn authentication. Verifies the assertion and issues tokens on success.
**Auth:** Public — no JWT required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `assertion_response` | `object` | Yes | WebAuthn authenticator assertion response |
| `auth_options` | `object` | Yes | Original authentication options from `authenticate-begin` |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` |
| `access_ttl` | `integer` | Access token lifetime in seconds |
| `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `400` if verification fails.
#### List Credentials
```
GET /api/auth/webauthn/credentials
```
List WebAuthn credentials for the current user.
**Auth:** Access token required.
**Response (`data`):**
Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCount`, `createdAt`).
#### Credential Counts
```
GET /api/auth/webauthn/credential-counts
```
Return credential counts for all users. Admin endpoint.
**Auth:** `auth: "rw"` required.
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `counts` | `object` | Dict mapping usernames to credential counts (`{"alice": 2, "bob": 1}`) |
#### Remove Credential
```
DELETE /api/auth/webauthn/creds/<credential_id>
```
Remove a WebAuthn credential.
**Auth:** Access token required.
**Response:** `data` is `null` on success.
Returns HTTP `404` if credential not found.
---
## Firewall API
Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade.
+72 -7
View File
@@ -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
```
+88
View File
@@ -264,6 +264,94 @@ acme.sh stores its state under `data/acme/` (the ACME home directory). Key files
The application reads `.account.conf` to determine registration status. If the file is missing or lacks required keys, the account is considered unregistered.
## Auth Configuration
**File**: `config/auth/config.json`
This file defines JWT settings and WebAuthn Relying Party configuration for the authentication system.
```json
{
"jwt": {
"access_token_ttl": 900,
"refresh_token_ttl": 604800,
"algorithm": "HS256"
},
"webauthn": {
"rp_name": "Vacuum Wall",
"rp_id": "<management-domain>",
"origin": "https://<management-domain>"
}
}
```
### JWT Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `access_token_ttl` | integer | No | Access token lifetime in seconds. Default: `900` (15 minutes). |
| `refresh_token_ttl` | integer | No | Refresh token lifetime in seconds. Default: `604800` (7 days). |
| `algorithm` | string | No | JWT signing algorithm. Default: `"HS256"`. |
**Note:** JWT signing secrets are per-user, not shared. Each user's secret is auto-generated as a 32-byte base64url token (`secrets.token_urlsafe(32)`) and stored in the `users.jwt_secret` database column. Secrets are rotated on password change to invalidate all prior sessions.
### WebAuthn Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `rp_name` | string | Yes | Display name for the WebAuthn Relying Party. Shown during credential registration. |
| `rp_id` | string | Yes | Domain for WebAuthn credential binding. Must match the management domain. |
| `origin` | string | Yes | HTTPS URL for WebAuthn origin check. Must match `https://<rp_id>`. |
## Database Schema
The SQLite database at `data/auth.db` stores authentication data across four tables. Created automatically on first access via `get_db()`.
### users
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER | Auto-increment primary key |
| `username` | TEXT | Unique username |
| `password_hash` | TEXT | Argon2id password hash |
| `jwt_secret` | TEXT | Per-user JWT signing secret (32-byte base64url) |
| `created_at` | INTEGER | Unix timestamp (auto-set) |
### permissions
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER | Auto-increment primary key |
| `username` | TEXT | Foreign key to `users.username` (CASCADE on delete) |
| `subsystem` | TEXT | Subsystem name (e.g., `"firewall"`, `"dhcp"`, `"auth"`) |
| `level` | TEXT | Permission level: `"read"` or `"rw"` |
UNIQUE constraint on `(username, subsystem)`.
### token_blacklist
| Column | Type | Description |
|---|---|---|
| `jti` | TEXT | Primary key — JWT unique identifier |
| `token_type` | TEXT | `"access"` or `"refresh"` |
| `expires` | INTEGER | Unix timestamp of token expiry |
Used to invalidate tokens on logout and password change. Expired entries are cleaned on every refresh operation.
### webauthn_creds
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER | Auto-increment primary key |
| `username` | TEXT | Foreign key to `users.username` (CASCADE on delete) |
| `credential_id` | TEXT | Base64url-encoded credential ID |
| `public_key` | TEXT | Base64url-encoded public key |
| `sign_count` | INTEGER | Signature counter (replay prevention) |
| `name` | TEXT | User-assigned display name |
| `transports` | TEXT | JSON array of transport types |
UNIQUE constraint on `(username, credential_id)`.
## WireGuard Configuration
**File**: `config/wireguard/config.json`
+53 -3
View File
@@ -42,7 +42,7 @@ All settings that can be passed as an environment variable also have a CLI flag
|---|---|---|---|
| -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Defaults to `$hostname.local` (mDNS). Auto-detected from system hostname. **Errors if hostname is undetectable and this is not set.** |
| `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) |
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. |
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for the initial admin user (default: `admin`). Creates the admin user in the SQLite database with full `rw` permissions on all subsystems. |
| `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. |
| `--user, -u` | `USER_NAME` | Yes* | WebUI service user (created if it does not exist). Required for non-dev mode. In `--dev` mode, auto-detected from repo owner. |
| `--path, -p` | `INSTALL_DIR` | No | Install directory. Defaults to repo root. Set to deploy from a custom path (e.g., `/opt/vacuum-wall`). |
@@ -115,7 +115,7 @@ The installer performs the following steps automatically:
- **mDNS broadcast**: Enables and starts avahi-daemon so the appliance advertises its hostname (`<hostname>.local`) on the local network.
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert. Skips if a certificate already exists (preserves real ACME certs).
- **Management proxy configuration**: Calls the daemon API (`POST_NGINX_DOMAINS_ADD`) to register the management domain as a regular proxy entry with paths-based config (`/` → Flask, `/ws` → WebSocket). Then applies nginx via `POST_NGINX_APPLY`.
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present.
- **Admin user**: Creates the admin user with the password provided via `--mgmt-pass` in the SQLite database (`data/auth.db`). The user gets `rw` permissions on all subsystems. On re-run, updates the admin password if already present.
- **Initial configs**: Firewall config and nginx proxy config are written via daemon API (skips if already exists).
- **System config import**: On startup, the daemon reconciles any live system configurations (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative JSON configs. This prevents drift when system files were edited manually.
- **Systemd units**: Installs four units (rendered from Jinja2 templates):
@@ -137,7 +137,7 @@ The installer performs the following steps automatically:
- Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes
- Preserves existing SSL certificates (skips self-signed generation if a cert exists)
- Preserves existing `config.json` files (skips initial write if file exists)
- Safely updates `htpasswd` (uses update mode instead of create mode)
- Updates admin user password if changed
This makes it safe for development workflows: simply run `bash scripts/install.sh` again to update an existing installation.
@@ -165,6 +165,21 @@ https://wall.example.com
Log in with the username and password you provided during installation.
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `VACUUM_WALL_DB_BACKEND` | `sqlite` | Database backend selection |
| `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path |
### Post-Deploy Verification
1. Confirm `config/auth/config.json` exists with JWT secret and WebAuthn RP configuration
2. Confirm `data/auth.db` exists with admin user present
3. Nginx config no longer has `auth_basic` for management domain
4. WebSocket location no longer has `auth_basic off`
5. Access the WebUI at `https://<management-domain>` — should show a login page
### Certificate Note
The initial certificate is **self-signed** and generated during installation. Your browser will show a security warning. This is expected. Once DNS is pointing to the appliance and port 80 is accessible from the internet, use the **Certs** tab in the WebUI to issue a real ACME certificate for the management domain. After issuance, go to the **Proxy** tab and click **Apply** to reload nginx with the new cert.
@@ -322,6 +337,41 @@ Verify that:
- A DHCP range is configured for the correct interface. Check dnsmasq config at `data/dnsmasq/`.
- The firewall allows DHCP traffic on the internal zone: `firewall-cmd --zone=internal --list-services` should include `dhcp` and `dns`.
### Locked Out of WebUI
If you lose access to the admin account, you can reset the password directly via SQLite:
```bash
# Stop the services
sudo systemctl stop vacuum-wall vacuum-walld
# Reset password (replace 'newpassword' with desired password)
sqlite3 data/auth.db "UPDATE users SET password_hash='NEW_HASH_HERE' WHERE username='admin';"
```
The password hash must be an Argon2id hash. You can generate one:
```bash
python3 -c "from lib.password import hash_password; print(hash_password('newpassword'))"
```
Alternatively, use the SQLite prompt to directly inspect and modify user data:
```bash
sqlite3 data/auth.db ".tables"
sqlite3 data/auth.db "SELECT username FROM users;"
sqlite3 data/auth.db "SELECT * FROM permissions WHERE username='admin';"
```
### Database Corruption
If the SQLite database becomes corrupted:
1. Stop the services: `sudo systemctl stop vacuum-wall vacuum-walld`
2. Inspect: `sqlite3 data/auth.db "PRAGMA integrity_check;"`
3. Restore from backup if needed: `cp data/auth.db.backup data/auth.db`
4. Start services: `sudo systemctl start vacuum-walld vacuum-wall`
### WebUI Not Accessible
1. Verify nginx is running: `systemctl status nginx`.
+43 -2
View File
@@ -11,6 +11,8 @@ ACME certificate operations via `acme.sh` run as the daemon user — not as root
This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo.
Authentication (JWT validation, token blacklist check, permission verification) is performed at the Flask layer — not the daemon. The daemon only receives requests from the Flask process via authenticated Unix socket connections. WebSocket connections to the daemon require a JWT access token as a query parameter for validation before upgrade.
## Communication Between WebUI and Daemon
The WebUI communicates with the daemon via synchronous HTTP requests over a Unix socket (`data/daemon.sock`), owned by `vacuum-walld:<group>` with mode `0660`. The shared group membership allows the WebUI user to connect to the socket. The daemon runs an `aiohttp` server that routes requests to handler modules (`daemon/handlers/*.py`), which execute the privileged commands.
@@ -68,9 +70,11 @@ The `daemon/client.py` module resolves `<param>` placeholders in URL paths befor
### Management Interface
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination and HTTP Basic Authentication. The `.htpasswd` file is stored at `data/nginx/.htpasswd`.
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain.
The management interface does not set security hardening headers (e.g., `X-Content-Type-Options`, `X-Frame-Options`, HSTS). It relies on nginx basic authentication, SSL termination, and the systemd sandbox for its security boundary.
JWT tokens are stored in browser `localStorage` and injected as `Authorization: Bearer <token>` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers.
The management interface does not set security hardening headers (e.g., `X-Content-Type-Options`, `X-Frame-Options`, HSTS) on proxied responses, as the SPA requires flexibility for its operation. It relies on JWT authentication, SSL termination, and the systemd sandbox for its security boundary.
### Proxy Domains
@@ -86,6 +90,43 @@ Every proxied domain configured in Vacuum Wall enforces:
Additional proxy headers (`headers` in the path-level config) are delivered to the upstream backend via nginx `proxy_set_header` directives — they are not sent as response headers to clients.
### JWT Authentication Lifecycle
JWT-based authentication replaces HTTP Basic Auth for the management WebUI. The token lifecycle is:
1. **Login**: User submits credentials via `POST /api/auth/login`. The daemon verifies the password hash (Argon2id) against `data/auth.db`. On success, an access token (15 min) and refresh token (7 days) are issued.
2. **Validation**: Every request to Flask includes `Authorization: Bearer <token>`. The `before_request` middleware validates the token signature, checks expiry, queries the SQLite `token_blacklist` table, and verifies per-subsystem permissions.
3. **Auto-refresh**: Before the access token expires, the frontend's `refreshScheduler()` calls `POST /api/auth/refresh` with the refresh token. The old refresh token is blacklisted and a new pair is issued.
4. **Blacklist**: On logout (`POST /api/auth/logout`) or password change, the current token's `jti` is inserted into `token_blacklist`. The expired blacklist entries are cleaned on every refresh operation via `Q_DELETE_EXPIRED`.
Token theft protection:
- Short-lived access tokens (15 min) limit the window of exploitation
- Token blacklist prevents reuse after logout or password change
- XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain
### WebAuthn Security
WebAuthn (passkeys) provides passwordless authentication via the browser's Web Authentication API. Security properties:
- **Credential binding**: Each credential is cryptographically bound to the specific `rp_id` (management domain) and `origin` (HTTPS URL). Credentials cannot be phished to a different domain.
- **Private key protection**: The private key never leaves the authenticator device. The server only stores the public key and signature counter in the `webauthn_creds` table.
- **Assertion verification**: Each authentication attempt verifies the signature against the stored public key and checks that the signature count has increased (replay prevention).
- **RP configuration**: `rp_id` and `origin` are configurable per deployment in `config/auth/config.json`.
- **Fallback**: Password authentication always remains available as a fallback. Losing a WebAuthn credential does not lock the user out.
### Header-Only Authentication and CSRF
The API exclusively reads the `Authorization` header — never cookies. This architecture eliminates CSRF risk:
- Cross-site requests cannot set custom HTTP headers due to browser CORS restrictions
- No cookie-based session to exploit
- No SameSite, double-submit, or origin checking needed
**XSS as the primary attack surface**: With header-only auth, XSS is the primary attack vector since `localStorage` is accessible to page scripts. Mitigations include:
- CSP headers on the management domain (configured in nginx)
- `X-XSS-Protection` header
- Short-lived access tokens (15 min) with blacklist on logout
### TLS Configuration
The default nginx SSL configuration enforces modern TLS only: