milestone 13: restructure docs to diataxis, tutorial, every command executed

This commit is contained in:
2026-08-02 18:02:58 +02:00
parent 35f23240e7
commit 16c9de2414
26 changed files with 3627 additions and 901 deletions
+189
View File
@@ -0,0 +1,189 @@
# REST API reference
nxdns serves its admin API itself, on `web.bind:web.port` (default port 8080),
as plain HTTP. TLS termination, where an operator wants it, belongs to a reverse
proxy in front; the session cookie deliberately omits the `Secure` attribute so
the supported plain-HTTP LAN deployment works.
The machine-readable contract is `src/web/openapi.yaml`, which the running
server hands out unauthenticated at `GET /api/openapi.yaml`. Request and
response schemas for every operation live there. When this page and the YAML
disagree, the YAML wins.
The route table is `src/web/routes.zig`; the [Operations](#operations) table
below carries all 56 of its entries.
## Conventions
- All request and response bodies are JSON (`application/json`), except
`/metrics` (Prometheus text format), `/api/openapi.yaml` (YAML) and
`/api/queries/live` (`text/event-stream`).
- Field names are snake_case, matching settings keys and SQL column names.
- Every error response carries the envelope `{"error": "<message>"}`. The
message is operator-facing text; internal detail never reaches the wire — a
500 body is generic and the cause goes to the server log.
- Request bodies are strict: an unknown field is a 400, a body over 1 MiB is a
413.
- A request whose path matches but whose method does not answers 405 with an
`Allow` header. An unknown `/api` path is a JSON 404; unknown non-`/api` paths
fall through to the embedded SPA (`index.html`), so client-side routing works.
- Item routes (`{id}`) match a positive integer id only.
- Mutations to groups, blocklists, rules, local records, forward zones, clients
and client prefixes take effect live. Upstreams and `/api/settings` are
restart-required.
## Authentication
Cookie sessions, in memory, no accounts — one operator password. Setting that
password is [set up admin
authentication](../how-to/set-up-admin-authentication.md).
- Authentication is on exactly when `web.password_hash` is set. When no password
is set, every route is open and `POST /api/auth/login` answers
`{"authenticated": true, "auth_required": false}` without setting a cookie.
- `POST /api/auth/login` takes `{"password": "..."}`. A correct password answers
200 with a `Set-Cookie` for `nxdns_session` (`HttpOnly; SameSite=Lax; Path=/`,
`Max-Age` = the session TTL). A wrong password is a 401; a stored hash the
server cannot read is a 500, never a 401. Login attempts spend rate-limit
tokens like any other request, and argon2id verification is deliberately slow.
- Every route whose auth policy is `session` answers 401
`{"error": "authentication required"}` without a valid cookie.
- Sessions live `web.session_ttl_hours` (default 24) from login; use does not
extend the lifetime. The table holds 32 sessions; a 33rd login evicts the
least recently used. Nothing is persisted — a server restart logs every
operator out.
- Changing the password through `PUT /api/settings` revokes every live session
immediately; the new password applies without a restart.
- `POST /api/auth/logout` ends the cookie's session and clears the cookie. The
route is `.session` like any other, so with a password configured the router
answers 401 before the handler runs when the cookie is missing, expired,
revoked or already logged out; only a live session gets the 200. With no
password configured every session route is open and logout answers 200.
## Rate limiting
A token bucket per client address: capacity and refill are both
`web.api_rate_limit_per_min` (default 300) per minute, so a page-load burst up
to the capacity is admitted and the long-run rate holds.
- An over-budget request answers 429 `{"error": "rate limited"}` with a
`Retry-After` header giving the seconds until a token is available (rounded
up, never zero).
- Loopback addresses (127.0.0.0/8 and ::1) are exempt while
`web.api_localhost_exempt` is true (the default).
- Exempt routes, which never consult a bucket: `/metrics` and `/api/health` (a
Prometheus scrape must never see 429) and `/api/queries/live` (one long-lived
stream must not drain its address's bucket; it is bounded by the SSE
connection cap instead).
- The limiter tracks at most 4096 addresses. When the table is full and no slot
is reclaimable, requests from unknown addresses are refused with 429.
## Live query stream (SSE)
`GET /api/queries/live` is server-sent events over chunked transfer,
`Content-Type: text/event-stream`, `Cache-Control: no-store`.
- The stream opens with `retry: 3000`, so a browser `EventSource` reconnects on
its own after a drop.
- Each query is one frame: `event: query` and a single `data:` line of JSON. The
payload carries the `GET /api/queries` row fields minus `id` (a live entry
precedes persistence): `ts`, `domain`, `client_ip`, `qtype`, `blocked`,
`block_reason`, `response_time_us`, `cache_hit`, `upstream`.
- A `: ping` comment heartbeat goes out after 15 s of quiet, keeping
middleboxes from reaping the idle connection.
- Each subscriber buffers up to 64 entries. A client too slow for the query rate
overflows its buffer and the server ends the stream cleanly after delivering
what the buffer held — queries are never held back for a slow reader. There is
no gap marker: on reconnect, re-sync through `GET /api/queries`, which has the
missed rows.
- Connections per client address are capped at `web.sse_max_connections_per_ip`
(default 3); over the cap is a 429. The cap binds loopback too. The server
holds at most 32 concurrent streams in total; when all slots are taken, the
answer is a 503.
## Operations
Auth `open` means no session is required; `session` means a valid session cookie
is required whenever a password is set. Rate limit `counted` spends a token;
`exempt` never consults the limiter.
| Method | Path | Auth | Rate limit | Purpose |
|---|---|---|---|---|
| GET | `/metrics` | open | exempt | Prometheus metrics |
| GET | `/api/health` | open | exempt | Health rollup |
| GET | `/api/version` | open | counted | Build and uptime |
| GET | `/api/openapi.yaml` | open | counted | This API's OpenAPI document |
| POST | `/api/auth/login` | open | counted | Log in |
| POST | `/api/auth/logout` | session | counted | Log out |
| GET | `/api/queries` | session | counted | Query log page |
| GET | `/api/queries/live` | session | exempt | Live query stream (server-sent events) |
| GET | `/api/stats` | session | counted | Totals for a period |
| GET | `/api/stats/timeseries` | session | counted | Bucketed counts for a period |
| GET | `/api/lookup` | session | counted | Explain a domain |
| GET | `/api/upstream/health` | session | counted | Upstream pool health |
| GET | `/api/groups` | session | counted | List groups |
| POST | `/api/groups` | session | counted | Create a group |
| GET | `/api/groups/{id}` | session | counted | Read a group |
| PUT | `/api/groups/{id}` | session | counted | Update a group |
| DELETE | `/api/groups/{id}` | session | counted | Delete a group |
| GET | `/api/groups/{id}/sources` | session | counted | Blocklist sources assigned to a group |
| PUT | `/api/groups/{id}/sources` | session | counted | Replace the assignment |
| GET | `/api/blocklists` | session | counted | List blocklist sources |
| POST | `/api/blocklists` | session | counted | Add a blocklist source |
| POST | `/api/blocklists/update` | session | counted | Refresh every enabled source now |
| GET | `/api/blocklists/{id}` | session | counted | Read a blocklist source |
| PUT | `/api/blocklists/{id}` | session | counted | Update a blocklist source |
| DELETE | `/api/blocklists/{id}` | session | counted | Delete a blocklist source |
| GET | `/api/rules` | session | counted | List rules |
| POST | `/api/rules` | session | counted | Create a rule |
| GET | `/api/rules/{id}` | session | counted | Read a rule |
| PUT | `/api/rules/{id}` | session | counted | Update a rule |
| DELETE | `/api/rules/{id}` | session | counted | Delete a rule |
| GET | `/api/local-records` | session | counted | List local DNS records |
| POST | `/api/local-records` | session | counted | Create a local record |
| GET | `/api/local-records/{id}` | session | counted | Read a local record |
| PUT | `/api/local-records/{id}` | session | counted | Update a local record |
| DELETE | `/api/local-records/{id}` | session | counted | Delete a local record |
| GET | `/api/forward-zones` | session | counted | List forward zones |
| POST | `/api/forward-zones` | session | counted | Create a forward zone |
| GET | `/api/forward-zones/{id}` | session | counted | Read a forward zone |
| PUT | `/api/forward-zones/{id}` | session | counted | Update a forward zone |
| DELETE | `/api/forward-zones/{id}` | session | counted | Delete a forward zone |
| GET | `/api/clients` | session | counted | List clients |
| GET | `/api/clients/{id}` | session | counted | Read a client |
| PUT | `/api/clients/{id}` | session | counted | Rename or regroup a client |
| DELETE | `/api/clients/{id}` | session | counted | Forget a client |
| GET | `/api/client-prefixes` | session | counted | List client prefixes |
| PUT | `/api/client-prefixes` | session | counted | Replace the prefix table |
| GET | `/api/upstreams` | session | counted | List upstream resolvers |
| POST | `/api/upstreams` | session | counted | Add an upstream |
| GET | `/api/upstreams/{id}` | session | counted | Read an upstream |
| PUT | `/api/upstreams/{id}` | session | counted | Update an upstream |
| DELETE | `/api/upstreams/{id}` | session | counted | Delete an upstream |
| GET | `/api/pause` | session | counted | Read the pause state |
| POST | `/api/pause` | session | counted | Pause or resume blocking |
| GET | `/api/settings` | session | counted | Read the scalar settings |
| PUT | `/api/settings` | session | counted | Update settings |
| POST | `/api/certs/reload` | session | counted | Reload the TLS certificates from disk |
There is no `POST /api/clients`: client rows come from DNS activity or import,
never from the API.
Static assets are not routes. The router sends unmatched non-`/api` paths to the
embedded SPA before any auth or rate-limit check.
## Settings keys
`GET /api/settings` and `PUT /api/settings` speak the `section.field` keys of
[the configuration reference](configuration.md), with the values in their
database spelling — notably `logging.level` is `"error"`, not `"err"`.
Two keys behave differently over the API than in the file: `web.password` is
write-only (accepted on a `PUT`, never returned, hashed before storage), and
`web.password_hash` is neither readable nor directly writable, because a client
that could install a hash could install one whose password it already knows.
## Schemas
Request and response schemas for every operation live in the OpenAPI document:
`src/web/openapi.yaml` in the repository, or `GET /api/openapi.yaml` from a
running server.
+150
View File
@@ -0,0 +1,150 @@
# CLI reference
```
nxdns <command> [options]
```
Six subcommands: `run`, `check`, `export`, `import`, `version`, `help`. Source
of truth: `src/cli.zig`.
Every flag takes both spellings, `--flag value` and `--flag=value`. An attached
value that is empty (`--config=`) is a missing value, not an empty path.
`--force` is boolean and takes no value at all, so `--force=1` is not a spelling
of any flag this program has. A flag is rejected by the subcommand that has no
use for it: `--web-dev` outside `run` is an unknown flag, not a no-op.
## `run`
Serves DNS until SIGINT or SIGTERM.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. |
| `--config FILE` | Seed configuration file (default `/etc/nxdns/config.zon`). Read only when the database has never been configured. |
| `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
Seeding failures are not treated as configuration faults here: a seed file that
is unparseable, oversized or invalid prints its diagnostics and exits 1, not 2.
See [exit codes](#exit-codes).
## `check`
Validates the configuration and probes the upstreams. Exit 0 when clean, 2 when
it found problems. It always reports every problem, not just the first. What it
checks, in order:
1. Which source to check (see [source selection](#source-selection)).
2. Full validation — the same rules `import` enforces.
3. For each enabled DoH/DoT listener: certificate and key are readable (FAIL if
not), and the key's permissions are owner-only (WARN if any group or other
bit is set; the WARN does not change the exit code).
4. A live probe: one real A query for `example.com` through every enabled
upstream, driving the same pool and failover machinery the server uses, with
`upstream.total_timeout_ms` as the per-attempt deadline. A FAIL line names
the upstream and the concrete cause recorded in its health. This probe leaves
the machine, so `check` needs network access to pass. It runs from the
command line but not from unit tests.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory to look for `config.db` in. |
| `--config FILE` | Check this file instead of the database. |
### Source selection
- `--config FILE` given explicitly: check that file, nothing else.
- Otherwise, if `<data-dir>/config.db` exists: check the database — the right
default, since the database is the truth on a configured server. Pending
schema migrations are applied first, so a `check` immediately after an upgrade
works.
- Otherwise, if the default config file path exists: check it.
- Otherwise: "nothing to check", exit 2.
The first line of output always names which source was checked. A file larger
than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
reported with its line and column.
A missing file is treated differently depending on how the path was reached. An
explicit `--config FILE` that does not exist is an I/O failure: `check failed:
FileNotFound` on stderr, exit 1. A default path that does not exist is one of
the cases above and produces "nothing to check", exit 2.
## `export`
Writes the configuration as ZON to stdout, or atomically at mode 0600 to
`--out FILE`. `--out` paths are relative to the shell's working directory, not
to the data directory. The output is canonical: every default emitted,
deterministic ordering, no timestamps, so `export``import``export` is
byte-identical.
`export` does not create the data directory; it fails if the directory is not
there. A directory that exists without a `config.db` is not that case: the
database is opened with create semantics, so an empty `config.db` is created and
migrated, and the export is of a default configuration.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db`. |
| `--out FILE` | Write to FILE instead of stdout. |
See [back up and restore](../how-to/back-up-and-restore.md).
## `import FILE`
Validates FILE and replaces the whole configuration with it in one transaction.
Prints every validation problem on failure; a failed import leaves the database
untouched. Refuses a database that already has content unless `--force` is
given (`DatabaseNotEmpty`, exit 2). Creates the data directory at mode 0700 if
it is missing.
`FILE` is positional and may appear before or after the flags.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
| `--force` | Replace a database that already has content. |
## `version`
Prints two lines: the nxdns version with the git commit, then the Zig version
the binary was built with. Takes no flags and no arguments.
## `help`
Prints the usage text to stdout and exits 0. `nxdns --help` and `nxdns -h` do
the same. A usage error prints the same text to stderr and exits 64.
## Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Success. |
| 1 | Runtime failure — I/O, database, out of memory. A partial diagnostic report caused by an allocation failure is a runtime failure, not a verdict on the configuration. |
| 2 | A configuration problem the operator can fix, or a `check` that found one. Which faults qualify differs per subcommand; see below. |
| 64 | Usage error — unknown command or flag, a flag without its value, a missing or extra argument. |
Code 2 is not a single rule shared by every subcommand. `check` and `import`
classify configuration faults; `run` maps only four errors to it and lets
everything else out as a runtime failure.
`check` exits 2 when validation reported at least one problem, when a
certificate or key named by an enabled listener is unreadable, when a probed
upstream failed, when the file is larger than 4 MiB, when the file has a ZON
syntax error, and when there was nothing to check. Any other error escaping the
run — a missing explicit `--config` file, an unreadable database — prints
`check failed: <Error>` and exits 1.
`import` exits 2 when validation recorded at least one problem, and for
`DatabaseNotEmpty`, `ConfigTooLarge`, `ParseZon` and `PasswordAndHashBothSet`.
`OutOfMemory` is 1 even when problems were recorded, because the report is then
incomplete. Every other error is 1.
`run` exits 2 for `NoUsableUpstreams`, `BadBindAddress`, `BadRateLimit` and
`BadCertificate`, and points the operator at `nxdns check` on stderr when it
does. Nothing else is remapped. In particular, seeding the database from
`--config` on first start happens before that classification applies to it: a
seed file that is unparseable (`ParseZon`), larger than 4 MiB
(`ConfigTooLarge`), or rejected by validation prints its diagnostics to stderr
and exits 1. The same file given to `check` or `import` exits 2.
Where an exit code sends you next: [troubleshoot](../how-to/troubleshoot.md).
+468
View File
@@ -0,0 +1,468 @@
# Configuration reference
Every section, field and collection nxdns accepts, with its type, default,
unit, validation rule and the subsystem that consumes it.
Source of truth: `src/config/model.zig` (the model and the defaults),
`src/config/validate.zig` (the rules), `src/config/{bootstrap,import,export}.zig`
(the lifecycle).
For how the file, the database and `export`/`import` relate to each other, see
[the configuration model](../explanation/configuration-model.md). For the
commands that read and write configuration, see [the CLI
reference](cli.md).
## File format
The file is ZON: a top-level anonymous struct whose fields are the sections and
collections below. Enum values are ZON enum literals (`.level = .err`,
`.response = .nxdomain`). Strings are double-quoted. The file may be at most
4 MiB (`max_config_bytes` in `src/config/import.zig`); beyond that the error is
`ConfigTooLarge`. A syntax error is reported with its line and column.
Absent fields keep their defaults, both in the file and in the database. A
settings key stored in the database that the running binary does not know is
warned about and ignored, never an error.
### The `logging.level = .err` quirk
The log level `error` is a Zig keyword, so the ZON and model tag is `.err` while
the database and the settings API store the operator-facing word `"error"`. The
file says `.err`; `GET /api/settings` says `"error"`. `"err"` is not accepted as
database text, and `.error` is not a ZON tag.
## What is not in the file
Storage paths are process arguments, not configuration:
| Argument | Default | Meaning |
| --- | --- | --- |
| `--data-dir DIR` | `/var/lib/nxdns` | Holds `config.db` and `querylog.db`; see [files and directories](files-and-directories.md). |
| `--config FILE` | `/etc/nxdns/config.zon` | Names the seed file. |
| `--web-dev DIR` | — | `run` only; serves the web interface from a directory instead of the embedded assets. |
## Scalar sections
The "Key" column is the settings key as stored in the database
(`section.field`); in the file the same field lives inside its section block,
for example `.dns = .{ .port = 53 }`.
### upstream
Timeouts for talking to upstream resolvers.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100120000 | read deadline on conditional-forward-zone exchanges (`src/local/forward_client.zig`) |
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100120000, and not below `read_timeout_ms` | per-query budget of the upstream pool (`src/upstream/pool.zig`), the whole attempt including the connect; also the `nxdns check` probe deadline |
### dns
The plain DNS listener (UDP and TCP).
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `dns.bind_ipv4` | string | `"0.0.0.0"` | IP address | must parse as an IPv4 literal | UDP/TCP listener bind (`src/app.zig`) |
| `dns.bind_ipv6` | string | `"::"` | IP address | must parse as an IPv6 literal | UDP/TCP listener bind (`src/app.zig`) |
| `dns.port` | u16 | 53 | port | 165535 (0 is refused) | UDP/TCP listener port |
| `dns.rate_limit` | u32 | 1000 | queries per window | at least 1 | per-client DNS rate limiter (`src/server/rate_limiter.zig`) |
| `dns.rate_window_seconds` | u32 | 60 | seconds | 13600 | window of the same limiter |
`dns.bind_ipv4` and `dns.bind_ipv6` each name one socket of the dual-stack pair,
so each is required to be a literal of its own family. An IPv4 wildcard in
`dns.bind_ipv6` is refused: it would bind IPv4 as the "v6" socket and make the
real IPv4 bind fail with `AddressInUse`, silently removing the IPv6 service.
### blocking
What a blocked query gets back.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `blocking.response` | enum `.zero` \| `.nxdomain` | `.zero` | — | one of the two tags | blocked-response synthesis (`src/filter/response.zig`): `.zero` answers 0.0.0.0 / `::`, `.nxdomain` answers NXDOMAIN |
| `blocking.ttl` | u32 | 5 | seconds | at most 86400; 0 allowed | TTL on the synthesized block answer |
### cache
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `cache.size` | u32 | 10000 | entries | none; 0 disables caching (puts short-circuit) | DNS answer cache capacity (`src/cache/dns_cache.zig`) |
| `cache.negative_ttl_max` | u32 | 3600 | seconds | at most 86400 | cap on cached negative answers; 0 disables negative caching |
### web
The web interface and REST API.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `web.enabled` | bool | true | — | — | gates the whole web stack: server, sessions, SSE hub, API limiter (`src/app.zig`) |
| `web.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | web listener bind (`src/web/server.zig`) |
| `web.port` | u16 | 8080 | port | 165535 (0 is refused) | web listener port |
| `web.password` | string | `""` | — | must not be set together with `web.password_hash` | operator input only; hashed at import and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
| `web.password_hash` | string | `""` | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication |
| `web.session_ttl_hours` | u16 | 24 | hours | at least 1 | session expiry and cookie `Max-Age` (`src/web/auth.zig`) |
| `web.api_rate_limit_per_min` | u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (`src/web/api_limiter.zig`) |
| `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter |
| `web.sse_max_connections_per_ip` | u16 | 3 | connections | at least 1 | cap on concurrent SSE streams per client address |
### doh_server
The DNS-over-HTTPS listener (server side, for clients on the LAN). See
[enable DoH and DoT](../how-to/enable-doh-and-dot.md).
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `doh_server.enabled` | bool | false | — | — | gates the DoH listener (`src/server/doh_server.zig`) |
| `doh_server.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | DoH listener bind |
| `doh_server.port` | u16 | 443 | port | 165535 (0 is refused, enabled or not) | DoH listener port |
| `doh_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate loaded into the hot-reloading `CertStore`; readability is checked by `nxdns check`, not by the validator |
| `doh_server.key_path` | string | `"/etc/nxdns/key.pem"` | path | non-empty when enabled | private key for the same; `nxdns check` warns when it is readable beyond its owner |
### dot_server
The DNS-over-TLS listener. Same shape as `doh_server`; only the default port
differs.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `dot_server.enabled` | bool | false | — | — | gates the DoT listener (`src/server/dot_server.zig`) |
| `dot_server.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | DoT listener bind |
| `dot_server.port` | u16 | 853 | port | 165535 (0 is refused, enabled or not) | DoT listener port |
| `dot_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate, shared `CertStore` with hot reload |
| `dot_server.key_path` | string | `"/etc/nxdns/key.pem"` | path | non-empty when enabled | private key for the same |
### edns
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `edns.ecs_mode` | enum `.strip` \| `.forward` | `.strip` | — | one of the two tags | EDNS Client Subnet handling in the query path (`src/server/handler.zig`, `src/dns/edns.zig`): `.strip` removes the client subnet before forwarding, `.forward` passes it through |
### logging
Process log and query log behavior.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `logging.level` | enum `.err` \| `.warn` \| `.info` \| `.debug` | `.info` | — | one of the four tags; stored as `"error"` / `"warn"` / `"info"` / `"debug"` | log threshold (`src/platform/logging.zig`) |
| `logging.retention_days` | u16 | 30 | days | at least 1 | query-log pruning cutoff (`src/storage/retention.zig`) and the client tracker's last-seen cutoff (`src/server/clients.zig`) |
| `logging.query_log_buffer_max` | u32 | 10000 | entries | at least 1 | in-memory query-log ring size and backpressure cap (`src/storage/logger.zig`) |
| `logging.hide_domains` | bool | false | — | — | the query log stores a hidden marker instead of the domain |
| `logging.hide_client_ips` | bool | false | — | — | the query log stores a hidden marker instead of the client address |
| `logging.output` | enum `.stderr` \| `.syslog` \| `.file` | `.stderr` | — | one of the three tags | log sink selection (`src/platform/logging.zig`); `.stderr` and `.syslog` both write to stderr (journald captures it), `.file` rotates |
| `logging.file_path` | string | `"/var/log/nxdns/nxdns.log"` | path | when `output` is `.file`: non-empty and starting with `/` | rotating log file; its directory also feeds the disk monitor. The binary does not create the directory |
| `logging.max_size_mb` | u32 | 50 | MiB | at least 1 | rotation trigger for the log file |
| `logging.max_files` | u8 | 5 | files | at least 1 | log files kept in total, the live one included, so the highest rotated generation is `max_files - 1`; the default 5 keeps `nxdns.log` plus `nxdns.log.1` through `nxdns.log.4`, and a value of 1 keeps only the live file, which rotation deletes rather than renames |
### disk
Free-space thresholds for the data directory. Below them the query-log writer,
the client tracker and the blocklist scheduler are throttled
(`src/storage/disk_monitor.zig`); DNS resolution is never gated.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `disk.min_free_mb` | u32 | 200 | MiB | at least 1, and not above `warn_free_mb` | `critical` threshold |
| `disk.warn_free_mb` | u32 | 500 | MiB | at least 1 | `warn` threshold |
### blocklist_update
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `blocklist_update.enabled` | bool | true | — | — | blocklist refresh scheduler (`src/filter/manager.zig`); when false the scheduler stops after the startup pass and only a manual refresh runs |
| `blocklist_update.interval_hours` | u16 | 24 | hours | at least 1 | sleep between refresh passes and the per-source staleness test |
## Collections
Collections are ZON lists of structs. Fields without a default are required.
Runtime columns (first/last seen timestamps, per-source download counters) are
deliberately not part of the model: import sets timestamps to the import time
and export omits them, which is what keeps the round trip byte-stable.
### groups
Client groups. A group named `default` is required; every client not assigned
elsewhere lands in it, and import guarantees it keeps database id 1.
| Field | Type | Default | Validation |
|---|---|---|---|
| `name` | string | required | non-empty, unique |
| `safe_search` | bool | false | — |
Consumed by the filter engine (`src/filter/matcher.zig`); `safe_search`
triggers the safe-search rewrite in the query path.
### upstreams
Upstream resolvers. At least one enabled upstream is required.
| Field | Type | Default | Validation |
|---|---|---|---|
| `url` | string | required | an `https://` (DoH) or `tls://` (DoT) endpoint accepted by `transport.Endpoint.parse`; unique |
| `priority` | i32 | 100 | — (lower is tried first) |
| `enabled` | bool | true | — |
| `tls_name` | string | `""` | DoT only — a `tls_name` on an `https://` upstream is an error; when set it must be a valid domain name |
Consumed by the upstream pool (`src/upstream/pool.zig`): entries are sorted by
ascending priority and tried in order with failover. `tls_name` sets SNI and the
certificate verification name for a `tls://` upstream written as an IP literal;
empty means "verify by the URL host" (`src/upstream/dot_client.zig`).
### clients
Known clients with a fixed group assignment.
| Field | Type | Default | Validation |
|---|---|---|---|
| `ip` | string | required | an IP address; unique after canonicalization (`FD00::1` and `fd00:0:0:0:0:0:0:1` collide) |
| `name` | string | `""` | — (display only, never read by the resolver) |
| `group` | string | `"default"` | must name a declared group |
Consumed by the filter engine's exact address-to-group lookup
(`src/filter/matcher.zig`).
### client_prefixes
Group assignment by CIDR prefix, for clients without an exact entry.
| Field | Type | Default | Validation |
|---|---|---|---|
| `prefix` | string | required | a CIDR prefix (`192.168.2.0/24`, `fd00:abcd::/48`); unique after canonicalization |
| `group` | string | `"default"` | must name a declared group |
| `priority` | i32 | 100 | — (ties on match are broken by lower priority) |
Consumed by the filter engine's longest-prefix match
(`src/filter/matcher.zig`).
### blocklist_sources
Downloadable blocklists.
| Field | Type | Default | Validation |
|---|---|---|---|
| `url` | string | required | an `http://` or `https://` URL with a host; unique |
| `name` | string | required | non-empty |
| `enabled` | bool | true | — |
| `is_suggested` | bool | false | — (web UI hint only, never read by the resolver) |
Consumed by the blocklist manager (`src/filter/manager.zig`): downloaded by the
fetcher and compiled into domain sets. A disabled source is neither downloaded
nor loaded.
### group_sources
Which groups consult which blocklist sources.
| Field | Type | Default | Validation |
|---|---|---|---|
| `group` | string | required | must name a declared group |
| `source_url` | string | required | must name a declared blocklist source's `url`; the (group, source_url) pair is unique |
Consumed by the filter engine when assembling each group's compiled domain sets
(`src/filter/matcher.zig`). A link to a disabled source is skipped.
### rules
Per-group allow and block overrides, checked before the blocklists.
| Field | Type | Default | Validation |
|---|---|---|---|
| `group` | string | required | must name a declared group |
| `pattern` | string | required | see below |
| `kind` | enum `.exact` \| `.wildcard` | required | — |
| `action` | enum `.allow` \| `.block` | required | — |
Pattern rules: an `.exact` pattern is a plain domain name and may not contain
`*`. A `.wildcard` pattern must contain at least one label that is exactly `*`
(`*.tracker.example`, or `*` alone), and every other label must be a legal DNS
label. `ads*.example` is not a valid wildcard.
Consumed by the filter engine's rule sets (`src/filter/rules.zig`).
### local_records
Local DNS answers, served without touching any upstream.
| Field | Type | Default | Validation |
|---|---|---|---|
| `name` | string | required | a valid domain name |
| `rtype` | enum `.a` \| `.aaaa` \| `.cname` | required | stored as `A` / `AAAA` / `CNAME` |
| `value` | string | required | an IPv4 address for `.a`, an IPv6 address for `.aaaa`, a domain name for `.cname` |
| `ttl` | u32 | 300 | 1604800 seconds |
The (name, rtype, value) triple is unique. Consumed by the local records table
in the query path (`src/local/records.zig`).
### forward_zones
Zones resolved by a specific resolver instead of the configured upstreams, for
LAN or corporate domains.
| Field | Type | Default | Validation |
|---|---|---|---|
| `zone` | string | required | a valid domain name; unique |
| `resolver` | string | required | `udp://IP:port` or `tcp://IP:port`; the host must be an IP literal and the port is mandatory |
The resolver host must be an IP literal because resolving the resolver's own
name would be a bootstrap problem. Matching is longest suffix
(`src/local/forward_zones.zig`); the exchange is UDP then TCP
(`src/local/forward_client.zig`) with `upstream.read_timeout_ms` as the read
deadline.
## Password and hash
Exactly one of `web.password` and `web.password_hash` may be set; setting both
is refused (`PasswordAndHashBothSet` — ambiguity in a security setting).
- `web.password` is operator input only. At import time it is hashed with
argon2id (OWASP parameters: t=2, m=19 MiB, p=1, PHC encoding) into
`web.password_hash` and discarded. There is no `web.password` settings row,
and `nxdns export` always writes `.password = ""`.
- `web.password_hash` is the stored argon2id PHC string. Supplying it directly,
for example from a previous export, is how a backup restores authentication
without knowing the password.
- Both empty disables web authentication entirely.
Because the export carries the hash and re-importing an exported file takes the
"password is empty" branch, the export/import round trip preserves the hash
byte for byte. See [set up admin
authentication](../how-to/set-up-admin-authentication.md).
## Validation errors
`nxdns check` and `nxdns import` print one `path: message` line per problem and
report every problem, not just the first. The error set is
`validate.ValidateError` in `src/config/validate.zig`:
| Error | Raised by |
| --- | --- |
| `NoUpstreams` | no upstream has `enabled = true` |
| `BadUpstreamUrl` | an upstream `url` is not an `https://` or `tls://` endpoint |
| `DuplicateUpstreamUrl` | two upstreams share a `url` |
| `BadTlsName` | a `tls_name` is not a valid domain name |
| `TlsNameOnNonTlsUpstream` | a `tls_name` on a non-`tls://` upstream |
| `MissingDefaultGroup` | no group is named `default` |
| `DuplicateGroupName` | two groups share a `name` |
| `EmptyGroupName` | a group `name` is empty |
| `UnknownGroup` | a client, prefix, group source or rule names a group that is not declared |
| `BadClientIp` / `DuplicateClientIp` | a client `ip` is unparseable, or collides after canonicalization |
| `BadClientPrefix` / `DuplicateClientPrefix` | the same for a `client_prefixes.prefix` |
| `BadSourceUrl` / `DuplicateSourceUrl` / `EmptySourceName` | blocklist source fields |
| `UnknownSource` / `DuplicateGroupSource` | `group_sources` links |
| `BadRulePattern` | a rule `pattern` does not match its `kind` |
| `BadLocalRecordName` / `BadLocalRecordValue` / `DuplicateLocalRecord` | local record fields |
| `BadForwardZone` / `DuplicateForwardZone` / `BadResolverUrl` | forward zone fields |
| `BadPort` | a port field is 0 |
| `BadTimeout` | a timeout is outside 100120000 ms, or `total` is below `read` |
| `BadTtl` | `blocking.ttl`, `cache.negative_ttl_max`, a record `ttl`, `web.session_ttl_hours` or `blocklist_update.interval_hours` outside its range |
| `BadRetention` | `logging.retention_days` or `logging.query_log_buffer_max` below 1 |
| `BadLogRotation` | `logging.max_size_mb` or `logging.max_files` below 1 |
| `BadDiskThresholds` | a threshold below 1, or `min_free_mb` above `warn_free_mb` |
| `BadRateLimit` | `dns.rate_limit`, `dns.rate_window_seconds`, `web.api_rate_limit_per_min` or `web.sse_max_connections_per_ip` out of range |
| `BadBindAddress` | a bind field is not an IP address, or not of the required family |
| `MissingCertPath` / `MissingKeyPath` | an enabled TLS listener with an empty path |
| `MissingLogPath` | `logging.output = .file` with an empty or relative `file_path` |
| `PasswordAndHashBothSet` | both `web.password` and `web.password_hash` are set |
## Minimal working example
The smallest file that passes validation: a `default` group and one enabled
upstream. Everything else keeps its default.
```zon
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://dns.quad9.net/dns-query" } },
}
```
## Fuller annotated example
```zon
.{
// Plain DNS on the standard port, rate-limited per client.
.dns = .{
.bind_ipv4 = "0.0.0.0",
.bind_ipv6 = "::",
.port = 53,
.rate_limit = 1000,
.rate_window_seconds = 60,
},
// Blocked queries answer 0.0.0.0 / :: with a 5 second TTL.
.blocking = .{ .response = .zero, .ttl = 5 },
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
// Web UI on 8080. The password is hashed at import and never stored;
// leave .password_hash out when setting .password (they are exclusive).
.web = .{
.enabled = true,
.port = 8080,
.password = "correct horse battery staple",
.session_ttl_hours = 24,
},
// Serve DoT to the LAN. The key file should be mode 0600.
.dot_server = .{
.enabled = true,
.port = 853,
.cert_path = "/etc/nxdns/cert.pem",
.key_path = "/etc/nxdns/key.pem",
},
// Strip EDNS Client Subnet before forwarding (the default).
.edns = .{ .ecs_mode = .strip },
// ".err" in the file; the settings API shows it as "error".
.logging = .{ .level = .err, .retention_days = 14 },
.blocklist_update = .{ .enabled = true, .interval_hours = 24 },
// "default" is mandatory. Additional groups get their own rules,
// blocklists and safe-search flag.
.groups = .{
.{ .name = "default" },
.{ .name = "kids", .safe_search = true },
},
// Lower priority is tried first; the second entry is a failover.
// tls_name is needed when a tls:// upstream is written as an IP
// literal, so certificate verification has a DNS name to match.
.upstreams = .{
.{ .url = "https://dns.quad9.net/dns-query", .priority = 10 },
.{ .url = "tls://9.9.9.9:853", .priority = 20, .tls_name = "dns.quad9.net" },
},
// Exact client assignments win over prefixes.
.clients = .{
.{ .ip = "192.168.1.20", .name = "tablet", .group = "kids" },
},
.client_prefixes = .{
.{ .prefix = "192.168.2.0/24", .group = "kids" },
},
.blocklist_sources = .{
.{ .url = "https://lists.example/ads.txt", .name = "ads" },
},
.group_sources = .{
.{ .group = "kids", .source_url = "https://lists.example/ads.txt" },
},
// Overrides beat blocklists. Wildcards need a label that is exactly "*".
.rules = .{
.{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
},
// Local names, answered without any upstream.
.local_records = .{
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan" },
},
// Everything under corp.lan goes to the LAN resolver directly.
.forward_zones = .{
.{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" },
},
}
```
+117
View File
@@ -0,0 +1,117 @@
# Files and directories
Every path nxdns reads or writes, and the mode it is created with. Source of
truth: `src/cli.zig` (`DataDir`), `src/storage/querylog_schema.zig` (the
preserved query-log databases), `src/filter/manager.zig` (the blocklist
snapshots), `src/platform/logging.zig` (the log file).
## The data directory
Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and
`nxdns import` create it and its parents at mode 0700 when it is missing;
`nxdns check` and `nxdns export` do not create it. `export` fails if it is not
there. `check` opens it only on the branch that resolved to the database, so an
absent data directory is not in itself a failure: an explicit `--config FILE`
never looks at the directory, and without one `check` falls back to the default
configuration file, or prints "nothing to check" and exits 2 when neither source
exists.
No subcommand opens the directory read-only. `run`, `import`, `export` and a
`check` that resolved to the database all go through the same
`DataDir.openConfigDb`, which opens `config.db` read/write, chmods it to 0600,
enables WAL — creating `config.db-wal` and `config.db-shm` — and then runs any
pending schema migrations. So `nxdns export` and `nxdns check` write to the data
directory, and on a database one schema version behind they migrate it. `export`
additionally creates an empty `config.db` if the directory exists without one;
`check` reaches the database branch only when `config.db` is already there.
| Path | What it is | Mode |
| --- | --- | --- |
| `config.db` | The configuration database — the single source of truth, including `web.password_hash`. | 0600 |
| `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created when WAL is enabled, inheriting the main file's permissions. | 0600 |
| `querylog.db` | The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. | 0600 |
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
| `querylog.db.corrupt-<unix-seconds>` | A `querylog.db` that could not be used, moved aside before an empty one was created in its place. Kept, never overwritten. | Whatever the renamed file had — no chmod reaches it |
| `querylog.db.corrupt-<unix-seconds>-<n>` | The same, when the plain name is taken — `<n>` counts from 1 and rises until the name is free. Two recreates within one second is the case it exists for. | The same |
| `blocklists/` | Compiled blocklist snapshots, one subdirectory of the data directory. | 0700 |
| `blocklists/<id>.list` | Exact domains for blocklist source `<id>`, one per line, behind a header. | 0600 |
| `blocklists/<id>.wild` | Wildcard entries for the same source. | 0600 |
| `blocklists/<id>.raw.tmp`, `<id>.list.tmp`, `<id>.wild.tmp` | Transient refresh state: the downloaded body and the two compile outputs before they are published by rename. | 0600 |
`<id>` is the `blocklist_sources` row id. A `.list` or `.wild` file whose id is
no longer a `blocklist_sources` row is deleted by the orphan sweep; files of a
live source are left alone whatever their state, and the sweep matches only the
two published suffixes, so a `.tmp` file left by an interrupted refresh is not
swept — the next refresh of that source overwrites it.
A `querylog.db` is moved aside when it is missing nothing but usability:
SQLite reports it corrupt or not a database, `PRAGMA quick_check` does not
answer `ok`, or its `user_version` fingerprint does not match the schema. Only
the main file is renamed — its `-wal` and `-shm` are deleted, because a stale
WAL would be replayed into the fresh database. A missing `querylog.db` is
created without any aside file. The rename happens inside
`querylog_schema.open`, before the 0600 chmod, and that chmod names
`querylog.db` and its two sidecars only — so an aside file keeps the mode the
file had at rename time, which for a `querylog.db` nxdns itself created is 0600
and for one an operator put there is whatever they left it at. Nothing prunes
the aside files; they accumulate until an operator removes them, and each one
holds the same browsing history the live query log holds.
The 0600 modes are not cosmetic. `config.db` holds the argon2id password hash
and `querylog.db` holds the browsing history of every client on the LAN, so both
are as sensitive as each other, and a WAL file holds the same rows as the
database it belongs to. SQLite creates the main database at `0644 & ~umask`;
nxdns chmods it to 0600 before enabling WAL, so the sidecars inherit 0600 rather
than being created world-readable.
## The configuration file
Default `/etc/nxdns/config.zon`, overridable with `--config FILE`. nxdns reads
it and never writes it: it seeds an unconfigured database once and is ignored
afterwards. nxdns does not create the file or its directory.
`nxdns export --out FILE` writes a ZON file at mode 0600 through a temporary
file and a rename. That file carries `web.password_hash`, so treat exports as
secrets. Without `--out` the export goes to stdout, where permissions are the
redirect's business.
## The log file
Only when `logging.output = .file`. The path is `logging.file_path`, default
`/var/log/nxdns/nxdns.log`, and validation requires it to be absolute.
**nxdns does not create the log directory.** It must exist and be writable by
the user the service runs as before the process starts; under systemd the unit's
`LogsDirectory=nxdns` does that.
Rotation triggers at `logging.max_size_mb` MiB and keeps `logging.max_files`
files in total, the live one included, so the highest generation on disk is
`logging.max_files - 1`. With the default 5 that is `nxdns.log` plus `nxdns.log.1`
through `nxdns.log.4`. Rotation deletes the highest generation, renames each
remaining one up by one, then renames the live file to `.1`. A `max_files` of 1
or 0 deletes the live file instead of renaming it. The directory holding the log
file is also sampled by the disk monitor.
| Path | What it is | Mode |
| --- | --- | --- |
| `logging.file_path` (default `/var/log/nxdns/nxdns.log`) | The live process log. Opened for append; created when it does not exist. | `0666 & ~umask` — nxdns never chmods it |
| `<file_path>.1``<file_path>.<max_files - 1>` | Rotated generations, newest first. | Inherited from the live file they were renamed from |
The log file is the one path here nxdns does not set a mode on. `config.db` and
`querylog.db` are chmodded to 0600 whatever the umask; the log file is left at
whatever the umask gives it. Under the shipped unit that is 0600, because
`deploy/systemd/nxdns.service` sets `UMask=0077`. Run from a shell with the
usual `umask 022` it is 0644, and `LogsDirectory=nxdns` leaves the directory at
systemd's default 0755, so a log written there is readable by any local user.
With `logging.output = .stderr` or `.syslog` nxdns writes to stderr and creates
no file; under systemd the journal captures it.
## Certificate and key files
`doh_server.cert_path` / `key_path` and `dot_server.cert_path` / `key_path`,
conventionally under `/etc/nxdns`. nxdns reads them, never writes or creates
them. Both must be readable by the user nxdns runs as; the key should be
readable by its owner only, which `nxdns check` warns about when it is not. A
watcher polls both files and swaps a renewed pair in without a restart. See
[enable DoH and DoT](../how-to/enable-doh-and-dot.md).
+69
View File
@@ -0,0 +1,69 @@
# Performance reference
The PLAN §18 targets and the numbers measured against them. Why the targets are
these targets, and why CI does not gate on them, is [performance and
testing](../explanation/performance-and-testing.md). To reproduce the numbers,
see [measure performance](../how-to/measure-performance.md).
## Targets (PLAN §18)
| Target | Where it is checked |
| --- | --- |
| Sustained ≥ 100 qps on Raspberry Pi 5 | End-to-end against the real binary on the Pi; not a harness number |
| Blocklist lookup p95 < 1 ms | `bench filter`: `matcher.normalize` + `Snapshot.evaluate` per op |
| Cached response p95 < 5 ms | `bench cache`: `buildKey` + `DnsCache.get` + `packet.setId` per op |
| Memory with ~1M blocked domains < 100 MB | `bench filter`: VmRSS with the 1M-domain snapshot loaded |
| Stripped static binary < 10 MB per arch (< 15 MB with the embedded frontend) | CI size assert on the `cross` artifacts |
The harness is `tools/bench.zig`. It measures the three targets that are
measurable in process; the qps target is end to end and the binary-size target
belongs to CI.
## Measured: x86_64 development host
Date: 2026-08-02. Hardware and build: Intel Core i7-14700K, Linux 6.18,
Zig 0.16.0, `-Doptimize=ReleaseFast`, harness defaults (1,000,000 domains,
200,000 iterations per suite, seed 0x5eed).
This is not the target platform. The Pi 5's Cortex-A76 is far slower and these
numbers do not transfer; they establish that the harness works and set a
baseline for regressions on the machine development happens on.
```
suite ops p50(us) p95(us) p99(us) max(us)
filter 200000 0.11 0.18 0.27 16.41
blocked 66699/200000, Snapshot.memoryBytes 28.0 MiB, VmRSS 31.8 MiB
target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS
cache 200000 0.10 0.14 0.17 3.53
hits 100000/200000, DnsCache.memoryBytes 4.3 MiB, VmRSS 7.6 MiB
target p95 < 5ms: PASS
compile 1000000 wall 96.025ms, 10413949 lines/s, 1000000 domains kept (informational)
```
Every in-process §18 target passes on this host: the two latency targets by
three to four orders of magnitude, the memory target by about 3x.
### The two memory figures
`Snapshot.memoryBytes` and `DnsCache.memoryBytes` are the in-repo accounting of
the structures themselves, which is the regression guard. VmRSS is what the
kernel holds resident for the whole process, allocator slack and code included.
The truth sits between them, and the §18 memory target is judged on VmRSS.
The filter suite frees the generated list source before reading VmRSS, so its
number reflects the loaded snapshot rather than the generator. The cache suite's
VmRSS is lower because the filter suite's snapshot has been freed by then.
## Raspberry Pi 5 (target platform)
| Target | Result |
| --- | --- |
| Blocklist lookup p95 < 1 ms | to be measured on hardware |
| Cached response p95 < 5 ms | to be measured on hardware |
| Memory with ~1M blocked domains < 100 MB | to be measured on hardware |
| Sustained ≥ 100 qps | to be measured on hardware, end to end |
The qps target belongs to the real binary rather than the harness: it means
`nxdns run` on the Pi, driven over the LAN by a DNS load generator against real
blocklists.