Files
nxdns/docs/reference/api.md
T
mokhtar 6a0630c288
Gates / frontend (push) Successful in 2m6s
Gates / test (push) Successful in 2m57s
Gates / test-aarch64 (push) Successful in 8m31s
Gates / package (push) Successful in 4m19s
Gates / container (push) Failing after 2s
CI / gates (push) Failing after 26m21s
overview: one endpoint, live projections and a response cache (m36)
2026-08-27 17:48:20 +02:00

21 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 64 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 take effect live, upstreams and /api/settings included: a write rebuilds or reconfigures the owner it belongs to in-process. The exceptions are the settings keys that create or destroy a socket — the DNS, web, DoH and DoT bind addresses, ports and enabled flags. Writing one of those commits the row and reports restart_pending: true on GET /api/config/status; the settings envelope lists exactly those keys under restart_required.
  • Every route has a policy class — read, config_write or runtime_action — and in file mode the config_write routes are refused. See Configuration authority.

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 is the Provenance object — the body of GET /api/queries/{id} without its id, which does not exist yet because a live entry precedes its own insert. Its six groups are request, group, policy, rewrites, route and response.
  • 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.

Configuration authority

Which authority is live decides whether the API may write configuration. Under nxdns run the database is authority and every route behaves as it always has. Under nxdns run --config FILE the file is authority, and the routes that would edit configuration are refused: the file is the only place configuration changes, and a restart is what applies them.

The refusal

A config write route in file mode answers 403 with the ordinary error envelope:

{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}

There is no code field and no richer body. 403 is used for nothing else in this API, so the status alone is the machine-readable part, and a client that wants to know the mode in advance reads it from GET /api/config/status rather than probing for errors.

401 comes first. The router matches the path, spends a rate-limit token, checks the session, and only then checks the policy. So an unauthenticated request to a config write route in file mode is a 401, not a 403 — answering 403 first would tell an anonymous caller which routes exist.

runtime action and read routes are unaffected in both modes. Pausing blocking, refreshing blocklists, reloading certificates and logging in are operations on a running process, not statements about configuration, so a file-mode box still does all of them.

DELETE /api/clients/{id} is the one route whose answer depends on the row. Deleting a client the file does not declare is a runtime action and succeeds: without it, a mis-identified or departed device would be immortal in file mode, since the file can add addresses but never remove one it has never named. Deleting a client the file does declare contradicts the file, and answers the same 403.

Discovering the authority

GET /api/config/status answers with the authority and the process's restart state:

Field Meaning
authority "database" or "managed_file".
path The managed file's path, or null in database mode.
reconciled_at Unix seconds when this process loaded the file, or null in database mode.
restart_pending True once this process has committed a configuration change that only a restart applies.

All four keys are always present; the two nullable ones carry null rather than being omitted, so a client can read authority without probing.

{"authority": "database", "path": null, "reconciled_at": null, "restart_pending": false}
{"authority": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016, "restart_pending": false}

The route requires a session, which is why the filesystem path is here rather than on the open /api/version and /api/health.

restart_pending is per-process state and nothing but process exit clears it. It rises for exactly one kind of change: a settings key that creates or destroys a socket — the DNS, web, DoH and DoT bind addresses, ports and enabled flags. Every other write, upstreams included, is applied in-process and leaves the flag alone. It is never persisted, so a false read after a restart means the restart happened, not that the flag was cleared. In file mode it stays false: those writes are refused before any handler runs.

reconciled_at answers exactly one question: when did this process last read the file? Compare it against the file's mtime to spot a restart that has not happened yet. It is a hint and not a verdict, in both directions — a clock that stepped, or a copy that preserved mtimes (git checkout, rsync -a), can make a newer file look older, and the database can change without either timestamp moving. It does not tell you whether the file and the running configuration agree; answering that would take content hashing, which nxdns deliberately does not do.

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. Policy config write is the class refused in file mode; read and runtime action are always served.

