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 to groups, blocklists, rules, local records, forward zones, clients and client prefixes take effect live. Upstreams and `/api/settings` are restart-required. - 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/settings` 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 the disk is not ok, no upstream is available, or the query-log writer failed. 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/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 whose `data:` line is a JSON object with the `/api/queries` row fields minus `id` (the entry precedes persistence). 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/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/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/upstream/health: get: summary: Upstream pool health responses: "200": description: Per-upstream state and the availability rollup. content: application/json: schema: $ref: "#/components/schemas/UpstreamHealth" "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: Restart-required; the running pool is not changed. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpstreamInput" responses: "201": description: Created; takes effect on restart. 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: Restart-required; the running pool is not changed. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpstreamInput" responses: "200": description: Updated; takes effect on restart. 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; takes effect on restart. "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 every key, because all scalar settings are restart-required this milestone; live behavior comes from the resource endpoints and `/api/pause`. 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/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, disk, upstreams, queries_dropped, writer_failed, refreshes_gated, snapshot_generation] properties: status: type: string enum: [ok, degraded] disk: type: object required: [state, free_bytes, db_bytes, log_bytes, sample_failures] properties: state: type: string enum: [ok, warn, critical] free_bytes: { type: integer } db_bytes: { type: integer } log_bytes: { type: integer } sample_failures: { type: integer } upstreams: type: object required: [available, total] properties: available: { type: integer } total: { type: integer } queries_dropped: { type: integer } writer_failed: { type: boolean } refreshes_gated: { type: integer } snapshot_generation: type: integer nullable: true description: Null until the first filter snapshot is published. 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. QueryRow: type: object required: [id, ts, domain, client_ip, qtype, blocked, block_reason, response_time_us, cache_hit, upstream] properties: id: { type: integer } ts: type: integer description: Unix seconds. domain: { type: string } client_ip: { type: string } qtype: type: integer nullable: true blocked: { type: boolean } block_reason: type: string description: Empty when the query was not blocked. response_time_us: type: integer nullable: true cache_hit: type: boolean nullable: true upstream: type: string description: Empty for cache hits and local answers. QueriesPage: type: object required: [queries, next_before] 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. StatsTotals: type: object required: [period, since, until, queries, blocked, cached, clients, avg_response_time_us] 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 } cached: { 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. 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] 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" 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 UpstreamHealth: type: object required: [upstreams, available, total] properties: upstreams: type: array items: type: object required: [url, enabled, available, consecutive_failures, total_successes, total_failures, success_rate, last_error] properties: url: { type: string } enabled: { type: boolean } available: { type: boolean } consecutive_failures: { type: integer } total_successes: { type: integer } total_failures: { type: integer } success_rate: { type: number } last_error: type: string description: Empty when the upstream never failed. available: { type: integer } total: { type: integer } 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, skipped_regex_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 } skipped_regex_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, skipped_regex] 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 } skipped_regex: { 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] 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] 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] 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, 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. 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: type: boolean enum: [true] 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, 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 } 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, authority] properties: settings: $ref: "#/components/schemas/Settings" restart_required: type: array items: { type: string } description: | Every `section.field` key that needs a restart to take effect — currently all of them. authority: $ref: "#/components/schemas/Authority" Authority: type: object description: | Which source governs this process's configuration. This is how a client learns that configuration is read-only; it never has to probe a write route for a 403. The block rides this authenticated endpoint because `path` is a filesystem path, and never appears on the open `/api/version` or `/api/health`. required: [mode, path, reconciled_at] properties: mode: 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. 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 } 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 }