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