Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
221 lines
20 KiB
Markdown
221 lines
20 KiB
Markdown
# 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](#operations) table below carries all 63 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.
|
|
- 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](#configuration-authority).
|
|
|
|
## Authentication
|
|
|
|
Cookie sessions, in memory, no accounts — one operator password. Setting that password is [set up admin authentication](../how-to/set-up-admin-authentication.md).
|
|
|
|
- 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:
|
|
|
|
```json
|
|
{"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.
|
|
|
|
```json
|
|
{"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/{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 |
|
|
| 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
|
|
|
|
`GET /api/settings` and `PUT /api/settings` speak the `section.field` keys of [the configuration reference](configuration.md), 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](configuration.md#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 (`||name^`) in a downloaded list |
|
|
|
|
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.
|