Method Path Auth Rate limit Policy Purpose
GET /metrics open exempt read Prometheus metrics
GET /api/health open exempt read Health rollup
GET /api/version open counted read Build and uptime
GET /api/openapi.yaml open counted read This API's OpenAPI document
POST /api/auth/login open counted runtime action Log in
POST /api/auth/logout session counted runtime action Log out
GET /api/queries session counted read Query log rows for the Activity page
GET /api/queries/{id} session counted read One query, fully explained
GET /api/queries/live session exempt read Live query stream (server-sent events)
GET /api/overview session counted read Everything the Overview page draws, for one period
GET /api/lookup session counted read Explain a domain
GET /api/diagnostics session counted read Operational event log
DELETE /api/diagnostics session counted runtime action Purge every resolved event
GET /api/diagnostics/{id} session counted read One operational event
DELETE /api/diagnostics/{id} session counted runtime action Purge one resolved event
GET /api/groups session counted read List groups
POST /api/groups session counted config write Create a group
GET /api/groups/{id} session counted read Read a group
PUT /api/groups/{id} session counted config write Update a group
DELETE /api/groups/{id} session counted config write Delete a group
GET /api/groups/{id}/sources session counted read Blocklist sources assigned to a group
PUT /api/groups/{id}/sources session counted config write Replace the assignment
GET /api/blocklists session counted read List blocklist sources
POST /api/blocklists session counted config write Add a blocklist source
POST /api/blocklists/update session counted runtime action Refresh every enabled source now
GET /api/blocklists/{id} session counted read Read a blocklist source
PUT /api/blocklists/{id} session counted config write Update a blocklist source
DELETE /api/blocklists/{id} session counted config write Delete a blocklist source
GET /api/rules session counted read List rules
POST /api/rules session counted config write Create a rule
GET /api/rules/{id} session counted read Read a rule
PUT /api/rules/{id} session counted config write Update a rule
DELETE /api/rules/{id} session counted config write Delete a rule
GET /api/local-records session counted read List local DNS records
POST /api/local-records session counted config write Create a local record
GET /api/local-records/{id} session counted read Read a local record
PUT /api/local-records/{id} session counted config write Update a local record
DELETE /api/local-records/{id} session counted config write Delete a local record
GET /api/forward-zones session counted read List forward zones
POST /api/forward-zones session counted config write Create a forward zone
GET /api/forward-zones/{id} session counted read Read a forward zone
PUT /api/forward-zones/{id} session counted config write Update a forward zone
DELETE /api/forward-zones/{id} session counted config write Delete a forward zone
GET /api/clients session counted read List clients
GET /api/clients/{id} session counted read Read a client
PUT /api/clients/{id} session counted config write Rename or regroup a client
DELETE /api/clients/{id} session counted runtime action Forget a client
GET /api/client-prefixes session counted read List client prefixes
PUT /api/client-prefixes session counted config write Replace the prefix table
GET /api/upstreams session counted read List upstream resolvers
POST /api/upstreams session counted config write Add an upstream
GET /api/upstreams/{id} session counted read Read an upstream
PUT /api/upstreams/{id} session counted config write Update an upstream
DELETE /api/upstreams/{id} session counted config write Delete an upstream
GET /api/pause session counted read Read the pause state
POST /api/pause session counted runtime action Pause or resume blocking
GET /api/settings session counted read Read the scalar settings
PUT /api/settings session counted config write Update settings
GET /api/config/status session counted read Read the configuration authority and restart state
POST /api/certs/reload session counted runtime action Reload the TLS certificates from disk

There is no POST /api/clients: client rows come from DNS activity or import, never from the API.

The two diagnostics deletes purge history only. DELETE /api/diagnostics/{id} answers 204 for a resolved event, 409 for one that is still active — an open episode is the current state of the box, not history — and 404 for an id no row holds. DELETE /api/diagnostics removes every resolved event and answers {"purged": N}, leaving the active ones. Events still resolve on their own; these only decide when the resolved rows go.

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

The envelope both operations answer with is {settings, restart_required}: the stored values, and the list of keys a restart applies — the bind addresses, ports and enabled flags of the four listeners, and nothing else. Every other key is applied by the PUT that changes it. It says nothing about the live authority or a restart already owed — those are per-process facts, and GET /api/config/status is their one home.

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.

In file mode PUT /api/settings is refused with the 403 above, password changes included. The password then lives where the rest of the configuration lives: set web.password in the file and restart. See Password and hash.

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.

Policy reasons

Two places carry the same closed set of tags: policy_reason on a GET /api/queries row and on a GET /api/queries/{id} body (where it is policy.reason, and where the live stream sends the same field), and reason on a GET /api/lookup answer. The tag names what decided the query.

The first nine are the matcher's own verdicts, listed in the order they are consulted — the first that matches wins, so a rule always outranks a list.

Tag Decided by
rule_allow_exact An exact rule with action allow
rule_block_exact An exact rule with action block
rule_allow_wildcard A wildcard rule with action allow
rule_block_wildcard A wildcard rule with action block
rule_allow_regex A regex rule with action allow
rule_block_regex A regex rule with action block
blocklist_exception An @@ exception line in a downloaded list
blocklist_domain A plain name in a downloaded list
blocklist_wildcard A domain anchor (`

The rest name a pipeline step that answered the query without consulting the matcher, and appear on a query row only.

Tag Decided by
local_record A configured local record, answered before filtering
forward_zone A configured forward zone, answered before filtering
non_in_class The question was not class IN, so no rule could apply
paused Filtering was paused
snapshot_unavailable No filter snapshot was published yet, so the query went unfiltered
no_match The matcher evaluated the name and nothing matched
protocol_error A parsed request refused on protocol grounds — BADVERS, NOTIMP, a malformed EDNS OPT

policy_action says which way the verdict went: block, allow, or not_evaluated for a query answered before any policy could apply. /api/lookup answers none when nothing matched, where a query row says no_match.

route_kind says where the answer came from: blocked, local, forward_zone, upstream, cache or rejected.

A non-empty rewrites.cname_target on a query detail means the decision landed on a CNAME target rather than on the name the client asked for; policy.reason is then the target's own reason. /api/lookup does not follow CNAMEs.

Coverage

Every window-bounded read — GET /api/queries and GET /api/overview — answers with a coverage object: available_since is the oldest instant the query log is still complete for, and complete is true only when the window the request asked about starts at or after it. Retention deletes rows and advances the watermark in one transaction, so a client can tell an empty window from a pruned one instead of charting the gap as zero. A request with no lower bound at all asks about the whole of history, and is never complete.

Each of these responses reads its rows and its watermark inside one SQLite read transaction, so retention cannot prune between the two and hand back pre-prune rows tagged with a post-prune available_since. GET /api/overview puts every Overview panel inside that one transaction, so its totals and its four breakdowns describe one database state. Coherence stops there: two separate requests are two separate reads, and queries logged between them can move the counts.