Files
mokhtar c50c6d285a
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 4m55s
CI / frontend (push) Successful in 39s
CI / cross (push) Successful in 7m57s
CI / docker (push) Failing after 1h10m42s
milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
2026-08-07 17:55:59 +02:00

12 KiB

REST API reference

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.

The machine-readable contract is src/web/openapi.yaml, which the running server hands out unauthenticated at GET /api/openapi.yaml. Request and response schemas for every operation live there. When this page and the YAML disagree, the YAML wins.

The route table is src/web/routes.zig; the Operations table below carries all 56 of its entries.

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 Allow header. An unknown /api path is a JSON 404; unknown non-/api paths 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/settings are restart-required.

Authentication

Cookie sessions, in memory, no accounts — one operator password. Setting that password is set up admin authentication.

  • Authentication is on exactly when web.password_hash is set. When no password is set, every route is open and POST /api/auth/login answers {"authenticated": true, "auth_required": false} without setting a cookie.
  • POST /api/auth/login takes {"password": "..."}. A correct password answers 200 with a Set-Cookie for nxdns_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 session answers 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/settings revokes every live session immediately; the new password applies without a restart.
  • POST /api/auth/logout ends the cookie's session and clears the cookie. The route is .session like any other, so with a password configured the router answers 401 before the handler runs when the cookie is missing, expired, revoked or already logged out; only a live session gets the 200. With no password configured every session route is open and logout answers 200.

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 a Retry-After header 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_exempt is true (the default).
  • The address a bucket keys on is the socket peer, unless that peer is listed in web.trusted_proxies. For a listed peer the address is instead the last entry of the request's X-Forwarded-For — the entry the proxy appended, which is the only one a client cannot write. A request from a trusted proxy with no such header keys on the proxy itself; one whose last entry is not an IP literal is answered 400, because the alternative is granting the proxy's own loopback exemption to whoever sent it. Only X-Forwarded-For is read; Forwarded (RFC 7239) and the PROXY protocol are not.
  • Without web.trusted_proxies, a same-box reverse proxy makes every request loopback, so the default exemption disables the limiter for all remote clients. Set the proxy's address there, or set web.api_localhost_exempt = false.
  • Exempt routes, which never consult a bucket: /metrics and /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 browser EventSource reconnects on its own after a drop.
  • Each query is one frame: event: query and a single data: line of JSON. The payload carries the GET /api/queries row fields minus id (a live entry precedes persistence): ts, domain, client_ip, qtype, blocked, block_reason, response_time_us, cache_hit, upstream.
  • A : ping comment 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 is 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.

Static assets are not routes. The router sends unmatched non-/api paths to the embedded SPA before any auth or rate-limit check.

Settings keys

GET /api/settings and PUT /api/settings speak the section.field keys of the configuration reference, with the values in their database spelling — notably logging.level is "error", not "err". Two keys behave differently over the API than in the file: web.password is write-only (accepted on a PUT, never returned, hashed before storage), and web.password_hash is neither readable nor directly writable, because a client that could install a hash could install one whose password it already knows.

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.