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