68 KiB
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. Authentication is handled at the Flask layer via JWT — the Authorization: Bearer <token> header. Public endpoints — POST /api/auth/login, POST /api/auth/refresh, and the WebAuthn authenticate endpoints — do not require a token. Every other request must send both the Authorization: Bearer <token> header and the mandatory X-Session-Id header (a missing/invalid token or a missing X-Session-Id yields HTTP 401).
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
- Call
POST /api/auth/loginwith credentials - Store the returned
access_token - Include
Authorization: Bearer <access_token>on all subsequent requests - 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". A request with no permission entry for its subsystem (or a method/level mismatch) is rejected with HTTP 403. User management endpoints (/api/auth/users/*) and credential counts (/api/auth/webauthn/credential-counts) follow the same rule: GET needs only auth: "read", while POST/PATCH/DELETE need auth: "rw".
Conventions
Success Responses
{
"ok": true,
"data": <value>
}
The data field contains the payload, which may be an object, array, string, or null.
Error Responses
{
"ok": false,
"error": "<human-readable message>"
}
Error responses carry one of the following HTTP status codes:
| Code | Meaning |
|---|---|
400 |
Bad request — invalid body, missing required field, or malformed value |
401 |
Unauthorized — missing/invalid Bearer token, missing X-Session-Id header, or invalid/expired/blacklisted token |
403 |
Forbidden — the caller lacks the required subsystem permission (auth: "rw" where needed, or no entry for the subsystem) |
404 |
Not found — the requested resource does not exist |
409 |
Conflict — the requested operation conflicts with an existing resource |
500 |
Internal server error — unexpected failure in the backend |
Route Patterns
Resource identification uses path parameters whenever possible. Exceptions occur only when the identifier is inherently long (e.g., a rich rule string), in which case the body carries the identifier.
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 {} (an empty object) 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. The request body must carry both refresh_token and session_id (session binding).
Auth: Public — no JWT required (this is a public endpoint, so the X-Session-Id header is not sent).
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
refresh_token |
string |
Yes | The refresh token to rotate |
session_id |
string |
Yes | Session ID from the token pair (session binding) |
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 (minimum 8 characters) |
Response: data is {"ok": true} on success.
Returns HTTP 400 for any failure — missing fields, incorrect old password, or a new password shorter than 8 characters.
User Management
List Users
GET /api/auth/users
List all users.
Auth: auth: "read" required (read-only endpoint).
Response (data): the array of user summaries directly (no users wrapper). Each entry has id, username, permissions ({ subsystem: "read" | "rw" }), and created_at.
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 (minimum 8 characters) |
permissions |
object |
No | Per-subsystem permissions ({ subsystem: "read" | "rw" }) |
Response (data):
| Field | Type | Description |
|---|---|---|
id |
int |
User ID |
username |
string |
Username |
permissions |
object |
Per-subsystem permissions ({ subsystem: "read" | "rw" }) |
Returns HTTP 409 if the username already exists. Returns HTTP 400 if the password is missing or shorter than 8 characters.
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):
| Field | Type | Description |
|---|---|---|
id |
int |
User ID |
username |
string |
Username |
permissions |
object |
Per-subsystem permissions ({ subsystem: "read" | "rw" }) |
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 {"ok": true} on success.
Returns HTTP 403 if the user attempts to delete their own account. Returns HTTP 404 if the user is not found.
WebAuthn
Check WebAuthn Capability
GET /api/auth/webauthn/capable
Check whether WebAuthn is available on the current request domain (the relying-party ID is derived from the request host).
Auth: Access token required.
Response (data):
When enabled:
| Field | Type | Description |
|---|---|---|
enabled |
boolean |
Always true |
rp_id |
string |
Relying-party ID (request host) |
rp_name |
string |
Relying-party display name |
origin |
string |
Resolved WebAuthn origin (scheme://host) |
When unavailable: {"enabled": false, "reason": "<reason>"}.
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 |
No | Auto-injected from the JWT (the authenticated user); any value in the body is overridden |
Response (data): Standard WebAuthn registration options.
| 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 |
No | Auto-injected from the JWT; any value in the body is overridden |
credential_response |
object |
Yes | WebAuthn authenticator attestation response |
registration_options |
object |
Yes | The registration options returned by register-begin |
name |
string |
No | Display name for this credential |
Response (data):
| Field | Type | Description |
|---|---|---|
ok |
boolean |
Always true |
credential |
object |
The stored credential (id, name, transports, sign_count) |
Returns HTTP 400 if verification fails or required fields are missing.
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 401 if verification fails or required fields are missing.
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, sign_count).
Credential Counts
GET /api/auth/webauthn/credential-counts
Return credential counts for all users.
Auth: auth: "read" required (read-only endpoint).
Response (data): The dict directly (no counts wrapper) — a mapping of 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 {"ok": true} on success.
Returns HTTP 404 if the credential is not found.
Firewall API
Endpoints prefixed with /api/firewall/.... Interact with firewalld for zone management, rich rules, NAT, and masquerade.
Declarative Config
The firewall supports a two-step declarative workflow: save config to config/firewall/config.json, then apply it to live firewalld. The config tracks rich_rules and forward_ports with auto-generated id fields.
Get Config
GET /api/firewall/config
Return the current declarative firewall config.
Response: data contains the config object with a zones mapping.
Save Config
POST /api/firewall/config
Replace the declarative config. Returns pending changes summary.
Request Body: Request body must contain zones. An optional top-level unmanaged array (list of interface names) exempts those interfaces from the interface-coverage invariant.
Errors: Returns HTTP 400 when the body is malformed (missing/non-dict zones, non-list unmanaged) or when the config would leave a network-managed interface without zone coverage (the interface-coverage invariant — see docs/config.md).
Response (data):
| Field | Type | Description |
|---|---|---|
config_saved |
boolean |
Always true |
pending |
[object, ...] |
List of pending changes |
needs_apply |
boolean |
Whether changes need to be applied |
unmanaged_zones |
object |
Zones active on system but not in config |
Apply Config
POST /api/firewall/config/apply
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
Request Body: None. The webui route accepts no body — the {"force": true} override of the management-lockout and interface-coverage guards is a daemon-only capability and cannot be sent through this webui endpoint. (To force an apply through the webui, use POST /api/status/apply-all with {"force": true}, which forwards force to the firewall apply.)
Errors: Returns HTTP 409 when the apply is refused by the management-lockout guard (https+ssh stripped from the default zone) or the interface-coverage invariant (a network-managed interface has no zone coverage and is not unmanaged). See docs/config.md.
Response (data):
| Field | Type | Description |
|---|---|---|
applied_zones |
[string, ...] |
List of zone names that were applied |
backup |
string |
Path to the firewall state backup file |
synced |
[string, ...] |
Subsystems that were automatically updated by the sync event bus |
Check Pending Changes
GET /api/firewall/config/pending
Compare declarative config against live firewalld state. Returns the diff for interfaces, services, targets, masquerade, rich rules, and forward ports.
Response (data):
| Field | Type | Description |
|---|---|---|
pending |
[object, ...] |
List of pending changes |
needs_apply |
boolean |
Whether changes need to be applied |
unmanaged_zones |
object |
Zones active on the system but not present in the config |
pending_summary |
[string, ...] |
Human-readable summary string per pending change |
Unlike the POST/PATCH /config save response, this endpoint does not include config_saved; instead it adds pending_summary.
Partial Update Config
PATCH /api/firewall/config
Deep-merge the provided fields into the existing config. Returns pending changes summary.
Request Body: Partial config object; a provided unmanaged array replaces the existing one.
Errors: Returns HTTP 400 when the merged config is malformed or would leave a network-managed interface without zone coverage (interface-coverage invariant — see docs/config.md).
Response (data):
| Field | Type | Description |
|---|---|---|
config_saved |
boolean |
Always true |
pending |
[object, ...] |
List of pending changes |
needs_apply |
boolean |
Whether changes need to be applied |
unmanaged_zones |
object |
Zones active on system but not in config |
Zone Management
List All Zones
GET /api/firewall/zones
Returns active zone-to-interface mappings and all available zone definitions.
Response:
| Field | Type | Description |
|---|---|---|
data.active |
object<name, [interface, ...]> |
Currently assigned interfaces per zone |
data.available |
[string, ...] |
All zones known to firewalld |
Get Zone Details
GET /api/firewall/zones/<name>
Return detailed configuration for a single zone.
Response (data):
| Field | Type | Description |
|---|---|---|
name |
string |
Zone name |
target |
string |
Zone target (e.g., "default", "ACCEPT", "REJECT") |
interfaces |
[string, ...] |
Interfaces assigned to this zone |
sources |
[string, ...] |
Source IPs addressed by this zone |
services |
[string, ...] |
Services allowed through the zone |
ports |
[string, ...] |
Explicit port rules (format: "443/tcp") |
protocols |
[string, ...] |
Protocols to accept |
icmp-blocks |
[string, ...] |
ICMP types blocked |
masquerade |
boolean |
Whether masquerade (NAT) is enabled |
ics |
boolean |
Whether ICMP redirect (ICS) is enabled |
forward-ports |
[{port, proto, toaddr, toport}, ...] |
Port forward rules (key is hyphenated) |
rich-rules |
[string, ...] |
Rich rule strings (key is hyphenated) |
Returns HTTP 404 if the zone does not exist.
Create Zone
POST /api/firewall/zones
Create a new firewalld zone.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Zone name |
target |
string |
No | Zone target; defaults to "default" |
Response: data is null on success.
Returns HTTP 400 if the zone already exists.
Delete Zone
DELETE /api/firewall/zones/<name>
Remove a zone from firewalld.
Response: data is null on success.
Returns HTTP 404 if the zone does not exist.
Zone Configuration
Set Zone Interfaces
POST /api/firewall/zones/<name>/interfaces
Replace all interfaces assigned to the zone with the provided list.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
interfaces |
[string, ...] |
Yes | List of interface names |
Response (data):
| Field | Type | Description |
|---|---|---|
zone |
string |
Zone name |
interfaces |
[string, ...] |
List of interface names now assigned |
Returns HTTP 404 if the zone does not exist.
Set Zone Services
POST /api/firewall/zones/<name>/services
Replace all services allowed in the zone with the provided list.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
services |
[string, ...] |
Yes | List of firewalld service names |
force |
boolean |
No | Override the management-lockout guard |
Response (data):
| Field | Type | Description |
|---|---|---|
zone |
string |
Zone name |
services |
[string, ...] |
List of services now allowed |
Returns HTTP 404 if the zone does not exist. Returns HTTP 409 if the change would strip both https and ssh from the default zone (the management-lockout guard) and force is not set.
Rich Rules
Add Rich Rule
POST /api/firewall/rich-rules
Add a firewalld rich rule to a zone. The rule is persisted to the declarative config with an auto-generated id.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
zone |
string |
Yes | Zone to add the rule to |
rule |
string |
Yes | Full rich rule string |
Response (data):
| Field | Type | Description |
|---|---|---|
zone |
string |
Zone name |
id |
string |
8-character unique ID |
rule |
string |
Full rich rule string |
Remove Rich Rule
DELETE /api/firewall/rich-rules/<zone>/<id>
Remove a rich rule by zone and auto-generated ID. (The rule string itself is too long for a URL path.)
Response (data):
| Field | Type | Description |
|---|---|---|
zone |
string |
Zone name |
id |
string |
ID of the removed rule |
Returns HTTP 404 if the rule ID is not found.
List Rich Rules
GET /api/firewall/rich-rules/<zone>
Return all rich rules for the specified zone. Each entry carries a rule string; rules that are tracked in the declarative config also carry an id.
Response:
| Field | Type | Description |
|---|---|---|
data |
[{id?, rule}, ...] |
Rich rules; id is present only for rules that have a matching config entry (live-only rules are returned without id) |
Port Forwarding
Add Port Forward
POST /api/firewall/forward-port
Add a port forwarding rule to a zone. The rule is persisted to the declarative config with an auto-generated id.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
zone |
string |
Yes | Zone to add the rule to |
port |
number |
Yes | External port |
proto |
string |
Yes | Protocol ("tcp" or "udp") |
toaddr |
string |
No | Internal destination address |
toport |
number |
No | Internal destination port |
Response (data):
| Field | Type | Description |
|---|---|---|
zone |
string |
Zone name |
id |
string |
8-character unique ID |
port |
number |
External port |
proto |
string |
Protocol ("tcp" or "udp") |
Remove Port Forward
DELETE /api/firewall/forward-port/<zone>/<port>/<proto>
Remove a port forwarding rule. Zone, port, and protocol are all path parameters.
Response (data):
| Field | Type | Description |
|---|---|---|
zone |
string |
Zone name |
port |
number |
External port |
proto |
string |
Protocol ("tcp" or "udp") |
Returns HTTP 404 if the forward port is not found.
Masquerade (NAT)
Enable / Disable Masquerade
POST /api/firewall/masquerade
Toggle masquerade (source NAT) for a zone.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
zone |
string |
Yes | Zone to configure |
enable |
boolean |
Yes | true to enable, false to disable |
Response (data):
| Field | Type | Description |
|---|---|---|
zone |
string |
Zone name |
masquerade |
boolean |
Whether masquerade is now enabled |
Returns HTTP 400 when attempting to enable masquerade on the public zone (it is not supported there — use internal or vpn).
State
Get Firewall State
GET /api/firewall/state
Return current firewall state from the daemon state store. Provides live firewall state data including active zones, services, and interfaces as polled by the daemon.
Response:
| Field | Type | Description |
|---|---|---|
data |
object |
Firewall state data from the state collector |
Info
Available Services
GET /api/firewall/services
List all service names known to firewalld.
Response:
| Field | Type | Description |
|---|---|---|
data |
[string, ...] |
Service names |
Available Interfaces
GET /api/firewall/interfaces
List all network interfaces currently available on the system.
Response:
| Field | Type | Description |
|---|---|---|
data |
[string, ...] |
Interface names |
DHCP / DNS API
Endpoints prefixed with /api/dhcp/.... Manage dnsmasq configuration, DHCP leases, and custom DNS records.
Configuration
Get Configuration
GET /api/dhcp/config
Return the current DHCP/DNS configuration object.
Response:
| Field | Type | Description |
|---|---|---|
data |
object |
Full dnsmasq configuration dictionary |
Replace Configuration
POST /api/dhcp/config
Replace the entire configuration with the provided JSON object.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
| (entire body) | object |
Yes | Complete configuration object |
Response: data is null on success.
Partial Update Configuration
PATCH /api/dhcp/config
Deep-merge the provided fields into the existing configuration. Useful for targeted updates (e.g., changing DNS upstream servers without replacing the full config).
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
| (any subset) | any |
Yes | Fields to merge into the existing config |
Response: data is null on success.
Apply Configuration
POST /api/dhcp/apply
Write the in-memory configuration to /etc/dnsmasq.d/vacuum-wall.conf and restart the dnsmasq service (systemctl restart dnsmasq, not a reload).
Response: data is null on success.
Status
Get Service Status
GET /api/dhcp/status
Return the current service status and pending-change summary.
Response (data):
| Field | Type | Description |
|---|---|---|
service_active |
boolean |
Whether dnsmasq is running |
config_file_exists |
boolean |
Whether the config file exists on disk |
active_leases |
number |
Number of active leases |
pending_changes |
boolean |
Whether the saved config differs from the last applied state |
pending_diff |
[object, ...] |
Per-field pending changes (diff of config vs applied baseline) |
DHCP Ranges
Add Range
POST /api/dhcp/ranges
Add or replace the DHCP range for a given interface.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
interface |
string |
No | Interface name (empty = all interfaces) |
start |
string |
Yes | Start of IP range |
end |
string |
Yes | End of IP range |
lease_time |
string |
No | Lease duration; defaults to "12h" |
Response: data is null on success.
Remove Range
DELETE /api/dhcp/ranges
Remove a DHCP range. Body contains identifying fields.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
interface |
string |
No | Interface name; defaults to "" (all interfaces) |
start |
string |
Yes | Start of IP range |
end |
string |
Yes | End of IP range |
Response: data is null on success.
Returns HTTP 404 if no range matches the given interface/start/end.
Static Leases
Add Static Lease
POST /api/dhcp/static-lease
Add a static (reserved) DHCP lease.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
mac |
string |
Yes | MAC address ("aa:bb:cc:dd:ee:ff") |
ip |
string |
Yes | Reserved IP address |
hostname |
string |
No | Hostname for the reservation |
Response (data):
| Field | Type | Description |
|---|---|---|
mac |
string |
MAC address |
ip |
string |
Reserved IP address |
hostname |
string |
Hostname |
Remove Static Lease
DELETE /api/dhcp/static-lease/<mac>
Remove a static lease by MAC address.
Response: data is null on success.
Returns HTTP 404 if no matching lease is found.
Live Leases
Get Live Leases
GET /api/dhcp/leases
Return the current DHCP lease table from dnsmasq.
Response:
| Field | Type | Description |
|---|---|---|
data |
[object, ...] |
Array of lease objects |
DNS Records
Add DNS Record
POST /api/dhcp/dns-record
Add a custom DNS A record served by dnsmasq.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Fully qualified domain name |
address |
string |
Yes | IP address to resolve to |
hostname |
string |
No | Short hostname |
Response (data):
| Field | Type | Description |
|---|---|---|
name |
string |
Fully qualified domain name |
address |
string |
IP address |
hostname |
string |
Short hostname |
Remove DNS Record
DELETE /api/dhcp/dns-record/<name>
Remove a custom DNS record by domain name.
Response: data is null on success.
Returns HTTP 404 if no matching record is found.
DNS Search Domain
Set Search Domain
POST /api/dhcp/domain
Set or clear the DNS search domain. Pass domain to set it, or null to clear it.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
domain |
string |
No | DNS search domain; null clears it |
Response: data is null on success.
Proxy API
Endpoints prefixed with /api/proxy/.... Manage reverse proxy domains, nginx configuration generation, and the management WebUI proxy.
Configuration
Get Proxy Configuration
GET /api/proxy/config
Return the current proxy configuration object.
Response:
| Field | Type | Description |
|---|---|---|
data |
object |
Full proxy configuration dictionary |
Replace Proxy Configuration
POST /api/proxy/config
Replace the entire proxy configuration with the provided JSON object.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
| (entire body) | object |
Yes | Complete proxy configuration object |
Response: data is null on success.
Partial Update Proxy Configuration
PATCH /api/proxy/config
Deep-merge the provided fields into the existing proxy configuration.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
| (any subset) | any |
Yes | Fields to merge into the config |
Response: data is null on success.
SSL
Apply SSL Snippet
POST /api/proxy/ssl-apply
Write the global nginx SSL snippet configuration.
Response: data is null on success.
Domain Management
List All Domains
GET /api/proxy/domains
Return all configured proxy domains. The response is flattened by path — each path within a domain produces a separate entry.
Response:
| Field | Type | Description |
|---|---|---|
data |
[object, ...] |
Array of path-level domain configuration objects |
Each entry contains domain (string), path (string), backend (object with host, port, proto), backend_name (string — the name of the referenced backend), cert (string or null), online (boolean), force_ssl (boolean), and optionally is_management or is_websocket flags.
Add Domain
POST /api/proxy/domains
Add a new reverse proxy domain that routes to a named backend. The "paths mode" / "legacy mode" split no longer exists — domains reference a backend by name and per-path routing lives on the backend itself.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
domain |
string |
Yes | Domain name to proxy |
backend |
string |
Yes | Name of an existing backend (a key under backends) |
cert |
string |
No | Certificate type |
force_ssl |
boolean |
No | HTTPS redirect flag (default true) |
auth |
object |
No | Basic auth as {user, pass}; when both are present a .htpasswd file is written as a side-effect and the raw password is not persisted (only {user, htpasswd: <path>} is stored) |
Response (data):
| Field | Type | Description |
|---|---|---|
domain |
string |
Domain name |
Returns HTTP 400 if the domain is already configured, if domain or backend is missing, or if the referenced backend does not exist.
Update Domain
PUT /api/proxy/domains/<domain>
Update one or more fields of an existing domain entry. Only fields present in the body are modified.
Request Body: Any subset of (backend, cert, force_ssl, auth).
backend— re-point the domain at a different existing backend name.cert— set a new certificate type, ornullto remove it.force_ssl— toggle the HTTPS redirect flag.auth— set basic auth (see Add Domain for the.htpasswdside-effect), ornullto remove it.
Response (data):
| Field | Type | Description |
|---|---|---|
domain |
string |
Domain name |
Returns HTTP 404 if the domain is not configured. Returns HTTP 400 if the body is empty or the new backend does not exist.
Remove Domain
DELETE /api/proxy/domains/<domain>
Remove a proxy domain and its nginx configuration.
Response (data):
| Field | Type | Description |
|---|---|---|
domain |
string |
Domain name |
Returns HTTP 404 if the domain is not configured.
Backend Management
Backends define the per-path routing (paths) and any basic auth; proxy domains reference a backend by name.
List All Backends
GET /api/proxy/backends
Return all configured backends. Secret material is stripped — each backend carries a has_auth boolean instead of its auth object.
Response:
| Field | Type | Description |
|---|---|---|
data |
object |
Map of backend name to {label, paths, has_auth, builtin?} (auth stripped) |
Update Backend
PATCH /api/proxy/backends
Deep-merge a partial update into an existing backend entry. Built-in backends cannot be modified.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Backend name to update |
label |
string |
No | New display label |
paths |
object |
No | New path-to-backend map |
auth |
object | false | null |
No | Set basic auth, or false/null to remove it |
Response (data): {"backend": "<name>"}.
Returns HTTP 400 if name is missing or the backend is built-in. Returns HTTP 500 if the backend name does not exist.
Add Backend
POST /api/proxy/backends
Add a new backend.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Backend name (must be unique) |
label |
string |
Yes | Display label |
paths |
object |
Yes | Path-to-backend map; each entry must carry host, port, proto |
auth |
object |
No | Basic auth configuration |
Response (data): {"backend": "<name>"}.
Returns HTTP 400 if name, label, or paths is missing, if the backend already exists, or if the paths schema is invalid.
Remove Backend
DELETE /api/proxy/backends/<name>
Remove a non-builtin backend.
Response (data): {"backend": "<name>"}.
Returns HTTP 409 if one or more domains reference the backend. Returns HTTP 400 if the backend is built-in.
Apply / Test
Apply Configuration
POST /api/proxy/apply
Regenerate nginx configuration files for all proxy domains and reload the nginx service.
Response: data is null on success.
Returns HTTP 500 if nginx config generation fails or the reload fails.
Test Configuration
POST /api/proxy/test
Run nginx -t against the generated configuration without reloading.
Response (valid):
| Field | Type | Description |
|---|---|---|
data.valid |
boolean |
Always true |
data.output |
string |
Raw nginx test output |
Error (invalid): HTTP 400 with standard {"ok": false, "error": "<nginx output>"} response.
Management Proxy
The legacy
POST /api/proxy/managementendpoint has been removed. The management WebUI proxy is now configured as a regular domain entry withis_management: trueon the root path andis_websocket: trueon the/wspath. Use the standard domain add/update endpoints to configure it.
Certificate API
Endpoints prefixed with /api/certs/.... Manage TLS certificates via ACME (ZeroSSL, Let's Encrypt, etc.).
Listing & Details
List All Certificates
GET /api/certs/list
Return all managed certificates with metadata.
Response:
| Field | Type | Description |
|---|---|---|
data |
[object, ...] |
Array of certificate objects |
Each certificate object contains domain, issuer (the CA/issuer name), san_domains (array of subject-alternative names), expires_at, days_until_expiry, cert_path, key_path, ca_path, and auto_renew.
Get Certificate Details
GET /api/certs/<domain>
Return details for a single certificate. Matches on the main domain or any of the certificate's san_domains.
Response (data): The full certificate object (domain, issuer, san_domains, expires_at, days_until_expiry, cert_path, key_path, ca_path, auto_renew). When an issuance is currently running for the domain, an additional issuance field (the issuance status object) is embedded.
If no certificate exists yet but an issuance is in progress, the response is {"domain": <domain>, "status": "issuing", "issuance": {...}}.
Returns HTTP 404 if no certificate is found and no issuance is in progress.
Validation
Validate Certificate Issuance
POST /api/certs/validate
Run pre-flight checks before certificate issuance. Verifies domain format, ACME account registration, DNS resolution, and port 80 accessibility.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
domain |
string |
Yes | Domain to validate |
Response (data): Validation results object with per-check status.
Returns HTTP 400 if the domain is missing.
Operations
Start Certificate Issuance
POST /api/certs/issue/start
Create a new certificate issuance request. Issuance runs asynchronously in the background.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
domain |
string |
Yes | Domain to issue the certificate for |
email |
string |
No | ACME contact email — deprecated, ignored in favor of the registered account email |
webroot |
string |
No | Custom webroot path for HTTP-01 validation |
Response (data):
| Field | Type | Description |
|---|---|---|
request_id |
string |
Unique identifier for polling issuance status |
domain |
string |
Domain being issued |
status |
string |
Only present when an issuance for this domain is already running — "existing" (the existing request_id is returned) |
Returns HTTP 400 if the domain is missing. Returns HTTP 409 if a valid certificate already exists for the domain (renew instead). An ACME account must be registered before issuance (verified by the account_registered blocking check in the validation pipeline).
Poll Certificate Issuance Status
GET /api/certs/issue/<request_id>
Poll the status of a certificate issuance request started by POST /api/certs/issue/start.
Response (data): Issuance status object containing progress, logs, and result.
Returns HTTP 404 if the request ID is not found. The frontend uses poll() to repeatedly fetch this endpoint until issuance completes or fails.
Renew Certificate
POST /api/certs/<domain>/renew
Start an async certificate renewal for an existing certificate. The renewal
runs in the background and is polled via
GET /api/certs/renew/<request_id>.
Request Body: Optional. The domain is taken from the path.
| Field | Type | Required | Description |
|---|---|---|---|
force |
boolean |
No | Force renewal even if the certificate's renewal window has not been reached (default false) |
Response (data):
| Field | Type | Description |
|---|---|---|
request_id |
string |
Unique identifier for polling renewal status |
domain |
string |
Domain being renewed |
status |
string |
Only when a renewal for this domain is already in progress ("existing" — the existing request_id is returned) |
The renewal is a no-op when the certificate's renewal window (default:
30 days before expiry) has not been reached — the request then completes with
status: "skipped".
Returns HTTP 400 if the domain is missing. Returns HTTP 500 when the
renewal cannot be started (e.g. daemon unreachable).
Poll Certificate Renewal Status
GET /api/certs/renew/<request_id>
Poll the status of a certificate renewal started by
POST /api/certs/<domain>/renew.
Response (data): Renewal status object containing request_id,
domain, status ("running", "completed", "skipped", or "failed"),
a steps array (each with per-step status and error message), and
timestamps.
Returns HTTP 404 if the request ID is not found. The frontend uses
poll() to repeatedly fetch this endpoint until the renewal completes,
is skipped, or fails.
Remove Certificate
DELETE /api/certs/<domain>
Delete a certificate and remove it from auto-renewal tracking. There is no existence check — the certificate may or may not exist.
Response: data is null on success.
Returns HTTP 400 if the domain is missing. Failures (e.g. acme.sh --remove failing) surface as HTTP 500; the endpoint never returns 404.
Account
Get ACME Account Status
GET /api/certs/account
Return the ACME account registration status.
Response (data):
| Field | Type | Description |
|---|---|---|
registered |
boolean |
Whether an ACME account is registered |
email |
string |
Registered contact email (empty if unregistered) |
ca |
string |
CA provider (e.g., "let's encrypt", "ZeroSSL") (empty if unregistered) |
Returns HTTP 500 if the account status cannot be determined.
Register ACME Account
POST /api/certs/account/register
Register a new ACME account with the specified email and CA provider.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
email |
string |
Yes | Contact email address |
server |
string |
No | CA provider: "letsencrypt" or "zerossl". Default: "letsencrypt" |
Response (data):
| Field | Type | Description |
|---|---|---|
registered |
boolean |
Always true on success |
email |
string |
Registered contact email |
ca |
string |
CA provider |
Returns HTTP 400 if the email is missing or invalid. Returns HTTP 500 if registration fails. An ACME account must be registered before certificates can be issued.
Deactivate ACME Account
DELETE /api/certs/account
Deactivate the ACME account via acme.sh --deactivate-account. Clears the email and ca fields from config/acme/config.json. A failure in the acme.sh call is caught and logged but does not fail the endpoint — the config cleanup always runs.
Response (data):
| Field | Type | Description |
|---|---|---|
email |
string |
Empty string indicating the account was deactivated |
Set ACME Contact Email
POST /api/certs/email
Set or update the ACME account contact email.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
email |
string |
Yes | Contact email address |
Response (data): Returns the set email field.
Generate Self-Signed Certificate (daemon-only)
There is no POST /api/certs/self-signed webui route. Self-signed generation is a daemon-only endpoint, POST /acme/self-signed (reached directly over the daemon socket, not via the WebUI).
It generates a self-signed certificate for a domain and is idempotent — it skips generation if <domain>.crt and <domain>.key already exist at data/certs/.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
domain |
string |
Yes | Domain name for the certificate CN |
days |
number |
No | Validity in days; defaults to 365 |
Response (data):
| Field | Type | Description |
|---|---|---|
domain |
string |
Domain name |
cert |
string |
Path to data/certs/<domain>.crt |
key |
string |
Path to data/certs/<domain>.key |
generated |
boolean |
true if a new cert was created, false if the existing cert was reused |
WireGuard API
Endpoints prefixed with /api/wireguard/.... Manage the WireGuard VPN server, peers, and client configuration.
Configuration
Get Configuration
GET /api/wireguard/config
Return the current WireGuard server configuration. The private_key field is stripped.
Response:
| Field | Type | Description |
|---|---|---|
data |
object |
WireGuard config (private_key omitted) |
Replace Configuration
POST /api/wireguard/config
Replace the entire WireGuard configuration. The private_key field is stripped from the response.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
| (entire body) | object |
Yes | Complete WireGuard configuration object |
Response: data is null on success.
Partial Update Configuration
PATCH /api/wireguard/config
Deep-merge the provided fields into the existing configuration.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
| (any subset) | any |
Yes | Fields to merge into the existing config |
Response: data is null on success.
Tunnel Control
Apply Configuration
POST /api/wireguard/apply
Write the current configuration to wg0.conf and bring the tunnel up.
Response: data is null on success.
Start Tunnel
POST /api/wireguard/up
Alias for /api/wireguard/apply — write config and bring the tunnel up.
Response: data is null on success.
Bring Tunnel Down
POST /api/wireguard/down
Bring down the WireGuard tunnel interface(s) (all class interfaces plus the legacy wg0).
Response: data is null on success (the webui route discards the daemon payload). The daemon itself returns {"down": true} — it does not include a synced field.
Status
Tunnel Status
GET /api/wireguard/status
Return live tunnel state with interface metrics and per-peer connection statistics.
Response (data):
| Field | Type | Description |
|---|---|---|
up |
boolean |
Whether the tunnel interface is up |
interface |
object |
Interface info (listen port, public key) |
peers |
[object, ...] |
Per-peer stats (handshake, bytes, endpoint) |
classes |
object |
Per-class runtime status keyed by class key ({up, interface, peers}) |
Initialize
POST /api/wireguard/initialize
First-time setup: generate server key pair, write initial config. Idempotent.
Response: data is null on success.
Peer Management
List Peers
GET /api/wireguard/peers
Return all configured peers. Private keys are stripped.
Response:
| Field | Type | Description |
|---|---|---|
data |
[object, ...] |
Peer objects (private keys omitted) |
Add Peer
POST /api/wireguard/peers
Add a new WireGuard peer, or upsert an existing one — if the name is already configured, the provided fields update that peer in place (a key pair is only generated for genuinely new peers). Private key stripped from response.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Peer identifier name |
endpoint |
string |
No | Allowed endpoint address ("ip:port") |
allowed_ips |
[string, ...] |
No | Allowed IPs; defaults to [] |
persistent_keepalive |
number |
No | Persistent keepalive interval (seconds) |
preshared_key |
string |
No | Preshared key |
description |
string |
No | Peer description |
access_class |
string |
No | Access class key this peer belongs to |
Response (data): The peer object with public_key, endpoint, allowed_ips, persistent_keepalive, preshared_key, description, access_class (no private_key).
Remove Peer
DELETE /api/wireguard/peers/<name>
Remove a configured peer by name.
Response (data):
| Field | Type | Description |
|---|---|---|
name |
string |
Peer name |
Returns HTTP 404 if the peer is not found.
Peer Connection Status
GET /api/wireguard/peer-status
Return live per-peer connection status from wg show.
Response:
| Field | Type | Description |
|---|---|---|
data |
[object, ...] |
Live peer status (handshake time, bytes, endpoint) |
Client Configuration
Generate Client Config
POST /api/wireguard/generate-client
Generate a complete WireGuard client configuration file. The returned config includes the peer's private key for provisioning.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Peer name to generate config for |
server_endpoint |
string |
Yes | Server public address ("ip:port") |
Response (data):
| Field | Type | Description |
|---|---|---|
config |
string |
Complete client config text ([Interface] + [Peer]) |
This is the only endpoint that returns a WireGuard private key. All other endpoints strip private keys from responses.
Returns HTTP 404 if the peer is not found.
Access Classes
Manage VPN access classes that categorize peers by access level (e.g., full LAN access, internet-only).
List Access Classes
GET /api/wireguard/classes
Return all configured access classes. Private keys are stripped.
Response (data):
Object keyed by class identifier, each entry carrying name, description, subnet, listen_port, lan_access, and public_key (private key omitted).
Create Access Class
POST /api/wireguard/classes
Create a new access class.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
key |
string |
Yes | Class identifier (lowercase alphanumeric) |
name |
string |
No | Display name (defaults to key) |
description |
string |
No | Description text |
subnet |
string |
No | Class subnet (CIDR) |
listen_port |
number |
No | Listen port for the class interface |
lan_access |
boolean |
No | Whether peers get LAN access (default false) |
Response (data): The created class object (name, description, subnet, listen_port, lan_access, public_key) — note there is no key field in the response; the class is keyed by the request key.
Returns HTTP 400 if the key is missing or is not lowercase alphanumeric. Returns HTTP 409 if the key already exists.
Update Access Class
PATCH /api/wireguard/classes
Update an existing access class. Only the fields present in the body are changed.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
key |
string |
Yes | Class identifier |
name |
string |
No | New display name |
description |
string |
No | New description |
subnet |
string |
No | New subnet (CIDR) |
listen_port |
number |
No | New listen port |
lan_access |
boolean |
No | New LAN access flag |
Response (data): The updated class object — key plus name, description, subnet, listen_port, lan_access, and public_key.
Returns HTTP 404 if the class is not found.
Delete Access Class
DELETE /api/wireguard/classes
Remove an access class. Cannot delete a class that has peers assigned to it.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
key |
string |
Yes | Class identifier |
Response (data): { "key": "<key>" }
Returns HTTP 404 if the class is not found. Returns HTTP 409 if peers reference the class.
Bring Class Tunnel Up
POST /api/wireguard/classes/<key>/up
Bring up a single class's tunnel interface (renders the class config and runs wg-quick up).
Response: data is null on success.
Returns HTTP 404 if the class does not exist. Returns HTTP 400 if the class has no assigned peers.
Bring Class Tunnel Down
POST /api/wireguard/classes/<key>/down
Bring down a single class's tunnel interface. Note: the webui exposes this as POST, while the underlying daemon endpoint is a DELETE (/wireguard/classes/<key>/down).
Response: data is null on success.
Returns HTTP 404 if the class does not exist.
Get Class Status
GET /api/wireguard/classes/<key>/status
Return live status for a single class's tunnel interface.
Response (data): The class status object (up, interface, peers).
Returns HTTP 404 if the class does not exist.
Generate Class Keys
POST /api/wireguard/classes/keys/<key>
Generate a key pair for a class (idempotent — reports generated: false if keys already exist).
Response: data is null on success via the webui (the webui route discards the daemon payload). The daemon itself returns {generated, class_key, public_key} (or {generated: false, class_key, reason} when keys already exist).
Returns HTTP 404 if the class does not exist.
Network API
Endpoints prefixed with /api/network/.... Manage systemd-networkd interface configuration including static addresses, routes, DNS, DHCP client settings, and link parameters.
Interface Management
List All Interfaces
GET /api/network/interfaces
Return all network interfaces (configured and live, including loopback) with their network config and runtime state from networkctl. runtime.state is the networkctl operational state (routable, degraded, carrier, off, …).
Response:
| Field | Type | Description |
|---|---|---|
data.interfaces |
object |
Map of interface name to {config, runtime} |
data.timestamp |
string |
Timestamp of runtime data collection |
Get Interface Details
GET /api/network/interfaces/<name>
Return config and runtime state for a specific interface.
Response (data):
| Field | Type | Description |
|---|---|---|
name |
string |
Interface name |
config |
object |
Full networkd config entry for this interface |
runtime |
object |
Runtime state from networkctl (addresses, gateway, DNS, state) |
Returns HTTP 400 if the interface name is invalid (contains path components, spaces, or characters outside ^[a-zA-Z0-9][a-zA-Z0-9._-]*$). Returns HTTP 404 if the interface is not found in config.
Save and Apply Interface
POST /api/network/interfaces/<name>
Save network config for an interface, render the .network file, copy it to /etc/systemd/network/, and reload networkd for that interface.
Request Body: Any networkd config keys (e.g., addresses, gateway, dns, routes, dhcp, link, dhcp_client).
Response (data):
| Field | Type | Description |
|---|---|---|
name |
string |
Interface name |
applied |
boolean |
Always true |
The webui response is a fixed { "name": ..., "applied": true } — it never reports false and carries no synced field (the daemon returns applied/synced internally, but the webui transform flattens it to this).
Returns HTTP 400 if the interface name is invalid.
Reload Interface
POST /api/network/interfaces/<name>/reload
Reload networkd for a single interface (runs networkctl reconfigure <name>, not networkctl reload).
Response (data):
| Field | Type | Description |
|---|---|---|
name |
string |
Interface name |
reloaded |
boolean |
Always true on success |
Returns HTTP 400 if the interface name is invalid.
Full Sync
Apply All Interfaces
POST /api/network/apply
Full sync: generate all .network files, remove stale files, copy to /etc/systemd/network/, reload all interfaces, and sync DNS upstreams to dnsmasq.
Response (data):
| Field | Type | Description |
|---|---|---|
applied |
number |
Number of interfaces applied |
files |
[string, ...] |
Paths of generated files |
cleaned |
[string, ...] |
Paths of removed stale files |
synced |
[string, ...] |
Subsystems that were automatically updated by the sync event bus |
Helpers
Infer DHCP Ranges
GET /api/network/infer-dhcp-ranges
Suggest candidate DHCP ranges based on static interface IPs. For each interface with a static IPv4 address, calculates a usable address range in the subnet.
Response:
| Field | Type | Description |
|---|---|---|
data.ranges |
object |
Map of interface name to {subnet, prefix, start, end} |
Infer Firewall Zones
GET /api/network/infer-zones
Suggest firewalld zone assignments for configured interfaces based on heuristics:
- Interface name contains
wg→wan - DHCP-enabled or public-facing IP →
wan - Has explicit routes →
management - Everything else →
lan
Response:
| Field | Type | Description |
|---|---|---|
data.zones |
object |
Map of interface name to suggested zone ("lan", "wan", "management") |
This is a read-only suggestion endpoint; it does not write anything and returns no write-verification errors.
Status API
Endpoints prefixed with /api/status/.... Aggregate status across all subsystems.
Pending Changes
Check All Pending Changes
GET /api/status/pending
Aggregate pending changes across all subsystems. Useful for the dashboard to show which subsystems need configuration applied.
Response (data):
| Field | Type | Description |
|---|---|---|
firewall |
object |
{ needs_apply, change_count, changes: [{summary, detail}], uncovered_interfaces: [string], coverage_warnings: [string] }. uncovered_interfaces lists network-config interfaces (excluding lo/wg*) that are in no live firewalld zone, and coverage_warnings carries the matching advisory text. Both are advisory only — they are not counted in needs_apply, change_count, or total_changes |
dnsmasq / nginx / wireguard / networkd |
object |
{ pending_changes, summary, changes: [{summary, detail}] } |
total_changes |
number |
Total count of pending changes across all subsystems |
Apply All Pending Changes
POST /api/status/apply-all
Apply pending changes for all subsystems in dependency order.
Request Body (optional):
{ "force": true }
force is forwarded to the firewall apply only — it overrides the
management-lockout guard and the interface-coverage invariant. Other
subsystems ignore it.
Response (data):
| Field | Type | Description |
|---|---|---|
applied |
[string, ...] |
List of subsystems that were applied |
errors |
object |
Map of subsystem label → error message |
The endpoint returns 200 even when some subsystems failed — per-subsystem
failures are reported in errors, so clients must check errors (not just
the HTTP status) before reporting success. Without force, the firewall
apply is refused when a network-managed interface has no zone coverage in
the config and is not unmanaged (the interface-coverage invariant) or when
the config would strip both https/ssh from the default zone (lockout guard);
the ConflictError surfaces in errors under "Firewall" while the other
subsystems proceed. Pending state comes from
the last state poll (firewall 30s, dnsmasq 10s, nginx 60s, wireguard 10s,
networkd 10s), so an edit saved within the last poll interval may not be
picked up by this call.
Cancel All Pending Changes
POST /api/status/cancel-all
Revert pending changes for all subsystems to the last applied
configuration. Restores each pending subsystem's config.json from its
recorded _last_applied_config snapshot, discarding unapplied edits.
Subsystems without a recorded baseline (config never applied) are
reported as skipped and left untouched. No live-system commands run —
only the declarative config files are written.
Notes: pending state comes from the last state poll (firewall 30s,
dnsmasq 10s, nginx 60s, wireguard 10s, networkd 10s), so an edit saved
within the last poll interval is not yet flagged pending and is left in
place. For the firewall, pending is a config-vs-live diff: cancel
restores only the config file, so live firewalld drift made outside the
declarative config (manual firewall-cmd) is not reverted and the
firewall may still report pending after a cancel.
Request Body: none.
Response (data):
| Field | Type | Description |
|---|---|---|
cancelled |
[string, ...] |
Subsystems reverted to their last applied config |
skipped |
object |
Map of subsystem label → reason (e.g. "No baseline recorded (never applied)") |
errors |
object |
Map of subsystem label → error message |
Refresh State
POST /api/status/refresh
Re-collect state from the daemon, optionally filtered by subsystem. Proxies the daemon's POST /status/refresh, which populates the state store for the requested subsystems, replies with their current state, and broadcasts a versions WS delta for each so all connected viewers stay in sync.
Request Body (optional — {} or omitted refreshes all subsystems):
| Field | Type | Required | Description |
|---|---|---|---|
subsystems |
[string, ...] |
No | Subsystem names to refresh (e.g., ["firewall"]) |
Response (data):
| Field | Type | Description |
|---|---|---|
| — | object |
Map of the requested subsystem name(s) to its full state dict (null = collector not populated / failed) |
Example:
// Request
{"subsystems": ["firewall"]}
// Response
{"ok": true, "data": {"firewall": {"config": {...}, "zones": {...}, "active_zones": {...}, "timestamp": "..."}}}
Returns HTTP 500 if the daemon is unreachable.
System Metrics
Get System Metrics
GET /api/status/system-metrics
Return system-wide CPU load, memory, swap, and per-interface network traffic metrics, read from the daemon's pre-collected system state.
Response (data):
| Field | Type | Description |
|---|---|---|
load |
object |
Load averages (load1, load5, load15) |
memory |
object |
Memory usage (total, available, used, used_pct) |
swap |
object |
Swap usage (total, used, used_pct) |
traffic |
object |
Per-interface network traffic stats (interface name → counters) |
Sysctl (daemon-only)
There is no POST /api/network/sysctl/set webui route. Setting a sysctl kernel parameter is a daemon-only endpoint, POST /network/sysctl/set (reached directly over the daemon socket, not via the WebUI).
It sets the value via sysctl -w and verifies by reading it back. Only a fixed allowlist of nine keys is permitted:
| net.ipv4.ip_forward | net.ipv4.conf.all.forwarding | net.ipv4.conf.all.accept_redirects |
| net.ipv4.conf.default.accept_redirects | net.ipv4.conf.all.send_redirects | net.ipv4.conf.default.send_redirects |
| net.ipv4.conf.all.rp_filter | net.ipv4.icmp_echo_ignore_all | net.ipv4.tcp_syncookies |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Kernel parameter name (must be one of the nine allowed keys) |
value |
string |
Yes | Value to set |
Response (data):
| Field | Type | Description |
|---|---|---|
name |
string |
Parameter name |
value |
string |
Value set |
Returns HTTP 400 if name/value is missing, name is malformed, or name is not in the allowlist. Returns HTTP 500 if the value cannot be verified after write.
Logs API
Endpoints prefixed with /api/logs/.... Return log lines as JSON strings, wrapped in the standard {"ok": true, "data": ...} response contract.
System Journal
Get Journal Entries
GET /api/logs/journal
Return recent system journal entries.
Response:
| Field | Type | Description |
|---|---|---|
data |
string |
Raw journal log text |
Nginx Logs
Nginx Access Log
GET /api/logs/nginx/access
Return recent nginx access log entries.
Response:
| Field | Type | Description |
|---|---|---|
data |
string |
Raw access log text |
Returns HTTP 404 if the log file does not exist.
Nginx Error Log
GET /api/logs/nginx/error
Return recent nginx error log entries.
Response:
| Field | Type | Description |
|---|---|---|
data |
string |
Raw error log text |
Returns HTTP 404 if the log file does not exist.
Dnsmasq Log
Dnsmasq Entries
GET /api/logs/dnsmasq
Return recent dnsmasq journal entries.
Response:
| Field | Type | Description |
|---|---|---|
data |
string |
Raw dnsmasq journal text |
Application Log
App Log Entries
GET /api/logs/app
Return recent application log entries.
Response:
| Field | Type | Description |
|---|---|---|
data |
string |
Raw application log text |
Returns HTTP 404 if the log file does not exist.
WebSocket Protocol
The daemon exposes a WebSocket at /ws (port 9091) for real-time state streaming. After authentication, the server pushes a full state snapshot on connect and then per-subsystem deltas — the client patches models in place (modelSet) with no HTTP round-trip.
Handshake Authentication
The JWT access token travels as the raw Sec-WebSocket-Protocol subprotocol name (the bundled client sends the bare token, no Bearer prefix — subprotocol names must be valid RFC 6455 tokens). The daemon additionally accepts a legacy Bearer <token> subprotocol (non-browser clients) and an X-Auth-Token header fallback. The token is validated without session binding (browsers cannot send custom headers on the WebSocket handshake) but with the jti revocation check. A missing or invalid token yields HTTP 401 and no socket is opened.
Message Types
| Type | Sent | Fields | Meaning |
|---|---|---|---|
snapshot |
On connect (after auth) | data: {subsystem: state|null, …} |
Full state for every subsystem. null = collector not populated / failed — clients skip those entries. |
versions |
Structural change | subsystem, data |
The full state of the one changed subsystem (zone added, config changed, …). Version counter bumped; data pushed. |
tick |
Volatile-only change | subsystem, data |
The full state of the one changed subsystem (stats/counters/DHCP IPs). No version bump. |
There is no legacy updated dict or subsystems array — each data-carrying message names a single subsystem and carries its full data.
Manual Refresh
POST /api/status/refresh re-collects state (optionally filtered by a subsystems array) and broadcasts a versions delta for each requested subsystem. It is the HTTP fallback the client uses for the initial load (3s timer) and reconnect recovery. See the Status API — Refresh State section for the full request/response contract.