Files
nxdns/src/web/openapi.yaml
T
mokhtar cc23c97218
Gates / frontend (push) Successful in 1m34s
Gates / test (push) Successful in 2m3s
Gates / test-aarch64 (push) Failing after 3h13m33s
Gates / package (push) Successful in 5m20s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 6h30m45s
milestone 33: contract closure — samples, file-authority enumeration, dead code, bundle ceiling
2026-08-22 23:31:37 +02:00

3058 lines
97 KiB
YAML

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": "<message>"}`.
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=<file>`, 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=<token>; HttpOnly; SameSite=Lax; Path=/; Max-Age=<ttl>`"
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: 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/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:
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, 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: |
Every `section.field` key that needs a restart to take effect —
currently all of them. 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 — an upstream write or a
settings key. 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 }