# 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`. **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. **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. **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 ``` Force-renew an existing certificate. **Response:** `data` is `null` on success. Returns HTTP `404` if the certificate is not found. Returns HTTP `500` if renewal 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 | |-------|------|-------------| | `subsystems` | `object` | Map of subsystem name to pending status | | `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. **Response (`data`):** | Field | Type | Description | |-------|------|-------------| | `applied` | `[string, ...]` | List of subsystems that were applied | | `errors` | `[object, ...]` | Any errors encountered during apply | --- #### 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.