milestone 20: declarative configuration for iac
Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s

This commit is contained in:
2026-08-11 23:31:40 +02:00
parent 348e955b8f
commit a8e0fe4617
74 changed files with 6722 additions and 1949 deletions
+137 -59
View File
@@ -31,6 +31,9 @@ below carries all 56 of its entries.
- 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
@@ -113,70 +116,140 @@ to the capacity is admitted and the long-run rate holds.
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.
### The refusal
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.
**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.
`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
`GET /api/settings` carries an `authority` object:
| Field | Meaning |
| --- | --- |
| `mode` | `"database"` or `"managed_file"`. |
| `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.
```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`.
`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.
`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 | Purpose |
|---|---|---|---|---|
| GET | `/metrics` | open | exempt | Prometheus metrics |
| GET | `/api/health` | open | exempt | Health rollup |
| GET | `/api/version` | open | counted | Build and uptime |
| GET | `/api/openapi.yaml` | open | counted | This API's OpenAPI document |
| POST | `/api/auth/login` | open | counted | Log in |
| POST | `/api/auth/logout` | session | counted | Log out |
| GET | `/api/queries` | session | counted | Query log page |
| GET | `/api/queries/live` | session | exempt | Live query stream (server-sent events) |
| GET | `/api/stats` | session | counted | Totals for a period |
| GET | `/api/stats/timeseries` | session | counted | Bucketed counts for a period |
| GET | `/api/lookup` | session | counted | Explain a domain |
| GET | `/api/upstream/health` | session | counted | Upstream pool health |
| GET | `/api/groups` | session | counted | List groups |
| POST | `/api/groups` | session | counted | Create a group |
| GET | `/api/groups/{id}` | session | counted | Read a group |
| PUT | `/api/groups/{id}` | session | counted | Update a group |
| DELETE | `/api/groups/{id}` | session | counted | Delete a group |
| GET | `/api/groups/{id}/sources` | session | counted | Blocklist sources assigned to a group |
| PUT | `/api/groups/{id}/sources` | session | counted | Replace the assignment |
| GET | `/api/blocklists` | session | counted | List blocklist sources |
| POST | `/api/blocklists` | session | counted | Add a blocklist source |
| POST | `/api/blocklists/update` | session | counted | Refresh every enabled source now |
| GET | `/api/blocklists/{id}` | session | counted | Read a blocklist source |
| PUT | `/api/blocklists/{id}` | session | counted | Update a blocklist source |
| DELETE | `/api/blocklists/{id}` | session | counted | Delete a blocklist source |
| GET | `/api/rules` | session | counted | List rules |
| POST | `/api/rules` | session | counted | Create a rule |
| GET | `/api/rules/{id}` | session | counted | Read a rule |
| PUT | `/api/rules/{id}` | session | counted | Update a rule |
| DELETE | `/api/rules/{id}` | session | counted | Delete a rule |
| GET | `/api/local-records` | session | counted | List local DNS records |
| POST | `/api/local-records` | session | counted | Create a local record |
| GET | `/api/local-records/{id}` | session | counted | Read a local record |
| PUT | `/api/local-records/{id}` | session | counted | Update a local record |
| DELETE | `/api/local-records/{id}` | session | counted | Delete a local record |
| GET | `/api/forward-zones` | session | counted | List forward zones |
| POST | `/api/forward-zones` | session | counted | Create a forward zone |
| GET | `/api/forward-zones/{id}` | session | counted | Read a forward zone |
| PUT | `/api/forward-zones/{id}` | session | counted | Update a forward zone |
| DELETE | `/api/forward-zones/{id}` | session | counted | Delete a forward zone |
| GET | `/api/clients` | session | counted | List clients |
| GET | `/api/clients/{id}` | session | counted | Read a client |
| PUT | `/api/clients/{id}` | session | counted | Rename or regroup a client |
| DELETE | `/api/clients/{id}` | session | counted | Forget a client |
| GET | `/api/client-prefixes` | session | counted | List client prefixes |
| PUT | `/api/client-prefixes` | session | counted | Replace the prefix table |
| GET | `/api/upstreams` | session | counted | List upstream resolvers |
| POST | `/api/upstreams` | session | counted | Add an upstream |
| GET | `/api/upstreams/{id}` | session | counted | Read an upstream |
| PUT | `/api/upstreams/{id}` | session | counted | Update an upstream |
| DELETE | `/api/upstreams/{id}` | session | counted | Delete an upstream |
| GET | `/api/pause` | session | counted | Read the pause state |
| POST | `/api/pause` | session | counted | Pause or resume blocking |
| GET | `/api/settings` | session | counted | Read the scalar settings |
| PUT | `/api/settings` | session | counted | Update settings |
| POST | `/api/certs/reload` | session | counted | Reload the TLS certificates from disk |
| Method | Path | Auth | Rate limit | Policy | Purpose |
|---|---|---|---|---|---|
| GET | `/metrics` | open | exempt | read | Prometheus metrics |
| GET | `/api/health` | open | exempt | read | Health rollup |
| GET | `/api/version` | open | counted | read | Build and uptime |
| GET | `/api/openapi.yaml` | open | counted | read | This API's OpenAPI document |
| POST | `/api/auth/login` | open | counted | runtime action | Log in |
| POST | `/api/auth/logout` | session | counted | runtime action | Log out |
| GET | `/api/queries` | session | counted | read | Query log page |
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
| GET | `/api/stats` | session | counted | read | Totals for a period |
| GET | `/api/stats/timeseries` | session | counted | read | Bucketed counts for a period |
| GET | `/api/lookup` | session | counted | read | Explain a domain |
| GET | `/api/upstream/health` | session | counted | read | Upstream pool health |
| GET | `/api/groups` | session | counted | read | List groups |
| POST | `/api/groups` | session | counted | config write | Create a group |
| GET | `/api/groups/{id}` | session | counted | read | Read a group |
| PUT | `/api/groups/{id}` | session | counted | config write | Update a group |
| DELETE | `/api/groups/{id}` | session | counted | config write | Delete a group |
| GET | `/api/groups/{id}/sources` | session | counted | read | Blocklist sources assigned to a group |
| PUT | `/api/groups/{id}/sources` | session | counted | config write | Replace the assignment |
| GET | `/api/blocklists` | session | counted | read | List blocklist sources |
| POST | `/api/blocklists` | session | counted | config write | Add a blocklist source |
| POST | `/api/blocklists/update` | session | counted | runtime action | Refresh every enabled source now |
| GET | `/api/blocklists/{id}` | session | counted | read | Read a blocklist source |
| PUT | `/api/blocklists/{id}` | session | counted | config write | Update a blocklist source |
| DELETE | `/api/blocklists/{id}` | session | counted | config write | Delete a blocklist source |
| GET | `/api/rules` | session | counted | read | List rules |
| POST | `/api/rules` | session | counted | config write | Create a rule |
| GET | `/api/rules/{id}` | session | counted | read | Read a rule |
| PUT | `/api/rules/{id}` | session | counted | config write | Update a rule |
| DELETE | `/api/rules/{id}` | session | counted | config write | Delete a rule |
| GET | `/api/local-records` | session | counted | read | List local DNS records |
| POST | `/api/local-records` | session | counted | config write | Create a local record |
| GET | `/api/local-records/{id}` | session | counted | read | Read a local record |
| PUT | `/api/local-records/{id}` | session | counted | config write | Update a local record |
| DELETE | `/api/local-records/{id}` | session | counted | config write | Delete a local record |
| GET | `/api/forward-zones` | session | counted | read | List forward zones |
| POST | `/api/forward-zones` | session | counted | config write | Create a forward zone |
| GET | `/api/forward-zones/{id}` | session | counted | read | Read a forward zone |
| PUT | `/api/forward-zones/{id}` | session | counted | config write | Update a forward zone |
| DELETE | `/api/forward-zones/{id}` | session | counted | config write | Delete a forward zone |
| GET | `/api/clients` | session | counted | read | List clients |
| GET | `/api/clients/{id}` | session | counted | read | Read a client |
| PUT | `/api/clients/{id}` | session | counted | config write | Rename or regroup a client |
| DELETE | `/api/clients/{id}` | session | counted | runtime action | Forget a client |
| GET | `/api/client-prefixes` | session | counted | read | List client prefixes |
| PUT | `/api/client-prefixes` | session | counted | config write | Replace the prefix table |
| GET | `/api/upstreams` | session | counted | read | List upstream resolvers |
| POST | `/api/upstreams` | session | counted | config write | Add an upstream |
| GET | `/api/upstreams/{id}` | session | counted | read | Read an upstream |
| PUT | `/api/upstreams/{id}` | session | counted | config write | Update an upstream |
| DELETE | `/api/upstreams/{id}` | session | counted | config write | Delete an upstream |
| GET | `/api/pause` | session | counted | read | Read the pause state |
| POST | `/api/pause` | session | counted | runtime action | Pause or resume blocking |
| GET | `/api/settings` | session | counted | read | Read the scalar settings |
| 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.
@@ -194,6 +267,11 @@ 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).
## Schemas
Request and response schemas for every operation live in the OpenAPI document:
+212 -48
View File
@@ -9,9 +9,10 @@ of truth: `src/cli.zig`.
Every flag takes both spellings, `--flag value` and `--flag=value`. An attached
value that is empty (`--config=`) is a missing value, not an empty path.
`--force` is boolean and takes no value at all, so `--force=1` is not a spelling
of any flag this program has. A flag is rejected by the subcommand that has no
use for it: `--web-dev` outside `run` is an unknown flag, not a no-op.
`--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`
@@ -20,12 +21,81 @@ Serves DNS until SIGINT or SIGTERM.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. |
| `--config FILE` | Seed configuration file (default `/etc/nxdns/config.zon`). Read only when the database has never been configured. |
| `--config FILE` | Make FILE the sole source of configuration and reconcile the database onto it at every start. No default: without this flag the database is the configuration and no file is read. |
| `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
A seed file that is unparseable, oversized or invalid prints its diagnostics and
exits 2 — the same code `check` and `import` give for the same file. See
[exit codes](#exit-codes).
### 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.
| 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:
```
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:
```
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
settings keys changed: dns.bind_ipv4 dns.port web.bind web.port …
web authentication is now enabled
```
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` and `<id>.wild`, 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.
```
FAIL /etc/nxdns/config.zon: no such file
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).
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.
## `check`
@@ -52,7 +122,7 @@ first. What it checks, in order:
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory to look for `config.db` in. |
| `--config FILE` | Check this file instead of the database. |
| `--config FILE` | Grade this file instead of the database. |
### Failures and warnings
@@ -71,23 +141,38 @@ The last line is a summary, and it never contradicts the lines above it:
### Source selection
- `--config FILE` given explicitly: check that file, nothing else.
- Otherwise, if `<data-dir>/config.db` exists: check the database — the right
default, since the database is the truth on a configured server. It is opened
immutable, so `check` writes nothing to it; see
[what `check` does not do](#what-check-does-not-do).
- Otherwise, if the default config file path exists: check it.
- Otherwise: "nothing to check", exit 2.
The flag decides, exactly as it does for `run`. There is no fallback and no
probing of a default path:
The first line of output always names which source was checked. A file larger
than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
- `--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).
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:
```
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.
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. That is the same code the implicit path
gives when there is nothing to check, so naming the file does not change what a
missing file costs.
`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` does not do
@@ -133,6 +218,15 @@ 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.
`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 |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db`. |
@@ -142,25 +236,79 @@ See [back up and restore](../how-to/back-up-and-restore.md).
## `import FILE`
Validates FILE and replaces the whole configuration with it in one transaction.
Prints every validation problem; a failed import leaves the database untouched.
Refuses a database that already holds configuration unless `--force` is given
(`DatabaseNotEmpty`, exit 2). The check measures what an operator set, not what
the network did: client rows the DNS path materialised from traffic never
trigger the refusal on their own. Creates the data directory at mode 0700 if it
is missing.
Client history survives the replacement. An address the database already knew
keeps its first-seen and last-seen even when FILE names it; only an address it
has never seen takes the import's clock. A client FILE leaves out is removed,
history included.
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.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
| `--force` | Replace a database that already holds configuration. |
| `--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.
### 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:
```
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.
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 |
| --- | --- |
| `blocklist_sources`, `upstreams` | `url` |
| `groups` | `name` |
| `clients` | `ip` |
| `client_prefixes` | `prefix` |
| `forward_zones` | `zone` |
| `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`.
### 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.
### `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.
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`
@@ -185,28 +333,44 @@ 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` and `BadCertificate`. In
practice that covers a seed 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,
and `password` and `password_hash` set together.
`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.
When `run` exits 2 it points at the diagnosis on stderr, whether the fault came
from the seed file or from the database it loaded:
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:
```
run `nxdns check` to see the configuration in full
```
`check` exits 2 for those faults and also when a probed upstream failed, when a
named configuration file is missing or unreadable, when the database cannot be
read or is not at this binary's schema version, and when there was nothing to
check. Warnings never contribute.
On a box with no configuration at all, `run` and `check` add the line that names
both ways to get one:
`import` exits 2 for those faults and for `DatabaseNotEmpty`. That last one is
deliberately not a configuration fault — it reports the state of the database
rather than the content of a file — and `import` decides it for itself; the
answer to it is `--force`, not an edit.
```
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.
`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.
+50 -18
View File
@@ -4,7 +4,7 @@ Every section, field and collection nxdns accepts, with its type, default,
unit, validation rule and the subsystem that consumes it.
Source of truth: `src/config/model.zig` (the model and the defaults),
`src/config/validate.zig` (the rules), `src/config/{bootstrap,import,export}.zig`
`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
@@ -17,7 +17,7 @@ reference](cli.md).
The file is ZON: a top-level anonymous struct whose fields are the sections and
collections below. Enum values are ZON enum literals (`.level = .err`,
`.response = .nxdomain`). Strings are double-quoted. The file may be at most
4 MiB (`max_config_bytes` in `src/config/import.zig`); beyond that the error is
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
@@ -38,7 +38,7 @@ Storage paths are process arguments, not configuration:
| Argument | Default | Meaning |
| --- | --- | --- |
| `--data-dir DIR` | `/var/lib/nxdns` | Holds `config.db` and `querylog.db`; see [files and directories](files-and-directories.md). |
| `--config FILE` | `/etc/nxdns/config.zon` | Names the seed file. |
| `--config FILE` | — | On `run`, makes FILE the sole source of configuration; on `check`, grades FILE instead of the database. No default: without it the database is the configuration. |
| `--web-dev DIR` | — | `run` only; serves the web interface from a directory instead of the embedded assets. |
## Scalar sections
@@ -115,8 +115,8 @@ The web interface and REST API.
| `web.enabled` | bool | true | — | — | gates the whole web stack: server, sessions, SSE hub, API limiter (`src/app.zig`) |
| `web.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | web listener bind (`src/web/server.zig`) |
| `web.port` | u16 | 8080 | port | 165535 (0 is refused) | web listener port |
| `web.password` | string | `""` | — | must not be set together with `web.password_hash` | operator input only; hashed at import and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
| `web.password_hash` | string | `""` | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication |
| `web.password` | optional string | absent | — | must not be set together with `web.password_hash`; the empty string is refused | operator input only; hashed and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
| `web.password_hash` | optional string | absent | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication, absent keeps the stored hash |
| `web.session_ttl_hours` | u16 | 24 | hours | at least 1 | session expiry and cookie `Max-Age` (`src/web/auth.zig`) |
| `web.api_rate_limit_per_min` | u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (`src/web/api_limiter.zig`) |
| `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter |
@@ -341,27 +341,59 @@ deadline.
## Password and hash
Exactly one of `web.password` and `web.password_hash` may be set; setting both
is refused (`PasswordAndHashBothSet` — ambiguity in a security setting).
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".
- `web.password` is operator input only. At import time it is hashed with
argon2id (OWASP parameters: t=2, m=19 MiB, p=1, PHC encoding) into
`web.password_hash` and discarded. There is no `web.password` settings row,
and `nxdns export` always writes `.password = ""`.
| The file says | What happens to the stored hash |
| --- | --- |
| Neither field | Untouched. Authentication stays exactly as it was. |
| `.password = "some-password"` | Verified against the stored hash; kept when it matches, replaced with a fresh hash when it does not. |
| `.password = ""` | Refused, with a diagnostic naming the remedy. |
| `.password_hash = "$argon2id$…"` | Written verbatim. |
| `.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:
```
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.
- Both empty disables web authentication entirely.
Because the export carries the hash and re-importing an exported file takes the
"password is empty" branch, the export/import round trip preserves the hash
byte for byte. See [set up admin
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:
```
web authentication is now enabled
web authentication is now disabled
```
See [set up admin
authentication](../how-to/set-up-admin-authentication.md).
## Validation errors
`nxdns check`, `nxdns import` and the `nxdns run` that seeds a database from the
file all print one `FAIL path: message` line per problem, and report every
`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
@@ -461,7 +493,7 @@ upstream. Everything else keeps its default.
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
// Web UI on 8080. The password is hashed at import and never stored;
// Web UI on 8080. The password is hashed and never stored as plaintext;
// leave .password_hash out when setting .password (they are exclusive).
.web = .{
.enabled = true,
+18 -9
View File
@@ -10,11 +10,9 @@ snapshots), `src/platform/logging.zig` (the log file).
Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and
`nxdns import` create it and its parents at mode 0700 when it is missing;
`nxdns check` and `nxdns export` do not create it. `export` fails if it is not
there. `check` opens it only on the branch that resolved to the database, so an
absent data directory is not in itself a failure: an explicit `--config FILE`
never looks at the directory, and without one `check` falls back to the default
configuration file, or prints "nothing to check" and exits 2 when neither source
exists.
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
@@ -39,7 +37,7 @@ older ones from the main file. That is the "uncheckpointed changes" failure in
| Path | What it is | Mode |
| --- | --- | --- |
| `config.db` | The configuration database — the single source of truth, including `web.password_hash`. | 0600 |
| `config.db` | The configuration database, including `web.password_hash`. The source of truth in database mode; in file mode it is the runtime substrate the file is reconciled onto (see [the configuration file](#the-configuration-file)). | 0600 |
| `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created by `run`, `import` and `export` when WAL is enabled, inheriting the main file's permissions. `check` creates neither. | 0600 |
| `querylog.db` | The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. | 0600 |
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
@@ -112,9 +110,20 @@ than being created world-readable.
## The configuration file
Default `/etc/nxdns/config.zon`, overridable with `--config FILE`. nxdns reads
it and never writes it: it seeds an unconfigured database once and is ignored
afterwards. nxdns does not create the file or its directory.
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.
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