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
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, and once one of them is writtenGET /api/config/statusreportsrestart_pending: true. - Every route has a policy class —
read,config_writeorruntime_action— and in file mode theconfig_writeroutes 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_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. The route is.sessionlike 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 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). - 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'sX-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. OnlyX-Forwarded-Foris 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 setweb.api_localhost_exempt = false. - 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 is theProvenanceobject — the body ofGET /api/queries/{id}without itsid, which does not exist yet because a live entry precedes its own insert. Its six groups arerequest,group,policy,rewrites,routeandresponse. - 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.
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 when this server writes an upstream or a settings key — the changes the running process cannot apply — and 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 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/stats |
session | counted | read | Totals for a period |
| GET | /api/stats/timeseries |
session | counted | read | Bucketed counts for a period |
| GET | /api/stats/types |
session | counted | read | Query-type breakdown for a period |
| GET | /api/stats/routes |
session | counted | read | How the period's queries were answered |
| GET | /api/stats/clients |
session | counted | read | Per-client bucketed counts for a 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. 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 the five GET /api/stats* endpoints — 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. Coherence stops there: two separate requests are two separate reads, and queries logged between them can move the counts.