milestone 20: declarative configuration for iac

This commit is contained in:
2026-08-11 23:31:40 +02:00
parent 2f29121e27
commit d76afc147a
74 changed files with 6722 additions and 1949 deletions
+182 -28
View File
@@ -154,11 +154,11 @@ in step 5, and step 4 has to write a file into it before then. systemd does not
mind finding the directory already there; it adjusts the mode and ownership to
what the unit asks for.
## 4. Write the seed configuration
## 4. Write the configuration
nxdns starts from an empty database only if a configuration file tells it what
to forward to. Write `/etc/nxdns/config.zon`. The smallest file that starts is
one group named `default` and one enabled upstream:
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`.
The smallest file that starts is one group named `default` and one enabled
upstream:
```zon
.{
@@ -213,36 +213,49 @@ The upstream probe sends a real query, so this needs working DNS on the host at
the time you run it. Exit 2 means `check` found something to fix and printed
every problem it found, not only the first.
The file seeds the database once. From the second start onwards it is ignored
and the database is the configuration; see
[the configuration model](../explanation/configuration-model.md) and
[Upgrade nxdns](upgrade.md) for how to change settings after that.
Now load it into the database:
Once the seed has been consumed — after step 6 confirms you can log in — the
plaintext in it is dead weight that only carries risk. The seed's
`web.password` is hashed into `web.password_hash` at import time and the
plaintext is never stored; `nxdns export` writes `.password = ""` back out
alongside the hash. Nothing downstream ever reads the plaintext again, so
delete the file:
```sh
nxdns import /etc/nxdns/config.zon
```
```
info(migrations): config.db migrated from schema version 0 to 2
imported /etc/nxdns/config.zon
```
The plaintext password is hashed into `web.password_hash` and never stored as
plaintext; `nxdns export` writes `.password = null` beside the hash. Nothing
downstream reads the plaintext again, so once step 6 confirms you can log in you
can delete the file:
```sh
rm /etc/nxdns/config.zon
```
Keep it only if you want the seed as a record of the intended starting
configuration, and if you keep it, leave it at 0640 root:nxdns. Note that a
kept seed is not a backup — `nxdns export` is
(see [Back up and restore](back-up-and-restore.md)), and the export carries the
password hash rather than the password.
A kept file is not a backup — `nxdns export` is (see
[Back up and restore](back-up-and-restore.md)), and the export carries the
password hash rather than the password. If you keep it, leave it at 0640
root:nxdns.
That is the **database mode** install, which is what the packaged unit runs:
`ExecStart=/usr/local/bin/nxdns run`, no `--config`, so nothing reads a file
after this step. Change settings afterwards through the admin interface, the
API, or an exporteditimport cycle.
If you would rather keep `/etc/nxdns/config.zon` in git and have every restart
converge onto it, do not delete the file — go to
[Run in file mode](#run-in-file-mode) instead, and skip the `rm`.
> Verified on this host, with a scratch `--config` and `--data-dir` in place of
> `/etc/nxdns` and `/var/lib/nxdns`: a seed written under umask 022 came out
> 0644, `nxdns check --config` on it printed `OK: no problems found` with no
> mode warning, and after `nxdns import` of that seed an `nxdns export` wrote
> `.password = ""` next to a populated `.password_hash =
> "$argon2id$v=19$..."`. The `chown`, `chmod` and `rm` lines above are the
> ordinary root-owned-file operations and were not run against a real
> `/etc/nxdns`, which this host does not have.
> `/etc/nxdns` and `/var/lib/nxdns` — those two paths are the only difference
> from the blocks above. A file written under umask 022 came out 0644,
> `nxdns check --config` on it printed `OK: no problems found` with no mode
> warning, `nxdns import` of it printed the migration line and `imported <path>`
> and exited 0, and a following `nxdns export` wrote `.password = null` next to
> a populated `.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…"`. The
> `chown`, `chmod` and `rm` lines are ordinary root-owned-file operations and
> were not run against a real `/etc/nxdns`, which this host does not have.
## 5. Start it
@@ -263,9 +276,16 @@ nxdns writes to stderr and systemd captures that into the journal; logging
needs no further configuration. Port 53 is privileged, and the unit grants
`CAP_NET_BIND_SERVICE` through `AmbientCapabilities`.
The unit does not restart the service after exit 2 or exit 64
(`RestartPreventExitStatus=2 64`). Those are a wrong configuration and a wrong
command line, and neither clears on a retry — restarting every two seconds until
`StartLimitBurst` gives up would only bury the diagnostics that are already in
the journal. `systemctl status nxdns` shows the failed state; fix the cause and
start it again.
If the start fails, read [Troubleshoot nxdns](troubleshoot.md). The two common
first-install failures are a port 53 already held by `systemd-resolved` and a
seed file that does not parse.
configuration file that does not parse.
## 6. Confirm it answers
@@ -276,7 +296,7 @@ dig @<server-ip> example.com A +short
```
The admin interface is on port 8080 by default; log in with the password from
the seed file. `http://<server-ip>:8080/api/health` reports upstream
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
availability and disk state without a login.
> Not verified on this host as written: `<server-ip>` is a placeholder, and a
@@ -287,6 +307,140 @@ availability and disk state without a login.
> port returned 200. Only the address and the port differ from the lines
> above.
## Run in file mode
In file mode `/etc/nxdns/config.zon` is the configuration: every start converges
the database onto it, and the admin interface refuses configuration edits with a
403 naming the file. Use it when you want the file in git and deployed by
Ansible. Stay in database mode when you want the UI to be the way things change.
The packaged unit is flagless on purpose — it is correct as shipped, and a
commented-out alternative `ExecStart` in a unit file is documentation
masquerading as configuration. File mode is a drop-in.
### Adopt file mode on a box that is already running
Run these in order. **Stop first**, and do not skip that: any edit made through
the UI between an export and the restart would be silently reverted by the first
reconcile, and `nxdns check` against a live database refuses to grade it (below).
If you are arriving here from an upgrade, the binary must already be the new
one before you export. An export written by 0.0.1 carries a `.password = ""`
line this binary refuses; see
[the order trap](upgrade.md#the-order-trap-export-with-the-new-binary-not-the-old-one).
```sh
systemctl stop nxdns
nxdns export --out /etc/nxdns/config.zon
nxdns check --config /etc/nxdns/config.zon
```
```
wrote /etc/nxdns/config.zon
checking configuration file /etc/nxdns/config.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
```
Then add the drop-in and start:
```sh
mkdir -p /etc/systemd/system/nxdns.service.d
cat > /etc/systemd/system/nxdns.service.d/file-mode.conf <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/local/bin/nxdns run --config=/etc/nxdns/config.zon
EOF
systemctl daemon-reload
systemctl start nxdns
```
The empty `ExecStart=` is required. Without it systemd appends a second command
to the list rather than replacing the first, and the unit tries to run nxdns
twice.
The first start after adoption changes nothing, because the file was rendered
from the database it is now governing:
```
reconciled '/etc/nxdns/config.zon': no changes
info(nxdns): authority: file (/etc/nxdns/config.zon)
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
```
`authority: file` is the line that confirms the drop-in took. Blocklists,
compiled snapshots and client history all survive, and every later start on an
unchanged file writes nothing either.
The file now carries `web.password_hash`, so restrict it the same way step 4
does — `chown root:nxdns`, `chmod 0640`. The unit's `ReadOnlyPaths=/etc/nxdns`
denies the service write access to that directory, so the process that reads the
file cannot modify it.
### Change the configuration from now on
Edit the file, validate it, restart:
```sh
$EDITOR /etc/nxdns/config.zon
nxdns check --config /etc/nxdns/config.zon
systemctl restart nxdns
```
Make `nxdns check --config` the precondition of any Ansible handler that
restarts nxdns. A file-mode start reads the file on **every** boot, so a bad
push that skips its handler does not fail at deploy time — it detonates at the
next power cut. Validating before restarting turns that into a failed deploy at
noon.
The restart prints what it changed:
```
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +0 ~2 -0;
settings keys changed: dns.port web.port
```
### Leave file mode
Remove the drop-in and restart. The database already holds the last reconciled
state, so nothing else is needed and the server comes back serving the same
configuration:
```sh
rm /etc/systemd/system/nxdns.service.d/file-mode.conf
systemctl daemon-reload
systemctl restart nxdns
```
```
info(nxdns): authority: database
```
> Verified on this host end to end, against a scratch `--data-dir` and a scratch
> configuration path instead of `/var/lib/nxdns` and `/etc/nxdns`, on
> unprivileged ports — this machine has neither of those directories, no root,
> and no installed unit. Every `nxdns` line above was run and produced the output
> shown, with only those paths and the port numbers in the `serving on` line
> differing.
>
> The run: a database-mode instance was started, a blocklist source was added
> through the API to make it UI-configured, then `nxdns check` against the live
> database printed the uncheckpointed-log FAIL and exited 2 (which is why this
> section stops the service first). After the stop, `nxdns export --out` wrote
> the file, `nxdns check --config` on it exited 0 with `OK: no problems found`,
> and the first file-mode start printed `reconciled '<path>': no changes` and
> `authority: file (<path>)`. A second file-mode start printed `no changes`
> again and loaded the 3096006-byte compiled blocklist from disk with no
> download. Dropping the flag printed `authority: database` and served the same
> configuration.
>
> The `systemctl`, `mkdir`, `cat > …/file-mode.conf` and `rm` lines need root and
> an installed unit and were **not** run. What was checked instead:
> `systemd-analyze verify` on `deploy/systemd/nxdns.service` with those exact two
> `ExecStart` lines appended, which reported only the usual
> `Command /usr/local/bin/nxdns is not executable` for the absent binary and
> nothing about the override.
## Raspberry Pi 5
The Pi 5 is aarch64. Nothing about the procedure changes except which tarball