milestone 11: systemd and docker packaging, operator and architecture docs, config and api reference, docs drift guards
This commit is contained in:
+169
@@ -0,0 +1,169 @@
|
||||
# nxdns REST API
|
||||
|
||||
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.
|
||||
|
||||
This page is orientation. The machine-readable contract is
|
||||
`src/web/openapi.yaml`, which the running server hands out unauthenticated at
|
||||
`GET /api/openapi.yaml`. When this page and the yaml disagree, the yaml wins.
|
||||
|
||||
## 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.
|
||||
|
||||
- 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.
|
||||
It answers 200 whether or not the session was live.
|
||||
|
||||
## 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).
|
||||
- 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 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.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user