docs: unwrap hand-wrapped prose repo-wide
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s
This commit is contained in:
+46
-164
@@ -1,159 +1,74 @@
|
||||
# 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.
|
||||
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 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.
|
||||
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`).
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Every route has a policy class — `read`, `config_write` or `runtime_action` —
|
||||
and in file mode the `config_write` routes are refused. See
|
||||
[Configuration authority](#configuration-authority).
|
||||
- Mutations to groups, blocklists, rules, local records, forward zones, clients and client prefixes take effect live. Upstreams and `/api/settings` are restart-required.
|
||||
- Every route has a policy class — `read`, `config_write` or `runtime_action` — and in file mode the `config_write` routes are refused. See [Configuration authority](#configuration-authority).
|
||||
|
||||
## 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).
|
||||
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.
|
||||
- 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.
|
||||
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).
|
||||
- The address a bucket keys on is the socket peer, unless that peer is listed in
|
||||
`web.trusted_proxies`. For a listed peer the address is instead the **last**
|
||||
entry of the request's `X-Forwarded-For` — the entry the proxy appended, which
|
||||
is the only one a client cannot write. A request from a trusted proxy with no
|
||||
such header keys on the proxy itself; one whose last entry is not an IP
|
||||
literal is answered 400, because the alternative is granting the proxy's own
|
||||
loopback exemption to whoever sent it. Only `X-Forwarded-For` is read;
|
||||
`Forwarded` (RFC 7239) and the PROXY protocol are not.
|
||||
- Without `web.trusted_proxies`, a same-box reverse proxy makes every request
|
||||
loopback, so the default exemption disables the limiter for all remote
|
||||
clients. Set the proxy's address there, or set
|
||||
`web.api_localhost_exempt = false`.
|
||||
- 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.
|
||||
- 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).
|
||||
- The address a bucket keys on is the socket peer, unless that peer is listed in `web.trusted_proxies`. For a listed peer the address is instead the **last** entry of the request's `X-Forwarded-For` — the entry the proxy appended, which is the only one a client cannot write. A request from a trusted proxy with no such header keys on the proxy itself; one whose last entry is not an IP literal is answered 400, because the alternative is granting the proxy's own loopback exemption to whoever sent it. Only `X-Forwarded-For` is read; `Forwarded` (RFC 7239) and the PROXY protocol are not.
|
||||
- Without `web.trusted_proxies`, a same-box reverse proxy makes every request loopback, so the default exemption disables the limiter for all remote clients. Set the proxy's address there, or set `web.api_localhost_exempt = false`.
|
||||
- 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`.
|
||||
`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.
|
||||
- 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.
|
||||
|
||||
## Configuration authority
|
||||
|
||||
Which authority is live decides whether the API may write configuration. Under
|
||||
`nxdns run` the database is authority and every route behaves as it always has.
|
||||
Under `nxdns run --config FILE` the file is authority, and the routes that would
|
||||
edit configuration are refused: the file is the only place configuration
|
||||
changes, and a restart is what applies them.
|
||||
Which authority is live decides whether the API may write configuration. Under `nxdns run` the database is authority and every route behaves as it always has. Under `nxdns run --config FILE` the file is authority, and the routes that would edit configuration are refused: the file is the only place configuration changes, and a restart is what applies them.
|
||||
|
||||
### The refusal
|
||||
|
||||
A `config write` route in file mode answers **403** with the ordinary error
|
||||
envelope:
|
||||
A `config write` route in file mode answers **403** with the ordinary error envelope:
|
||||
|
||||
```json
|
||||
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
|
||||
```
|
||||
|
||||
There is no `code` field and no richer body. 403 is used for nothing else in
|
||||
this API, so the status alone is the machine-readable part, and a client that
|
||||
wants to know the mode in advance reads it from `GET /api/settings` rather than
|
||||
probing for errors.
|
||||
There is no `code` field and no richer body. 403 is used for nothing else in this API, so the status alone is the machine-readable part, and a client that wants to know the mode in advance reads it from `GET /api/settings` rather than probing for errors.
|
||||
|
||||
**401 comes first.** The router matches the path, spends a rate-limit token,
|
||||
checks the session, and only then checks the policy. So an unauthenticated
|
||||
request to a `config write` route in file mode is a 401, not a 403 — answering
|
||||
403 first would tell an anonymous caller which routes exist.
|
||||
**401 comes first.** The router matches the path, spends a rate-limit token, checks the session, and only then checks the policy. So an unauthenticated request to a `config write` route in file mode is a 401, not a 403 — answering 403 first would tell an anonymous caller which routes exist.
|
||||
|
||||
`runtime action` and `read` routes are unaffected in both modes. Pausing
|
||||
blocking, refreshing blocklists, reloading certificates and logging in are
|
||||
operations on a running process, not statements about configuration, so a
|
||||
file-mode box still does all of them.
|
||||
`runtime action` and `read` routes are unaffected in both modes. Pausing blocking, refreshing blocklists, reloading certificates and logging in are operations on a running process, not statements about configuration, so a file-mode box still does all of them.
|
||||
|
||||
`DELETE /api/clients/{id}` is the one route whose answer depends on the row.
|
||||
Deleting a client the file does not declare is a runtime action and succeeds:
|
||||
without it, a mis-identified or departed device would be immortal in file mode,
|
||||
since the file can add addresses but never remove one it has never named.
|
||||
Deleting a client the file *does* declare contradicts the file, and answers the
|
||||
same 403.
|
||||
`DELETE /api/clients/{id}` is the one route whose answer depends on the row. Deleting a client the file does not declare is a runtime action and succeeds: without it, a mis-identified or departed device would be immortal in file mode, since the file can add addresses but never remove one it has never named. Deleting a client the file *does* declare contradicts the file, and answers the same 403.
|
||||
|
||||
### Discovering the authority
|
||||
|
||||
@@ -165,32 +80,20 @@ same 403.
|
||||
| `path` | The managed file's path, or `null` in database mode. |
|
||||
| `reconciled_at` | Unix seconds when this process loaded the file, or `null` in database mode. |
|
||||
|
||||
All three keys are always present; the two nullable ones carry `null` rather
|
||||
than being omitted, so a client can read `authority.mode` without probing.
|
||||
All three keys are always present; the two nullable ones carry `null` rather than being omitted, so a client can read `authority.mode` without probing.
|
||||
|
||||
```json
|
||||
{"mode": "database", "path": null, "reconciled_at": null}
|
||||
{"mode": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016}
|
||||
```
|
||||
|
||||
The route requires a session, which is why the filesystem path is here rather
|
||||
than on the open `/api/version` and `/api/health`.
|
||||
The route requires a session, which is why the filesystem path is here rather than on the open `/api/version` and `/api/health`.
|
||||
|
||||
`reconciled_at` answers exactly one question: **when did this process last read
|
||||
the file?** Compare it against the file's mtime to spot a restart that has not
|
||||
happened yet. It is a hint and not a verdict, in both directions — a clock that
|
||||
stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make
|
||||
a newer file look older, and the database can change without either timestamp
|
||||
moving. It does not tell you whether the file and the running configuration
|
||||
agree; answering that would take content hashing, which nxdns deliberately does
|
||||
not do.
|
||||
`reconciled_at` answers exactly one question: **when did this process last read the file?** Compare it against the file's mtime to spot a restart that has not happened yet. It is a hint and not a verdict, in both directions — a clock that stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make a newer file look older, and the database can change without either timestamp moving. It does not tell you whether the file and the running configuration agree; answering that would take content hashing, which nxdns deliberately does not do.
|
||||
|
||||
## 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. Policy `config write` is the class refused
|
||||
in file mode; `read` and `runtime action` are always served.
|
||||
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. Policy `config write` is the class refused in file mode; `read` and `runtime action` are always served.
|
||||
|
||||
| Method | Path | Auth | Rate limit | Policy | Purpose |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -251,40 +154,23 @@ in file mode; `read` and `runtime action` are always served.
|
||||
| PUT | `/api/settings` | session | counted | config write | Update settings |
|
||||
| POST | `/api/certs/reload` | session | counted | runtime action | Reload the TLS certificates from disk |
|
||||
|
||||
There is no `POST /api/clients`: client rows come from DNS activity or import,
|
||||
never from the API.
|
||||
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.
|
||||
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.
|
||||
`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.
|
||||
|
||||
In file mode `PUT /api/settings` is refused with the 403 above, password changes
|
||||
included. The password then lives where the rest of the configuration lives: set
|
||||
`web.password` in the file and restart. See
|
||||
[Password and hash](configuration.md#password-and-hash).
|
||||
In file mode `PUT /api/settings` is refused with the 403 above, password changes included. The password then lives where the rest of the configuration lives: set `web.password` in the file and restart. See [Password and hash](configuration.md#password-and-hash).
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
### Block reasons
|
||||
|
||||
Three places carry the same tag: `block_reason` on a `GET /api/queries` row,
|
||||
`block_reason` on a live-stream frame, and `reason` on a `GET /api/lookup`
|
||||
answer. The tag names the level that decided the query, and the levels are
|
||||
listed here in the order they are consulted — the first one that matches wins,
|
||||
so a rule always outranks a list.
|
||||
Three places carry the same tag: `block_reason` on a `GET /api/queries` row, `block_reason` on a live-stream frame, and `reason` on a `GET /api/lookup` answer. The tag names the level that decided the query, and the levels are listed here in the order they are consulted — the first one that matches wins, so a rule always outranks a list.
|
||||
|
||||
| Tag | Decided by |
|
||||
| --- | --- |
|
||||
@@ -298,10 +184,6 @@ so a rule always outranks a list.
|
||||
| `blocklist_domain` | A plain name in a downloaded list |
|
||||
| `blocklist_wildcard` | A domain anchor (`||name^`) in a downloaded list |
|
||||
|
||||
`/api/lookup` also answers `none` when nothing matched. A query row never
|
||||
carries `none`: `block_reason` is null unless the query was blocked.
|
||||
`/api/lookup` also answers `none` when nothing matched. A query row never carries `none`: `block_reason` is null unless the query was blocked.
|
||||
|
||||
A `cname:` prefix means the decision landed on a CNAME target rather than on
|
||||
the name the client asked for, so `cname:blocklist_domain` reads as "the list
|
||||
blocks a name this answer redirects to". Only `/api/queries` and the live
|
||||
stream show the prefix; `/api/lookup` does not follow CNAMEs.
|
||||
A `cname:` prefix means the decision landed on a CNAME target rather than on the name the client asked for, so `cname:blocklist_domain` reads as "the list blocks a name this answer redirects to". Only `/api/queries` and the live stream show the prefix; `/api/lookup` does not follow CNAMEs.
|
||||
|
||||
+48
-184
@@ -4,15 +4,9 @@
|
||||
nxdns <command> [options]
|
||||
```
|
||||
|
||||
Six subcommands: `run`, `check`, `export`, `import`, `version`, `help`. Source
|
||||
of truth: `src/cli.zig`.
|
||||
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.
|
||||
`--allow-delete` is boolean and takes no value at all, so `--allow-delete=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.
|
||||
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. `--allow-delete` is boolean and takes no value at all, so `--allow-delete=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`
|
||||
|
||||
@@ -26,27 +20,21 @@ Serves DNS until SIGINT or SIGTERM.
|
||||
|
||||
### Which authority the invocation selects
|
||||
|
||||
The presence of `--config` picks the authority, and nothing else does. There is
|
||||
no default path, no probe of `/etc/nxdns`, and nothing recorded in the database:
|
||||
a configuration file sitting at `/etc/nxdns/config.zon` that no flag names
|
||||
changes nothing at all.
|
||||
The presence of `--config` picks the authority, and nothing else does. There is no default path, no probe of `/etc/nxdns`, and nothing recorded in the database: a configuration file sitting at `/etc/nxdns/config.zon` that no flag names changes nothing at all.
|
||||
|
||||
| Invocation | Authority | What a start does |
|
||||
| --- | --- | --- |
|
||||
| `nxdns run` | The database | Serves what `config.db` holds. Nothing reads a file. |
|
||||
| `nxdns run --config FILE` | FILE | Reads and validates FILE, reconciles the database onto it, then serves. |
|
||||
|
||||
The first log line after the migrations names the mode, so a journal says which
|
||||
authority was live:
|
||||
The first log line after the migrations names the mode, so a journal says which authority was live:
|
||||
|
||||
```
|
||||
info(nxdns): authority: database
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
```
|
||||
|
||||
In file mode the reconcile prints what it changed before that line — per-table
|
||||
inserted (`+`), updated (`~`) and deleted (`-`) counts, the settings keys whose
|
||||
values changed, and any change to whether the admin password is set:
|
||||
In file mode the reconcile prints what it changed before that line — per-table inserted (`+`), updated (`~`) and deleted (`-`) counts, the settings keys whose values changed, and any change to whether the admin password is set:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
|
||||
@@ -60,17 +48,11 @@ A start whose file matches the database writes nothing and says so:
|
||||
reconciled '/etc/nxdns/config.zon': no changes
|
||||
```
|
||||
|
||||
Blocklist state is not declarative and survives every reconcile: a source whose
|
||||
URL the file still names keeps its row id, its checksum, its counters and its
|
||||
compiled `<id>.list`, `<id>.wild` and `<id>.allow`, so a restart in file mode
|
||||
downloads nothing. Editing a source's URL is a new identity — a new row, a new id, and a
|
||||
fresh download.
|
||||
Blocklist state is not declarative and survives every reconcile: a source whose URL the file still names keeps its row id, its checksum, its counters and its compiled `<id>.list`, `<id>.wild` and `<id>.allow`, so a restart in file mode downloads nothing. Editing a source's URL is a new identity — a new row, a new id, and a fresh download.
|
||||
|
||||
### Failing to start in file mode
|
||||
|
||||
File mode fails closed. A file that is missing, unreadable, unparseable,
|
||||
oversized or invalid stops the start; nxdns never falls back to the database,
|
||||
because a fallback turns a deploy typo into a silently stale configuration.
|
||||
File mode fails closed. A file that is missing, unreadable, unparseable, oversized or invalid stops the start; nxdns never falls back to the database, because a fallback turns a deploy typo into a silently stale configuration.
|
||||
|
||||
```
|
||||
FAIL /etc/nxdns/config.zon: no such file
|
||||
@@ -78,46 +60,21 @@ nxdns run failed: ManagedConfigUnreadable
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
That is exit 2, and `check --config` on the same path agrees. Only path-class
|
||||
open failures map that way — the file is not there, or the process may not read
|
||||
it. An open that fails for a reason a retry could clear, such as
|
||||
file-descriptor exhaustion or an I/O error, is exit 1: the box is wrong, not the
|
||||
configuration. See [exit codes](#exit-codes).
|
||||
That is exit 2, and `check --config` on the same path agrees. Only path-class open failures map that way — the file is not there, or the process may not read it. An open that fails for a reason a retry could clear, such as file-descriptor exhaustion or an I/O error, is exit 1: the box is wrong, not the configuration. See [exit codes](#exit-codes).
|
||||
|
||||
The exit-2 agreement covers `run --config` and `check --config`, which read the
|
||||
managed file through one shared helper. It does **not** extend to `import`'s
|
||||
positional argument: a missing file there is `import failed: FileNotFound`, exit
|
||||
1. That is deliberate rather than an oversight — the managed file is a
|
||||
declarative input an operator deploys, so its absence is a fact about the
|
||||
configuration, while `import`'s argument is a path typed at a prompt, and a
|
||||
mistyped path is a failed command rather than a verdict on anything.
|
||||
The exit-2 agreement covers `run --config` and `check --config`, which read the managed file through one shared helper. It does **not** extend to `import`'s positional argument: a missing file there is `import failed: FileNotFound`, exit
|
||||
1. That is deliberate rather than an oversight — the managed file is a declarative input an operator deploys, so its absence is a fact about the configuration, while `import`'s argument is a path typed at a prompt, and a mistyped path is a failed command rather than a verdict on anything.
|
||||
|
||||
Run `nxdns check --config FILE` before restarting anything that deploys a file.
|
||||
It grades every declarative fault `run` would hit — read, parse, size,
|
||||
validation — through the same code, which is what makes it a usable precondition
|
||||
in an Ansible handler.
|
||||
Run `nxdns check --config FILE` before restarting anything that deploys a file. It grades every declarative fault `run` would hit — read, parse, size, validation — through the same code, which is what makes it a usable precondition in an Ansible handler.
|
||||
|
||||
## `check`
|
||||
|
||||
Validates the configuration and probes the upstreams. Exit 0 when it found no
|
||||
failures, 2 when it found one. It always reports every problem, not just the
|
||||
first. What it checks, in order:
|
||||
Validates the configuration and probes the upstreams. Exit 0 when it found no failures, 2 when it found one. 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: both PEM files are read and the key is
|
||||
tested against the certificate, through the same `CertStore.init` the
|
||||
listeners boot with. A file that is missing, unreadable, too large or
|
||||
unparseable, and a key that does not belong to the certificate, are all FAIL.
|
||||
The key's permissions are a separate finding: WARN when any group or other
|
||||
bit is set, which 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
|
||||
the same two deadlines: `upstream.attempt_timeout_ms` bounds one try against
|
||||
one upstream, `upstream.total_timeout_ms` the whole probe. 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.
|
||||
3. For each enabled DoH/DoT listener: both PEM files are read and the key is tested against the certificate, through the same `CertStore.init` the listeners boot with. A file that is missing, unreadable, too large or unparseable, and a key that does not belong to the certificate, are all FAIL. The key's permissions are a separate finding: WARN when any group or other bit is set, which 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 the same two deadlines: `upstream.attempt_timeout_ms` bounds one try against one upstream, `upstream.total_timeout_ms` the whole probe. 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 |
|
||||
| --- | --- |
|
||||
@@ -126,10 +83,7 @@ first. What it checks, in order:
|
||||
|
||||
### Failures and warnings
|
||||
|
||||
Every finding carries a severity. `FAIL` is a problem that sets exit 2. `WARN`
|
||||
is legal configuration that is almost certainly not what was meant — a blocklist
|
||||
source no group links to, a TLS key readable beyond its owner — and never
|
||||
changes an exit code, because the service starts either way.
|
||||
Every finding carries a severity. `FAIL` is a problem that sets exit 2. `WARN` is legal configuration that is almost certainly not what was meant — a blocklist source no group links to, a TLS key readable beyond its owner — and never changes an exit code, because the service starts either way.
|
||||
|
||||
The last line is a summary, and it never contradicts the lines above it:
|
||||
|
||||
@@ -141,91 +95,55 @@ The last line is a summary, and it never contradicts the lines above it:
|
||||
|
||||
### Source selection
|
||||
|
||||
The flag decides, exactly as it does for `run`. There is no fallback and no
|
||||
probing of a default path:
|
||||
The flag decides, exactly as it does for `run`. There is no fallback and no probing of a default path:
|
||||
|
||||
- `--config FILE`: grade that file, and never open the database.
|
||||
- No `--config`: grade `<data-dir>/config.db`. It is opened immutable, so
|
||||
`check` writes nothing to it; see
|
||||
[what `check` does not do](#what-check-does-not-do).
|
||||
- No `--config`: grade `<data-dir>/config.db`. It is opened immutable, so `check` writes nothing to it; see [what `check` does not do](#what-check-does-not-do).
|
||||
|
||||
So `nxdns check` and `nxdns check --config FILE` grade what the matching `run`
|
||||
invocation would serve. That is what makes `check` a pre-restart gate rather
|
||||
than an approximation of one.
|
||||
So `nxdns check` and `nxdns check --config FILE` grade what the matching `run` invocation would serve. That is what makes `check` a pre-restart gate rather than an approximation of one.
|
||||
|
||||
A bare `check` on a box with no database says so and names both ways out of the
|
||||
state, exit 2:
|
||||
A bare `check` on a box with no database says so and names both ways out of the state, exit 2:
|
||||
|
||||
```
|
||||
no config database at /var/lib/nxdns/config.db
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
The first line of output otherwise 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.
|
||||
The first line of output otherwise 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 named file that is missing or unreadable is a finding like any other, not an
|
||||
I/O failure that escapes the run: `FAIL <path>: no such file` or
|
||||
`FAIL <path>: not readable`, exit 2.
|
||||
A named file that is missing or unreadable is a finding like any other, not an I/O failure that escapes the run: `FAIL <path>: no such file` or `FAIL <path>: not readable`, exit 2.
|
||||
|
||||
What `check --config` cannot see is the reconcile itself. It needs no database,
|
||||
so faults that only a write can produce — a disk that is full, a lock held by a
|
||||
restart that started first — are invisible to it. Those are runtime failures,
|
||||
exit 1, and they are not verdicts on the file.
|
||||
What `check --config` cannot see is the reconcile itself. It needs no database, so faults that only a write can produce — a disk that is full, a lock held by a restart that started first — are invisible to it. Those are runtime failures, exit 1, and they are not verdicts on the file.
|
||||
|
||||
### What `check` does not do
|
||||
|
||||
`check` never writes to `config.db`. It opens the file immutable, which means
|
||||
SQLite refuses every statement that would write and builds no write-ahead log,
|
||||
so no `config.db-wal` and no `config.db-shm` appear beside it. It does not chmod
|
||||
the file and it does not migrate the schema.
|
||||
`check` never writes to `config.db`. It opens the file immutable, which means SQLite refuses every statement that would write and builds no write-ahead log, so no `config.db-wal` and no `config.db-shm` appear beside it. It does not chmod the file and it does not migrate the schema.
|
||||
|
||||
Two consequences are worth knowing before you read a FAIL line as damage:
|
||||
|
||||
- A database behind this binary's schema is reported, not upgraded. Start the
|
||||
service to migrate it.
|
||||
- A database behind this binary's schema is reported, not upgraded. Start the service to migrate it.
|
||||
|
||||
```
|
||||
FAIL <db>: schema version <n>, this nxdns expects <m>; `nxdns run` migrates it, `check` will not
|
||||
```
|
||||
|
||||
- A database with an unapplied write-ahead log cannot be graded without writing,
|
||||
because the newest settings are in the log and the main file holds older ones.
|
||||
`check` says so rather than reading the stale values:
|
||||
- A database with an unapplied write-ahead log cannot be graded without writing, because the newest settings are in the log and the main file holds older ones. `check` says so rather than reading the stale values:
|
||||
|
||||
```
|
||||
FAIL <db>: uncheckpointed changes are waiting in <db>-wal, and reading without writing would answer from the older settings in the main file; `nxdns run` applies them. A running nxdns normally holds this log, which is the usual reason to see this line.
|
||||
```
|
||||
|
||||
A running nxdns is the usual holder of that log, so this is a common answer
|
||||
when checking a live server rather than a sign of damage. It depends on what
|
||||
is in the log, not on whether a server is up: a configuration write that has
|
||||
not been checkpointed puts bytes there, and a server that has only been
|
||||
answering queries leaves `config.db-wal` empty and reads normally. Check an
|
||||
`export` with `--config`, or stop the service first.
|
||||
A running nxdns is the usual holder of that log, so this is a common answer when checking a live server rather than a sign of damage. It depends on what is in the log, not on whether a server is up: a configuration write that has not been checkpointed puts bytes there, and a server that has only been answering queries leaves `config.db-wal` empty and reads normally. Check an `export` with `--config`, or stop the service first.
|
||||
|
||||
## `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.
|
||||
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.
|
||||
`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.
|
||||
|
||||
The output is the same in both authority modes — nothing marks a file as
|
||||
exported from a file-mode box. That is what lets `export` be the adoption tool:
|
||||
the file you check is the file you deploy.
|
||||
The output is the same in both authority modes — nothing marks a file as exported from a file-mode box. That is what lets `export` be the adoption tool: the file you check is the file you deploy.
|
||||
|
||||
`web.password` is always written as `null` and `web.password_hash` carries the
|
||||
stored value, so an export re-imports without anyone knowing the password. A
|
||||
`null` password is not the same statement as an empty one: see
|
||||
[`web.password` and `web.password_hash`](configuration.md#password-and-hash).
|
||||
`web.password` is always written as `null` and `web.password_hash` carries the stored value, so an export re-imports without anyone knowing the password. A `null` password is not the same statement as an empty one: see [`web.password` and `web.password_hash`](configuration.md#password-and-hash).
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
@@ -236,10 +154,7 @@ See [back up and restore](../how-to/back-up-and-restore.md).
|
||||
|
||||
## `import FILE`
|
||||
|
||||
Converges the database onto FILE in one transaction — the same reconcile a
|
||||
file-mode `run` performs, done once from the command line. Prints every
|
||||
validation problem; a failed import leaves the database untouched. Creates the
|
||||
data directory at mode 0700 if it is missing.
|
||||
Converges the database onto FILE in one transaction — the same reconcile a file-mode `run` performs, done once from the command line. Prints every validation problem; a failed import leaves the database untouched. Creates the data directory at mode 0700 if it is missing.
|
||||
|
||||
`FILE` is positional and may appear before or after the flags.
|
||||
|
||||
@@ -248,29 +163,20 @@ data directory at mode 0700 if it is missing.
|
||||
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
|
||||
| `--allow-delete` | Apply a file whose diff deletes rows. |
|
||||
|
||||
**`import` is a stop-first operation.** It rewrites configuration underneath a
|
||||
process that read it at startup, and a running server notices only some of it:
|
||||
filtering picks up the imported rows at the next reload, while upstreams,
|
||||
listeners and settings stay at their boot values until a restart.
|
||||
**`import` is a stop-first operation.** It rewrites configuration underneath a process that read it at startup, and a running server notices only some of it: filtering picks up the imported rows at the next reload, while upstreams, listeners and settings stay at their boot values until a restart.
|
||||
|
||||
### The delete gate
|
||||
|
||||
Rows the database holds and FILE does not name are deleted. That is the point of
|
||||
a declarative apply, and it is also how a mistaken `nxdns import ./wrong.zon`
|
||||
empties a configured server, so it takes a flag:
|
||||
Rows the database holds and FILE does not name are deleted. That is the point of a declarative apply, and it is also how a mistaken `nxdns import ./wrong.zon` empties a configured server, so it takes a flag:
|
||||
|
||||
```
|
||||
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
import failed: DestructiveImport
|
||||
```
|
||||
|
||||
That is exit 2, and the transaction rolls back. The message names every table
|
||||
with a non-zero delete count, so you can tell an intended pruning from a wrong
|
||||
file before applying anything.
|
||||
That is exit 2, and the transaction rolls back. The message names every table with a non-zero delete count, so you can tell an intended pruning from a wrong file before applying anything.
|
||||
|
||||
An import that only adds rows, or only edits them, needs no flag. "Edit" here
|
||||
means a change to a row nxdns can still recognise as the same row. Each table
|
||||
has one column, or one tuple, that establishes identity:
|
||||
An import that only adds rows, or only edits them, needs no flag. "Edit" here means a change to a row nxdns can still recognise as the same row. Each table has one column, or one tuple, that establishes identity:
|
||||
|
||||
| Table | Identity |
|
||||
| --- | --- |
|
||||
@@ -282,43 +188,25 @@ has one column, or one tuple, that establishes identity:
|
||||
| `local_records` | `(name, rtype, value)` |
|
||||
| `rules` | `(group, pattern, kind, action)` |
|
||||
|
||||
Change anything else on a row — a source's name, a group's `safe_search`, a
|
||||
client's group — and it is an edit, applied without a flag. Change the identity
|
||||
itself, such as renaming a group or correcting a typo in an upstream URL, and
|
||||
the engine sees a row that vanished and a row that appeared: that needs
|
||||
`--allow-delete`.
|
||||
Change anything else on a row — a source's name, a group's `safe_search`, a client's group — and it is an edit, applied without a flag. Change the identity itself, such as renaming a group or correcting a typo in an upstream URL, and the engine sees a row that vanished and a row that appeared: that needs `--allow-delete`.
|
||||
|
||||
### What survives an import
|
||||
|
||||
Runtime state is not declarative and is preserved by identity, not by luck. A
|
||||
blocklist source whose URL is unchanged keeps its row id, its checksum, its
|
||||
counters and its compiled files, so an import costs no downloads. Clients the
|
||||
DNS path materialised from traffic are kept whole; naming one in the file
|
||||
promotes that row in place, keeping its first-seen and last-seen. Observed
|
||||
clients whose group the file no longer declares are moved to the `default`
|
||||
group rather than deleted with it, and none of that ever trips the delete gate.
|
||||
Runtime state is not declarative and is preserved by identity, not by luck. A blocklist source whose URL is unchanged keeps its row id, its checksum, its counters and its compiled files, so an import costs no downloads. Clients the DNS path materialised from traffic are kept whole; naming one in the file promotes that row in place, keeping its first-seen and last-seen. Observed clients whose group the file no longer declares are moved to the `default` group rather than deleted with it, and none of that ever trips the delete gate.
|
||||
|
||||
### `import` against a file-mode box
|
||||
|
||||
It behaves like any other import. Nothing in the database records that a file
|
||||
governs it — authority lives in the invocation — so `import` neither detects nor
|
||||
refuses that case. The next restart's reconcile converges the database back to
|
||||
the file and its summary reports what it corrected. A source the import deleted
|
||||
comes back with a new row id, which means a fresh download of the whole list.
|
||||
It behaves like any other import. Nothing in the database records that a file governs it — authority lives in the invocation — so `import` neither detects nor refuses that case. The next restart's reconcile converges the database back to the file and its summary reports what it corrected. A source the import deleted comes back with a new row id, which means a fresh download of the whole list.
|
||||
|
||||
If a restart and an import race for the write lock, one of them simply wins: both
|
||||
take `BEGIN IMMEDIATE` under a 5-second busy timeout, so the outcome is an
|
||||
ordering, never a corrupted database.
|
||||
If a restart and an import race for the write lock, one of them simply wins: both take `BEGIN IMMEDIATE` under a 5-second busy timeout, so the outcome is an ordering, never a corrupted database.
|
||||
|
||||
## `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.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -329,50 +217,26 @@ the same. A usage error prints the same text to stderr and exits 64.
|
||||
| 2 | A configuration problem the operator can fix, or a `check` that found one. |
|
||||
| 64 | Usage error — unknown command or flag, a flag without its value, a missing or extra argument. |
|
||||
|
||||
Code 2 means the same thing from every subcommand. `src/config/faults.zig`
|
||||
holds the one list of errors that mean "the configuration the operator supplied
|
||||
is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits
|
||||
2 whichever command read it. The list is every error the validator raises, plus
|
||||
`ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and
|
||||
`ManagedConfigUnreadable`. In practice that covers a file with a syntax error,
|
||||
one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one
|
||||
with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit,
|
||||
an unusable certificate, `password` and `password_hash` set together, and a
|
||||
`--config` path that is absent or unreadable.
|
||||
Code 2 means the same thing from every subcommand. `src/config/faults.zig` holds the one list of errors that mean "the configuration the operator supplied is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits 2 whichever command read it. The list is every error the validator raises, plus `ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and `ManagedConfigUnreadable`. In practice that covers a file with a syntax error, one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit, an unusable certificate, `password` and `password_hash` set together, and a `--config` path that is absent or unreadable.
|
||||
|
||||
The last of those is the one deliberate seam. A file nxdns cannot open is a
|
||||
configuration fault only when the *path* is the problem — the file is missing,
|
||||
permissions deny it, a path component is not a directory. Every other open
|
||||
failure, such as running out of file descriptors, is exit 1. The distinction
|
||||
earns its keep under the shipped systemd unit, which stops the service on exit 2
|
||||
rather than restarting it: a transient box fault graded as a configuration fault
|
||||
would take the resolver down until someone noticed.
|
||||
The last of those is the one deliberate seam. A file nxdns cannot open is a configuration fault only when the *path* is the problem — the file is missing, permissions deny it, a path component is not a directory. Every other open failure, such as running out of file descriptors, is exit 1. The distinction earns its keep under the shipped systemd unit, which stops the service on exit 2 rather than restarting it: a transient box fault graded as a configuration fault would take the resolver down until someone noticed.
|
||||
|
||||
When `run` exits 2 it points at the diagnosis on stderr, whichever authority the
|
||||
fault came from:
|
||||
When `run` exits 2 it points at the diagnosis on stderr, whichever authority the fault came from:
|
||||
|
||||
```
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
On a box with no configuration at all, `run` and `check` add the line that names
|
||||
both ways to get one:
|
||||
On a box with no configuration at all, `run` and `check` add the line that names both ways to get one:
|
||||
|
||||
```
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
`check` exits 2 for those faults and also when a probed upstream failed, when a
|
||||
named configuration file is missing or unreadable, and when the database cannot
|
||||
be read, is absent, or is not at this binary's schema version. Warnings never
|
||||
contribute.
|
||||
`check` exits 2 for those faults and also when a probed upstream failed, when a named configuration file is missing or unreadable, and when the database cannot be read, is absent, or is not at this binary's schema version. Warnings never contribute.
|
||||
|
||||
`import` exits 2 for those faults and for `DestructiveImport`. That last one is
|
||||
deliberately not a configuration fault — it reports what applying the file would
|
||||
delete, rather than anything wrong with its content — and `import` decides it
|
||||
for itself; the answer to it is `--allow-delete`, not an edit.
|
||||
`import` exits 2 for those faults and for `DestructiveImport`. That last one is deliberately not a configuration fault — it reports what applying the file would delete, rather than anything wrong with its content — and `import` decides it for itself; the answer to it is `--allow-delete`, not an edit.
|
||||
|
||||
`OutOfMemory` is exit 1 even when problems were recorded, because the report is
|
||||
then incomplete. Every other error is 1.
|
||||
`OutOfMemory` is exit 1 even when problems were recorded, because the report is then incomplete. Every other error is 1.
|
||||
|
||||
Where an exit code sends you next: [troubleshoot](../how-to/troubleshoot.md).
|
||||
|
||||
+55
-214
@@ -1,35 +1,20 @@
|
||||
# Configuration reference
|
||||
|
||||
Every section, field and collection nxdns accepts, with its type, default,
|
||||
unit, validation rule and the subsystem that consumes it.
|
||||
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/{loader,reconcile,import,export}.zig`
|
||||
(the lifecycle).
|
||||
Source of truth: `src/config/model.zig` (the model and the defaults), `src/config/validate.zig` (the rules), `src/config/{loader,reconcile,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).
|
||||
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/loader.zig`); beyond that the error is
|
||||
`ConfigTooLarge`. A syntax error is reported with its line and column.
|
||||
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/loader.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.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -43,9 +28,7 @@ Storage paths are process arguments, not configuration:
|
||||
|
||||
## 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 }`.
|
||||
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
|
||||
|
||||
@@ -57,16 +40,9 @@ Timeouts for talking to upstream resolvers.
|
||||
| `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 | per-query budget of the upstream pool (`src/upstream/pool.zig`): every failover attempt together, not one of them; also the `nxdns check` probe deadline |
|
||||
|
||||
The two pool budgets nest. `attempt_timeout_ms` bounds one try against one
|
||||
upstream; when it expires the pool records the failure and moves to the next
|
||||
candidate. `total_timeout_ms` bounds the whole loop, so a query against five
|
||||
unreachable upstreams costs the total budget once, not five attempt budgets in
|
||||
a row. When the total expires the in-flight attempt is canceled and the query
|
||||
fails with a timeout.
|
||||
The two pool budgets nest. `attempt_timeout_ms` bounds one try against one upstream; when it expires the pool records the failure and moves to the next candidate. `total_timeout_ms` bounds the whole loop, so a query against five unreachable upstreams costs the total budget once, not five attempt budgets in a row. When the total expires the in-flight attempt is canceled and the query fails with a timeout.
|
||||
|
||||
`read_timeout_ms` is unrelated to both. It bounds a different subsystem — the
|
||||
conditional-forward-zone client — so no cross-check relates it to the pool's
|
||||
budgets, and it is free to sit above either of them.
|
||||
`read_timeout_ms` is unrelated to both. It bounds a different subsystem — the conditional-forward-zone client — so no cross-check relates it to the pool's budgets, and it is free to sit above either of them.
|
||||
|
||||
### dns
|
||||
|
||||
@@ -80,10 +56,7 @@ The plain DNS listener (UDP and TCP).
|
||||
| `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.
|
||||
`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
|
||||
|
||||
@@ -101,10 +74,7 @@ What a blocked query gets back.
|
||||
| `cache.size` | u32 | 10000 | entries | 1–1000000 | 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 |
|
||||
|
||||
The cache's slot array is allocated in full at startup, so `cache.size` carries
|
||||
a ceiling: it is a sanity bound against a typo, not a promise that the value
|
||||
fits in the box's memory. There is no "off" value — to run without a cache, set
|
||||
`cache.size` to 1.
|
||||
The cache's slot array is allocated in full at startup, so `cache.size` carries a ceiling: it is a sanity bound against a typo, not a promise that the value fits in the box's memory. There is no "off" value — to run without a cache, set `cache.size` to 1.
|
||||
|
||||
### web
|
||||
|
||||
@@ -125,8 +95,7 @@ The web interface and REST API.
|
||||
|
||||
### 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).
|
||||
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 |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -138,8 +107,7 @@ The DNS-over-HTTPS listener (server side, for clients on the LAN). See
|
||||
|
||||
### dot_server
|
||||
|
||||
The DNS-over-TLS listener. Same shape as `doh_server`; only the default port
|
||||
differs.
|
||||
The DNS-over-TLS listener. Same shape as `doh_server`; only the default port differs.
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -173,9 +141,7 @@ Process log and query log behavior.
|
||||
|
||||
### 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.
|
||||
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 |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -191,23 +157,18 @@ the client tracker and the blocklist scheduler are throttled
|
||||
|
||||
## 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.
|
||||
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.
|
||||
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.
|
||||
Consumed by the filter engine (`src/filter/matcher.zig`); `safe_search` triggers the safe-search rewrite in the query path.
|
||||
|
||||
### upstreams
|
||||
|
||||
@@ -220,23 +181,15 @@ Upstream resolvers. At least one enabled upstream is required.
|
||||
| `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`).
|
||||
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`).
|
||||
|
||||
A `tls://` upstream's host must be an IP literal. nxdns does not resolve an
|
||||
upstream's own name — that is a bootstrap problem, and the DoT client refuses a
|
||||
non-literal host on every dial — so the validator rejects the hostname form
|
||||
rather than letting it fail at query time as an unreachable upstream. Write the
|
||||
address and put the name in `tls_name`:
|
||||
A `tls://` upstream's host must be an IP literal. nxdns does not resolve an upstream's own name — that is a bootstrap problem, and the DoT client refuses a non-literal host on every dial — so the validator rejects the hostname form rather than letting it fail at query time as an unreachable upstream. Write the address and put the name in `tls_name`:
|
||||
|
||||
```zig
|
||||
.{ .url = "tls://9.9.9.9:853", .tls_name = "dns.quad9.net" },
|
||||
```
|
||||
|
||||
A DoH upstream is not affected: `https://` goes through the HTTP client, which
|
||||
resolves normally, so a hostname there is the usual form.
|
||||
A DoH upstream is not affected: `https://` goes through the HTTP client, which resolves normally, so a hostname there is the usual form.
|
||||
|
||||
### clients
|
||||
|
||||
@@ -248,36 +201,19 @@ Known clients with a fixed group assignment.
|
||||
| `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`).
|
||||
Consumed by the filter engine's exact address-to-group lookup (`src/filter/matcher.zig`).
|
||||
|
||||
#### Learned names
|
||||
|
||||
A client row that carries no name of its own can still show one. Every flush
|
||||
pass the client tracker takes at most 16 unnamed rows that are due, builds each
|
||||
address's reverse name (`192.168.1.10` becomes `10.1.168.192.in-addr.arpa`), and
|
||||
matches it against the declared [`forward_zones`](#forward_zones). On a match it
|
||||
sends one PTR query to that zone's resolver and stores the answer as the row's
|
||||
*learned* name. A row becomes due again 24 hours after the resolver answered or
|
||||
returned NXDOMAIN, and 1 hour after any other outcome — no covering zone, a
|
||||
transport failure, or a reply nxdns rejected — so declaring the missing zone or
|
||||
fixing the resolver shows names within the hour. This
|
||||
requires a conditional forward zone that covers the LAN's reverse space — for
|
||||
example `168.192.in-addr.arpa` pointed at the router. Without such a zone nxdns
|
||||
sends the reverse name to nobody, and the row stays unnamed.
|
||||
A client row that carries no name of its own can still show one. Every flush pass the client tracker takes at most 16 unnamed rows that are due, builds each address's reverse name (`192.168.1.10` becomes `10.1.168.192.in-addr.arpa`), and matches it against the declared [`forward_zones`](#forward_zones). On a match it sends one PTR query to that zone's resolver and stores the answer as the row's *learned* name. A row becomes due again 24 hours after the resolver answered or returned NXDOMAIN, and 1 hour after any other outcome — no covering zone, a transport failure, or a reply nxdns rejected — so declaring the missing zone or fixing the resolver shows names within the hour. This requires a conditional forward zone that covers the LAN's reverse space — for example `168.192.in-addr.arpa` pointed at the router. Without such a zone nxdns sends the reverse name to nobody, and the row stays unnamed.
|
||||
|
||||
The PTR query goes to the resolver of the declared zone, wherever the operator
|
||||
pointed it; nxdns does not second-guess that declaration, and it cannot stop a
|
||||
LAN resolver from forwarding the query onward.
|
||||
The PTR query goes to the resolver of the declared zone, wherever the operator pointed it; nxdns does not second-guess that declaration, and it cannot stop a LAN resolver from forwarding the query onward.
|
||||
|
||||
A learned name is runtime state, like `last_seen`:
|
||||
|
||||
- A hand-typed `name` always wins, and a named row is never asked about again.
|
||||
- Learned names never appear in `nxdns export`, and `nxdns import` never sets
|
||||
one.
|
||||
- Each row refreshes once a day. A device rename or a DHCP lease change can
|
||||
therefore display a stale name for up to 24 hours, until the next refresh
|
||||
overwrites it or the router reports no name and nxdns clears it.
|
||||
- Learned names never appear in `nxdns export`, and `nxdns import` never sets one.
|
||||
- Each row refreshes once a day. A device rename or a DHCP lease change can therefore display a stale name for up to 24 hours, until the next refresh overwrites it or the router reports no name and nxdns clears it.
|
||||
|
||||
### client_prefixes
|
||||
|
||||
@@ -289,8 +225,7 @@ Group assignment by CIDR prefix, for clients without an exact entry.
|
||||
| `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`).
|
||||
Consumed by the filter engine's longest-prefix match (`src/filter/matcher.zig`).
|
||||
|
||||
### blocklist_sources
|
||||
|
||||
@@ -303,32 +238,11 @@ Downloadable blocklists.
|
||||
| `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.
|
||||
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.
|
||||
|
||||
A list in Adblock Plus syntax may also carry exception lines, `@@||name^` and
|
||||
`@@||name`, either of which may end in `$important`. Those become allow entries
|
||||
that cancel what any attached list blocks, for the name and its subdomains. They
|
||||
cancel nothing an operator decided: every rule of the table above is checked
|
||||
first, so a downloaded list can reopen only a hole another downloaded list dug.
|
||||
Each source reports how many it carried as `exceptions`; there is no way to write
|
||||
one by hand, and no reason to want one — write an allow rule instead.
|
||||
A list in Adblock Plus syntax may also carry exception lines, `@@||name^` and `@@||name`, either of which may end in `$important`. Those become allow entries that cancel what any attached list blocks, for the name and its subdomains. They cancel nothing an operator decided: every rule of the table above is checked first, so a downloaded list can reopen only a hole another downloaded list dug. Each source reports how many it carried as `exceptions`; there is no way to write one by hand, and no reason to want one — write an allow rule instead.
|
||||
|
||||
Two counters report what a compile skipped, and they are different facts.
|
||||
`skipped_regex` counts regex lines: nxdns has a regex engine, but it takes
|
||||
patterns only from the operator, so a regex line in a downloaded list is counted,
|
||||
skipped and surfaced — adopt the ones you trust as `regex` rules.
|
||||
`skipped_unsupported` counts lines nxdns cannot safely translate into a DNS
|
||||
decision: cosmetic element hiding (`##`, `#@#`, `#?#`), rules carrying a `$`
|
||||
modifier (except `$important` on an exception line, tolerated above), scheme
|
||||
anchors, non-anchored `@@` forms — and, in a `domains`-format
|
||||
list, a line holding more than one field before its inline comment, which usually
|
||||
means the list is really a hosts file that was declared as `domains`. Neither is
|
||||
an error, and the two are never one number. A large `skipped_unsupported` beside
|
||||
a small `domain_count` usually means the list is written for browser extensions,
|
||||
and its DNS or hosts variant will block more here. Both appear per source in the
|
||||
blocklists UI and on `/api/blocklists`.
|
||||
Two counters report what a compile skipped, and they are different facts. `skipped_regex` counts regex lines: nxdns has a regex engine, but it takes patterns only from the operator, so a regex line in a downloaded list is counted, skipped and surfaced — adopt the ones you trust as `regex` rules. `skipped_unsupported` counts lines nxdns cannot safely translate into a DNS decision: cosmetic element hiding (`##`, `#@#`, `#?#`), rules carrying a `$` modifier (except `$important` on an exception line, tolerated above), scheme anchors, non-anchored `@@` forms — and, in a `domains`-format list, a line holding more than one field before its inline comment, which usually means the list is really a hosts file that was declared as `domains`. Neither is an error, and the two are never one number. A large `skipped_unsupported` beside a small `domain_count` usually means the list is written for browser extensions, and its DNS or hosts variant will block more here. Both appear per source in the blocklists UI and on `/api/blocklists`.
|
||||
|
||||
### group_sources
|
||||
|
||||
@@ -339,8 +253,7 @@ Which groups consult which blocklist sources.
|
||||
| `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.
|
||||
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
|
||||
|
||||
@@ -353,45 +266,21 @@ Per-group allow and block overrides, checked before the blocklists.
|
||||
| `kind` | enum `.exact` \| `.wildcard` \| `.regex` | 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; a partial label is what the
|
||||
`.regex` kind is for.
|
||||
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; a partial label is what the `.regex` kind is for.
|
||||
|
||||
A `.regex` pattern is a regular expression matched against the whole normalized
|
||||
lowercase name, unanchored unless you write `^` or `$` — the POSIX-grep
|
||||
convention. It is stored exactly as you typed it, which the other two kinds are
|
||||
not: lowercasing would turn `\D` into `\d`, and trimming a trailing `.` would
|
||||
delete an any-byte atom. The engine (`src/filter/regex.zig`) accepts literal
|
||||
bytes, `.` for any byte, character classes `[a-z0-9]` with a leading `^` for
|
||||
negation, the escapes `\d` and `\w` plus `\` before any other ASCII punctuation
|
||||
to make it a literal, the repetitions `*` `+` `?` `{n}` `{n,m}` `{n,}`,
|
||||
alternation `|`, grouping `(...)`, and the anchors `^` and `$`.
|
||||
A `.regex` pattern is a regular expression matched against the whole normalized lowercase name, unanchored unless you write `^` or `$` — the POSIX-grep convention. It is stored exactly as you typed it, which the other two kinds are not: lowercasing would turn `\D` into `\d`, and trimming a trailing `.` would delete an any-byte atom. The engine (`src/filter/regex.zig`) accepts literal bytes, `.` for any byte, character classes `[a-z0-9]` with a leading `^` for negation, the escapes `\d` and `\w` plus `\` before any other ASCII punctuation to make it a literal, the repetitions `*` `+` `?` `{n}` `{n,m}` `{n,}`, alternation `|`, grouping `(...)`, and the anchors `^` and `$`.
|
||||
|
||||
Everything else is refused at the edge rather than approximated, so a pattern
|
||||
written for another engine fails where you can read the diagnostic instead of
|
||||
silently matching names you did not mean:
|
||||
Everything else is refused at the edge rather than approximated, so a pattern written for another engine fails where you can read the diagnostic instead of silently matching names you did not mean:
|
||||
|
||||
- backreferences, lookaround, captures, named groups, Unicode classes and the
|
||||
`(?…)` prefix they share;
|
||||
- backreferences, lookaround, captures, named groups, Unicode classes and the `(?…)` prefix they share;
|
||||
- any alphanumeric escape the list above omits — `\s`, `\b`, `\1`, `\D`;
|
||||
- a `]` inside a class, unless written `\]`;
|
||||
- an empty pattern, and an empty branch: `ads|` is refused rather than read as a
|
||||
pattern that matches every name;
|
||||
- a quantifier applied straight to another quantifier: `a+?` is refused rather
|
||||
than read as `(a+)?`, which matches every name. Write `(a+)?` to mean that.
|
||||
- an empty pattern, and an empty branch: `ads|` is refused rather than read as a pattern that matches every name;
|
||||
- a quantifier applied straight to another quantifier: `a+?` is refused rather than read as `(a+)?`, which matches every name. Write `(a+)?` to mean that.
|
||||
|
||||
A pattern is at most 256 bytes and compiles to at most 1024 instructions, each
|
||||
limit with its own diagnostic, and one group holds at most 256 regex rules.
|
||||
Groups do not capture, and the engine simulates every alternative in lockstep,
|
||||
so a pattern costs at most its compiled length times the length of the name —
|
||||
`(a+)+b` is as cheap here as it is expensive in a backtracking engine.
|
||||
A pattern is at most 256 bytes and compiles to at most 1024 instructions, each limit with its own diagnostic, and one group holds at most 256 regex rules. Groups do not capture, and the engine simulates every alternative in lockstep, so a pattern costs at most its compiled length times the length of the name — `(a+)+b` is as cheap here as it is expensive in a backtracking engine.
|
||||
|
||||
Consumed by the filter engine's rule sets (`src/filter/rules.zig`), which checks
|
||||
the three kinds in the order they are listed above, allow before block within
|
||||
each. Regex is checked last of the three because it is the only kind that costs
|
||||
more than a hash lookup or a label walk.
|
||||
Consumed by the filter engine's rule sets (`src/filter/rules.zig`), which checks the three kinds in the order they are listed above, allow before block within each. Regex is checked last of the three because it is the only kind that costs more than a hash lookup or a label walk.
|
||||
|
||||
### local_records
|
||||
|
||||
@@ -404,27 +293,20 @@ Local DNS answers, served without touching any upstream.
|
||||
| `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`).
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
Reverse zones are declared the same way, and one is the prerequisite for
|
||||
[learned client names](#learned-names):
|
||||
Reverse zones are declared the same way, and one is the prerequisite for [learned client names](#learned-names):
|
||||
|
||||
```zig
|
||||
.forward_zones = .{
|
||||
@@ -435,9 +317,7 @@ Reverse zones are declared the same way, and one is the prerequisite for
|
||||
|
||||
## Password and hash
|
||||
|
||||
Both fields are optional, and the difference between *absent* and *empty* is the
|
||||
whole design. Absent means "keep whatever is stored". Empty means "there is no
|
||||
password".
|
||||
Both fields are optional, and the difference between *absent* and *empty* is the whole design. Absent means "keep whatever is stored". Empty means "there is no password".
|
||||
|
||||
| The file says | What happens to the stored hash |
|
||||
| --- | --- |
|
||||
@@ -448,72 +328,35 @@ password".
|
||||
| `.password_hash = ""` | Cleared, which disables authentication. |
|
||||
| Both fields | Refused (`PasswordAndHashBothSet` — ambiguity in a security setting). |
|
||||
|
||||
Silence has to mean "keep", because the alternative is a trap. An operator who
|
||||
exports a configuration and trims the long PHC string out of it before
|
||||
committing the file to git means "leave the password alone", not "open the admin
|
||||
interface to the LAN". So disabling authentication takes the explicit empty
|
||||
string, and the empty *plaintext* — which would otherwise hash into a real hash
|
||||
that no login can ever satisfy — is refused outright:
|
||||
Silence has to mean "keep", because the alternative is a trap. An operator who exports a configuration and trims the long PHC string out of it before committing the file to git means "leave the password alone", not "open the admin interface to the LAN". So disabling authentication takes the explicit empty string, and the empty *plaintext* — which would otherwise hash into a real hash that no login can ever satisfy — is refused outright:
|
||||
|
||||
```
|
||||
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
|
||||
```
|
||||
|
||||
- `web.password` is operator input only. 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 = null`.
|
||||
- `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.
|
||||
- `web.password` is operator input only. 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 = null`.
|
||||
- `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.
|
||||
|
||||
A plaintext password that has not changed is verified rather than re-hashed, so
|
||||
applying the same file twice leaves the same bytes in the database. That is what
|
||||
keeps the export/import round trip byte-stable with a password in the file. It
|
||||
costs a full argon2id computation either way — the verification is not a
|
||||
shortcut, and caching the plaintext to skip it would be a security bug.
|
||||
A plaintext password that has not changed is verified rather than re-hashed, so applying the same file twice leaves the same bytes in the database. That is what keeps the export/import round trip byte-stable with a password in the file. It costs a full argon2id computation either way — the verification is not a shortcut, and caching the plaintext to skip it would be a security bug.
|
||||
|
||||
Any change to whether a password is set is announced at startup, never left as a
|
||||
count:
|
||||
Any change to whether a password is set is announced at startup, never left as a count:
|
||||
|
||||
```
|
||||
web authentication is now enabled
|
||||
web authentication is now disabled
|
||||
```
|
||||
|
||||
See [set up admin
|
||||
authentication](../how-to/set-up-admin-authentication.md).
|
||||
See [set up admin authentication](../how-to/set-up-admin-authentication.md).
|
||||
|
||||
## Validation errors
|
||||
|
||||
`nxdns check`, `nxdns import` and a `nxdns run --config` that reads the file
|
||||
all print one `FAIL path: message` line per problem, and report every
|
||||
problem rather than the first. A finding that is legal but almost certainly
|
||||
unintended is prefixed `WARN` instead: it does not change the exit code, and it
|
||||
is printed by all three even when nothing failed, so an accepted configuration
|
||||
still says what is odd about it.
|
||||
`nxdns check`, `nxdns import` and a `nxdns run --config` that reads the file all print one `FAIL path: message` line per problem, and report every problem rather than the first. A finding that is legal but almost certainly unintended is prefixed `WARN` instead: it does not change the exit code, and it is printed by all three even when nothing failed, so an accepted configuration still says what is odd about it.
|
||||
|
||||
A url in a diagnostic is redacted to its scheme, host and port. The userinfo,
|
||||
the path, the query and the fragment are dropped, and control characters are
|
||||
escaped. These lines reach the journal, and every one of those parts can carry a
|
||||
credential: a NextDNS DoH upstream is `https://dns.nextdns.io/abcd12`, where the
|
||||
path segment is the whole account identifier. The field path beside the message
|
||||
names the entry, so `blocklist_sources[1].url` still says which one to go and
|
||||
fix.
|
||||
A url in a diagnostic is redacted to its scheme, host and port. The userinfo, the path, the query and the fragment are dropped, and control characters are escaped. These lines reach the journal, and every one of those parts can carry a credential: a NextDNS DoH upstream is `https://dns.nextdns.io/abcd12`, where the path segment is the whole account identifier. The field path beside the message names the entry, so `blocklist_sources[1].url` still says which one to go and fix.
|
||||
|
||||
What redaction cannot remove is the host, because a hostname is not a secret in
|
||||
the general case — it is resolved publicly and offered as SNI on every
|
||||
connection — and dropping it would leave a diagnostic that names nothing worth
|
||||
reading. A vendor that puts an account identifier in the hostname therefore has
|
||||
that identifier appear in any line naming the entry. NextDNS is the example:
|
||||
`abcd12.dns.nextdns.io` is a profile id.
|
||||
What redaction cannot remove is the host, because a hostname is not a secret in the general case — it is resolved publicly and offered as SNI on every connection — and dropping it would leave a diagnostic that names nothing worth reading. A vendor that puts an account identifier in the hostname therefore has that identifier appear in any line naming the entry. NextDNS is the example: `abcd12.dns.nextdns.io` is a profile id.
|
||||
|
||||
Two things limit the exposure. A hostname `tls://` upstream is rejected by the
|
||||
validator (see `upstreams` above), so the DoT form of that URL can only reach
|
||||
the log once, in the line rejecting it — never on every failover. And NextDNS
|
||||
publishes a DoH endpoint, `https://dns.nextdns.io/abcd12`, whose identifier sits
|
||||
in the path and is redacted in full; configure that form and nothing identifying
|
||||
reaches the log at all.
|
||||
Two things limit the exposure. A hostname `tls://` upstream is rejected by the validator (see `upstreams` above), so the DoT form of that URL can only reach the log once, in the line rejecting it — never on every failover. And NextDNS publishes a DoH endpoint, `https://dns.nextdns.io/abcd12`, whose identifier sits in the path and is redacted in full; configure that form and nothing identifying reaches the log at all.
|
||||
|
||||
The error set is `validate.ValidateError` in `src/config/validate.zig`:
|
||||
|
||||
@@ -550,8 +393,7 @@ The error set is `validate.ValidateError` in `src/config/validate.zig`:
|
||||
| `MissingLogPath` | `logging.output = .file` with an empty or relative `file_path` |
|
||||
| `PasswordAndHashBothSet` | both `web.password` and `web.password_hash` are set |
|
||||
|
||||
Warnings are a separate set, outside `ValidateError` because they are not
|
||||
failures. There is one:
|
||||
Warnings are a separate set, outside `ValidateError` because they are not failures. There is one:
|
||||
|
||||
| Warning | Raised by |
|
||||
| --- | --- |
|
||||
@@ -559,8 +401,7 @@ failures. There is one:
|
||||
|
||||
## Minimal working example
|
||||
|
||||
The smallest file that passes validation: a `default` group and one enabled
|
||||
upstream. Everything else keeps its default.
|
||||
The smallest file that passes validation: a `default` group and one enabled upstream. Everything else keeps its default.
|
||||
|
||||
```zon
|
||||
.{
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
# 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).
|
||||
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 when no `--config FILE` was given: with that flag it
|
||||
grades the file and never looks at the directory at all. Without it, an absent
|
||||
`config.db` is a failure naming the two ways to get one, exit 2.
|
||||
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 when no `--config FILE` was given: with that flag it grades the file and never looks at the directory at all. Without it, an absent `config.db` is a failure naming the two ways to get one, exit 2.
|
||||
|
||||
`run`, `import` and `export` go through `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` writes to the data directory, and on a database
|
||||
one schema version behind it migrates it. `export` also creates an empty
|
||||
`config.db` if the directory exists without one.
|
||||
`run`, `import` and `export` go through `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` writes to the data directory, and on a database one schema version behind it migrates it. `export` also creates an empty `config.db` if the directory exists without one.
|
||||
|
||||
`nxdns check` is the exception: it does not use that path at all. It opens
|
||||
`config.db` immutable, which is `SQLITE_OPEN_READONLY` plus `immutable=1`, so
|
||||
SQLite refuses every statement that would write and builds no wal-index. No
|
||||
`config.db-wal` and no `config.db-shm` appear beside the file, the mode is left
|
||||
alone, and no migration runs — a database behind this binary's schema is
|
||||
reported as a failure naming `nxdns run` as the fix. A `check` against a data
|
||||
directory leaves it byte-identical, and `check` reaches the database branch only
|
||||
when `config.db` is already there.
|
||||
`nxdns check` is the exception: it does not use that path at all. It opens `config.db` immutable, which is `SQLITE_OPEN_READONLY` plus `immutable=1`, so SQLite refuses every statement that would write and builds no wal-index. No `config.db-wal` and no `config.db-shm` appear beside the file, the mode is left alone, and no migration runs — a database behind this binary's schema is reported as a failure naming `nxdns run` as the fix. A `check` against a data directory leaves it byte-identical, and `check` reaches the database branch only when `config.db` is already there.
|
||||
|
||||
`immutable=1` ignores any `-wal` file, so it is refused rather than used when
|
||||
one holds bytes: the newest settings would be invisible and `check` would grade
|
||||
older ones from the main file. That is the "uncheckpointed changes" failure in
|
||||
[the CLI reference](cli.md#what-check-does-not-do).
|
||||
`immutable=1` ignores any `-wal` file, so it is refused rather than used when one holds bytes: the newest settings would be invisible and `check` would grade older ones from the main file. That is the "uncheckpointed changes" failure in [the CLI reference](cli.md#what-check-does-not-do).
|
||||
|
||||
| Path | What it is | Mode |
|
||||
| --- | --- | --- |
|
||||
@@ -53,29 +30,15 @@ older ones from the main file. That is the "uncheckpointed changes" failure in
|
||||
|
||||
### The orphan sweep
|
||||
|
||||
The sweep decides by id, not by suffix. It matches all seven names above and
|
||||
deletes those whose `<id>` is no longer a `blocklist_sources` row, so the
|
||||
compiled `.list`, `.wild` and `.allow` of a removed source go, and so do a
|
||||
`.raw.tmp`, `.list.tmp`, `.wild.tmp` or `.allow.tmp` left behind by a refresh
|
||||
that was killed before it could clean up. Files belonging to a source that still
|
||||
has a row are never touched, whatever state they are in: the sweep holds the same
|
||||
lock every refresh takes, so it never reads the directory while a refresh is
|
||||
part-way through.
|
||||
The sweep decides by id, not by suffix. It matches all seven names above and deletes those whose `<id>` is no longer a `blocklist_sources` row, so the compiled `.list`, `.wild` and `.allow` of a removed source go, and so do a `.raw.tmp`, `.list.tmp`, `.wild.tmp` or `.allow.tmp` left behind by a refresh that was killed before it could clean up. Files belonging to a source that still has a row are never touched, whatever state they are in: the sweep holds the same lock every refresh takes, so it never reads the directory while a refresh is part-way through.
|
||||
|
||||
It runs at three moments:
|
||||
|
||||
- at startup, before the first refresh pass — this is what collects what a
|
||||
killed process left behind, and the compiled files of a source deleted while
|
||||
the server was down;
|
||||
- before each scheduled update pass, ahead of the disk-space gate: the sweep
|
||||
only unlinks, so it is the one step here that can give a critically full disk
|
||||
room back, and gating it would keep the residue that helped fill the disk;
|
||||
- immediately after `DELETE /api/blocklists/{id}`, which is when an orphan is
|
||||
actually created in normal operation. Without it a deleted list would keep its
|
||||
megabytes until the next scheduled pass.
|
||||
- at startup, before the first refresh pass — this is what collects what a killed process left behind, and the compiled files of a source deleted while the server was down;
|
||||
- before each scheduled update pass, ahead of the disk-space gate: the sweep only unlinks, so it is the one step here that can give a critically full disk room back, and gating it would keep the residue that helped fill the disk;
|
||||
- immediately after `DELETE /api/blocklists/{id}`, which is when an orphan is actually created in normal operation. Without it a deleted list would keep its megabytes until the next scheduled pass.
|
||||
|
||||
With `blocklist_update.enabled = false` there are no scheduled passes, so only
|
||||
the first and the last of those three happen.
|
||||
With `blocklist_update.enabled = false` there are no scheduled passes, so only the first and the last of those three happen.
|
||||
|
||||
Each deletion is logged:
|
||||
|
||||
@@ -83,110 +46,48 @@ Each deletion is logged:
|
||||
info(blocklist_manager): pruned orphaned blocklist file 9999.list
|
||||
```
|
||||
|
||||
A failed sweep is a warning, not an outage — leftover bytes do not justify
|
||||
losing the refresh pass behind them, let alone the server.
|
||||
A failed sweep is a warning, not an outage — leftover bytes do not justify losing the refresh pass behind them, let alone the server.
|
||||
|
||||
The temporaries of a source that still exists are cleaned by the refresh that
|
||||
owns them rather than by the sweep: each refresh deletes its own `.raw.tmp`,
|
||||
`.list.tmp`, `.wild.tmp` and `.allow.tmp` as it finishes, successfully or not.
|
||||
The temporaries of a source that still exists are cleaned by the refresh that owns them rather than by the sweep: each refresh deletes its own `.raw.tmp`, `.list.tmp`, `.wild.tmp` and `.allow.tmp` as it finishes, successfully or not.
|
||||
|
||||
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.
|
||||
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 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
|
||||
|
||||
There is no default path. `--config FILE` names the file, and without that flag
|
||||
no file is read at all — a `config.zon` sitting in `/etc/nxdns` that no
|
||||
invocation names is inert. `/etc/nxdns/config.zon` is a convention the packaging
|
||||
follows, not a location nxdns probes.
|
||||
There is no default path. `--config FILE` names the file, and without that flag no file is read at all — a `config.zon` sitting in `/etc/nxdns` that no invocation names is inert. `/etc/nxdns/config.zon` is a convention the packaging follows, not a location nxdns probes.
|
||||
|
||||
nxdns reads the file and never writes it, in either authority mode. It does not
|
||||
create the file or its directory either; the systemd unit's
|
||||
`ConfigurationDirectory=nxdns` creates `/etc/nxdns`, and the same unit's
|
||||
`ReadOnlyPaths=/etc/nxdns` denies the service write access to it, so the file
|
||||
cannot be modified by the process that reads it.
|
||||
nxdns reads the file and never writes it, in either authority mode. It does not create the file or its directory either; the systemd unit's `ConfigurationDirectory=nxdns` creates `/etc/nxdns`, and the same unit's `ReadOnlyPaths=/etc/nxdns` denies the service write access to it, so the file cannot be modified by the process that reads it.
|
||||
|
||||
Under `run --config FILE` the file is the configuration and the database is the
|
||||
runtime substrate the server reads from: every start reconciles the one onto the
|
||||
other. So in that mode `config.db` is not the backup — the file is.
|
||||
Under `run --config FILE` the file is the configuration and the database is the runtime substrate the server reads from: every start reconciles the one onto the other. So in that mode `config.db` is not the backup — the file is.
|
||||
|
||||
`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.
|
||||
`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.
|
||||
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.
|
||||
**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.
|
||||
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 write-only and written at a tracked offset; 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 |
|
||||
|
||||
**Do not point external logrotate at this file.** nxdns owns the rotation of
|
||||
its own log. There is no append mode in Zig 0.16, so the writer reads the file
|
||||
length once when it opens the file and then writes every line at an offset it
|
||||
tracks in the process. A rotator that moves the file behind it breaks that
|
||||
offset, and nxdns does not notice until the next restart:
|
||||
**Do not point external logrotate at this file.** nxdns owns the rotation of its own log. There is no append mode in Zig 0.16, so the writer reads the file length once when it opens the file and then writes every line at an offset it tracks in the process. A rotator that moves the file behind it breaks that offset, and nxdns does not notice until the next restart:
|
||||
|
||||
- With `copytruncate` the offset survives the truncation, so the next line
|
||||
lands where it would have without it. The file regrows with a sparse,
|
||||
NUL-filled prefix as long as the log that was just rotated away.
|
||||
- With rename-and-create nxdns keeps writing to the renamed inode. The new
|
||||
file stays empty, the renamed one grows without bound, and
|
||||
`logging.max_size_mb` bounds nothing on disk.
|
||||
- With `copytruncate` the offset survives the truncation, so the next line lands where it would have without it. The file regrows with a sparse, NUL-filled prefix as long as the log that was just rotated away.
|
||||
- With rename-and-create nxdns keeps writing to the renamed inode. The new file stays empty, the renamed one grows without bound, and `logging.max_size_mb` bounds nothing on disk.
|
||||
|
||||
If an external rotator has to own the file, set `logging.output = .stderr` and
|
||||
let the collector capture the stream instead.
|
||||
If an external rotator has to own the file, set `logging.output = .stderr` and let the collector capture the stream instead.
|
||||
|
||||
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.
|
||||
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.
|
||||
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, and the key must belong
|
||||
to the certificate: `nxdns check` loads the pair and fails when it does not. The
|
||||
key should also be readable by its owner only, which `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).
|
||||
`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, and the key must belong to the certificate: `nxdns check` loads the pair and fails when it does not. The key should also be readable by its owner only, which `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).
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# 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).
|
||||
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)
|
||||
|
||||
@@ -15,25 +12,15 @@ see [measure performance](../how-to/measure-performance.md).
|
||||
| Memory with ~1M blocked domains < 100 MiB | `bench filter`: VmRSS with the 1M-domain snapshot loaded |
|
||||
| Stripped static binary ≤ 10,485,760 bytes per arch without the embedded frontend, ≤ 15,728,640 bytes with it | `zig build verify-dist`, run by the `package` gate and by the release |
|
||||
|
||||
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 the packaging step.
|
||||
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 the packaging step.
|
||||
|
||||
The two size budgets are exact byte counts, not rounded mebibytes, because an
|
||||
assert on a rounded number is an assert on a number nobody wrote down.
|
||||
`verify-dist` checks the shipped binary against the larger budget and builds a
|
||||
second time against a generated empty assets directory for the smaller one, so
|
||||
the asset-free figure is a real measurement rather than an estimate.
|
||||
The two size budgets are exact byte counts, not rounded mebibytes, because an assert on a rounded number is an assert on a number nobody wrote down. `verify-dist` checks the shipped binary against the larger budget and builds a second time against a generated empty assets directory for the smaller one, so the asset-free figure is a real measurement rather than an estimate.
|
||||
|
||||
## Measured: x86_64 development host
|
||||
|
||||
Date: 2026-08-13. 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).
|
||||
Date: 2026-08-13. 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.
|
||||
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)
|
||||
@@ -47,27 +34,15 @@ cache 200000 0.11 0.17 0.22 5.40
|
||||
compile 1000000 wall 98.597ms, 10142224 lines/s, 1000000 domains kept (informational)
|
||||
```
|
||||
|
||||
Every in-process §18 target passes on this host: the filter target by about
|
||||
360x, the cache target by about four orders of magnitude, the memory target by
|
||||
about 3x.
|
||||
Every in-process §18 target passes on this host: the filter target by about 360x, the cache target by about four orders of magnitude, the memory target by about 3x.
|
||||
|
||||
The filter suite loads 32 regex rules that no query in the mix matches, which is
|
||||
the expensive case rather than the cheap one: the regex levels sit below every
|
||||
hash and wildcard level, so a name no pattern matches is the name that runs all
|
||||
32 programs to their end. Every op pays that, which is what moved the filter p95
|
||||
from 0.18 µs before regex rules existed to the 2.80 µs above. The margin against
|
||||
the 1 ms target is what makes paying it on every miss an acceptable price.
|
||||
The filter suite loads 32 regex rules that no query in the mix matches, which is the expensive case rather than the cheap one: the regex levels sit below every hash and wildcard level, so a name no pattern matches is the name that runs all 32 programs to their end. Every op pays that, which is what moved the filter p95 from 0.18 µs before regex rules existed to the 2.80 µs above. The margin against the 1 ms target is what makes paying it on every miss an acceptable price.
|
||||
|
||||
### 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.
|
||||
`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.
|
||||
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)
|
||||
|
||||
@@ -78,6 +53,4 @@ VmRSS is lower because the filter suite's snapshot has been freed by then.
|
||||
| Memory with ~1M blocked domains < 100 MiB | 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.
|
||||
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