Files
vacuum-wall/docs/api.md
T
mteehan 55309cfd86 status: cancel-all reverts pending changes to last applied config
- lib.common.revert_to_applied(): restore a config file from its
  _last_applied_config snapshot (stamped hash); no baseline -> skip with
  reason, file untouched
- firewall config_apply now stamps the applied baseline like the other
  subsystems; GET /firewall/config and the state collector strip the
  internal _last_applied_* keys
- POST /status/cancel-all + /api/status/cancel-all: revert pending
  subsystems, {cancelled, skipped, errors}, partial-failure safe
- dashboard: "Cancel All Changes" button with confirm modal
  (CancelConfirm, reuses the pending-changes modal rows); the pending
  changes card is hidden entirely when nothing is pending
- tests: revert_to_applied, status_cancel_all, firewall stamping/meta
  stripping, /api/status/cancel-all route, node tests for CancelConfirm;
  firewall _config_apply tests no longer write the real repo config
- docs: api.md, state-model.md, config.md, hoover.md
2026-08-21 02:10:05 +00:00

2185 lines
53 KiB
Markdown

# REST API Reference
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination. Authentication is handled at the Flask layer via JWT — the `Authorization: Bearer <token>` header. Public endpoints (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 <access_token>` 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": <value>
}
```
The `data` field contains the payload, which may be an object, array, string, or `null`.
### Error Responses
```json
{
"ok": false,
"error": "<human-readable message>"
}
```
Error responses carry one of the following HTTP status codes:
| Code | Meaning |
|------|---------|
| `400` | Bad request — invalid body, missing required field, or malformed value |
| `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/<username>
```
Update user's permissions. (To change a password, use `POST /api/auth/password`.)
**Auth:** `auth: "rw"` required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `permissions` | `object` | No | New per-subsystem permissions |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `id` | `int` | User ID |
| `username` | `string` | Username |
| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
Returns HTTP `404` if user not found.
#### Delete User
```
DELETE /api/auth/users/<username>
```
Delete a user and all associated permissions and WebAuthn credentials (CASCADE).
**Auth:** `auth: "rw"` required. Cannot delete self.
**Response:** `data` is `{"ok": true}` on success.
Returns HTTP `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/<credential_id>
```
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<name, [interface, ...]>` | Currently assigned interfaces per zone |
| `data.available` | `[string, ...]` | All zones known to firewalld |
---
#### Get Zone Details
```
GET /api/firewall/zones/<name>
```
Return detailed configuration for a single zone.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `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/<name>
```
Remove a zone from firewalld.
**Response:** `data` is `null` on success.
Returns HTTP `404` if the zone does not exist.
### Zone Configuration
#### Set Zone Interfaces
```
POST /api/firewall/zones/<name>/interfaces
```
Replace all interfaces assigned to the zone with the provided list.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `interfaces` | `[string, ...]` | Yes | List of interface names |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `interfaces` | `[string, ...]` | List of interface names now assigned |
---
#### Set Zone Services
```
POST /api/firewall/zones/<name>/services
```
Replace all services allowed in the zone with the provided list.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `services` | `[string, ...]` | Yes | List of firewalld service names |
**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/<zone>/<id>
```
Remove a rich rule by zone and auto-generated ID. (The rule string itself is too long for a URL path.)
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `id` | `string` | ID of the removed rule |
Returns HTTP `404` if the rule ID is not found.
---
#### List Rich Rules
```
GET /api/firewall/rich-rules/<zone>
```
Return all rich rules for the specified zone, each 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/<zone>/<port>/<proto>
```
Remove a port forwarding rule. Zone, port, and protocol are all path parameters.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `port` | `number` | External port |
| `proto` | `string` | Protocol (`"tcp"` or `"udp"`) |
Returns HTTP `404` if the forward port is not found.
### Masquerade (NAT)
#### Enable / Disable Masquerade
```
POST /api/firewall/masquerade
```
Toggle masquerade (source NAT) for a zone.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `zone` | `string` | Yes | Zone to configure |
| `enable` | `boolean` | Yes | `true` to enable, `false` to disable |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `zone` | `string` | Zone name |
| `masquerade` | `boolean` | Whether masquerade is now enabled |
### 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/<mac>
```
Remove a static lease by MAC address.
**Response:** `data` is `null` on success.
Returns HTTP `404` if no matching lease is found.
### Live Leases
#### Get Live Leases
```
GET /api/dhcp/leases
```
Return the current DHCP lease table from dnsmasq.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of lease objects |
### DNS Records
#### Add DNS Record
```
POST /api/dhcp/dns-record
```
Add a custom DNS A record served by dnsmasq.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Fully qualified domain name |
| `address` | `string` | Yes | IP address to resolve to |
| `hostname` | `string` | No | Short hostname |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Fully qualified domain name |
| `address` | `string` | IP address |
| `hostname` | `string` | Short hostname |
---
#### Remove DNS Record
```
DELETE /api/dhcp/dns-record/<name>
```
Remove a custom DNS record by domain name.
**Response:** `data` is `null` on success.
Returns HTTP `404` if no matching record is found.
---
## 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/<domain>
```
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/<domain>
```
Remove a proxy domain and its nginx configuration.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Domain name |
Returns HTTP `404` if the domain is not configured.
### Apply / Test
#### Apply Configuration
```
POST /api/proxy/apply
```
Regenerate nginx configuration files for all proxy domains and reload the nginx service.
**Response:** `data` is `null` on success.
Returns HTTP `500` if nginx config generation fails or the reload fails.
---
#### Test Configuration
```
POST /api/proxy/test
```
Run `nginx -t` against the generated configuration without reloading.
**Response (valid):**
| Field | Type | Description |
|-------|------|-------------|
| `data.valid` | `boolean` | Always `true` |
| `data.output` | `string` | Raw nginx test output |
**Error (invalid):** HTTP `400` with standard `{"ok": false, "error": "<nginx output>"}` response.
### Management Proxy
>The legacy `POST /api/proxy/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/<domain>
```
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/<request_id>
```
Poll the status of a certificate issuance request started by `POST /api/certs/issue/start`.
**Response (`data`):** Issuance status object containing progress, logs, and result.
Returns HTTP `404` if the request ID is not found. The frontend uses `poll()` to repeatedly fetch this endpoint until issuance completes or fails.
---
#### Renew Certificate
```
POST /api/certs/<domain>/renew
```
Start an async certificate renewal for an existing certificate. The renewal
runs in the background and is polled via
`GET /api/certs/renew/<request_id>`.
**Request Body:** none (domain is taken from the path).
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `request_id` | `string` | Unique identifier for polling renewal status |
| `domain` | `string` | Domain being renewed |
| `status` | `string` | Only when a renewal for this domain is already in progress (`"existing"` — the existing `request_id` is returned) |
The renewal is a **no-op** when the certificate's renewal window (default:
30 days before expiry) has not been reached — the request then completes with
`status: "skipped"`.
Returns HTTP `400` if the domain is missing. Returns HTTP `500` when the
renewal cannot be started (e.g. daemon unreachable).
---
#### Poll Certificate Renewal Status
```
GET /api/certs/renew/<request_id>
```
Poll the status of a certificate renewal started by
`POST /api/certs/<domain>/renew`.
**Response (`data`):** Renewal status object containing `request_id`,
`domain`, `status` (`"running"`, `"completed"`, `"skipped"`, or `"failed"`),
a `steps` array (each with per-step status and error message), and
timestamps.
Returns HTTP `404` if the request ID is not found. The frontend uses
`poll()` to repeatedly fetch this endpoint until the renewal completes,
is skipped, or fails.
---
#### Remove Certificate
```
DELETE /api/certs/<domain>
```
Delete a certificate and remove it from auto-renewal tracking.
**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 `<domain>.key` already exist at `data/acme/<domain>/`.
**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 `<domain>.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/<name>
```
Remove a configured peer by name.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Peer name |
Returns HTTP `404` if the peer is not found.
---
#### Peer Connection Status
```
GET /api/wireguard/peer-status
```
Return live per-peer connection status from `wg show`.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Live peer status (handshake time, bytes, endpoint) |
### Client Configuration
#### Generate Client Config
```
POST /api/wireguard/generate-client
```
Generate a complete WireGuard client configuration file. The returned config includes the peer's private key for provisioning.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Peer name to generate config for |
| `server_endpoint` | `string` | Yes | Server public address (`"ip:port"`) |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `config` | `string` | Complete client config text (`[Interface]` + `[Peer]`) |
This is the only endpoint that returns a WireGuard private key. All other endpoints strip private keys from responses.
Returns HTTP `404` if the peer is not found.
### Access Classes
Manage VPN access classes that categorize peers by access level (e.g., full LAN access, internet-only).
#### List Access Classes
```
GET /api/wireguard/classes
```
Return all configured access classes.
**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": "<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/<name>
```
Return config and runtime state for a specific interface.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Interface name |
| `config` | `object` | Full networkd config entry for this interface |
| `runtime` | `object` | Runtime state from `networkctl` (addresses, gateway, DNS, state) |
Returns HTTP `400` if the interface name is invalid (contains path components, spaces, or characters outside `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`). Returns HTTP `404` if the interface is not found in config.
---
#### Save and Apply Interface
```
POST /api/network/interfaces/<name>
```
Save network config for an interface, render the `.network` file, copy it to `/etc/systemd/network/`, and reload networkd for that interface.
**Request Body:** Any networkd config keys (e.g., `addresses`, `gateway`, `dns`, `routes`, `dhcp`, `link`, `dhcp_client`).
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Interface name |
| `applied` | `boolean` | `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/<name>/reload
```
Reload networkd for a single interface (runs `networkctl reload <name>`).
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Interface name |
| `reloaded` | `boolean` | Always `true` on success |
Returns HTTP `400` if the interface name is invalid.
### Full Sync
#### Apply All Interfaces
```
POST /api/network/apply
```
Full sync: generate all `.network` files, remove stale files, copy to `/etc/systemd/network/`, reload all interfaces, and sync DNS upstreams to dnsmasq.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `applied` | `number` | Number of interfaces applied |
| `files` | `[string, ...]` | Paths of generated files |
| `cleaned` | `[string, ...]` | Paths of removed stale files |
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
### Helpers
#### Infer DHCP Ranges
```
GET /api/network/infer-dhcp-ranges
```
Suggest candidate DHCP ranges based on static interface IPs. For each interface with a static IPv4 address, calculates a usable address range in the subnet.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data.ranges` | `object` | Map of interface name to `{subnet, prefix, start, end}` |
---
#### Infer Firewall Zones
```
GET /api/network/infer-zones
```
Suggest firewalld zone assignments for configured interfaces based on heuristics:
- Interface name contains `wg``wan`
- DHCP-enabled or public-facing IP → `wan`
- Has explicit routes → `management`
- Everything else → `lan`
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) |
Returns HTTP `500` if the value cannot be verified after write.
---
## Status API
Endpoints prefixed with `/api/status/...`. Aggregate status across all subsystems.
### Pending Changes
#### Check All Pending Changes
```
GET /api/status/pending
```
Aggregate pending changes across all subsystems. Useful for the dashboard to show which subsystems need configuration applied.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `firewall` | `object` | `{ needs_apply, change_count, changes: [{summary, detail}] }` |
| `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.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `applied` | `[string, ...]` | List of subsystems that were applied |
| `errors` | `[object, ...]` | Any errors encountered during apply |
---
#### 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.
**Request Body:** none.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `cancelled` | `[string, ...]` | Subsystems reverted to their last applied config |
| `skipped` | `object` | Map of subsystem label → reason (e.g. "No baseline recorded (never applied)") |
| `errors` | `object` | Map of subsystem label → error message |
---
#### Refresh State
```
POST /api/status/refresh
```
Re-collect state from the daemon, optionally filtered by subsystem. Proxies the daemon's `POST /status/refresh`, which populates the state store for the requested subsystems, replies with their current state, and broadcasts a `versions` WS delta for each so all connected viewers stay in sync.
**Request Body** (optional — `{}` or omitted refreshes all subsystems):
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `subsystems` | `[string, ...]` | No | Subsystem names to refresh (e.g., `["firewall"]`) |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| — | `object` | Map of the requested subsystem name(s) to its full state dict (`null` = collector not populated / failed) |
**Example:**
```json
// Request
{"subsystems": ["firewall"]}
// Response
{"ok": true, "data": {"firewall": {"config": {...}, "zones": {...}, "active_zones": {...}, "timestamp": "..."}}}
```
Returns HTTP `500` if the daemon is unreachable.
### Sysctl
#### Set Kernel Parameter
```
POST /api/network/sysctl/set
```
Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Kernel parameter name (e.g., `"net.ipv4.ip_forward"`) |
| `value` | `string` | Yes | Value to set |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Parameter name |
| `value` | `string` | Value set |
Returns HTTP `500` if the value cannot be verified after write.
---
## Logs API
Endpoints prefixed with `/api/logs/...`. Return log lines as JSON strings, wrapped in the standard `{"ok": true, "data": ...}` response contract.
### System Journal
#### Get Journal Entries
```
GET /api/logs/journal
```
Return recent system journal entries.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw journal log text |
### Nginx Logs
#### Nginx Access Log
```
GET /api/logs/nginx/access
```
Return recent nginx access log entries.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw access log text |
Returns HTTP `404` if the log file does not exist.
---
#### Nginx Error Log
```
GET /api/logs/nginx/error
```
Return recent nginx error log entries.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw error log text |
Returns HTTP `404` if the log file does not exist.
### Dnsmasq Log
#### Dnsmasq Entries
```
GET /api/logs/dnsmasq
```
Return recent dnsmasq journal entries.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw dnsmasq journal text |
### Application Log
#### App Log Entries
```
GET /api/logs/app
```
Return recent application log entries.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw application log text |
Returns HTTP `404` if the log file does not exist.
---
## WebSocket Protocol
The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state streaming. After authentication, the server pushes a full state snapshot on connect and then per-subsystem deltas — the client patches models in place (`modelSet`) with no HTTP round-trip.
### Handshake Authentication
The JWT **access** token travels as the **raw `Sec-WebSocket-Protocol` subprotocol name** (the bundled client sends the bare token, no `Bearer ` prefix — subprotocol names must be valid RFC 6455 tokens). The daemon additionally accepts a legacy `Bearer <token>` subprotocol (non-browser clients) and an `X-Auth-Token` header fallback. The token is validated without session binding (browsers cannot send custom headers on the WebSocket handshake) but with the jti revocation check. A missing or invalid token yields HTTP `401` and no socket is opened.
### Message Types
| Type | Sent | Fields | Meaning |
|------|------|--------|---------|
| `snapshot` | On connect (after auth) | `data: {subsystem: state\|null, …}` | Full state for every subsystem. `null` = collector not populated / failed — clients skip those entries. |
| `versions` | Structural change | `subsystem`, `data` | The full state of the one changed subsystem (zone added, config changed, …). Version counter bumped; data pushed. |
| `tick` | Volatile-only change | `subsystem`, `data` | The full state of the one changed subsystem (stats/counters/DHCP IPs). No version bump. |
There is no legacy `updated` dict or `subsystems` array — each data-carrying message names a single `subsystem` and carries its full `data`.
### Manual Refresh
`POST /api/status/refresh` re-collects state (optionally filtered by a `subsystems` array) and broadcasts a `versions` delta for each requested subsystem. It is the HTTP fallback the client uses for the initial load (3s timer) and reconnect recovery. See the [Status API — Refresh State](#refresh-state) section for the full request/response contract.