openapi: 3.0.3 info: title: nxdns admin API version: "1.0" description: | The REST API of the nxdns DNS sinkhole, served by nxdns itself on `web.bind:web.port` (plain HTTP; TLS termination is the operator's proxy). Conventions, which every endpoint follows: - All JSON field names are snake_case, matching settings keys and SQL column names. - Every error response carries the envelope `{"error": ""}`. 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. - Authentication is enabled iff `web.password_hash` is set. When it is, every operation marked with `sessionCookie` security answers 401 without a valid session cookie. When it is not, no operation requires authentication. Monitoring endpoints, the login, this document and the static assets are always open. - Requests spend one token of the per-address bucket (`web.api_rate_limit_per_min`); over-budget requests answer 429 with a `Retry-After` header. `/metrics` and `/api/health` are exempt so a Prometheus scrape can never be throttled; `/api/queries/live` is exempt because one long-lived stream is bounded by `web.sse_max_connections_per_ip` instead. - Mutations take effect live, including upstreams and `/api/settings`: the owner of every changed setting is told in-process by the write that changes it. The exceptions are the keys that create or destroy a socket — the listener addresses and ports, and `web.enabled` — which `/api/settings` reports in its `restart_required` list. - nxdns runs under one of two configuration authorities. Started with `--config=`, that file is the sole declarative source, and every operation that writes configuration answers 403 with the same error envelope, naming the file. Operations that change runtime state — `/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the login and the logout — stay live, as does `DELETE /api/clients/{id}` for a client the file does not declare. `GET /api/config/status` reports the live authority, so a client reads the mode rather than discovering it from a rejection. servers: - url: / security: - sessionCookie: [] paths: /metrics: get: summary: Prometheus metrics description: | Prometheus text exposition format 0.0.4, `nxdns_` prefix. Always unauthenticated and never rate limited. security: [] responses: "200": description: Metrics exposition. content: text/plain: schema: type: string /api/health: get: summary: Health rollup description: | Always 200. `status` is `degraded` when, and only when, one of the five condition objects is in a degrading state: protection `unavailable`, upstreams `unavailable`, query history `losing` or `failed`, diagnostics `unavailable`, or disk `low` or `critical`. A paused protection is an operator's own choice and does not degrade. Always unauthenticated and never rate limited. security: [] responses: "200": description: Health snapshot. content: application/json: schema: $ref: "#/components/schemas/Health" /api/version: get: summary: Build and uptime security: [] responses: "200": description: Version information. content: application/json: schema: $ref: "#/components/schemas/Version" "429": $ref: "#/components/responses/RateLimited" /api/openapi.yaml: get: summary: This document security: [] responses: "200": description: The OpenAPI contract, verbatim. content: application/yaml: schema: type: string "429": $ref: "#/components/responses/RateLimited" /api/auth/login: post: summary: Log in description: | Verifies the admin password. On success the response sets the `nxdns_session` cookie (`HttpOnly; SameSite=Lax; Path=/`). When no password is configured the request succeeds without minting a session and `auth_required` is false. security: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LoginRequest" responses: "200": description: Authenticated; the cookie is set iff `auth_required`. headers: Set-Cookie: schema: type: string description: "`nxdns_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=`" content: application/json: schema: $ref: "#/components/schemas/LoginResponse" "400": $ref: "#/components/responses/BadRequest" "401": description: Wrong or empty password. content: application/json: schema: $ref: "#/components/schemas/Error" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/auth/logout: post: summary: Log out description: Deletes the session and clears the cookie. Always succeeds. responses: "200": description: Logged out; the cookie is cleared. headers: Set-Cookie: schema: type: string description: "`nxdns_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`" content: application/json: schema: type: object required: [authenticated] properties: authenticated: type: boolean enum: [false] "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" /api/queries: get: summary: Query log page description: | Keyset pagination over the query log, newest first. Follow `next_before` until it is null. parameters: - name: limit in: query schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } - name: before in: query description: Return rows with id strictly below this cursor. schema: { type: integer, minimum: 1 } - name: domain in: query description: Substring match; `%` and `_` are literal. schema: { type: string, maxLength: 253 } - name: client in: query description: Exact client address. schema: { type: string, maxLength: 64 } - name: blocked in: query schema: { type: boolean } - name: since in: query description: Unix seconds, inclusive. schema: { type: integer } - name: until in: query description: Unix seconds, exclusive. schema: { type: integer } responses: "200": description: One page. content: application/json: schema: $ref: "#/components/schemas/QueriesPage" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/queries/{id}: get: summary: One query, fully explained description: | The full provenance of one logged query: what was asked, which group's policy applied, what that policy decided and matched on, what was rewritten, where the answer came from, and what the client received. Every field is a fact recorded when the query was answered, so a group or blocklist renamed since keeps the name it had. parameters: - name: id in: path required: true schema: { type: integer, minimum: 1 } responses: "200": description: The query. content: application/json: schema: $ref: "#/components/schemas/QueryDetail" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/queries/live: get: summary: Live query stream (server-sent events) description: | `text/event-stream`. The stream opens with `retry: 3000`, then sends one `event: query` frame per resolved query. The `data:` line is one query fully explained, in the order a query meets the pipeline: the six objects `QueryDetail` documents — `request`, `group`, `policy`, `rewrites`, `route`, `response` — all of them required, and without that schema's `id`, which does not exist yet because the entry precedes its own insert. A `: ping` comment goes out every 15 seconds. A client that falls more than 64 events behind is disconnected and should re-sync via `/api/queries` after reconnecting. Connections per address are capped by `web.sse_max_connections_per_ip`; the stream does not spend rate-limit tokens. responses: "200": description: An event stream; stays open until either side closes. content: text/event-stream: schema: type: string "401": $ref: "#/components/responses/Unauthorized" "429": description: This address already holds its maximum number of live streams. content: application/json: schema: $ref: "#/components/schemas/Error" "503": $ref: "#/components/responses/Unavailable" /api/diagnostics: get: summary: Operational event log description: | Failure episodes, newest first. One event is one subject failing continuously: it opens on the first failure, counts repeats in `occurrences`, and gets a `resolved_at` when the subject recovers. A subject that fails again opens a new event rather than reopening the old one. Keyset pagination — follow `next_before` until it is null. parameters: - name: state in: query schema: { type: string, enum: [active, resolved, all], default: all } - name: severity in: query schema: { type: string, enum: [warning, error] } - name: component in: query description: Matches the part of `code` before the dot, exactly. schema: { type: string, maxLength: 64 } - name: since in: query description: | Unix seconds. With `until`, selects episodes overlapping the window; an episode resolved exactly at `since` does not overlap. schema: { type: integer } - name: until in: query description: Unix seconds, exclusive. schema: { type: integer } - name: limit in: query schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } - name: before in: query description: Return events with id strictly below this cursor. schema: { type: integer, minimum: 1 } responses: "200": description: One page. content: application/json: schema: $ref: "#/components/schemas/DiagnosticsPage" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" delete: summary: Purge every resolved event description: | Deletes the resolved history and answers with how many rows went. Active events are never touched, so clearing the page cannot lose an episode that is still failing. Resolution stays automatic; this only decides when the history disappears. A runtime action, served in file mode too — the event log is not configuration. responses: "200": description: How many resolved events were removed. content: application/json: schema: $ref: "#/components/schemas/DiagnosticsPurge" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/diagnostics/{id}: parameters: - $ref: "#/components/parameters/RowId" get: summary: One operational event description: | 404 for an id that never existed and for one retention has removed — the API does not distinguish them. responses: "200": description: The event. content: application/json: schema: $ref: "#/components/schemas/DiagnosticEvent" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" delete: summary: Purge one resolved event description: | Deletes a resolved event. An event that is still active answers 409 — an open episode is the current state of the box, not history — and an id no row holds answers 404. A runtime action, served in file mode too. responses: "204": description: Purged. "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/stats: get: summary: Totals for a period parameters: - $ref: "#/components/parameters/Period" responses: "200": description: Totals over the period's UTC-aligned window. content: application/json: schema: $ref: "#/components/schemas/StatsTotals" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/stats/timeseries: get: summary: Bucketed counts for a period description: | Fixed-width UTC buckets covering the same window `/api/stats` reports for the period: 1h into 60 one-minute buckets, 24h into 48 half-hour buckets, 7d into 168 one-hour buckets, 30d into 120 six-hour buckets. Empty buckets are zero-filled. parameters: - $ref: "#/components/parameters/Period" responses: "200": description: The bucket series. content: application/json: schema: $ref: "#/components/schemas/StatsTimeseries" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/stats/types: get: summary: Query-type breakdown for a period description: | How many queries of each DNS type the period's window holds, over the same UTC-aligned window `/api/stats` reports for. Rows carry the numeric type only: the type-name table lives in the admin, and a second copy here would drift out of agreement with it. `qtype` is nullable in the query log, so the rows that carry no type group into a row of their own rather than vanishing from a breakdown that claims to add up. Ordered by count descending, then type ascending with the null row last. Types absent from the window are absent from the list. parameters: - $ref: "#/components/parameters/Period" responses: "200": description: The type breakdown. content: application/json: schema: $ref: "#/components/schemas/StatsTypes" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/stats/routes: get: summary: How the period's queries were answered description: | A breakdown by answering route over the same window `/api/stats` reports for. `source` is the answering resolver's identity — the upstream url on `upstream` rows, the zone on `forward_zone` rows, null on every other kind and on rows whose identity the log did not record. It is not the blocklist a block came from. Ordered by count descending, then route ascending, then source ascending with nulls last. parameters: - $ref: "#/components/parameters/Period" responses: "200": description: The route breakdown. content: application/json: schema: $ref: "#/components/schemas/StatsRoutes" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/stats/clients: get: summary: Per-client bucketed counts for a period description: | One zero-filled series per client, bucketed exactly like `/api/stats/timeseries` so the two charts share an x-axis. The eight clients with the most queries in the window are named, ranked by count descending then address ascending; every other client sums into `other`, which is always present and always holds one entry per bucket in the window — including when `clients` is empty, when no client fell outside the named eight, and when the window holds no queries at all. parameters: - $ref: "#/components/parameters/Period" responses: "200": description: The per-client series. content: application/json: schema: $ref: "#/components/schemas/StatsClients" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/lookup: get: summary: Explain a domain description: | Runs the full pipeline view for a domain without answering DNS: local records, forward zone, block decision with reason and matched pattern, source URL when a blocklist matched, and any safe-search rewrite. parameters: - name: domain in: query required: true schema: { type: string, maxLength: 253 } - name: group_id in: query description: Group row id; the default group when absent. schema: { type: integer, minimum: 1 } responses: "200": description: The pipeline's view of the domain. content: application/json: schema: $ref: "#/components/schemas/Lookup" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/Unavailable" /api/groups: get: summary: List groups responses: "200": description: Every group. content: application/json: schema: type: object required: [groups] properties: groups: type: array items: $ref: "#/components/schemas/Group" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" post: summary: Create a group requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/GroupInput" responses: "201": description: Created; applied live. content: application/json: schema: $ref: "#/components/schemas/Group" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/groups/{id}: parameters: - $ref: "#/components/parameters/RowId" get: summary: Read a group responses: "200": description: The group. content: application/json: schema: $ref: "#/components/schemas/Group" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" put: summary: Update a group description: The `default` group cannot be renamed (409). requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/GroupInput" responses: "200": description: Updated; applied live. content: application/json: schema: $ref: "#/components/schemas/Group" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" delete: summary: Delete a group description: | The `default` group cannot be deleted, and neither can a group that still has clients (409 either way). responses: "204": description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/groups/{id}/sources: parameters: - $ref: "#/components/parameters/RowId" get: summary: Blocklist sources assigned to a group responses: "200": description: The assignment. content: application/json: schema: $ref: "#/components/schemas/GroupSources" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" put: summary: Replace the assignment description: Idempotent full-set replace; duplicates collapse. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/GroupSources" responses: "200": description: Replaced; applied live. content: application/json: schema: $ref: "#/components/schemas/GroupSources" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/blocklists: get: summary: List blocklist sources responses: "200": description: Every source with its load counters. content: application/json: schema: type: object required: [blocklists] properties: blocklists: type: array items: $ref: "#/components/schemas/Blocklist" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" post: summary: Add a blocklist source requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BlocklistInput" responses: "201": description: Created; applied live. content: application/json: schema: $ref: "#/components/schemas/BlocklistEcho" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/blocklists/update: post: summary: Refresh every enabled source now description: | Runs a fetch-and-compile pass over all enabled sources, reloads the filter, and reports the per-source status snapshot. responses: "202": description: Refresh ran; the snapshot reflects it. content: application/json: schema: type: object required: [sources] properties: sources: type: array items: $ref: "#/components/schemas/SourceStatus" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/blocklists/{id}: parameters: - $ref: "#/components/parameters/RowId" get: summary: Read a blocklist source responses: "200": description: The source with its load counters. content: application/json: schema: $ref: "#/components/schemas/Blocklist" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" put: summary: Update a blocklist source requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BlocklistInput" responses: "200": description: Updated; applied live. content: application/json: schema: $ref: "#/components/schemas/BlocklistEcho" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" delete: summary: Delete a blocklist source responses: "204": description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/rules: get: summary: List rules responses: "200": description: Every allow/block rule. content: application/json: schema: type: object required: [rules] properties: rules: type: array items: $ref: "#/components/schemas/Rule" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" post: summary: Create a rule requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RuleInput" responses: "201": description: Created; applied live. content: application/json: schema: $ref: "#/components/schemas/RuleEcho" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/rules/{id}: parameters: - $ref: "#/components/parameters/RowId" get: summary: Read a rule responses: "200": description: The rule. content: application/json: schema: $ref: "#/components/schemas/Rule" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" put: summary: Update a rule requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RuleInput" responses: "200": description: Updated; applied live. content: application/json: schema: $ref: "#/components/schemas/RuleEcho" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" delete: summary: Delete a rule responses: "204": description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/local-records: get: summary: List local DNS records responses: "200": description: Every local record. content: application/json: schema: type: object required: [local_records] properties: local_records: type: array items: $ref: "#/components/schemas/LocalRecord" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" post: summary: Create a local record requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LocalRecordInput" responses: "201": description: Created; applied live. content: application/json: schema: $ref: "#/components/schemas/LocalRecord" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/local-records/{id}: parameters: - $ref: "#/components/parameters/RowId" get: summary: Read a local record responses: "200": description: The record. content: application/json: schema: $ref: "#/components/schemas/LocalRecord" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" put: summary: Update a local record requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LocalRecordInput" responses: "200": description: Updated; applied live. content: application/json: schema: $ref: "#/components/schemas/LocalRecord" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" delete: summary: Delete a local record responses: "204": description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/forward-zones: get: summary: List forward zones responses: "200": description: Every forward zone. content: application/json: schema: type: object required: [forward_zones] properties: forward_zones: type: array items: $ref: "#/components/schemas/ForwardZone" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" post: summary: Create a forward zone requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ForwardZoneInput" responses: "201": description: Created; applied live. content: application/json: schema: $ref: "#/components/schemas/ForwardZone" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/forward-zones/{id}: parameters: - $ref: "#/components/parameters/RowId" get: summary: Read a forward zone responses: "200": description: The zone. content: application/json: schema: $ref: "#/components/schemas/ForwardZone" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" put: summary: Update a forward zone requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ForwardZoneInput" responses: "200": description: Updated; applied live. content: application/json: schema: $ref: "#/components/schemas/ForwardZone" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" delete: summary: Delete a forward zone responses: "204": description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/clients: get: summary: List clients description: | Every known client, materialized ones included, each with `hand_edited`. Clients have no POST: rows appear through DNS activity or configuration import. responses: "200": description: Every client. content: application/json: schema: type: object required: [clients] properties: clients: type: array items: $ref: "#/components/schemas/Client" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/clients/{id}: parameters: - $ref: "#/components/parameters/RowId" get: summary: Read a client responses: "200": description: The client. content: application/json: schema: $ref: "#/components/schemas/Client" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" put: summary: Rename or regroup a client description: | Sets `hand_edited`. The address is the client's identity and cannot be changed. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ClientEdit" responses: "200": description: The updated row; applied live. content: application/json: schema: $ref: "#/components/schemas/Client" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" delete: summary: Forget a client description: A live client re-materializes on its next query. responses: "204": description: Deleted; applied live. "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/client-prefixes: get: summary: List client prefixes responses: "200": description: The whole prefix table. content: application/json: schema: type: object required: [client_prefixes] properties: client_prefixes: type: array items: $ref: "#/components/schemas/ClientPrefix" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" put: summary: Replace the prefix table description: | Atomic whole-list replace; an empty array clears the table. A prefix listed twice is a 409. requestBody: required: true content: application/json: schema: type: object required: [client_prefixes] properties: client_prefixes: type: array items: $ref: "#/components/schemas/ClientPrefixInput" responses: "200": description: The stored table, re-read; applied live. content: application/json: schema: type: object required: [client_prefixes] properties: client_prefixes: type: array items: $ref: "#/components/schemas/ClientPrefix" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/upstreams: get: summary: List upstream resolvers responses: "200": description: Every configured upstream. content: application/json: schema: type: object required: [upstreams] properties: upstreams: type: array items: $ref: "#/components/schemas/Upstream" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" post: summary: Add an upstream description: Applies live; the resolver pool is rebuilt in-process. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpstreamInput" responses: "201": description: Created; live at once. content: application/json: schema: $ref: "#/components/schemas/UpstreamEcho" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/upstreams/{id}: parameters: - $ref: "#/components/parameters/RowId" get: summary: Read an upstream responses: "200": description: The upstream. content: application/json: schema: $ref: "#/components/schemas/Upstream" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" put: summary: Update an upstream description: Applies live; the resolver pool is rebuilt in-process. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpstreamInput" responses: "200": description: Updated; live at once. content: application/json: schema: $ref: "#/components/schemas/UpstreamEcho" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" delete: summary: Delete an upstream description: The last enabled upstream cannot be removed (409). responses: "204": description: Deleted; live at once. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "404": $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/pause: get: summary: Read the pause state responses: "200": description: | `until` is null while unpaused and while paused indefinitely; `paused` disambiguates. content: application/json: schema: $ref: "#/components/schemas/Pause" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/Unavailable" post: summary: Pause or resume blocking requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PauseRequest" responses: "200": description: The state after applying. content: application/json: schema: $ref: "#/components/schemas/Pause" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "503": $ref: "#/components/responses/Unavailable" /api/settings: get: summary: Read the scalar settings description: | Every stored settings key, plus the derived `web.auth_enabled`. `restart_required` lists only the keys that create or destroy a socket — the DNS, web, DoH and DoT bind addresses, ports and enabled flags. Every other setting is applied in-process by the write that changes it. Passwords and hashes are never serialized. responses: "200": description: The settings and the restart-required key list. content: application/json: schema: $ref: "#/components/schemas/SettingsEnvelope" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" put: summary: Update settings description: | Partial update: omitted sections and fields keep their stored values. The merged configuration is validated before anything is written; on any failure nothing changes. A non-empty `web.password` is hashed with argon2id and stored as the hash; changing it clears every session. `web.password_hash` itself is not accepted. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SettingsPatch" responses: "200": description: The merged settings, as `GET` would report them. content: application/json: schema: $ref: "#/components/schemas/SettingsEnvelope" "400": $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/ManagedByFile" "413": $ref: "#/components/responses/BodyTooLarge" "429": $ref: "#/components/responses/RateLimited" "500": $ref: "#/components/responses/Internal" "503": $ref: "#/components/responses/Unavailable" /api/config/status: get: summary: Read the configuration authority and restart state description: | Which source governs this process's configuration, and whether a change already written waits for a restart. Both are per-process facts the database cannot answer, and this is their one home: a client reads the mode here rather than discovering it from a 403. responses: "200": description: The live authority and the pending-restart flag. content: application/json: schema: $ref: "#/components/schemas/ConfigStatus" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" /api/certs/reload: post: summary: Reload the TLS certificates from disk description: | Reloads the certificate and key of every enabled DoH/DoT endpoint. Always answers 200: the per-endpoint outcome is the payload, and a failed reload leaves the previous certificate serving. responses: "200": description: The outcome for each endpoint. content: application/json: schema: $ref: "#/components/schemas/CertsReload" "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" components: securitySchemes: sessionCookie: type: apiKey in: cookie name: nxdns_session description: | Minted by `POST /api/auth/login`. Only meaningful when `web.password_hash` is configured; without it, every endpoint is open and the cookie is ignored. parameters: RowId: name: id in: path required: true description: Positive integer row id. schema: type: integer minimum: 1 Period: name: period in: query description: Statistics window; the default is `24h`. schema: type: string enum: [1h, 24h, 7d, 30d] default: 24h responses: BadRequest: description: Malformed path, query parameter or body; validation failure. content: application/json: schema: $ref: "#/components/schemas/Error" Unauthorized: description: | Authentication is enabled and the request carried no valid session cookie. content: application/json: schema: $ref: "#/components/schemas/Error" ManagedByFile: description: | nxdns is running under file authority and this operation writes configuration. The message names the file. Authentication is checked first, so an unauthenticated request to a protected route still answers 401 rather than disclosing that the route exists. content: application/json: schema: $ref: "#/components/schemas/Error" NotFound: description: No row has this id. content: application/json: schema: $ref: "#/components/schemas/Error" Conflict: description: | The change contradicts stored data: a duplicate key, a reference to a missing row, or a delete the data still depends on. content: application/json: schema: $ref: "#/components/schemas/Error" BodyTooLarge: description: The request body exceeds 1 MiB. content: application/json: schema: $ref: "#/components/schemas/Error" RateLimited: description: | The per-address token bucket is empty. `Retry-After` says when one token will be available. headers: Retry-After: schema: type: integer minimum: 1 description: Seconds until a retry can succeed. content: application/json: schema: $ref: "#/components/schemas/Error" Internal: description: | Something on the server failed; the detail is in the server log, never on the wire. content: application/json: schema: $ref: "#/components/schemas/Error" Unavailable: description: | A collaborator this endpoint needs is not running: no configuration database, no query log, no snapshot loaded, or the server is shutting down. content: application/json: schema: $ref: "#/components/schemas/Error" schemas: Error: type: object required: [error] properties: error: type: string description: Operator-facing message. Health: type: object required: [status, protection, upstreams, query_history, diagnostics, disk] properties: status: type: string enum: [ok, degraded] protection: $ref: "#/components/schemas/HealthProtection" upstreams: $ref: "#/components/schemas/HealthUpstreams" query_history: $ref: "#/components/schemas/HealthQueryHistory" diagnostics: $ref: "#/components/schemas/HealthDiagnostics" disk: $ref: "#/components/schemas/HealthDisk" HealthProtection: type: object required: [state, until] properties: state: type: string enum: [active, paused, unavailable] description: > unavailable when no filter snapshot exists for the query path to evaluate against, which outranks any pause and is the only one of the three that degrades health. An expired timed pause is active. until: type: integer nullable: true description: > The second filtering resumes at. Null for an indefinite pause and for every state other than paused. HealthUpstreams: type: object required: [state, available, total] properties: state: type: string enum: [ok, unavailable] description: unavailable exactly when `available` is 0; that degrades health. available: { type: integer } total: type: integer description: Enabled upstreams, which is what the routing pool is built from. HealthQueryHistory: type: object required: [state, dropped_total, last_drop_s] properties: state: type: string enum: [recording, losing, failed] description: > failed when the query-log writer never started; losing while the disk gate is holding writes back and has already cost rows in the episode open now. Both degrade health. Drops from an earlier episode do not change the state - they are reported by the two fields below. dropped_total: type: integer description: Query rows lost since this process started, cumulative. last_drop_s: type: integer nullable: true description: > The newest drop, unix seconds; null until one happens. Stamped by a separate atomic from the count, so a non-zero `dropped_total` beside a null here is a legal momentary answer. HealthDiagnostics: type: object required: [state, active_warnings, active_errors] properties: state: type: string enum: [recording, unavailable] description: unavailable when the event store failed to open or its writes are failing; either state degrades health. active_warnings: { type: integer } active_errors: { type: integer } HealthDisk: type: object required: [state, free_bytes] properties: state: type: string enum: [ok, low, critical] description: > The disk monitor's own states; its `warn` is renamed `low` here, because `warn` reads as a log level rather than as a quantity of disk. Both `low` and `critical` degrade health. free_bytes: { type: integer } Version: type: object required: [version, git_commit, zig_version, uptime_seconds] properties: version: { type: string } git_commit: { type: string } zig_version: { type: string } uptime_seconds: { type: integer } LoginRequest: type: object required: [password] properties: password: { type: string } LoginResponse: type: object required: [authenticated, auth_required] properties: authenticated: type: boolean enum: [true] auth_required: type: boolean description: False when no password is configured; no cookie is set. PolicyAction: type: string description: | Whether the filtering policy reached a verdict. `not_evaluated` is the honest answer for a query answered before filtering could apply, and is not the same as `allow`. enum: [not_evaluated, allow, block] PolicyReason: type: string description: | Why the policy landed where it did. The first nine are the matcher's own verdicts; the rest name a pipeline step that decided without consulting the matcher. enum: [rule_allow_exact, rule_block_exact, rule_allow_wildcard, rule_block_wildcard, rule_allow_regex, rule_block_regex, blocklist_exception, blocklist_domain, blocklist_wildcard, local_record, forward_zone, non_in_class, paused, snapshot_unavailable, no_match, protocol_error] RouteKind: type: string description: Where the answer the client received came from. enum: [blocked, local, forward_zone, upstream, cache, rejected] QueryRow: type: object description: | The summary projection the query-log table scans. The full provenance of a row is one request away at `/api/queries/{id}`. required: [id, ts, domain, client_ip, qtype, qclass, rcode, blocked, response_time_us, cache_hit, upstream, policy_action, policy_reason, route_kind] properties: id: { type: integer } ts: type: integer description: Unix seconds. domain: { type: string } client_ip: { type: string } qtype: type: integer nullable: true qclass: { type: integer } rcode: type: integer description: The twelve-bit EDNS extended code, not the four header bits alone. blocked: { type: boolean } response_time_us: type: integer nullable: true cache_hit: type: boolean nullable: true upstream: type: string description: Empty for cache hits, local answers and blocked queries. policy_action: $ref: "#/components/schemas/PolicyAction" policy_reason: $ref: "#/components/schemas/PolicyReason" route_kind: $ref: "#/components/schemas/RouteKind" ProvenanceRequest: type: object required: [time, domain, client, qtype, qclass] properties: time: type: integer description: Unix seconds. domain: { type: string } client: { type: string } qtype: type: integer nullable: true qclass: { type: integer } ProvenanceGroup: type: object description: | The client's filtering group at the time of the query, as a historical fact: the id may name a group since renamed or deleted. required: [id, name] properties: id: type: integer nullable: true name: { type: string } ProvenancePolicy: type: object required: [action, reason, matched, source_id, source_name] properties: action: $ref: "#/components/schemas/PolicyAction" reason: $ref: "#/components/schemas/PolicyReason" matched: type: string description: The rule pattern or list entry that decided; empty when nothing matched. source_id: type: integer nullable: true source_name: type: string description: The blocklist the match came from; empty for a rule. ProvenanceRewrites: type: object required: [cname_target, safe_search_target] properties: cname_target: type: string description: Set when the decision was made about a CNAME target rather than the queried name. safe_search_target: { type: string } ProvenanceRoute: type: object required: [kind, forward_zone, upstream] properties: kind: $ref: "#/components/schemas/RouteKind" forward_zone: { type: string } upstream: type: string description: | Non-empty only for an attempted upstream or forward-zone exchange, including one that failed. Already redacted: the userinfo, path, query and fragment of a resolver url never reach here. ProvenanceResponse: type: object required: [rcode, duration_us] properties: rcode: type: integer description: The twelve-bit EDNS extended code, not the four header bits alone. duration_us: type: integer nullable: true QueryDetail: type: object description: | One query, fully explained, in the order a query meets the pipeline, plus the row id. Written out rather than composed with `allOf` so the drift guard reads one property list per schema. required: [id, request, group, policy, rewrites, route, response] properties: id: { type: integer } request: $ref: "#/components/schemas/ProvenanceRequest" group: $ref: "#/components/schemas/ProvenanceGroup" policy: $ref: "#/components/schemas/ProvenancePolicy" rewrites: $ref: "#/components/schemas/ProvenanceRewrites" route: $ref: "#/components/schemas/ProvenanceRoute" response: $ref: "#/components/schemas/ProvenanceResponse" Coverage: type: object description: | How much of the requested window the query log can still answer for. 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. required: [complete, available_since] properties: complete: type: boolean description: | True only when the window's lower bound is at or after `available_since`. A request with no lower bound asks about all of history, which no file that has ever pruned can promise. available_since: type: integer description: The oldest instant the file is complete for, unix seconds. QueriesPage: type: object required: [queries, next_before, coverage] properties: queries: type: array items: $ref: "#/components/schemas/QueryRow" next_before: type: integer nullable: true description: Cursor for the next page; null on the last page. coverage: $ref: "#/components/schemas/Coverage" DiagnosticEvent: type: object required: [id, code, component, subject, severity, first_seen, last_seen, occurrences, resolved_at, detail] properties: id: { type: integer } code: type: string description: | The failure kind, as `component.name`. One of a fixed set of fifteen; new codes are added with new releases. enum: - disk.space - disk.probe - blocklist.refresh - blocklist.snapshot - blocklist.storage - certificate.reload - query_log.write - query_log.maintenance - query_log.recreated # Legacy: nothing emits this any more (milestone 30 deleted the # upstream-minute history subsystem), but stored rows survive and # the list endpoint passes their code through. - upstream_history.write - upstream.exchange - client_names.storage - clients.storage - listener.start - configuration.load component: type: string description: The part of `code` before the dot, repeated for filtering. subject: type: string description: | What failed, as a display name: a blocklist source name, an endpoint, an operation. Redacted where it derives from a url; the store's internal identity for the subject is never exposed. severity: type: string enum: [warning, error] first_seen: type: integer description: When this episode opened, unix seconds. last_seen: type: integer description: The most recent failure of this episode, unix seconds. occurrences: type: integer description: How many failures this episode has held; at least 1. resolved_at: type: integer nullable: true description: | When the subject recovered, unix seconds. Null while the episode is still open. A subject that fails again opens a new event rather than reopening this one. detail: type: string description: The last error of this episode, truncated to 512 bytes. DiagnosticsPage: type: object required: [events, next_before, active] properties: events: type: array items: $ref: "#/components/schemas/DiagnosticEvent" next_before: type: integer nullable: true description: Cursor for the next page; null on the last page. active: type: object required: [warnings, errors] description: Episodes open right now, whatever this page filtered to. properties: warnings: { type: integer } errors: { type: integer } DiagnosticsPurge: type: object required: [purged] properties: purged: type: integer description: How many resolved events the purge removed; zero when there were none. StatsTotals: type: object required: [period, since, until, queries, blocked, clients, avg_response_time_us, coverage] properties: period: type: string enum: [1h, 24h, 7d, 30d] since: type: integer description: Window start, unix seconds, inclusive. until: type: integer description: Window end, unix seconds, exclusive. queries: { type: integer } blocked: { type: integer } clients: type: integer description: Distinct client addresses in the window. avg_response_time_us: type: integer nullable: true description: Null when no query in the window recorded a time. coverage: $ref: "#/components/schemas/Coverage" Bucket: type: object required: [ts, queries, blocked, cached] properties: ts: type: integer description: Bucket start, unix seconds. queries: { type: integer } blocked: { type: integer } cached: { type: integer } StatsTimeseries: type: object required: [period, since, until, bucket_seconds, buckets, coverage] properties: period: type: string enum: [1h, 24h, 7d, 30d] since: { type: integer } until: { type: integer } bucket_seconds: { type: integer } buckets: type: array items: $ref: "#/components/schemas/Bucket" coverage: $ref: "#/components/schemas/Coverage" TypeCount: type: object required: [qtype, count] properties: qtype: type: integer nullable: true description: | The numeric DNS type. Null is the group of logged queries that recorded no type, not an absent row. count: { type: integer } StatsTypes: type: object required: [period, since, until, types, coverage] properties: period: type: string enum: [1h, 24h, 7d, 30d] since: { type: integer } until: { type: integer } types: type: array items: $ref: "#/components/schemas/TypeCount" coverage: $ref: "#/components/schemas/Coverage" RouteCount: type: object required: [route, source, count] properties: route: $ref: "#/components/schemas/RouteKind" source: type: string nullable: true description: | The answering upstream's url or the forward zone, and null on every other route kind. Also null when an `upstream` or `forward_zone` row recorded no identity. count: { type: integer } StatsRoutes: type: object required: [period, since, until, routes, coverage] properties: period: type: string enum: [1h, 24h, 7d, 30d] since: { type: integer } until: { type: integer } routes: type: array items: $ref: "#/components/schemas/RouteCount" coverage: $ref: "#/components/schemas/Coverage" ClientSeries: type: object required: [client, buckets] properties: client: type: string description: The client address as the log recorded it, redaction included. buckets: type: array description: | One count per bucket in the window, zero-filled. Every series in a response has this same length, `other` included. items: type: integer StatsClients: type: object required: [period, since, until, bucket_seconds, clients, other, coverage] properties: period: type: string enum: [1h, 24h, 7d, 30d] since: { type: integer } until: { type: integer } bucket_seconds: { type: integer } clients: type: array items: $ref: "#/components/schemas/ClientSeries" other: type: array description: | Every client outside the named eight, summed per bucket. Always present, and always one entry per bucket in the window — including when `clients` is empty, when no client fell outside the named eight, and when the window holds no queries at all. items: type: integer coverage: $ref: "#/components/schemas/Coverage" Lookup: type: object required: [domain, group_id, local_records, forward_zone, blocked, reason, matched, source_url, safe_search_rewrite] properties: domain: type: string description: The normalized form of the queried domain. group_id: { type: integer } local_records: type: boolean description: Whether a local record answers this name. forward_zone: type: string nullable: true blocked: { type: boolean } reason: type: string description: The pipeline's decision, e.g. `none` or `blocklist_domain`. matched: type: string description: The matched pattern; empty when nothing matched. source_url: type: string nullable: true safe_search_rewrite: type: string nullable: true Group: type: object required: [id, name, safe_search] properties: id: { type: integer } name: { type: string } safe_search: { type: boolean } GroupInput: type: object required: [name] properties: name: { type: string } safe_search: type: boolean default: false GroupSources: type: object required: [source_ids] properties: source_ids: type: array items: { type: integer } Blocklist: type: object required: [id, url, name, enabled, is_suggested, last_updated, domain_count, wildcard_count, exception_count, skipped_regex_count, skipped_unsupported_count, checksum] properties: id: { type: integer } url: { type: string } name: { type: string } enabled: { type: boolean } is_suggested: { type: boolean } last_updated: type: integer nullable: true domain_count: { type: integer } wildcard_count: { type: integer } exception_count: { type: integer } skipped_regex_count: { type: integer } skipped_unsupported_count: { type: integer } checksum: type: string nullable: true BlocklistInput: type: object required: [url, name] properties: url: { type: string } name: { type: string } enabled: type: boolean default: true is_suggested: type: boolean default: false BlocklistEcho: type: object required: [id, url, name, enabled, is_suggested] properties: id: { type: integer } url: { type: string } name: { type: string } enabled: { type: boolean } is_suggested: { type: boolean } SourceStatus: type: object required: [id, state, loaded, last_attempt, last_success, url, last_error, domains, wildcards, exceptions, skipped_regex, skipped_unsupported] properties: id: { type: integer } state: type: string description: The refresh outcome, e.g. `loaded` or `fetch_failed`. loaded: { type: boolean } last_attempt: { type: integer } last_success: { type: integer } url: { type: string } last_error: type: string description: Empty when the last attempt succeeded. domains: { type: integer } wildcards: { type: integer } exceptions: { type: integer } skipped_regex: { type: integer } skipped_unsupported: { type: integer } Rule: type: object required: [id, group_id, group, pattern, kind, action, created_at] properties: id: { type: integer } group_id: { type: integer } group: { type: string } pattern: { type: string } kind: type: string enum: [exact, wildcard, regex] action: type: string enum: [allow, block] created_at: { type: integer } RuleInput: type: object required: [group_id, pattern, kind, action] properties: group_id: { type: integer } pattern: { type: string } kind: type: string enum: [exact, wildcard, regex] action: type: string enum: [allow, block] RuleEcho: type: object required: [id, group_id, pattern, kind, action] properties: id: { type: integer } group_id: { type: integer } pattern: { type: string } kind: type: string enum: [exact, wildcard, regex] action: type: string enum: [allow, block] LocalRecord: type: object required: [id, name, rtype, value, ttl] properties: id: { type: integer } name: { type: string } rtype: type: string enum: [A, AAAA, CNAME] value: { type: string } ttl: { type: integer } LocalRecordInput: type: object required: [name, rtype, value] properties: name: { type: string } rtype: type: string enum: [A, AAAA, CNAME] value: { type: string } ttl: type: integer default: 300 ForwardZone: type: object required: [id, zone, resolver] properties: id: { type: integer } zone: { type: string } resolver: { type: string } ForwardZoneInput: type: object required: [zone, resolver] properties: zone: { type: string } resolver: { type: string } Client: type: object required: [id, ip, name, learned_name, group_id, group, hand_edited, first_seen, last_seen] properties: id: { type: integer } ip: { type: string } name: type: string description: Empty when the client was never named. learned_name: type: string description: | The name learned over reverse DNS, or empty when nothing was learned. Display-only runtime state: `name` wins whenever it is non-empty, and a learned name never appears in an export. The server writes it; a client cannot. group_id: { type: integer } group: { type: string } hand_edited: { type: boolean } first_seen: { type: integer } last_seen: { type: integer } ClientEdit: type: object required: [group_id] properties: name: type: string default: "" group_id: { type: integer } ClientPrefix: type: object required: [id, prefix, group_id, group, priority] properties: id: { type: integer } prefix: { type: string } group_id: { type: integer } group: { type: string } priority: { type: integer } ClientPrefixInput: type: object required: [prefix, group_id] properties: prefix: { type: string } group_id: { type: integer } priority: type: integer default: 100 Upstream: type: object required: [id, url, priority, enabled, tls_name] properties: id: { type: integer } url: { type: string } priority: { type: integer } enabled: { type: boolean } tls_name: type: string description: Only meaningful for DoT upstreams; empty otherwise. UpstreamInput: type: object required: [url] properties: url: { type: string } priority: type: integer default: 100 enabled: type: boolean default: true tls_name: type: string default: "" UpstreamEcho: type: object required: [id, url, priority, enabled, tls_name, restart_required] properties: id: { type: integer } url: { type: string } priority: { type: integer } enabled: { type: boolean } tls_name: { type: string } restart_required: description: | Always false: an upstream write rebuilds the resolver pool in-process. The field stays in the shape so every mutation response answers the same question the same way. type: boolean enum: [false] Pause: type: object required: [paused, until] properties: paused: { type: boolean } until: type: integer nullable: true description: | Unix second filtering resumes; null while unpaused and while paused indefinitely. PauseRequest: type: object required: [paused] properties: paused: { type: boolean } duration_seconds: type: integer nullable: true minimum: 1 maximum: 604800 description: Only meaningful with `paused = true`; absent means indefinite. CertReloadOutcome: type: object required: [enabled, reloaded, error] properties: enabled: type: boolean description: Whether the endpoint is enabled in the configuration. reloaded: type: boolean error: type: string nullable: true description: Why the reload failed; null on success and while disabled. CertsReload: type: object required: [doh, dot] properties: doh: $ref: "#/components/schemas/CertReloadOutcome" dot: $ref: "#/components/schemas/CertReloadOutcome" Settings: type: object required: [upstream, dns, blocking, cache, web, doh_server, dot_server, edns, logging, disk, blocklist_update] properties: upstream: type: object required: [attempt_timeout_ms, read_timeout_ms, total_timeout_ms] properties: attempt_timeout_ms: { type: integer } read_timeout_ms: { type: integer } total_timeout_ms: { type: integer } dns: type: object required: [bind_ipv4, bind_ipv6, port, rate_limit, rate_window_seconds] properties: bind_ipv4: { type: string } bind_ipv6: { type: string } port: { type: integer } rate_limit: { type: integer } rate_window_seconds: { type: integer } blocking: type: object required: [response, ttl] properties: response: type: string enum: [zero, nxdomain] ttl: { type: integer } cache: type: object required: [size, negative_ttl_max] properties: size: { type: integer } negative_ttl_max: { type: integer } web: type: object required: [enabled, bind, port, session_ttl_hours, api_rate_limit_per_min, api_localhost_exempt, sse_max_connections_per_ip, trusted_proxies, auth_enabled] properties: enabled: { type: boolean } bind: { type: string } port: { type: integer } session_ttl_hours: { type: integer } api_rate_limit_per_min: { type: integer } api_localhost_exempt: { type: boolean } sse_max_connections_per_ip: { type: integer } trusted_proxies: type: string description: | Comma-separated IP literals. A request from one of these peers is identified by the last entry of its X-Forwarded-For header. Empty trusts no proxy. auth_enabled: type: boolean description: Derived, read-only; true iff a password hash is stored. doh_server: $ref: "#/components/schemas/TlsListenerSettings" dot_server: $ref: "#/components/schemas/TlsListenerSettings" edns: type: object required: [ecs_mode] properties: ecs_mode: type: string enum: [strip, forward] logging: type: object required: [level, retention_days, query_log_buffer_max, query_log_flush_interval_s, hide_domains, hide_client_ips, output, file_path, max_size_mb, max_files] properties: level: type: string enum: [error, warn, info, debug] retention_days: { type: integer } query_log_buffer_max: { type: integer } query_log_flush_interval_s: { type: integer } hide_domains: { type: boolean } hide_client_ips: { type: boolean } output: type: string enum: [stderr, syslog, file] file_path: { type: string } max_size_mb: { type: integer } max_files: { type: integer } disk: type: object required: [min_free_mb, warn_free_mb] properties: min_free_mb: { type: integer } warn_free_mb: { type: integer } blocklist_update: type: object required: [enabled, interval_hours] properties: enabled: { type: boolean } interval_hours: { type: integer } TlsListenerSettings: type: object required: [enabled, bind, port, cert_path, key_path] properties: enabled: { type: boolean } bind: { type: string } port: { type: integer } cert_path: { type: string } key_path: { type: string } SettingsEnvelope: type: object required: [settings, restart_required] properties: settings: $ref: "#/components/schemas/Settings" restart_required: type: array items: { type: string } description: | The `section.field` keys that need a restart to take effect: the DNS, web, DoH and DoT bind addresses, ports and enabled flags, and nothing else. Every key absent from this list is applied in-process by the write that changes it. Whether a restart is *owed* right now is process state, and lives on `/api/config/status`. ConfigStatus: type: object description: | Which source governs this process's configuration, and whether a committed change waits for a restart. This is how a client learns that configuration is read-only; it never has to probe a write route for a 403. It rides an authenticated endpoint because `path` is a filesystem path, and never appears on the open `/api/version` or `/api/health`. required: [authority, path, reconciled_at, restart_pending] properties: authority: type: string enum: [database, managed_file] description: | `database` when nxdns runs without `--config`; `managed_file` when it runs with it, in which case every configuration write answers 403. path: type: string nullable: true description: The managed file, or null in `database` mode. reconciled_at: type: integer nullable: true description: | When this process loaded the managed file, in epoch seconds, and null in `database` mode. It means exactly that: a file whose mtime is newer has not been loaded by the running process. It cannot answer whether the file matches what the server serves — a stepped clock or a preserved mtime defeats the comparison either way, and `nxdns import` can move the database without moving either timestamp. restart_pending: type: boolean description: | True once this process has committed a configuration change that takes effect only at the next start: a DNS, web, DoH or DoT bind address, port or enabled flag. No other write raises it — everything else, upstreams included, is applied in-process. Nothing clears it but process exit, and it is never persisted, so a false after a restart is the truth. SettingsPatch: type: object description: | Partial update; every section and every field is optional. The shape is `Settings` without the derived `web.auth_enabled`, plus the write-only `web.password`. `web.password_hash` is rejected as an unknown field. properties: upstream: type: object properties: attempt_timeout_ms: { type: integer } read_timeout_ms: { type: integer } total_timeout_ms: { type: integer } dns: type: object properties: bind_ipv4: { type: string } bind_ipv6: { type: string } port: { type: integer } rate_limit: { type: integer } rate_window_seconds: { type: integer } blocking: type: object properties: response: { type: string } ttl: { type: integer } cache: type: object properties: size: { type: integer } negative_ttl_max: { type: integer } web: type: object properties: enabled: { type: boolean } bind: { type: string } port: { type: integer } password: type: string description: | Write-only. Hashed with argon2id and stored as the hash; never echoed. An empty string is ignored, not a removal. session_ttl_hours: { type: integer } api_rate_limit_per_min: { type: integer } api_localhost_exempt: { type: boolean } sse_max_connections_per_ip: { type: integer } trusted_proxies: { type: string } doh_server: $ref: "#/components/schemas/TlsListenerPatch" dot_server: $ref: "#/components/schemas/TlsListenerPatch" edns: type: object properties: ecs_mode: { type: string } logging: type: object properties: level: { type: string } retention_days: { type: integer } query_log_buffer_max: { type: integer } query_log_flush_interval_s: { type: integer } hide_domains: { type: boolean } hide_client_ips: { type: boolean } output: { type: string } file_path: { type: string } max_size_mb: { type: integer } max_files: { type: integer } disk: type: object properties: min_free_mb: { type: integer } warn_free_mb: { type: integer } blocklist_update: type: object properties: enabled: { type: boolean } interval_hours: { type: integer } TlsListenerPatch: type: object properties: enabled: { type: boolean } bind: { type: string } port: { type: integer } cert_path: { type: string } key_path: { type: string }