47 KiB
Configuration Reference
This document describes the JSON configuration files used by Vacuum Wall to manage each subsystem. All persistent configuration is stored in the config/ directory as declarative JSON. Runtime artifacts and generated files live in data/. The application renders these declarations into the format expected by each underlying service.
DHCP/DNS Configuration
File: config/dnsmasq/config.json
This file defines all DHCP server settings and DNS resolution behavior for the dnsmasq service. The application renders it into /etc/dnsmasq.d/vacuum-wall.conf.
{
"dhcp": {
"ranges": [
{
"interface": "eth1",
"start": "192.168.2.100",
"end": "192.168.2.200",
"lease_time": "12h",
"gateway": "192.168.2.1",
"dns": "192.168.2.1"
}
],
"static_leases": [
{
"mac": "aa:bb:cc:dd:ee:ff",
"ip": "192.168.2.50",
"hostname": "printer"
}
]
},
"dns": {
"upstreams": ["8.8.8.8", "1.1.1.1"],
"domain": "lan",
"custom_records": [
{
"name": "nas.lan",
"address": "192.168.2.10",
"hostname": "nas"
}
]
}
}
DHCP Fields
| 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 | 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. |
ranges[].gateway |
string | No | Default gateway advertised to DHCP clients. Typically the router's LAN IP. |
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. 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
| Field | Type | Required | Description |
|---|---|---|---|
upstreams |
array | Yes | Upstream DNS servers to forward unresolved queries to. Supports IPv4 and IPv6 addresses. Default: ["8.8.8.8", "1.1.1.1"]. |
domain |
string | No | Local domain suffix. Hostnames without a FQDN are resolved within this domain (e.g., printer becomes printer.lan). Default: null. |
custom_records |
array | No | Static DNS A records for internal services and devices. Default: []. |
custom_records[].name |
string | Yes | Fully qualified domain name (e.g., nas.lan). |
custom_records[].address |
string | Yes | The IP address to resolve the name to. |
custom_records[].hostname |
string | No | Short hostname without the domain suffix. Adds a reverse DNS entry as well. |
Additional dnsmasq directives can be appended verbatim by placing plain-text files in data/dnsmasq/fragments/. Each file's contents are concatenated into the generated config. This is useful for advanced options not covered by the JSON schema (e.g., bogus-priv, cache-size, log-queries).
Nginx Configuration
File: config/nginx/config.json
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).
{
"backends": {
"webui": {
"label": "Vacuum Wall WebUI",
"builtin": true,
"_migrated": true,
"paths": {
"/": {
"backend": {
"host": "127.0.0.1",
"port": 9090,
"proto": "http"
},
"is_management": true,
"auth": null
},
"/ws": {
"backend": {
"host": "127.0.0.1",
"port": 9091,
"proto": "http"
},
"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": {
"protocols": "TLSv1.2 TLSv1.3",
"ciphers": "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305",
"prefer_server_ciphers": false
}
}
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 |
|---|---|---|---|
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 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 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 | 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
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 anauthkey falls back to the backend's. - Path-level
auth: nullmeans "no auth" for that path. - Path-level
auth: { ... }overrides for that path. - No other settings inherit between backends and domains —
headersis 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
Nginx evaluates location blocks by specificity: more specific prefixes (e.g., /api) always match before / by nginx's own priority rules. The order of keys in the paths dict does not affect routing behavior.
Certificate Types
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, 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 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).
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:
htpasswd -bc data/nginx/.htpasswd admin yourpassword
Backward Compatibility
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:
- Materializes the builtin
webuibackend (marked_migrated: true). The daemon handler's migration pass additionally harvests the legacy management domain's root-path auth intobackends.webui.auth. - Rewrites legacy management domains (a root path pointing at
127.0.0.1:9090withis_managementand a/wspath pointing at127.0.0.1:9091withis_websocket) to"backend": "webui", deleting their inlinepathsandauth. - Strips the legacy
application: "webui"key.
Global SSL Settings
The ssl block defines TLS parameters applied to all HTTPS server blocks via the shared snippet.
| Field | Type | Required | Description |
|---|---|---|---|
protocols |
string | No | nginx ssl_protocols directive value. Default: TLSv1.2 TLSv1.3. |
ciphers |
string | No | nginx ssl_ciphers directive value. Default is a curated AEAD-only cipher string. |
prefer_server_ciphers |
boolean | No | Whether to prefer server cipher order. Default: false. |
ACME (Certificate) Configuration
File: config/acme/config.json
This file stores the ACME account settings used by acme.sh for certificate provisioning. Account registration, modification, and deactivation are performed through the WebUI at the Certificates page — not by editing this file directly.
{
"email": "admin@example.com",
"ca": "letsencrypt"
}
ACME Fields
| 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 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:
- The user navigates to the Certificates page and clicks "Register Account".
- Provides an email address and a CA server (defaults to
letsencrypt). - The backend calls
acme.sh --register-account -m <email> --server <ca>. - On success, the
emailandcafields inconfig/acme/config.jsonare populated, and acme.sh writes its account state underdata/acme/(modern acme.sh v3.x writesaccount.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.
Account Management
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 -m <email>(there is no-uflag; 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 theemailandcafields 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 (containsACME_LEEMAIL,ACME_MCA). Modern acme.sh (v3.x) writesaccount.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_eccdirectory first, then the plain<domain>/directory.www/— ACME HTTP-01 webroot. Challenge files are served from here by nginx.
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
File: config/auth/config.json
This file defines JWT settings and WebAuthn Relying Party configuration for the authentication system.
{
"jwt": {
"access_token_ttl": 900,
"refresh_token_ttl": 604800,
"algorithm": "HS256"
},
"webauthn": {
"enabled": true,
"rp_name": "Vacuum Wall"
}
}
JWT Fields
| Field | Type | Required | Description |
|---|---|---|---|
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". |
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
The WebAuthn config block holds only two fields:
| Field | Type | Required | Description |
|---|---|---|---|
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 six 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 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
| 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).
init_sequence
| Column | Type | Description |
|---|---|---|
seq |
INTEGER | Primary key — bookkeeping sequence marker |
WireGuard Configuration
File: config/wireguard/config.json
This file defines the WireGuard server interface, access classes, and all connected peers. The application renders it into /etc/wireguard/wg0.conf and applies it with wg-quick. The file is created automatically when initialize() generates the server key pair and pre-seeds default access classes.
{
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "<generated>",
"public_key": "<generated>",
"addresses": ["10.137.0.1/24"],
"server_endpoint": "vpn.example.com:51820",
"description": "Main WireGuard server",
"post_up": null,
"post_down": null
},
"access_classes": {
"full": {
"name": "Full LAN Access",
"description": "Peers get full access to internal networks",
"subnet": "10.137.0.0/24",
"listen_port": 51820,
"lan_access": true,
"private_key": "<generated>",
"public_key": "<generated>"
},
"internet": {
"name": "Internet Only",
"description": "Peers can only reach the internet",
"subnet": "10.137.1.0/24",
"listen_port": 51821,
"lan_access": false,
"private_key": "<generated>",
"public_key": "<generated>"
}
},
"peers": {
"alice": {
"public_key": "<auto-generated>",
"private_key": "<auto-generated>",
"endpoint": "203.0.113.1:51820",
"allowed_ips": ["0.0.0.0/0"],
"persistent_keepalive": 25,
"preshared_key": null,
"description": "Alice's office laptop",
"access_class": "full"
}
}
}
Interface Fields
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | No | WireGuard interface name. Default: wg0. |
listen_port |
integer | No | Port the WireGuard interface listens on. Default: 51820. Must be opened in the firewall. |
private_key |
string | Yes (after init) | Base64-encoded private key for the server interface. Generated automatically by initialize() via wg genkey. |
public_key |
string | Yes (after init) | Corresponding public key. Generated automatically by initialize() via wg pubkey. |
addresses |
array | No | IP address(es) assigned to the server interface in CIDR notation (e.g., 10.137.0.1/24). Default: ["10.137.0.1/24"]. |
server_endpoint |
string | No | External hostname:port for client connection. Used in generated client configs. Default: "". |
description |
string | No | Free-text description of the WireGuard server. Default: "". |
post_up |
string | No | Shell command to run after the interface is brought up. Common uses: adding NAT rules, enabling IP forwarding for the tunnel. Set to null to omit. Default: null. |
post_down |
string | No | Shell command to run after the interface is brought down. Used to clean up rules added by post_up. Set to null to omit. Default: null. |
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 (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 | 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. |
lan_access |
boolean | No | When true, the sync subscriber adds inter-zone accept rules for internal subnets, allowing peers to reach the LAN. When false, peers can only reach the internet via masquerade. Default: false. |
private_key |
string | Yes (auto) | Base64-encoded private key for the class's WireGuard interface. Auto-generated via POST /api/wireguard/classes/keys/<key>. |
public_key |
string | Yes (auto) | Corresponding public key. Auto-generated with private_key. |
Multi-Interface Behavior
When peers are assigned to an access class, the daemon:
- Renders a separate
wg-<key>.conffor each class that has assigned peers. - Each class interface gets its own private/public key pair.
- The sync subscriber creates a
vpn-<key>firewall zone per class with masquerade enabled. - Classes with
lan_access=trueget additional inter-zone rules for internal subnets. applybrings up all class interfaces independently. Per-classup/downendpoints control individual tunnels.
Legacy Single-Interface Mode
When no peers are assigned to any access class, the system falls back to the legacy single-interface mode where all peers share wg0.
Peer Fields
Peers are stored in an object keyed by a human-readable identifier (e.g., alice, office-laptop). Each peer entry defines a WireGuard peer configuration. When add_peer() is called, the peer's key pair is auto-generated. The private_key is stored for client configuration generation but stripped from all API responses.
| Field | Type | Required | Description |
|---|---|---|---|
public_key |
string | Yes | The peer's public key. Auto-generated when the peer is added. |
private_key |
string | No | The peer's private key, stored for generating downloadable client configuration files. Auto-generated when the peer is added. Stripped from all API responses — the WebUI never exposes peer private keys over the network. |
endpoint |
string | No | The peer's public endpoint (IP:port). Required for server-initiated connections (e.g., the server reaching out to a peer behind a firewall). Leave empty or null for peer-initiated connections where the peer connects to the server. Default: null. |
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. 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() 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
When configuration is saved through the WebUI or API, the application:
- Renders the
wg0.conffile from the JSON configuration. - Writes the file to
/etc/wireguard/wg0.confwith600permissions viasudo cp. - Runs
sudo wg-quick up <name>to apply the configuration. - Returns success or error status to the caller.
If the interface is already up, wg-quick up will reconfigure it in place without dropping existing connections.
Firewall Configuration
File: config/firewall/config.json
This file defines the declarative firewalld zone configuration. The application compares it against the live firewalld state via _compute_pending_changes() and applies incremental changes. Before every apply a pre-apply recovery snapshot is written to data/firewall/rules.json: {timestamp, default_zone, zones, config} where zones is the permanent firewalld zone view (--list-all-zones --permanent) and config is the declarative config at apply time. The permanent view is what is reproducible for manual recovery.
{
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["dhcp", "dns", "https", "ssh"],
"target": "DEFAULT",
"masquerade": true,
"forward_ports": [
{
"id": "abc123",
"port": 443,
"proto": "tcp",
"toaddr": "192.168.2.50",
"toport": 8080
}
],
"rich_rules": [
{
"rule": "rule family=\"ipv4\" source address=\"10.0.0.0/8\" reject"
}
]
}
},
"unmanaged": ["eth9"]
}
Top-Level Fields
| Field | Type | Required | Description |
|---|---|---|---|
zones |
object | Yes | Zone name → zone configuration (below). |
unmanaged |
array | No | Network interfaces that are deliberately not covered by any zone. Exempts them from the interface-coverage invariant. Default: []. |
Zone Fields
The zones object maps zone names (keys) to zone configurations. Each zone corresponds to a firewalld zone applied via firewall-cmd.
| Field | Type | Required | Description |
|---|---|---|---|
interfaces |
array | No | Network interfaces assigned to this zone. The config is the source of truth: an omitted key counts as an empty list (apply unassigns the zone's live interfaces). Default: []. |
services |
array | No | Firewalld services to allow in this zone (e.g., ssh, https, dns, dhcp). Default: []. |
target |
string | No | Zone target policy. ACCEPT, DROP, or REJECT is fully managed. When the key is omitted (the canonical "unmanaged" notation) or normalizes to default (e.g. a legacy explicit "DEFAULT"), the live value is preserved — it is not diffed and never re-set by apply (firewalld cannot set default back). |
masquerade |
boolean | No | Enable IP masquerading (NAT) for this zone. Default: false. |
forward_ports |
array | No | Port forwarding rules. Each entry has an auto-generated id field and the standard firewalld forward-port fields. Default: []. |
forward_ports[].id |
string | No | Auto-generated unique identifier for the port forwarding rule. Not user-settable. |
forward_ports[].port |
integer | Yes | Destination port to forward. |
forward_ports[].proto |
string | Yes | Protocol: tcp or udp. |
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. 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.
Management-lockout guard. The firewalld default zone is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that neither https nor ssh remains raises 409 Conflict — from POST /firewall/zones/<name>/services and POST /firewall/config/apply — before any mutation runs. Send "force": true in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed.
Interface-coverage invariant. Every network-subsystem-managed interface must be covered by a zone in the firewall config — otherwise all traffic (and DHCP) from that segment is dropped. Guarded interfaces are the keys of the network config's interfaces, excluding lo and wg* (vpn zones are managed by the WireGuard sync and lo is normally zoneless). Because the config is the source of truth for zone interfaces (an omitted interfaces key counts as empty), coverage is computed from the config alone via validate_coverage() — there is no live-state fallback and no hands-off zones. Interfaces listed in the top-level unmanaged key are exempt. The invariant is enforced at two points:
- Save time —
POST /firewall/configandPATCH /firewall/configreject a config that leaves a managed interface uncovered with400 Bad Request, before anything is written. - Apply time —
POST /firewall/config/applyre-checks the (possibly stale) saved config against the current network config and raises409 Conflictbefore any mutation. A conflict here means the network config changed after the firewall config was saved (e.g. a new interface no zone covers).
Send "force": true in the request body to override the apply-time check (the UI offers this via the Apply dialog). Live drift — an interface that is covered by the config but not in any live zone — is advisory only: it is surfaced as the uncovered_interfaces field in firewall state (see docs/state-model.md), the Zones-page banner, and an advisory in GET /api/status/pending, and is never blocked by the invariant.
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 99-<name>.network INI file in data/networkd/, which the handler copies to /etc/systemd/network/.
{
"interfaces": {
"eth0": {
"addresses": ["192.168.1.1/24"],
"gateway": "192.168.1.254",
"dns": ["8.8.8.8", "1.1.1.1"],
"dhcp": "no"
},
"eth1": {
"dhcp": "ipv4",
"dns_default_route": true,
"dhcp_client": {
"hostname": "router",
"use_dns": true
}
},
"wg0": {
"addresses": [{"address": "10.137.0.1/24"}],
"routes": [
{
"destination": "10.0.0.0/8",
"gateway": "10.137.0.2"
}
]
}
}
}
Interface Entry Fields
Each key in the interfaces object is an interface name (e.g., eth0, eth1, wg0). The value is a dict with the following keys:
| Field | Type | Description |
|---|---|---|
addresses |
array |
IPv4 addresses. Each item is either a bare CIDR string ("192.168.1.1/24") or a dict with address, label, scope, route_metric, duplicate_address_detection, manage_temporary_address, add_prefix_route. Renders to [Address] sections. |
ipv6_addresses |
array |
Same as addresses, but for IPv6. |
gateway |
string |
Default IPv4 gateway ([Network] Gateway=). |
ipv6_gateway |
string |
Default IPv6 gateway ([Network] IPv6Gateway=). |
dns |
array |
IPv4 DNS servers ([Network] DNS=, one per line). |
ipv6_dns |
array |
IPv6 DNS servers ([Network] IPv6DNS=). |
domains |
array |
Search domains ([Network] Domains=). |
ipv6_domains |
array |
IPv6 search domains ([Network] IPv6Domains=). |
dns_default_route |
boolean |
Whether DNS is the default route for resolution ([Network] DNSDefaultRoute=). |
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. [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. |
configure_without_carrier |
boolean |
Configure even without carrier. |
link_local_addressing |
string |
Link-local addressing mode. |
ipv6_link_local_address_generation_mode |
string |
IPv6 link-local address generation mode. |
ipv6_stable_secret_address |
string |
Stable secret for IPv6 address generation. |
ipv4_ll_start_address |
string |
Link-local IPv4 start address. |
ipv4_ll_route |
boolean |
Add route to link-local IPv4 address. |
default_route_on_device |
boolean |
Always add default route via this device. |
ipv6_hop_limit |
int |
IPv6 hop limit. |
ipv6_retransmission_time_sec |
string |
IPv6 retransmission timeout. |
ipv4_duplicate_address_detection_timeout_sec |
string |
IPv4 DAD timeout. |
ipv4_reverse_path_filter |
string |
IPv4 reverse path filtering mode. |
ipv4_accept_local |
boolean |
Accept packets to local addresses as non-local. |
ipv4_route_localnet |
boolean |
Route local network traffic. |
ipv4_proxy_arp |
boolean |
Enable proxy ARP. |
ipv4_proxy_arp_private_vlan |
boolean |
Private VLAN proxy ARP. |
ipv6_proxy_ndp |
boolean |
Enable IPv6 proxy NDP. |
ipv6_proxy_ndp_address |
string |
IPv6 proxy NDP address. |
ipv6_send_ra |
boolean |
Send IPv6 Router Advertisements. |
m_pls_routing |
boolean |
Enable MPLS routing. |
keep_master |
boolean |
Keep master on stop. |
ip_family |
string |
IP family to use. |
Keys not in the recognized set will be saved to config.json but won't be rendered to .network files. A warning is logged identifying any unrecognized keys.
DNS Upstream Sync
When POST /api/network/apply is called, the handler automatically collects public DNS servers from all networkd interface configs (via collect_upstream_dns()), filters out local/private-range addresses, and syncs the deduplicated list to dnsmasq's upstream DNS configuration. This keeps dnsmasq's upstream resolvers in sync with whatever DNS the WAN interface receives (whether statically configured or via DHCP).
Generated Files
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
Some subsystems depend on each other. When you modify one, related subsystems are updated automatically through the event bus.
| Trigger Subsystem | Affected Subsystem | What Happens |
|---|---|---|
| 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 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. |
Note: The firewall "Apply" button is still needed to push config changes to firewalld. Sync only updates the declarative JSON.
Additionally, on daemon startup, lib/system_import.py reconciles live system
configs (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative
JSON. This prevents drift when configs were created by the install script or
edited manually in system files. Reconciliation only writes when the existing
JSON differs or is missing — no data is lost on re-run.