16 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
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. - 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 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.
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/settings 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/settings carries an authority object:
| Field | Meaning |
|---|---|
mode |
"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. |
All three keys are always present; the two nullable ones carry null rather
than being omitted, so a client can read authority.mode without probing.
{"mode": "database", "path": null, "reconciled_at": null}
{"mode": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016}
The route requires a session, which is why the filesystem path is here rather
than on the open /api/version and /api/health.
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/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/lookup |
session | counted | read | Explain a domain |
| GET | /api/upstream/health |
session | counted | read | Upstream pool health |
| 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 |
| 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.
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.
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.