# 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 ` header. Public endpoints (login, WebAuthn authenticate) do not require a token. Every request and response uses `Content-Type: application/json`. ## Authentication Most endpoints require a valid JWT access token. The token is obtained by logging in via `POST /api/auth/login` or completing a WebAuthn authentication ceremony. ### Obtaining a Token 1. Call `POST /api/auth/login` with credentials 2. Store the returned `access_token` 3. Include `Authorization: Bearer ` on all subsequent requests 4. Refresh before expiry via `POST /api/auth/refresh` ### Permission Checks Each request is checked against per-subsystem permissions. `GET` requires `"read"` or `"rw"` on the subsystem. `POST`/`PATCH`/`DELETE` requires `"rw"`. User management endpoints (`/api/auth/users/*`) require `auth: "rw"`. ## Conventions ### Success Responses ```json { "ok": true, "data": } ``` The `data` field contains the payload, which may be an object, array, string, or `null`. ### Error Responses ```json { "ok": false, "error": "" } ``` Error responses carry one of the following HTTP status codes: | Code | Meaning | |------|---------| | `400` | Bad request — invalid body, missing required field, or malformed value | | `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 `null` on success. #### Refresh Tokens ``` POST /api/auth/refresh ``` Rotate token pair. Validates the refresh token, blacklists the old pair, and issues new access and refresh tokens. **Auth:** Refresh token required. **Response (`data`):** | Field | Type | Description | |---|---|---| | `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` | | `access_ttl` | `integer` | Access token lifetime in seconds | | `user` | `object` | User info (`username`, `id`) | | `permissions` | `object` | Per-subsystem permissions | Returns HTTP `401` if refresh token is invalid, expired, or blacklisted. #### Change Password ``` POST /api/auth/password ``` Change the current user's password. **Auth:** Access token required. **Request Body:** | Field | Type | Required | Description | |---|---|---|---| | `username` | `string` | No | Auto-injected from JWT context | | `oldPassword` | `string` | Yes | Current password | | `newPassword` | `string` | Yes | New password | **Response:** `data` is `null` on success. Returns HTTP `400` if old password is incorrect. --- ### User Management #### List Users ``` GET /api/auth/users ``` List all users. Requires admin permission (`auth: "rw"`). **Auth:** `auth: "rw"` required. **Response (`data`):** | Field | Type | Description | |---|---|---| | `users` | `[object, ...]` | Array of user summaries (`id`, `username`, `permissions`) | #### Create User ``` POST /api/auth/users ``` Create a new user with password and per-subsystem permissions. **Auth:** `auth: "rw"` required. **Request Body:** | Field | Type | Required | Description | |---|---|---|---| | `username` | `string` | Yes | Username | | `password` | `string` | Yes | Plain-text password | | `permissions` | `object` | 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 username already exists. #### Update User ``` POST /api/auth/users/ ``` 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/ ``` 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 `404` if user not found. --- ### WebAuthn #### Begin Registration ``` POST /api/auth/webauthn/register-begin ``` Start WebAuthn credential registration. Returns options for `navigator.credentials.create()`. **Auth:** Access token required. **Request Body:** | Field | Type | Required | Description | |---|---|---|---| | `username` | `string` | Yes | Username to register for | **Response (`data`):** | Field | Type | Description | |---|---|---| | `challenge` | `string` | Base64url challenge | | `rp` | `object` | Relying party config (`id`, `name`) | | `user` | `object` | User info for registration | | `excludeCredentials` | `[object, ...]` | Credentials to exclude | #### Finish Registration ``` POST /api/auth/webauthn/register-finish ``` Complete WebAuthn credential registration. Verifies the attestation response and stores the credential in the database. **Auth:** Access token required. **Request Body:** | Field | Type | Required | Description | |---|---|---|---| | `username` | `string` | Yes | Username | | `response` | `object` | Yes | WebAuthn authenticator attestation response | | `name` | `string` | No | Display name for this credential | **Response:** `data` is `null` on success. Returns HTTP `400` if verification fails. #### Begin Authentication ``` POST /api/auth/webauthn/authenticate-begin ``` Start WebAuthn authentication. Returns options for `navigator.credentials.get()`. **Auth:** Public — no JWT required. **Request Body:** | Field | Type | Required | Description | |---|---|---|---| | `username` | `string` | Yes | Username to authenticate | **Response (`data`):** | Field | Type | Description | |---|---|---| | `challenge` | `string` | Base64url challenge | | `allowCredentials` | `[object, ...]` | Registered credentials for this user | Returns `{"ok": true, "data": {"no_webauthn": true}}` if user has no WebAuthn credentials (use password instead). #### Finish Authentication ``` POST /api/auth/webauthn/authenticate-finish ``` Complete WebAuthn authentication. Verifies the assertion and issues tokens on success. **Auth:** Public — no JWT required. **Request Body:** | Field | Type | Required | Description | |---|---|---|---| | `username` | `string` | Yes | Username | | `assertion_response` | `object` | Yes | WebAuthn authenticator assertion response | | `auth_options` | `object` | Yes | Original authentication options from `authenticate-begin` | **Response (`data`):** | Field | Type | Description | |---|---|---| | `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` | | `access_ttl` | `integer` | Access token lifetime in seconds | | `user` | `object` | User info (`username`, `id`) | | `permissions` | `object` | Per-subsystem permissions | Returns HTTP `400` if verification fails. #### List Credentials ``` GET /api/auth/webauthn/credentials ``` List WebAuthn credentials for the current user. **Auth:** Access token required. **Response (`data`):** Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCount`, `createdAt`). #### Credential Counts ``` GET /api/auth/webauthn/credential-counts ``` Return credential counts for all users. Admin endpoint. **Auth:** `auth: "rw"` required. **Response (`data`):** | Field | Type | Description | |---|---|---| | `counts` | `object` | Dict mapping usernames to credential counts (`{"alice": 2, "bob": 1}`) | #### Remove Credential ``` DELETE /api/auth/webauthn/creds/ ``` Remove a WebAuthn credential. **Auth:** Access token required. **Response:** `data` is `null` on success. Returns HTTP `404` if credential not found. --- ## Firewall API Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade. ### 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:** Optional. Send `{"force": true}` to override the management-lockout and interface-coverage guards. **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 diff for interfaces, services, targets, masquerade, rich rules, and forward ports. **Response:** Same structure as POST /config response. #### 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` | Currently assigned interfaces per zone | | `data.available` | `[string, ...]` | All zones known to firewalld | --- #### Get Zone Details ``` GET /api/firewall/zones/ ``` Return detailed configuration for a single zone. **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `target` | `string` | Zone target (e.g., `"default"`, `"ACCEPT"`, `"REJECT"`) | | `interfaces` | `[string, ...]` | Interfaces assigned to this zone | | `services` | `[string, ...]` | Services allowed through the zone | | `ports` | `[string, ...]` | Explicit port rules (format: `"443/tcp"`) | | `masquerade` | `boolean` | Whether masquerade (NAT) is enabled | | `forward_ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules | | `rich_rules` | `[{rule, id}, ...]` | Rich rule definitions with IDs | 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/ ``` 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//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 | --- #### Set Zone Services ``` POST /api/firewall/zones//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 | **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `zone` | `string` | Zone name | | `services` | `[string, ...]` | List of services now allowed | ### 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// ``` 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/ ``` Return all rich rules for the specified zone, each with an `id` and `rule` string. **Response:** | Field | Type | Description | |-------|------|-------------| | `data` | `[{id, rule}, ...]` | Rich rules with IDs | ### 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/// ``` 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 | ### 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 reload the dnsmasq service. **Response:** `data` is `null` on success. ### Status #### Get Service Status ``` GET /api/dhcp/status ``` Return the current service status, config summary, and active lease count. **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `service_active` | `boolean` | Whether dnsmasq is running | | `config_file_exists` | `boolean` | Whether config file exists on disk | | `config_in_sync` | `boolean` | Whether disk config matches expected | | `dhcp_ranges` | `number` | Number of DHCP ranges | | `static_leases` | `number` | Number of static leases | | `custom_dns_records` | `number` | Number of custom DNS records | | `upstreams` | `[string, ...]` | Upstream DNS servers | | `domain` | `string` | Local DNS domain | | `active_leases` | `number` | Number of active leases | | `leases` | `[object, ...]` | Active lease objects | ### 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` | Yes | Interface name | | `start` | `string` | Yes | Start of IP range | | `end` | `string` | Yes | End of IP range | **Response:** `data` is `null` on success. ### 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/ ``` 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/ ``` Remove a custom DNS record by domain name. **Response:** `data` is `null` on success. Returns HTTP `404` if no matching record is found. --- ## 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`), `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. Accepts two modes: **Paths mode (preferred):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `domain` | `string` | Yes | Domain name to proxy | | `paths` | `object` | Yes | Path-to-config map. Each path entry must have a `backend` key with `host`, `port`, `proto`. | | `cert` | `string` | No | Certificate type | | `force_ssl` | `boolean` | No | HTTPS redirect flag (default `true`) | **Legacy mode (backward compatible):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `domain` | `string` | Yes | Domain name to proxy | | `backend_host` | `string` | Yes | Backend server IP or hostname | | `backend_port` | `number` | Yes | Backend server port | | `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` | | `cert` | `string` | No | Certificate type | | `extra_headers` | `object` | No | Extra proxy headers | **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `domain` | `string` | Domain name | Returns HTTP `400` if the domain is already configured. --- #### Update Domain ``` PUT /api/proxy/domains/ ``` Update one or more fields of an existing domain entry. Only fields present in the body are modified. Supports both domain-level keys (`paths`, `force_ssl`, `cert`, `auth`) and path-level shorthand (`backend`, `headers` for the root path). **Request Body:** Any subset of (`paths`, `backend`, `backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`, `force_ssl`, `auth`). **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `domain` | `string` | Domain name | Returns HTTP `404` if the domain is not configured. --- #### Remove Domain ``` DELETE /api/proxy/domains/ ``` 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. ### 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": ""}` response. ### Management Proxy >The legacy `POST /api/proxy/management` endpoint has been removed. The management WebUI proxy is now configured as a regular domain entry with `is_management: true` on the root path and `is_websocket: true` on the `/ws` path. 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`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`. --- #### Get Certificate Details ``` GET /api/certs/ ``` Return details for a single certificate. **Response (`data`):** Fields: `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`. Returns HTTP `404` if no certificate is found for the domain. ### 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 | 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/ ``` 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//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 Body:** none (domain is taken from the path). **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/ ``` Poll the status of a certificate renewal started by `POST /api/certs//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/ ``` Delete a certificate and remove it from auto-renewal tracking. **Response:** `data` is `null` on success. Returns HTTP `404` if the certificate is not found. ### 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`. **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `email` | `string` | Empty string indicating the account was deactivated | Returns HTTP `500` if deactivation fails. --- #### 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 ``` POST /api/certs/self-signed ``` Generate a self-signed certificate for a domain. Idempotent — skips if `fullchain.cer` and `.key` already exist at `data/acme//`. **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 `fullchain.cer` | | `key` | `string` | Path to `.key` | | `generated` | `boolean` | `true` if a new cert was created, `false` if 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 (`wg0`). **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `down` | `boolean` | Always `true` on success | | `synced` | `[string, ...]` | Subsystems auto-synced as a result | ### 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) | --- #### 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. A key pair is auto-generated. 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 `["0.0.0.0/0"]` | | `persistent_keepalive` | `number` | No | Persistent keepalive interval (seconds) | | `preshared_key` | `string` | No | Preshared key | **Response (`data`):** Peer object with `name`, `public_key`, `allowed_ips`, etc. (no `private_key`). --- #### Remove Peer ``` DELETE /api/wireguard/peers/ ``` 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. **Response (`data`):** Object keyed by class identifier, each with `name` and `description` fields. #### Create Access Class ``` POST /api/wireguard/classes ``` Create a new access class. **Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `key` | `string` | Yes | Class identifier (alphanumeric) | | `name` | `string` | No | Display name (defaults to key) | | `description` | `string` | No | Description text | **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `key` | `string` | Class key | | `name` | `string` | Display name | | `description` | `string` | Description | Returns HTTP `409` if the key already exists. #### Update Access Class ``` PATCH /api/wireguard/classes ``` Update an existing access class. **Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `key` | `string` | Yes | Class identifier | | `name` | `string` | No | New display name | | `description` | `string` | No | New description | **Response (`data`):** Updated class object with `key`, `name`, `description`. 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": "" }` Returns HTTP `404` if the class is not found. Returns HTTP `409` if peers reference the class. --- ## 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/ ``` 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/ ``` 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` | `true` if deploy to systemd-networkd succeeded, `false` if the system call was unavailable | | `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus | Returns HTTP `400` if the interface name is invalid. --- #### Reload Interface ``` POST /api/network/interfaces//reload ``` Reload networkd for a single interface (runs `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"`) | Returns HTTP `500` if the value cannot be verified after write. --- ## 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):** ```json { "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:** ```json // Request {"subsystems": ["firewall"]} // Response {"ok": true, "data": {"firewall": {"config": {...}, "zones": {...}, "active_zones": {...}, "timestamp": "..."}}} ``` Returns HTTP `500` if the daemon is unreachable. ### Sysctl #### Set Kernel Parameter ``` POST /api/network/sysctl/set ``` Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back. **Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | `string` | Yes | Kernel parameter name (e.g., `"net.ipv4.ip_forward"`) | | `value` | `string` | Yes | Value to set | **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `name` | `string` | Parameter name | | `value` | `string` | Value set | 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 ` 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](#refresh-state) section for the full request/response contract.