milestone 13: restructure docs to diataxis, tutorial, every command executed
This commit is contained in:
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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 | 100–120000 | read deadline on conditional-forward-zone exchanges (`src/local/forward_client.zig`) |
|
||||
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100–120000, 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 | 1–65535 (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 | 1–3600 | 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 | 1–65535 (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 | 1–65535 (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 | 1–65535 (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 | 1–604800 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 100–120000 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" },
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user