docs: full refresh per DOCSPLAN (auth subsystem, backends model, access classes, sudo table, state-model mechanics) + 3 stale docstrings

This commit is contained in:
2026-09-05 16:34:57 +00:00
parent 78fcb01877
commit b503a6dcf0
13 changed files with 1468 additions and 543 deletions
+138 -84
View File
@@ -48,7 +48,7 @@ This file defines all DHCP server settings and DNS resolution behavior for the d
| Field | Type | Required | Description |
|---|---|---|---|
| `ranges` | array | No | One or more DHCP address pools. Each range defines a subnet from which addresses are leased. Default: `[]`. |
| `ranges[].interface` | string | Yes | Network interface on which to serve this DHCP range (e.g., `eth1`). |
| `ranges[].interface` | string | No | Network interface on which to serve this DHCP range (e.g., `eth1`). Omit for a global range served on all interfaces (renders an untagged `dhcp-range`). |
| `ranges[].start` | string | Yes | First IP address in the pool. |
| `ranges[].end` | string | Yes | Last IP address in the pool. |
| `ranges[].lease_time` | string | No | DHCP lease duration. Accepts values like `12h`, `1d`, `30m`. Default: `12h`. |
@@ -56,7 +56,7 @@ This file defines all DHCP server settings and DNS resolution behavior for the d
| `ranges[].dns` | string | No | DNS server address advertised to DHCP clients. Typically the Vacuum Wall host's LAN IP. |
| `static_leases` | array | No | Fixed IP assignments tied to MAC addresses. Clients with matching MACs always receive the specified IP. Default: `[]`. |
| `static_leases[].mac` | string | Yes | MAC address of the client (colon-separated lowercase hex). |
| `static_leases[].ip` | string | Yes | The IP address to assign to this MAC. Must be outside the dynamic pool ranges. |
| `static_leases[].ip` | string | Yes | The IP address to assign to this MAC. Vacuum Wall does not validate that this is outside the dynamic pool ranges — keep it outside the pool to avoid address conflicts. |
| `static_leases[].hostname` | string | No | Hostname to associate with the lease. Used for reverse DNS and mDNS. |
### DNS Fields
@@ -76,42 +76,15 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil
**File**: `config/nginx/config.json`
This file defines reverse proxy domains with path-based routing, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
This file defines named backends (path-based routing definitions), reverse proxy domains that reference those backends, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/`, the include file `/etc/nginx/conf.d/vacuum-wall.conf` (which also defines the `$connection_upgrade` map used for WebSocket pass-through), the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`, and a catch-all ACME challenge site at `data/nginx/sites-enabled/_acme-challenge.conf` (a port-80 `default_server` serving `/.well-known/acme-challenge/` from the `data/acme/www` webroot for domains without a dedicated server block yet).
```json
{
"domains": {
"app.example.com": {
"force_ssl": true,
"cert": "acme",
"auth": {
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
},
"paths": {
"/": {
"backend": {
"host": "192.168.2.50",
"port": 8080,
"proto": "http"
},
"headers": {
"X-Forwarded-Proto": "https"
}
},
"/api": {
"backend": {
"host": "192.168.2.51",
"port": 3000,
"proto": "http"
},
"auth": null
}
}
},
"mgmt.example.com": {
"force_ssl": true,
"cert": "acme",
"backends": {
"webui": {
"label": "Vacuum Wall WebUI",
"builtin": true,
"_migrated": true,
"paths": {
"/": {
"backend": {
@@ -120,10 +93,7 @@ This file defines reverse proxy domains with path-based routing, and global SSL
"proto": "http"
},
"is_management": true,
"auth": {
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
}
"auth": null
},
"/ws": {
"backend": {
@@ -134,6 +104,37 @@ This file defines reverse proxy domains with path-based routing, and global SSL
"is_websocket": true
}
}
},
"nas": {
"label": "NAS",
"paths": {
"/": {
"backend": {
"host": "192.168.2.50",
"port": 8080,
"proto": "http"
},
"headers": {
"X-Forwarded-Proto": "https"
}
}
},
"auth": {
"user": "admin",
"htpasswd": "data/nginx/.htpasswd"
}
}
},
"domains": {
"app.example.com": {
"force_ssl": true,
"cert": "acme",
"backend": "nas"
},
"mgmt.example.com": {
"force_ssl": true,
"cert": "acme",
"backend": "webui"
}
},
"ssl": {
@@ -144,38 +145,58 @@ This file defines reverse proxy domains with path-based routing, and global SSL
}
```
### Domain Entries
### Backends
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block. All routing is path-based — a domain can proxy multiple paths to different backends.
The `backends` object maps backend names (keys) to shared routing definitions. Each backend carries the path map and an optional auth block; domains reference a backend by name and serve all of the backend's paths. Paths live on the backend — a domain entry never carries inline `paths`.
| Field | Type | Required | Description |
|---|---|---|---|
| `paths` | object | Yes | Path-to-config map. Each key is a URL path (e.g., `"/"`, `"/api"`). No catch-all unless `"/"` is explicitly defined. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htpasswd }`). Applies to all paths unless overridden at the path level. |
| `label` | string | Yes (on create) | Human-readable display name for the backend. Required when adding via `POST /nginx/backends/add`. |
| `paths` | object | Yes | Path-to-config map (schema in [Path Entries](#path-entries) below). |
| `auth` | object | No | Backend-level HTTP basic auth (`{ user, htpasswd }`). Used by any domain referencing this backend unless overridden at the domain level. |
| `builtin` | boolean | No (read-only) | Read-only flag set on the built-in `webui` backend. Builtin backends cannot be modified or removed. |
| `_migrated` | boolean | No (internal) | Internal marker set by the legacy-format migration. Not user-settable; stripped from API responses. |
Backends are managed through the daemon endpoints `GET /nginx/backends` (secrets stripped; each entry reports a `has_auth` boolean instead of the auth object), `PATCH /nginx/backends` (deep-merge partial update; `auth: null` or `auth: false` removes auth), `POST /nginx/backends/add` (creates a new backend; `400` if the name already exists), and `DELETE /nginx/backends/remove` (`400` for builtin backends, `409` when a domain still references the backend).
### Path Entries
Each entry under `paths` defines a location block and its proxy backend.
Each entry in a backend's `paths` map defines an nginx `location` block and its proxy target.
| Field | Type | Required | Description |
|---|---|---|---|
| `backend` | object | Yes | The upstream service for this path. |
| `backend.host` | string | Yes | IP address or hostname of the backend service. |
| `backend.port` | integer | Yes | Port the backend service is listening on. |
| `backend.proto` | string | No | Protocol: `http` or `https`. Default: `http`. |
| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. |
| `auth` | object \| null | No | Path-level auth override. `{ user, htpasswd }` replaces domain-level auth. `null` disables auth for this path. |
| `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. Suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. |
| `backend.proto` | string | Yes | Protocol: `http` or `https`. Required — no default; absence is a validation error when adding or updating a backend. |
| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. Not rendered on `is_management` paths. |
| `auth` | object \| null | No | Path-level auth override. `{ user, htpasswd }` replaces domain/backend-level auth. `null` renders `auth_basic off` for this path. |
| `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. The server block gets a `/static/` alias block serving `webui/static/` from disk (with `no-cache` revalidation), uses the dedicated `wall_mgmt_access.log` / `wall_mgmt_error.log` log files, and suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. |
| `is_websocket` | boolean | No | Marks this path as a WebSocket pass-through. Disables auth, sets Upgrade/Connection headers, uses extended timeouts. |
### Domain Entries
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block and references a shared backend by name — all of that backend's paths are served under the domain.
| Field | Type | Required | Description |
|---|---|---|---|
| `backend` | string | Yes | Name of the backend (in `backends`) to proxy through (e.g., `"webui"`). Must reference an existing backend. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
| `cert` | string \| object | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
| `cert_path` | string | No | For `cert: "file"`: path to the certificate file. (Also settable as `cert: { "cert_path": ..., "cert_key_path": ... }` in dict form.) |
| `cert_key_path` | string | No | For `cert: "file"`: path to the private key file. |
| `auth` | object \| null | No | Domain-level HTTP basic auth override (`{ user, htpasswd }`). Takes precedence over the referenced backend's `auth`; see [Auth Inheritance Rules](#auth-inheritance-rules). |
### Auth Inheritance Rules
- Domain-level `auth` applies to all paths unless overridden.
Effective auth for a domain is resolved in order: **domain `auth` → referenced backend `auth` → `None`**.
- A domain `auth: { ... }` overrides the referenced backend's auth for that domain; a domain without an `auth` key falls back to the backend's.
- Path-level `auth: null` means "no auth" for that path.
- Path-level `auth: { ... }` overrides domain-level for that path.
- No other domain-level settings inherit — `headers` is path-only.
- Path-level `auth: { ... }` overrides for that path.
- No other settings inherit between backends and domains `headers` is path-only.
**API auth form.** When adding or updating a domain through the API, `auth` may be given as `{ user, pass }`. The daemon writes the password into the `.htpasswd` file (SHA-256 crypt, default `data/nginx/.htpasswd`, or the `htpasswd` path supplied in the auth object) and persists only `{ user, htpasswd }` — the raw password is never stored in the config.
### Path ordering
@@ -188,12 +209,14 @@ The `cert` field is a string that selects the provisioning method:
| Value | Description |
|---|---|
| `acme` | Vacuum Wall uses acme.sh to request and renew an ACME certificate via the HTTP-01 challenge. The nginx configuration serves ACME challenge files at `/.well-known/acme-challenge/`. |
| `file` | Use a pre-existing certificate and private key from the local file system. Vacuum Wall will not attempt to renew these certificates. |
| `file` | Use a pre-existing certificate and private key from the local file system, via the domain's `cert_path` / `cert_key_path` fields (or the dict form `cert: { "cert_path": ..., "cert_key_path": ... }`). Vacuum Wall will not attempt to renew these certificates. |
| `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored at `data/certs/`. |
### Management Domain
The Vacuum Wall admin interface is configured as a regular domain entry under `domains`, with `is_management: true` on the path pointing to the Flask app. A second path (`/ws`) with `is_websocket: true` provides WebSocket pass-through for real-time state updates. This replaces the legacy `management` top-level key.
The Vacuum Wall admin interface is configured as a regular domain entry under `domains` that references the built-in `webui` backend (`"backend": "webui"`). That backend carries `is_management: true` on the root path (Flask app) and a `/ws` path with `is_websocket: true` for WebSocket pass-through. Because the built-in `webui` backend's root path has `auth: null`, the management path never gets nginx basic auth — management authentication is the Flask-layer JWT (bearer tokens); nginx `auth_basic` would suppress the SPA's Bearer requests. This replaces the legacy inline-`paths` form in which the management domain carried its own root and `/ws` paths (see [Backward Compatibility](#backward-compatibility)).
For a management domain without an explicit `cert` (or with `cert: "selfsigned"`), the apply step auto-generates a self-signed certificate at `data/certs/<domain>.crt` / `data/certs/<domain>.key` (RSA-2048, 365 days) if one is not already present.
The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's SHA-256 crypt). Manual creation is also possible:
@@ -203,9 +226,11 @@ htpasswd -bc data/nginx/.htpasswd admin yourpassword
### Backward Compatibility
Config files using the legacy format are auto-migrated on first load:
- Domain entries with a top-level `backend` key are wrapped into `paths["/"]`.
- A legacy `management` top-level key is migrated into `domains[management.domain]` with `is_management` on the root path and a `/ws` WebSocket path.
Config files using the legacy format are auto-migrated. The migration runs in-memory on every config read and is persisted to disk one-shot at daemon startup. It performs three steps:
1. Materializes the builtin `webui` backend (marked `_migrated: true`). The daemon handler's migration pass additionally harvests the legacy management domain's root-path auth into `backends.webui.auth`.
2. Rewrites legacy management domains (a root path pointing at `127.0.0.1:9090` with `is_management` and a `/ws` path pointing at `127.0.0.1:9091` with `is_websocket`) to `"backend": "webui"`, deleting their inline `paths` and `auth`.
3. Strips the legacy `application: "webui"` key.
### Global SSL Settings
@@ -235,16 +260,16 @@ This file stores the ACME account settings used by acme.sh for certificate provi
| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | No | Contact email for the ACME account. Used for certificate expiry notifications and recovery. Populated automatically when an account is registered via the WebUI. Default: `""`. |
| `ca` | string | No | ACME CA provider. One of: `"letsencrypt"` (Let's Encrypt), `"zerossl"` (ZeroSSL). Populated automatically when an account is registered. Default: `""`. |
| `ca` | string | No | ACME CA server. Any `server` string — passed through verbatim to `acme.sh --server` (e.g., `letsencrypt`, `zerossl`, or a private/staging CA). Not a closed enum. Populated automatically when an account is registered (the WebUI defaults to `letsencrypt` when no server is given). Default: `""`. |
### Account Registration
ACME account registration is handled entirely through the WebUI. When the user registers an account:
1. The user navigates to the Certificates page and clicks "Register Account".
2. Provides an email address and selects a CA provider (Let's Encrypt or ZeroSSL).
3. The backend calls `acme.sh --register-account` with the provided parameters.
4. On success, the `email` and `ca` fields in `config/acme/config.json` are populated, and acme.sh writes its `.account.conf` file under `data/acme/`.
2. Provides an email address and a CA server (defaults to `letsencrypt`).
3. The backend calls `acme.sh --register-account -m <email> --server <ca>`.
4. On success, the `email` and `ca` fields in `config/acme/config.json` are populated, and acme.sh writes its account state under `data/acme/` (modern acme.sh v3.x writes `account.conf`, without a leading dot).
Before any certificate can be issued, an ACME account must be registered. The certificate validation flow includes a blocking check (`account_registered`) that prevents issuance if no account exists.
@@ -252,17 +277,21 @@ Before any certificate can be issued, an ACME account must be registered. The ce
After registration, the account can be managed from the WebUI:
- **Update email**: The Settings modal allows changing the contact email, which triggers an update via `acme.sh --register-account -u`.
- **Update email**: The Settings modal allows changing the contact email, which triggers an update via `acme.sh --register-account -m <email>` (there is no `-u` flag; re-running account registration with the new email updates the account).
- **Deactivate account**: The Settings modal includes a button to deactivate the account via `acme.sh --deactivate-account`, which clears the `email` and `ca` fields and removes the ACME account.
### ACME Home Directory
acme.sh stores its state under `data/acme/` (the ACME home directory). Key files:
- `.account.conf` — ACME account credentials and settings (contains `ACME_LEEMAIL`, `ACME_MCA`).
- `<domain>/` — Per-domain certificate and key files issued by acme.sh.
- `account.conf` — ACME account credentials and settings (contains `ACME_LEEMAIL`, `ACME_MCA`). Modern acme.sh (v3.x) writes `account.conf` (no leading dot); older v2.x wrote `.account.conf`, and both names are still recognized.
- `ca/<server>/` — Per-CA account files, keyed by the ACME server name (e.g., `ca/letsencrypt/`).
- `<domain>/` — Per-domain certificate and key files issued by acme.sh. For ECC certificates the directory is `<domain>_ecc/`; `find_cert_dir()` checks the `_ecc` directory first, then the plain `<domain>/` directory.
- `www/` — ACME HTTP-01 webroot. Challenge files are served from here by nginx.
The application reads `.account.conf` to determine registration status. If the file is missing or lacks required keys, the account is considered unregistered.
The application determines registration status in this order: `account.conf` `.account.conf` → the declarative `config/acme/config.json` (kept in sync by the register/email handlers). If no source yields both an email and a CA, the account is considered unregistered.
In addition to ACME-issued certificates, `POST /acme/self-signed` (daemon endpoint) generates a self-signed certificate for a domain under `data/certs/` (takes a `days` parameter, default `365`; idempotent — skips generation when the cert and key already exist).
## Auth Configuration
@@ -278,9 +307,8 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the
"algorithm": "HS256"
},
"webauthn": {
"rp_name": "Vacuum Wall",
"rp_id": "<management-domain>",
"origin": "https://<management-domain>"
"enabled": true,
"rp_name": "Vacuum Wall"
}
}
```
@@ -289,7 +317,7 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the
| Field | Type | Required | Description |
|---|---|---|---|
| `access_token_ttl` | integer | No | Access token lifetime in seconds. Default: `900` (15 minutes). |
| `access_token_ttl` | integer | No | Access token lifetime in seconds. Code fallback default: `900` s; the fresh-install bootstrap writes `300` s (5 min). |
| `refresh_token_ttl` | integer | No | Refresh token lifetime in seconds. Default: `604800` (7 days). |
| `algorithm` | string | No | JWT signing algorithm. Default: `"HS256"`. |
@@ -297,15 +325,18 @@ This file defines JWT settings and WebAuthn Relying Party configuration for the
### WebAuthn Fields
The WebAuthn config block holds only two 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>`. |
| `enabled` | boolean | No | Whether WebAuthn is enabled. Default: `true`. |
| `rp_name` | string | No | Display name for the WebAuthn Relying Party. Shown during credential registration. Default: `"Vacuum Wall"`. |
`rp_id` and `origin` are **not** config fields. They are derived per-request from the management domain the request arrives on and validated against the live management domains (the WebAuthn endpoints refuse domains that do not serve the management UI).
## Database Schema
The SQLite database at `data/auth.db` stores authentication data across four tables. Created automatically on first access via `get_db()`.
The SQLite database at `data/auth.db` stores authentication data across six tables. Created automatically on first access via `get_db()`.
### users
@@ -336,7 +367,17 @@ UNIQUE constraint on `(username, subsystem)`.
| `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.
Used to invalidate tokens on logout and password change. Expired entries are cleaned up by the daemon's periodic poll loop (at most every 60 seconds) and probabilistically (roughly 2% of the time) inside `blacklist_token()` — not on every refresh.
### refresh_tokens
| Column | Type | Description |
|---|---|---|
| `username` | TEXT | Primary key (unique) — the owning user |
| `jti` | TEXT | JWT unique identifier of the current refresh token |
| `issued_at` | INTEGER | Unix timestamp when the refresh token was issued |
At most one active refresh session per user: the `username` column is unique, so issuing a new refresh token replaces the stored entry for that user. The active refresh token is blacklisted and removed on logout and password change.
### webauthn_creds
@@ -352,6 +393,12 @@ Used to invalidate tokens on logout and password change. Expired entries are cle
UNIQUE constraint on `(username, credential_id)`.
### init_sequence
| Column | Type | Description |
|---|---|---|
| `seq` | INTEGER | Primary key — bookkeeping sequence marker |
## WireGuard Configuration
**File**: `config/wireguard/config.json`
@@ -422,11 +469,13 @@ This file defines the WireGuard server interface, access classes, and all connec
### Access Classes
Access classes define categories of VPN access. Each class gets its own WireGuard interface (``wg-<key>``), firewall zone (``vpn-<key>``), subnet, and listen port. Peers are assigned to a class and their config is rendered to that class's interface. Pre-seeded with `full` and `internet` defaults on first initialization. Manageable via `GET/POST/PATCH/DELETE /api/wireguard/classes`. Per-class tunnel lifecycle: `POST /api/wireguard/classes/<key>/up`, `POST /api/wireguard/classes/<key>/down`.
Access classes define categories of VPN access. Each class gets its own WireGuard interface (``wg-<key>``), firewall zone (``vpn-<key>``), subnet, and listen port. Peers are assigned to a class and their config is rendered to that class's interface. Pre-seeded with `full` and `internet` defaults on first initialization. Manageable via `GET/POST/PATCH/DELETE /api/wireguard/classes`. Per-class tunnel lifecycle: `POST /api/wireguard/classes/<key>/up`, `POST /api/wireguard/classes/<key>/down` (the down route forwards to the daemon's `DELETE /wireguard/classes/<class_key>/down`).
**Class key validation.** The class `key` (object key) must be lowercase alphanumeric — anything else is rejected (`400`). `name` defaults to the key when omitted. Creating a class whose key already exists raises `409 Conflict`. Deleting a class is refused with `409 Conflict` while any peer still references it (the response lists the offending peers).
| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Human-readable display name for the class. |
| `name` | string | No | Human-readable display name for the class. Defaults to the class key when omitted. |
| `description` | string | No | Optional description of what access level this class provides. Default: `""`. |
| `subnet` | string | Yes | CIDR subnet for the class's WireGuard interface (e.g., ``10.137.0.0/24``). Server address is derived as ``<base>.1/<prefix>``. |
| `listen_port` | integer | Yes | UDP port for the class's WireGuard interface. Must be unique per class. |
@@ -460,12 +509,14 @@ Peers are stored in an object keyed by a human-readable identifier (e.g., `alice
| `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Default: `[]` (no routing restrictions from the server side). `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. |
| `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. Default: `null`. |
| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. Default: `null`. |
| `description` | string | No | Optional description for the peer. Default: `""`. |
| `description` | string | No | Optional description for the peer. The API defaults it to `""` when a peer is added via the endpoint; the lib-level `add_peer()` stores `null` when the field is omitted. |
| `access_class` | string | No | Key of the access class this peer belongs to (e.g., `"full"`, `"internet"`). `null` means unassigned. Default: `null`. |
### Client Configuration Generation
When a peer's `private_key` is set (which is the case when `add_peer()` auto-generates it), the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a `[Peer]` entry, and the appropriate `Endpoint` and `AllowedIPs` values. The `private_key` field is written into the client config file for download but is never returned by the API. `generate_client_conf()` computes the client IP address from the server's subnet and the peer's sorted index position.
When a peer's `private_key` is set (which is the case when `add_peer()` auto-generates it), the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a `[Peer]` entry, and the appropriate `Endpoint` and `AllowedIPs` values. The `private_key` field is written into the client config file for download but is never returned by the API.
`generate_client_conf()` derives the client IP address and the `Endpoint` port from the peer's **access class** when the peer is class-assigned — the class's `subnet` and `listen_port` are used, not the server interface's. For unassigned peers it falls back to the server interface's `addresses[0]` and `listen_port`. The client's host index is the peer's position in the sorted list of **all** peer keys (across every class) plus 2 (index 1 is reserved for the server).
### Applying Configuration
@@ -536,11 +587,12 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
| `forward_ports[].toaddr` | string | No | Internal IP address to forward to. Omit for broadcast forwarding. |
| `forward_ports[].toport` | integer | No | Internal port to forward to. Omit to keep the same port. |
| `rich_rules` | array | No | Rich rule entries for advanced firewall policies. Default: `[]`. |
| `rich_rules[].id` | string | No | Auto-generated unique identifier (8-hex UUID) for the rich rule. Not user-settable; assigned when the rule is added via the API. The `DELETE /firewall/rich-rules/remove` endpoint addresses rules by this `id`. |
| `rich_rules[].rule` | string | Yes | The full firewalld rich rule string, e.g., `rule family="ipv4" source address="10.0.0.0/8" reject`. |
### Applying Firewall Configuration
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`. A `target` entry is only reported when the config carries an explicit target that normalizes to something other than `default`; an omitted key or a `default`-normalizing value is unmanaged, so live target drift is neither flagged nor applied. The `interfaces` entry is reported for **every** config zone — the config is the source of truth for zone interfaces, so an omitted `interfaces` key counts as an empty list and pending changes are diffed accordingly.
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Masquerade is **skipped for the `public` zone** in both the pending diff and the apply step — the public zone's masquerade is driven by the nftables propagation step described below, so diffing it would advertise a change that never happens. Zones that exist live but not in config are reported as `unmanaged_zones`, excluding the zones firewalld ships by default (`block`, `dmz`, `drop`, `external`, `home`, `host`, `internal`, `public`, `trusted`), which are always present live and never meaningful to flag. A `target` entry is only reported when the config carries an explicit target that normalizes to something other than `default`; an omitted key or a `default`-normalizing value is unmanaged, so live target drift is neither flagged nor applied. The `interfaces` entry is reported for **every** config zone — the config is the source of truth for zone interfaces, so an omitted `interfaces` key counts as an empty list and pending changes are diffed accordingly.
Both `/api/firewall/zones/<name>/services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift.
@@ -555,11 +607,13 @@ Send `"force": true` in the request body to override the apply-time check (the U
**Applied baseline.** Like the other config-backed subsystems, a successful apply records `_last_applied_hash` and `_last_applied_config` (the meta-stripped config snapshot) inside `config.json`. They are internal bookkeeping — ignored by all parsing, hashing, and UI surfaces — and let the aggregate cancel action (`POST /api/status/cancel-all`) revert this file to the last applied state. Configs that have never been applied have no baseline and are skipped by cancel.
**Public-zone masquerade propagation.** With firewalld's nftables backend, traffic leaving through the public zone hits the public zone's POSTROUTING chain, so NAT only works if the public zone itself has masquerade enabled. During apply, if any non-public zone has masquerade enabled but the public zone does not, apply propagates masquerade to the public zone (and writes it back into the config); conversely, when no non-public zone needs masquerade, apply removes it from the public zone. Consistently, the `POST /firewall/masquerade` endpoint **refuses** to enable masquerade on the `public` zone directly (enable it on `internal` or a `vpn` zone instead — the API returns an error directing you there).
## Networkd (IP Configuration)
**File**: `config/network/config.json`
This file defines static IP configuration for network interfaces managed by systemd-networkd. The application renders each interface entry into a `50-<name>.network` INI file in `data/networkd/`, which the handler copies to `/etc/systemd/network/`.
This file defines static IP configuration for network interfaces managed by systemd-networkd. The application renders each interface entry into a `99-<name>.network` INI file in `data/networkd/`, which the handler copies to `/etc/systemd/network/`.
```json
{
@@ -609,7 +663,7 @@ Each key in the `interfaces` object is an interface name (e.g., `eth0`, `eth1`,
| `dhcp` | `string` | DHCP mode: `"yes"`, `"ipv4"`, `"ipv6"`, `"no"`. Controls `[Network] DHCP=` and whether `[DHCPv4]`/`[DHCPv6]` sections are rendered. |
| `routes` | `array` | Static routes. Each dict has `destination`, `gateway`, `metric`, `table`, `type`, `scope`, `gateway_on_link`, `ipv6_preference`, `initial_congestion_window`, `initial_advertised_receive_window`, `quick_ack`, `fast_open_no_cookie`, `mtu_bytes`, `protocol`, `next_hop`, `multi_path_route`. Renders to `[Route#N]` sections. |
| `link` | `object` | Link settings: `mtu_bytes`, `mac_address`, `arp`, `multicast`, `all_multicast`, `promiscuous`, `unmanaged`, `activation_policy`, `required_for_online`. Renders to `[Link]` section. |
| `dhcp_client` | `object` | DHCP client settings. Shared keys for both `[DHCPv4]` and `[DHCPv6]`: `hostname`, `duid_type`, `duid_raw_data`, `iaid`, `client_identifier`, `rapid_commit`, `anonymize`, `use_dns`, `use_ntp`, `use_sip`, `use_captive_portal`, `use_mtu`, `use_hostname`, `use_domains`, `use_routes`, `route_metric`, `send_decline`, `net_label`, `nft_set`, `ip_service_type`, `socket_priority`, `bootp`, `label`, `max_attempts`, `listen_port`, `server_port`, `mud_url`, `boot_filename`, `send_option`, `send_vendor_option`, `user_class`, `vendor_class_identifier`, `request_options`. |
| `dhcp_client` | `object` | DHCP client settings. `[DHCPv4]` and `[DHCPv6]` have **different** key sets (which sections render is controlled by `dhcp`). Shared by both: `hostname`, `duid`, `duid_type`, `duid_raw_data`, `iaid`, `anonymize`, `rapid_commit`, `use_dns`, `use_ntp`, `use_sip`, `use_captive_portal`, `use_hostname`, `use_domains`, `net_label`, `nft_set`, `send_option`, `send_vendor_option`, `user_class`. IPv4-only (`[DHCPv4]`): `client_identifier`, `use_mtu`, `use_routes`, `route_metric`, `send_decline`, `ip_service_type`, `socket_priority`, `bootp`, `label`, `max_attempts`, `listen_port`, `server_port`, `mud_url`, `boot_filename`, `vendor_class_identifier`, `request_options`. IPv6-only (`[DHCPv6]`): `send_hostname`, `prefix_delegation_hint`, `unassigned_subnet_policy`, `use_address`, `use_delegated_prefix`, `use_dnr`, `send_release`, `without_ra`, `vendor_class` (a list; each entry renders a `VendorClass=` line). |
| `bind_carrier` | `array` | Carrier interfaces to bind to. |
| `ignore_carrier_loss` | `boolean` | Ignore carrier loss events. |
| `keep_configuration` | `boolean` | Keep configuration on stop. |
@@ -643,7 +697,7 @@ When `POST /api/network/apply` is called, the handler automatically collects pub
### Generated Files
Each interface config entry produces a `50-<name>.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`).
Each interface config entry produces a `99-<name>.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`).
## Cross-Subsystem Dependencies
@@ -654,7 +708,7 @@ are updated automatically through the event bus.
|---|---|---|
| dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services. Removing the last range removes them. DHCP ranges also back-propagate gateway (interface IP) so clients receive their default route. |
| wireguard (peer add/remove) | firewall | Per-class `vpn-<key>` zones are created with `wg-<key>` interface, masquerade, UDP port rule, and inter-zone accept rules (only when ``lan_access=true``). Falls back to single `vpn` zone in legacy mode. Cleanup removes stale rules when classes have no peers. |
| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Masquerade-enabled zones ensure DHCP ranges carry the gateway. Zones with dhcp service but no range are logged as warnings. |
| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are **kept in the config and flagged inactive — never removed**. Masquerade-enabled zones ensure DHCP ranges carry the gateway. Zones with dhcp service but no range are logged as warnings. |
| network (interface config) | firewall | Zone interface assignments in firewall config are updated — new interfaces are flagged, stale ones removed. |
| network (interface config) | dnsmasq | Suggested DHCP ranges are logged when an interface has a static IP but no DHCP range. |