Files
vacuum-wall/docs/api.md
T

68 KiB

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

{
  "ok": true,
  "data": <value>
}

The data field contains the payload, which may be an object, array, string, or null.

Error Responses

{
  "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 wgwan
  • 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):

{ "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:

// 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 section for the full request/response contract.