9.5 KiB
nxdns REST API
nxdns serves its admin API itself, on web.bind:web.port (default port 8080),
as plain HTTP. TLS termination, where an operator wants it, belongs to a
reverse proxy in front; the session cookie deliberately omits the Secure
attribute so the supported plain-HTTP LAN deployment works.
This page is orientation. The machine-readable contract is
src/web/openapi.yaml, which the running server hands out unauthenticated at
GET /api/openapi.yaml. When this page and the yaml disagree, the yaml wins.
Conventions
- All request and response bodies are JSON (
application/json), except/metrics(Prometheus text format),/api/openapi.yaml(YAML) and/api/queries/live(text/event-stream). - Field names are snake_case, matching settings keys and SQL column names.
- Every error response carries the envelope
{"error": "<message>"}. The message is operator-facing text; internal detail never reaches the wire — a 500 body is generic and the cause goes to the server log. - Request bodies are strict: an unknown field is a 400, a body over 1 MiB is a 413.
- A request whose path matches but whose method does not answers 405 with an
Allowheader. An unknown/apipath is a JSON 404; unknown non-/apipaths fall through to the embedded SPA (index.html), so client-side routing works. - Item routes (
{id}) match a positive integer id only. - Mutations to groups, blocklists, rules, local records, forward zones,
clients and client prefixes take effect live. Upstreams and
/api/settingsare restart-required.
Authentication
Cookie sessions, in memory, no accounts — one operator password.
- Authentication is on exactly when
web.password_hashis set. When no password is set, every route is open andPOST /api/auth/loginanswers{"authenticated": true, "auth_required": false}without setting a cookie. POST /api/auth/logintakes{"password": "..."}. A correct password answers 200 with aSet-Cookiefornxdns_session(HttpOnly; SameSite=Lax; Path=/,Max-Age= the session TTL). A wrong password is a 401; a stored hash the server cannot read is a 500, never a 401. Login attempts spend rate-limit tokens like any other request, and argon2id verification is deliberately slow.- Every route whose auth policy is
sessionanswers 401{"error": "authentication required"}without a valid cookie. - Sessions live
web.session_ttl_hours(default 24) from login; use does not extend the lifetime. The table holds 32 sessions; a 33rd login evicts the least recently used. Nothing is persisted — a server restart logs every operator out. - Changing the password through
PUT /api/settingsrevokes every live session immediately; the new password applies without a restart. POST /api/auth/logoutends the cookie's session and clears the cookie. It answers 200 whether or not the session was live.
Rate limiting
A token bucket per client address: capacity and refill are both
web.api_rate_limit_per_min (default 300) per minute, so a page-load burst
up to the capacity is admitted and the long-run rate holds.
- An over-budget request answers 429
{"error": "rate limited"}with aRetry-Afterheader giving the seconds until a token is available (rounded up, never zero). - Loopback addresses (127.0.0.0/8 and ::1) are exempt while
web.api_localhost_exemptis true (the default). - Exempt routes, which never consult a bucket:
/metricsand/api/health(a Prometheus scrape must never see 429) and/api/queries/live(one long-lived stream must not drain its address's bucket; it is bounded by the SSE connection cap instead). - The limiter tracks at most 4096 addresses. When the table is full and no slot is reclaimable, requests from unknown addresses are refused with 429.
Live query stream (SSE)
GET /api/queries/live is server-sent events over chunked transfer,
Content-Type: text/event-stream, Cache-Control: no-store.
- The stream opens with
retry: 3000, so a browserEventSourcereconnects on its own after a drop. - Each query is one frame:
event: queryand a singledata:line of JSON. The payload carries theGET /api/queriesrow fields minusid(a live entry precedes persistence):ts,domain,client_ip,qtype,blocked,block_reason,response_time_us,cache_hit,upstream. - A
: pingcomment heartbeat goes out after 15 s of quiet, keeping middleboxes from reaping the idle connection. - Each subscriber buffers up to 64 entries. A client too slow for the query
rate overflows its buffer and the server ends the stream cleanly after
delivering what the buffer held — queries are never held back for a slow
reader. There is no gap marker: on reconnect, re-sync through
GET /api/queries, which has the missed rows. - Connections per client address are capped at
web.sse_max_connections_per_ip(default 3); over the cap is a 429. The cap binds loopback too. The server holds at most 32 concurrent streams in total; when all slots are taken, the answer is a 503.
Operations
Auth open means no session required; session means a valid session
cookie is required whenever a password is set. Rate limit counted spends a
token; exempt never consults the limiter.
| Method | Path | Auth | Rate limit | Purpose |
|---|---|---|---|---|
| GET | /metrics |
open | exempt | Prometheus metrics |
| GET | /api/health |
open | exempt | Health rollup |
| GET | /api/version |
open | counted | Build and uptime |
| GET | /api/openapi.yaml |
open | counted | This API's OpenAPI document |
| POST | /api/auth/login |
open | counted | Log in |
| POST | /api/auth/logout |
session | counted | Log out |
| GET | /api/queries |
session | counted | Query log page |
| GET | /api/queries/live |
session | exempt | Live query stream (server-sent events) |
| GET | /api/stats |
session | counted | Totals for a period |
| GET | /api/stats/timeseries |
session | counted | Bucketed counts for a period |
| GET | /api/lookup |
session | counted | Explain a domain |
| GET | /api/upstream/health |
session | counted | Upstream pool health |
| GET | /api/groups |
session | counted | List groups |
| POST | /api/groups |
session | counted | Create a group |
| GET | /api/groups/{id} |
session | counted | Read a group |
| PUT | /api/groups/{id} |
session | counted | Update a group |
| DELETE | /api/groups/{id} |
session | counted | Delete a group |
| GET | /api/groups/{id}/sources |
session | counted | Blocklist sources assigned to a group |
| PUT | /api/groups/{id}/sources |
session | counted | Replace the assignment |
| GET | /api/blocklists |
session | counted | List blocklist sources |
| POST | /api/blocklists |
session | counted | Add a blocklist source |
| POST | /api/blocklists/update |
session | counted | Refresh every enabled source now |
| GET | /api/blocklists/{id} |
session | counted | Read a blocklist source |
| PUT | /api/blocklists/{id} |
session | counted | Update a blocklist source |
| DELETE | /api/blocklists/{id} |
session | counted | Delete a blocklist source |
| GET | /api/rules |
session | counted | List rules |
| POST | /api/rules |
session | counted | Create a rule |
| GET | /api/rules/{id} |
session | counted | Read a rule |
| PUT | /api/rules/{id} |
session | counted | Update a rule |
| DELETE | /api/rules/{id} |
session | counted | Delete a rule |
| GET | /api/local-records |
session | counted | List local DNS records |
| POST | /api/local-records |
session | counted | Create a local record |
| GET | /api/local-records/{id} |
session | counted | Read a local record |
| PUT | /api/local-records/{id} |
session | counted | Update a local record |
| DELETE | /api/local-records/{id} |
session | counted | Delete a local record |
| GET | /api/forward-zones |
session | counted | List forward zones |
| POST | /api/forward-zones |
session | counted | Create a forward zone |
| GET | /api/forward-zones/{id} |
session | counted | Read a forward zone |
| PUT | /api/forward-zones/{id} |
session | counted | Update a forward zone |
| DELETE | /api/forward-zones/{id} |
session | counted | Delete a forward zone |
| GET | /api/clients |
session | counted | List clients |
| GET | /api/clients/{id} |
session | counted | Read a client |
| PUT | /api/clients/{id} |
session | counted | Rename or regroup a client |
| DELETE | /api/clients/{id} |
session | counted | Forget a client |
| GET | /api/client-prefixes |
session | counted | List client prefixes |
| PUT | /api/client-prefixes |
session | counted | Replace the prefix table |
| GET | /api/upstreams |
session | counted | List upstream resolvers |
| POST | /api/upstreams |
session | counted | Add an upstream |
| GET | /api/upstreams/{id} |
session | counted | Read an upstream |
| PUT | /api/upstreams/{id} |
session | counted | Update an upstream |
| DELETE | /api/upstreams/{id} |
session | counted | Delete an upstream |
| GET | /api/pause |
session | counted | Read the pause state |
| POST | /api/pause |
session | counted | Pause or resume blocking |
| GET | /api/settings |
session | counted | Read the scalar settings |
| PUT | /api/settings |
session | counted | Update settings |
| POST | /api/certs/reload |
session | counted | Reload the TLS certificates from disk |
There is no POST /api/clients: client rows come from DNS activity or
import, never from the API.
Schemas
Request and response schemas for every operation live in the OpenAPI
document: src/web/openapi.yaml in the repository, or
GET /api/openapi.yaml from a running server.