docs: full refresh per DOCSPLAN (auth subsystem, backends model, access classes, sudo table, state-model mechanics) + 3 stale docstrings

This commit is contained in:
2026-09-05 16:34:57 +00:00
parent 78fcb01877
commit b503a6dcf0
13 changed files with 1468 additions and 543 deletions
+348 -123
View File
@@ -1,6 +1,6 @@
# 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.
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination. Authentication is handled at the Flask layer via JWT — the `Authorization: Bearer <token>` header. Public endpoints `POST /api/auth/login`, `POST /api/auth/refresh`, and the WebAuthn authenticate endpoints — do not require a token. Every other request must send both the `Authorization: Bearer <token>` header and the mandatory `X-Session-Id` header (a missing/invalid token **or** a missing `X-Session-Id` yields HTTP `401`).
Every request and response uses `Content-Type: application/json`.
@@ -17,7 +17,7 @@ Most endpoints require a valid JWT access token. The token is obtained by loggin
### 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"`.
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
@@ -46,6 +46,8 @@ 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 |
@@ -123,7 +125,7 @@ Invalidate the current session by blacklisting the access token.
**Auth:** Access token required.
**Response:** `data` is `null` on success.
**Response:** `data` is `{}` (an empty object) on success.
#### Refresh Tokens
@@ -131,9 +133,16 @@ Invalidate the current session by blacklisting the access token.
POST /api/auth/refresh
```
Rotate token pair. Validates the refresh token, blacklists the old pair, and issues new access and refresh tokens.
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:** Refresh token required.
**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`):**
@@ -162,11 +171,11 @@ Change the current user's password.
|---|---|---|---|
| `username` | `string` | No | Auto-injected from JWT context |
| `oldPassword` | `string` | Yes | Current password |
| `newPassword` | `string` | Yes | New password |
| `newPassword` | `string` | Yes | New password (minimum 8 characters) |
**Response:** `data` is `null` on success.
**Response:** `data` is `{"ok": true}` on success.
Returns HTTP `400` if old password is incorrect.
Returns HTTP `400` for any failure — missing fields, incorrect old password, or a new password shorter than 8 characters.
---
@@ -178,15 +187,11 @@ Returns HTTP `400` if old password is incorrect.
GET /api/auth/users
```
List all users. Requires admin permission (`auth: "rw"`).
List all users.
**Auth:** `auth: "rw"` required.
**Auth:** `auth: "read"` required (read-only endpoint).
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `users` | `[object, ...]` | Array of user summaries (`id`, `username`, `permissions`) |
**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
@@ -203,7 +208,7 @@ Create a new user with password and per-subsystem permissions.
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `password` | `string` | Yes | Plain-text password |
| `password` | `string` | Yes | Plain-text password (minimum 8 characters) |
| `permissions` | `object` | No | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
**Response (`data`):**
@@ -214,7 +219,7 @@ Create a new user with password and per-subsystem permissions.
| `username` | `string` | Username |
| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
Returns HTTP `409` if username already exists.
Returns HTTP `409` if the username already exists. Returns HTTP `400` if the password is missing or shorter than 8 characters.
#### Update User
@@ -254,12 +259,37 @@ Delete a user and all associated permissions and WebAuthn credentials (CASCADE).
**Response:** `data` is `{"ok": true}` on success.
Returns HTTP `404` if user not found.
Returns HTTP `403` if the user attempts to delete their own account. Returns HTTP `404` if the user is not found.
---
### WebAuthn
#### Check WebAuthn Capability
```
GET /api/auth/webauthn/capable
```
Check whether WebAuthn is available on the current request domain (the relying-party ID is derived from the request host).
**Auth:** Access token required.
**Response (`data`):**
When enabled:
| Field | Type | Description |
|---|---|---|
| `enabled` | `boolean` | Always `true` |
| `rp_id` | `string` | Relying-party ID (request host) |
| `rp_name` | `string` | Relying-party display name |
| `origin` | `string` | Resolved WebAuthn origin (`scheme://host`) |
When unavailable: `{"enabled": false, "reason": "<reason>"}`.
---
#### Begin Registration
```
@@ -274,9 +304,9 @@ Start WebAuthn credential registration. Returns options for `navigator.credentia
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username to register for |
| `username` | `string` | No | Auto-injected from the JWT (the authenticated user); any value in the body is overridden |
**Response (`data`):**
**Response (`data`):** Standard WebAuthn registration options.
| Field | Type | Description |
|---|---|---|
@@ -299,13 +329,19 @@ Complete WebAuthn credential registration. Verifies the attestation response and
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `response` | `object` | Yes | WebAuthn authenticator attestation response |
| `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` is `null` on success.
**Response (`data`):**
Returns HTTP `400` if verification fails.
| 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
@@ -359,7 +395,7 @@ Complete WebAuthn authentication. Verifies the assertion and issues tokens on su
| `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `400` if verification fails.
Returns HTTP `401` if verification fails or required fields are missing.
#### List Credentials
@@ -373,7 +409,7 @@ List WebAuthn credentials for the current user.
**Response (`data`):**
Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCount`, `createdAt`).
Array of credential objects (`id`, `name`, `transports`, `sign_count`).
#### Credential Counts
@@ -381,15 +417,11 @@ Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCo
GET /api/auth/webauthn/credential-counts
```
Return credential counts for all users. Admin endpoint.
Return credential counts for all users.
**Auth:** `auth: "rw"` required.
**Auth:** `auth: "read"` required (read-only endpoint).
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `counts` | `object` | Dict mapping usernames to credential counts (`{"alice": 2, "bob": 1}`) |
**Response (`data`):** The dict directly (no `counts` wrapper) — a mapping of usernames to credential counts (`{"alice": 2, "bob": 1}`).
#### Remove Credential
@@ -401,9 +433,9 @@ Remove a WebAuthn credential.
**Auth:** Access token required.
**Response:** `data` is `null` on success.
**Response:** `data` is `{"ok": true}` on success.
Returns HTTP `404` if credential not found.
Returns HTTP `404` if the credential is not found.
---
@@ -454,7 +486,7 @@ POST /api/firewall/config/apply
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
**Request Body:** Optional. Send `{"force": true}` to override the management-lockout and interface-coverage guards.
**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`.
@@ -472,9 +504,18 @@ Apply the declarative config to live firewalld. Applies targets, services, inter
GET /api/firewall/config/pending
```
Compare declarative config against live firewalld state. Returns diff for interfaces, services, targets, masquerade, rich rules, and forward ports.
Compare declarative config against live firewalld state. Returns the diff for interfaces, services, targets, masquerade, rich rules, and forward ports.
**Response:** Same structure as POST /config response.
**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
@@ -528,13 +569,18 @@ Return detailed configuration for a single zone.
| 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 |
| `forward_ports` | `[{port, proto, toaddr, toport}, ...]` | Port forward rules |
| `rich_rules` | `[{rule, id}, ...]` | Rich rule definitions with IDs |
| `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.
@@ -596,6 +642,8 @@ Replace all interfaces assigned to the zone with the provided list.
| `zone` | `string` | Zone name |
| `interfaces` | `[string, ...]` | List of interface names now assigned |
Returns HTTP `404` if the zone does not exist.
---
#### Set Zone Services
@@ -611,6 +659,7 @@ Replace all services allowed in the zone with the provided list.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `services` | `[string, ...]` | Yes | List of firewalld service names |
| `force` | `boolean` | No | Override the management-lockout guard |
**Response (`data`):**
@@ -619,6 +668,8 @@ Replace all services allowed in the zone with the provided list.
| `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
@@ -671,13 +722,13 @@ Returns HTTP `404` if the rule ID is not found.
GET /api/firewall/rich-rules/<zone>
```
Return all rich rules for the specified zone, each with an `id` and `rule` string.
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 with IDs |
| `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
@@ -752,6 +803,8 @@ Toggle masquerade (source NAT) for a zone.
| `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
@@ -866,7 +919,7 @@ Deep-merge the provided fields into the existing configuration. Useful for targe
POST /api/dhcp/apply
```
Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reload the dnsmasq service.
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.
@@ -878,22 +931,17 @@ Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reloa
GET /api/dhcp/status
```
Return the current service status, config summary, and active lease count.
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 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 |
| `config_file_exists` | `boolean` | Whether the config file exists on disk |
| `active_leases` | `number` | Number of active leases |
| `leases` | `[object, ...]` | Active lease objects |
| `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
@@ -930,12 +978,14 @@ Remove a DHCP range. Body contains identifying fields.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `interface` | `string` | Yes | Interface name |
| `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
@@ -1034,6 +1084,26 @@ 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.
@@ -1122,7 +1192,7 @@ Return all configured proxy domains. The response is flattened by path — each
|-------|------|-------------|
| `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.
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.
---
@@ -1132,27 +1202,17 @@ Each entry contains `domain` (string), `path` (string), `backend` (object with `
POST /api/proxy/domains
```
Add a new reverse proxy domain. Accepts two modes:
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.
**Paths mode (preferred):**
**Request Body:**
| 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`. |
| `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`) |
**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 |
| `auth` | `object` | No | Basic auth as `{user, pass}`; when both are present a `.htpasswd` file is written as a side-effect and the raw password is **not** persisted (only `{user, htpasswd: <path>}` is stored) |
**Response (`data`):**
@@ -1160,7 +1220,7 @@ Add a new reverse proxy domain. Accepts two modes:
|-------|------|-------------|
| `domain` | `string` | Domain name |
Returns HTTP `400` if the domain is already configured.
Returns HTTP `400` if the domain is already configured, if `domain` or `backend` is missing, or if the referenced backend does not exist.
---
@@ -1170,9 +1230,14 @@ Returns HTTP `400` if the domain is already configured.
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).
Update one or more fields of an existing domain entry. Only fields present in the body are modified.
**Request Body:** Any subset of (`paths`, `backend`, `backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`, `force_ssl`, `auth`).
**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`):**
@@ -1180,7 +1245,7 @@ Update one or more fields of an existing domain entry. Only fields present in th
|-------|------|-------------|
| `domain` | `string` | Domain name |
Returns HTTP `404` if the domain is not configured.
Returns HTTP `404` if the domain is not configured. Returns HTTP `400` if the body is empty or the new `backend` does not exist.
---
@@ -1200,6 +1265,86 @@ Remove a proxy domain and its nginx configuration.
Returns HTTP `404` if the domain is not configured.
### Backend Management
Backends define the per-path routing (`paths`) and any basic auth; proxy domains reference a backend by name.
#### List All Backends
```
GET /api/proxy/backends
```
Return all configured backends. Secret material is stripped — each backend carries a `has_auth` boolean instead of its `auth` object.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `object` | Map of backend name to `{label, paths, has_auth, builtin?}` (auth stripped) |
---
#### Update Backend
```
PATCH /api/proxy/backends
```
Deep-merge a partial update into an existing backend entry. Built-in backends cannot be modified.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Backend name to update |
| `label` | `string` | No | New display label |
| `paths` | `object` | No | New path-to-backend map |
| `auth` | `object` \| `false` \| `null` | No | Set basic auth, or `false`/`null` to remove it |
**Response (`data`):** `{"backend": "<name>"}`.
Returns HTTP `400` if `name` is missing or the backend is built-in. Returns HTTP `500` if the backend name does not exist.
---
#### Add Backend
```
POST /api/proxy/backends
```
Add a new backend.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Backend name (must be unique) |
| `label` | `string` | Yes | Display label |
| `paths` | `object` | Yes | Path-to-backend map; each entry must carry `host`, `port`, `proto` |
| `auth` | `object` | No | Basic auth configuration |
**Response (`data`):** `{"backend": "<name>"}`.
Returns HTTP `400` if `name`, `label`, or `paths` is missing, if the backend already exists, or if the `paths` schema is invalid.
---
#### Remove Backend
```
DELETE /api/proxy/backends/<name>
```
Remove a non-builtin backend.
**Response (`data`):** `{"backend": "<name>"}`.
Returns HTTP `409` if one or more domains reference the backend. Returns HTTP `400` if the backend is built-in.
---
### Apply / Test
#### Apply Configuration
@@ -1259,7 +1404,7 @@ Return all managed certificates with metadata.
|-------|------|-------------|
| `data` | `[object, ...]` | Array of certificate objects |
Each certificate object contains `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
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`.
---
@@ -1269,11 +1414,13 @@ Each certificate object contains `domain`, `expires_at`, `days_until_expiry`, `c
GET /api/certs/<domain>
```
Return details for a single certificate.
Return details for a single certificate. Matches on the main domain **or** any of the certificate's `san_domains`.
**Response (`data`):** Fields: `domain`, `expires_at`, `days_until_expiry`, `cert_path`, `key_path`.
**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.
Returns HTTP `404` if no certificate is found for the domain.
If no certificate exists yet but an issuance is in progress, the response is `{"domain": <domain>, "status": "issuing", "issuance": {...}}`.
Returns HTTP `404` if no certificate is found and no issuance is in progress.
### Validation
@@ -1318,6 +1465,8 @@ Create a new certificate issuance request. Issuance runs asynchronously in the b
| 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).
@@ -1347,7 +1496,11 @@ 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).
**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`):**
@@ -1392,11 +1545,11 @@ is skipped, or fails.
DELETE /api/certs/<domain>
```
Delete a certificate and remove it from auto-renewal tracking.
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 `404` if the certificate is not found.
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
@@ -1453,7 +1606,7 @@ Returns HTTP `400` if the email is missing or invalid. Returns HTTP `500` if reg
DELETE /api/certs/account
```
Deactivate the ACME account via `acme.sh --deactivate-account`. Clears the `email` and `ca` fields from `config/acme/config.json`.
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`):**
@@ -1461,8 +1614,6 @@ Deactivate the ACME account via `acme.sh --deactivate-account`. Clears the `emai
|-------|------|-------------|
| `email` | `string` | Empty string indicating the account was deactivated |
Returns HTTP `500` if deactivation fails.
---
#### Set ACME Contact Email
@@ -1481,13 +1632,11 @@ Set or update the ACME account contact email.
**Response (`data`):** Returns the set `email` field.
#### Generate Self-Signed Certificate
#### Generate Self-Signed Certificate (daemon-only)
```
POST /api/certs/self-signed
```
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).
Generate a self-signed certificate for a domain. Idempotent — skips if `fullchain.cer` and `<domain>.key` already exist at `data/acme/<domain>/`.
It generates a self-signed certificate for a domain and is idempotent — it skips generation if `<domain>.crt` and `<domain>.key` already exist at `data/certs/`.
**Request Body:**
@@ -1501,9 +1650,9 @@ Generate a self-signed certificate for a domain. Idempotent — skips if `fullch
| 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 |
| `cert` | `string` | Path to `data/certs/<domain>.crt` |
| `key` | `string` | Path to `data/certs/<domain>.key` |
| `generated` | `boolean` | `true` if a new cert was created, `false` if the existing cert was reused |
## WireGuard API
@@ -1593,14 +1742,9 @@ Alias for `/api/wireguard/apply` — write config and bring the tunnel up.
POST /api/wireguard/down
```
Bring down the WireGuard tunnel interface (`wg0`).
Bring down the WireGuard tunnel interface(s) (all class interfaces plus the legacy `wg0`).
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `down` | `boolean` | Always `true` on success |
| `synced` | `[string, ...]` | Subsystems auto-synced as a result |
**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
@@ -1619,6 +1763,7 @@ Return live tunnel state with interface metrics and per-peer connection statisti
| `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}`) |
---
@@ -1656,7 +1801,7 @@ Return all configured peers. Private keys are stripped.
POST /api/wireguard/peers
```
Add a new WireGuard peer. A key pair is auto-generated. Private key stripped from response.
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:**
@@ -1664,11 +1809,13 @@ Add a new WireGuard peer. A key pair is auto-generated. Private key stripped fro
|-------|------|----------|-------------|
| `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"]` |
| `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`):** Peer object with `name`, `public_key`, `allowed_ips`, etc. (no `private_key`).
**Response (`data`):** The peer object with `public_key`, `endpoint`, `allowed_ips`, `persistent_keepalive`, `preshared_key`, `description`, `access_class` (no `private_key`).
---
@@ -1741,11 +1888,11 @@ Manage VPN access classes that categorize peers by access level (e.g., full LAN
GET /api/wireguard/classes
```
Return all configured access classes.
Return all configured access classes. Private keys are stripped.
**Response (`data`):**
Object keyed by class identifier, each with `name` and `description` fields.
Object keyed by class identifier, each entry carrying `name`, `description`, `subnet`, `listen_port`, `lan_access`, and `public_key` (private key omitted).
#### Create Access Class
@@ -1759,19 +1906,16 @@ Create a new access class.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | `string` | Yes | Class identifier (alphanumeric) |
| `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`):**
**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`.
| Field | Type | Description |
|-------|------|-------------|
| `key` | `string` | Class key |
| `name` | `string` | Display name |
| `description` | `string` | Description |
Returns HTTP `409` if the key already exists.
Returns HTTP `400` if the `key` is missing or is not lowercase alphanumeric. Returns HTTP `409` if the key already exists.
#### Update Access Class
@@ -1779,7 +1923,7 @@ Returns HTTP `409` if the key already exists.
PATCH /api/wireguard/classes
```
Update an existing access class.
Update an existing access class. Only the fields present in the body are changed.
**Request Body:**
@@ -1788,8 +1932,11 @@ Update an existing access class.
| `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`):** Updated class object with `key`, `name`, `description`.
**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.
@@ -1813,6 +1960,62 @@ Returns HTTP `404` if the class is not found. Returns HTTP `409` if peers refere
---
#### Bring Class Tunnel Up
```
POST /api/wireguard/classes/<key>/up
```
Bring up a single class's tunnel interface (renders the class config and runs `wg-quick up`).
**Response:** `data` is `null` on success.
Returns HTTP `404` if the class does not exist. Returns HTTP `400` if the class has no assigned peers.
---
#### Bring Class Tunnel Down
```
POST /api/wireguard/classes/<key>/down
```
Bring down a single class's tunnel interface. Note: the webui exposes this as `POST`, while the underlying daemon endpoint is a `DELETE` (`/wireguard/classes/<key>/down`).
**Response:** `data` is `null` on success.
Returns HTTP `404` if the class does not exist.
---
#### Get Class Status
```
GET /api/wireguard/classes/<key>/status
```
Return live status for a single class's tunnel interface.
**Response (`data`):** The class status object (`up`, `interface`, `peers`).
Returns HTTP `404` if the class does not exist.
---
#### Generate Class Keys
```
POST /api/wireguard/classes/keys/<key>
```
Generate a key pair for a class (idempotent — reports `generated: false` if keys already exist).
**Response:** `data` is `null` on success via the webui (the webui route discards the daemon payload). The daemon itself returns `{generated, class_key, public_key}` (or `{generated: false, class_key, reason}` when keys already exist).
Returns HTTP `404` if the class does not exist.
---
## Network API
Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface configuration including static addresses, routes, DNS, DHCP client settings, and link parameters.
@@ -1871,8 +2074,9 @@ Save network config for an interface, render the `.network` file, copy it to `/e
| 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 |
| `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.
@@ -1884,7 +2088,7 @@ Returns HTTP `400` if the interface name is invalid.
POST /api/network/interfaces/<name>/reload
```
Reload networkd for a single interface (runs `networkctl reload <name>`).
Reload networkd for a single interface (runs `networkctl reconfigure <name>`, not `networkctl reload`).
**Response (`data`):**
@@ -1950,7 +2154,7 @@ Suggest firewalld zone assignments for configured interfaces based on heuristics
|-------|------|-------------|
| `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) |
Returns HTTP `500` if the value cannot be verified after write.
This is a read-only suggestion endpoint; it does not write anything and returns no write-verification errors.
---
@@ -2082,21 +2286,42 @@ Re-collect state from the daemon, optionally filtered by subsystem. Proxies the
Returns HTTP `500` if the daemon is unreachable.
### Sysctl
### System Metrics
#### Set Kernel Parameter
#### Get System Metrics
```
POST /api/network/sysctl/set
GET /api/status/system-metrics
```
Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back.
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 (e.g., `"net.ipv4.ip_forward"`) |
| `name` | `string` | Yes | Kernel parameter name (must be one of the nine allowed keys) |
| `value` | `string` | Yes | Value to set |
**Response (`data`):**
@@ -2106,7 +2331,7 @@ Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it b
| `name` | `string` | Parameter name |
| `value` | `string` | Value set |
Returns HTTP `500` if the value cannot be verified after write.
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.
---