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

This commit is contained in:
2026-08-02 18:02:58 +02:00
parent 35f23240e7
commit 16c9de2414
26 changed files with 3627 additions and 901 deletions
+192
View File
@@ -0,0 +1,192 @@
# Back up and restore
The configuration database is the only state worth keeping. `nxdns export`
writes it out as a ZON file and `nxdns import` writes one back. The query log is
deliberately not part of a backup: it is expendable history, and if it is
missing it gets recreated empty.
The commands below use the scratch lab from
[enable DoH and DoT](enable-doh-and-dot.md), data directory
`/tmp/nxdns-lab/data`. On a real install drop `--data-dir` and the default
`/var/lib/nxdns` applies.
## Back up
One command, against a stopped or a running server:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/backup.zon
```
```
wrote /tmp/nxdns-lab/backup.zon
```
The write is a temp file plus a rename, so an interrupted export leaves no
half-written backup, and the result is mode 0600:
```sh
stat -c '%a %n' /tmp/nxdns-lab/backup.zon
```
```
600 /tmp/nxdns-lab/backup.zon
```
That mode is not decoration. The file carries `web.password_hash`:
```sh
grep password /tmp/nxdns-lab/backup.zon
```
```
.password = "",
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$Gh+zg9xke6BqSVOiouRbqG50+Bs8ZGcXA6oKgs7lrKg$crTNMu5OI8yKBkp31r4+Y1OUQmLiAlH/qvsIxjBQRq4",
```
Treat backups as secrets. `.password` is always exported as `""` — the
plaintext is never stored anywhere — so the file re-imports without anyone
knowing the password.
Without `--out` the export goes to stdout, where the file mode is your
redirect's problem:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data | head -6
```
```
// nxdns configuration
// generated by `nxdns export` — the database is the source of truth
.{
.upstream = .{ .read_timeout_ms = 3000, .total_timeout_ms = 5000 },
.dns = .{
.bind_ipv4 = "127.0.0.1",
```
Runtime facts are left out on purpose: client first-seen and last-seen times,
rule creation times, per-source domain counts and checksums. They are things a
running server produces, not configuration, and including them would make two
exports taken minutes apart differ.
## Restore onto a fresh data directory
This is the normal restore: new machine, new disk, empty data directory. No
`--force`, because there is nothing to overwrite.
```sh
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data-restored
```
```
info(migrations): config.db migrated from schema version 0 to 2
imported /tmp/nxdns-lab/backup.zon
```
The migration line is expected: `import` creates and migrates the database
before writing to it.
## Restore over an existing database
`import` refuses a database that already has content, so a plain `import` can
never clobber a configured server by accident:
```sh
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data
```
```
import failed: DatabaseNotEmpty
```
That exits 2. Say `--force` when replacing is what you mean:
```sh
nxdns import /tmp/nxdns-lab/backup.zon --force --data-dir /tmp/nxdns-lab/data
```
```
imported /tmp/nxdns-lab/backup.zon
```
Stop the server first. `import` replaces the whole configuration underneath a
process that has already read it, and a running server will not notice.
On a real install that is the systemd unit:
```sh
systemctl stop nxdns
nxdns import /var/backups/nxdns-config.zon --force
systemctl start nxdns
```
**Not verified on this host.** Those three lines are the only commands on this
page that were not run: this machine has no installed nxdns systemd unit
(`systemctl status nxdns` answers `Unit nxdns.service could not be found.`) and
`systemctl stop`/`start` need root. The lab equivalent below was run, and it
exercises the same stop-import-start sequence. In the lab the server is a
foreground `nxdns run`, so stopping it is Ctrl-C in its own terminal:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/pre-restore.zon
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
nxdns import /tmp/nxdns-lab/pre-restore.zon --force --data-dir /tmp/nxdns-lab/data
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
```
```
wrote /tmp/nxdns-lab/pre-restore.zon
imported /tmp/nxdns-lab/pre-restore.zon
```
## Verify a backup
The round trip is byte-stable: exporting, importing and exporting again gives
an identical file. That is the cheapest check that a backup is complete and
that it will load.
```sh
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/backup2.zon
diff /tmp/nxdns-lab/backup.zon /tmp/nxdns-lab/backup2.zon \
&& echo "round trip is byte-identical"
```
```
wrote /tmp/nxdns-lab/backup2.zon
round trip is byte-identical
```
The same check works across data directories — export from the restored copy
and diff against the backup you restored from:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data-restored --out /tmp/nxdns-lab/backup3.zon
diff /tmp/nxdns-lab/backup.zon /tmp/nxdns-lab/backup3.zon \
&& echo "restored database exports identically"
```
```
wrote /tmp/nxdns-lab/backup3.zon
restored database exports identically
```
## What is not in the backup
Everything under the data directory other than `config.db`:
- `querylog.db` — expendable history, recreated empty when absent.
- `blocklists/` — compiled snapshots. They are rebuilt from the sources named in
the configuration, so restoring the configuration is enough; the first refresh
after a restore downloads them again.
The data directory layout is in
[files and directories](../reference/files-and-directories.md).
## A backup before every upgrade
There is no downgrade path. Schema migrations run forward automatically at
startup and before `check`, `export` and `import`; nothing walks them back. Take
an export before installing a new binary — see [upgrade](upgrade.md).
Every command on this page was executed on this host as written, except the
`systemctl` block marked **Not verified on this host** above.
+296
View File
@@ -0,0 +1,296 @@
# Enable DoH and DoT
nxdns can answer encrypted queries on two extra listeners: DNS over HTTPS
(`doh_server`) and DNS over TLS (`dot_server`). Both are off by default and both
need a certificate and a private key in PEM form.
This page uses a scratch lab under `/tmp/nxdns-lab` so the commands run without
root and without touching a real install. On a real install the files live under
`/etc/nxdns` and the data directory is `/var/lib/nxdns`; the ports are 443 and
853 rather than the unprivileged ones below.
Every field mentioned here is documented in
[configuration reference](../reference/configuration.md).
## 1. Get a certificate and key
For a LAN service the practical options are a certificate from your ACME client
(certbot, lego, caddy) for a name you control, or a self-signed pair. The lab
below uses a self-signed pair, because it needs no domain:
```sh
mkdir -p /tmp/nxdns-lab/etc
cd /tmp/nxdns-lab
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout etc/key.pem -out etc/cert.pem -days 365 \
-subj "/CN=nxdns.lan" \
-addext "subjectAltName=DNS:nxdns.lan,DNS:localhost,IP:127.0.0.1"
chmod 0600 etc/key.pem
```
A self-signed certificate means every client has to be told to trust it, or told
to skip verification. That is why the client commands further down pass
`--insecure` and `+tls` without a CA. A real deployment uses a real certificate
and drops those flags.
## 2. Turn the listeners on
`cert_path` and `key_path` must be absolute, or relative to the process working
directory. Point both endpoints at the same pair unless you have a reason not
to:
```zon
.{
.groups = .{.{ .name = "default" }},
.upstreams = .{.{ .url = "https://cloudflare-dns.com/dns-query" }},
.dns = .{ .bind_ipv4 = "127.0.0.1", .bind_ipv6 = "::1", .port = 15400 },
.web = .{ .bind = "127.0.0.1", .port = 8451, .password = "lab-password" },
.doh_server = .{
.enabled = true,
.bind = "127.0.0.1",
.port = 8443,
.cert_path = "/tmp/nxdns-lab/etc/cert.pem",
.key_path = "/tmp/nxdns-lab/etc/key.pem",
},
.dot_server = .{
.enabled = true,
.bind = "127.0.0.1",
.port = 8853,
.cert_path = "/tmp/nxdns-lab/etc/cert.pem",
.key_path = "/tmp/nxdns-lab/etc/key.pem",
},
}
```
Write that to `/tmp/nxdns-lab/etc/config.zon`. The configuration file seeds an
empty database and is then ignored; to change these settings on a server that
already has a database, edit them through the API or through
`export`/`import` — see [the configuration model](../explanation/configuration-model.md).
## 3. Check the files before starting
```sh
nxdns check --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
```
```
checking configuration file /tmp/nxdns-lab/etc/config.zon
OK https://cloudflare-dns.com/dns-query
OK: no problems found
```
`check` reads both endpoints' certificate and key. An unreadable file is a
failure and exits 2:
```
FAIL doh_server.cert_path: '/tmp/nxdns-lab/etc/cert.pem' is not readable
FAIL dot_server.cert_path: '/tmp/nxdns-lab/etc/cert.pem' is not readable
```
A key readable by anyone but its owner is a warning, and does not change the
exit code, because the service still starts:
```
WARN doh_server.key_path: '/tmp/nxdns-lab/etc/key.pem' is mode 644; a TLS key must be readable by its owner only
```
## 4. Start and confirm the listeners
```sh
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
```
Two lines in the log say the listeners bound:
```
info(nxdns): doh listener on 127.0.0.1:8443
info(nxdns): dot listener on 127.0.0.1:8853
```
If a certificate cannot be loaded while its endpoint is enabled, nxdns refuses
to start and exits 2 rather than serving DNS without the listener you asked
for:
```
doh_server: '/tmp/nxdns-lab/etc/cert.pem' + '/tmp/nxdns-lab/etc/key.pem': private key file is not readable
nxdns run failed: BadCertificate
run `nxdns check` to see the configuration in full
```
## 5. Query DoT
Recent `dig` speaks DNS over TLS with `+tls` (this page was checked with BIND
9.20.26):
```sh
dig @127.0.0.1 -p 8853 +tls example.com A +short
```
```
172.66.147.243
104.20.23.154
```
## 6. Query DoH
The only path the DoH listener serves is `/dns-query`; anything else is a 404.
It accepts both the POST form (the query as an `application/dns-message` body)
and the GET form (`?dns=` with base64url of the same bytes).
The body is a raw DNS query in wire format. Build one for `example.com A`
header with the recursion-desired bit, one question, then the QNAME as
length-prefixed labels:
```sh
printf '%s' '000001000001000000000000076578616d706c6503636f6d0000010001' \
| xxd -r -p > /tmp/nxdns-lab/query.bin
```
POST it:
```sh
curl -sS --insecure --http1.1 \
-H 'content-type: application/dns-message' \
--data-binary @/tmp/nxdns-lab/query.bin \
--output /tmp/nxdns-lab/answer.bin \
-w 'http %{http_code}, %{size_download} bytes\n' \
https://127.0.0.1:8443/dns-query
xxd /tmp/nxdns-lab/answer.bin
```
```
http 200, 72 bytes
00000000: 0000 8180 0001 0002 0000 0001 0765 7861 .............exa
00000010: 6d70 6c65 0363 6f6d 0000 0100 01c0 0c00 mple.com........
00000020: 0100 0100 0000 0400 04ac 4293 f3c0 0c00 ..........B.....
00000030: 0100 0100 0000 0400 0468 1417 9a00 0029 .........h.....)
00000040: 04d0 0000 0000 0000 ........
```
The second flag byte `80` and the third answer-count field `0002` say: response,
no error, two answer records.
The GET form takes the same bytes, base64url-encoded with the padding removed:
```sh
Q=$(base64 -w0 /tmp/nxdns-lab/query.bin | tr '+/' '-_' | tr -d '=')
curl -sS --insecure --http1.1 -o /tmp/nxdns-lab/get.bin \
-w 'GET http %{http_code}, %{size_download} bytes\n' \
"https://127.0.0.1:8443/dns-query?dns=$Q"
```
```
GET http 200, 61 bytes
```
`--http1.1` matters: without it curl offers HTTP/2 over ALPN, and the DoH
listener negotiates only what it advertises. `--insecure` is only needed for the
self-signed lab certificate.
## 7. Renewals
A watcher polls both files every 30 seconds and compares their modification time
and size against the pair currently loaded. When either differs it reloads and
swaps the new pair in; connections already open finish on the old certificate.
Nothing has to restart.
Replace the pair and wait one poll interval:
```sh
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout /tmp/nxdns-lab/etc/key.pem -out /tmp/nxdns-lab/etc/cert.pem -days 365 \
-subj "/CN=nxdns.lan" \
-addext "subjectAltName=DNS:nxdns.lan,DNS:localhost,IP:127.0.0.1"
chmod 0600 /tmp/nxdns-lab/etc/key.pem
```
Within 30 seconds the log says so, once per endpoint that watches the file:
```
info(cert_store): certificate reloaded from /tmp/nxdns-lab/etc/cert.pem
info(cert_store): certificate reloaded from /tmp/nxdns-lab/etc/cert.pem
```
## 8. Reload immediately
To skip the wait — from an ACME deploy hook, for example — call
`POST /api/certs/reload`. It needs a session; see
[set up admin authentication](set-up-admin-authentication.md) for the login
call that fills `cookies.txt`.
```sh
curl -sS -b /tmp/nxdns-lab/cookies.txt -X POST http://127.0.0.1:8451/api/certs/reload
```
```json
{"doh":{"enabled":true,"reloaded":true,"error":null},"dot":{"enabled":true,"reloaded":true,"error":null}}
```
The route always answers 200: the per-endpoint outcome is the payload, not the
status code. A disabled endpoint reports `"enabled":false`. A reload that fails
names the reason and leaves the old certificate serving:
```sh
chmod 000 /tmp/nxdns-lab/etc/key.pem
curl -sS -b /tmp/nxdns-lab/cookies.txt -X POST http://127.0.0.1:8451/api/certs/reload
```
```json
{"doh":{"enabled":true,"reloaded":false,"error":"private key file is not readable"},"dot":{"enabled":true,"reloaded":false,"error":"private key file is not readable"}}
```
The listeners keep working through that failure:
```sh
dig @127.0.0.1 -p 8853 +tls example.com A +short
```
```
104.20.23.154
172.66.147.243
```
Undo it with `chmod 0600 /tmp/nxdns-lab/etc/key.pem` and reload again. The
counters are on `/metrics`:
```
nxdns_cert_reloads_total{endpoint="doh"} 3
nxdns_cert_reloads_total{endpoint="dot"} 3
nxdns_cert_reload_failures_total{endpoint="doh"} 2
nxdns_cert_reload_failures_total{endpoint="dot"} 2
```
## File ownership on a real install
The key must be readable by the user nxdns runs as, and by nobody else.
Under systemd the service runs as the `nxdns` user:
```sh
chown nxdns:nxdns /etc/nxdns/cert.pem /etc/nxdns/key.pem
chmod 0644 /etc/nxdns/cert.pem
chmod 0600 /etc/nxdns/key.pem
```
Under Docker the container runs as uid 65532, fixed in the image, and
`/etc/nxdns` is a read-only bind mount — the container cannot fix permissions
itself, so the host-side files must already be readable by that uid. It has no
name on the host, so chown it numerically:
```sh
cd deploy/docker
chown 65532:65532 etc-nxdns/cert.pem etc-nxdns/key.pem
chmod 0644 etc-nxdns/cert.pem
chmod 0600 etc-nxdns/key.pem
```
**Not verified on this host:** the two `chown` blocks above. Both need root, and
the systemd one needs an `nxdns` user this development machine does not have.
Everything else on this page was executed as written.
## Ports 443 and 853
The defaults are the standard ports, which are privileged. Under the packaged
systemd unit that is already handled: it grants `CAP_NET_BIND_SERVICE` for port
53 and the same capability covers 443 and 853. See
[install with systemd](install-with-systemd.md).
+170
View File
@@ -0,0 +1,170 @@
# Install nxdns with Docker
Builds the nxdns image and runs it with Docker Compose. At the end a container
answers DNS on port 53 and keeps its data in a named volume.
For what each configuration field means, see
[the configuration reference](../reference/configuration.md).
> Verification: every command on this page was run on the machine that wrote
> it, with three exceptions marked below — the `chown` to uid 65532 needs root,
> the arm64 image was built but not run, and pushing to a registry needs
> credentials. One command was run in altered form: host port 8080 was occupied
> here, so the run and the two verification commands in step 3 were executed
> with the host side of the port mappings moved to 25353 and 28088 rather than
> the 53 and 8080 printed below. The container side was unchanged. See the note
> in step 3.
## 1. Build the image
The Dockerfile does not compile anything. It assembles a filesystem around a
binary you build first, so build the admin interface and the binaries from the
repository root:
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
docker build -t nxdns -f deploy/docker/Dockerfile .
```
Build `web/dist` every time, before the binary. A stale bundle is embedded
silently and ships an admin interface that does not match its API.
The context has to be the repository root, because the Dockerfile copies
`zig-out/cross`. The result is a `scratch` image holding the binary, a CA
bundle and two empty directories — 28.2 MB here.
Compose runs the same build with the right context:
```sh
docker compose -f deploy/docker/compose.yaml build
```
Every block on this page runs from the repository root, and none of them change
directory, so they can be pasted in order. `-f` is what makes that work:
Compose resolves the relative paths inside `compose.yaml` — the build context,
the `etc-nxdns` bind mount — against the directory holding the file, not
against your shell, and it takes the project name `docker` from that directory
either way, which is why the container is `docker-nxdns-1`.
## 2. Write the seed configuration
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create
it and put the seed file in it:
```sh
mkdir -p deploy/docker/etc-nxdns
$EDITOR deploy/docker/etc-nxdns/config.zon
```
The smallest file that starts is one group named `default` and one enabled
upstream:
```zon
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
.web = .{ .password = "choose-a-real-password" },
}
```
Without that file the container exits with code 2 on a fresh volume: an empty
database has nothing to forward to. The log is
`no configuration file at '/etc/nxdns/config.zon'; using the database as it is`
followed by `nxdns run failed: NoUsableUpstreams`.
A file that is present but rejected is a different failure and a different exit
code. No `default` group, no enabled upstream, a syntax error — `run` prints the
diagnostic and exits **1**, for example `nxdns run failed: MissingDefaultGroup`.
Both were run here against this image: a seed file whose only group was named
`other` exited 1, and an empty `/etc/nxdns` exited 2 with `NoUsableUpstreams`.
Under `restart: unless-stopped` either one is a restart loop, so read the exit
code from `docker inspect` to tell them apart; see
[Troubleshoot nxdns](troubleshoot.md).
The container runs as uid 65532, and the mount is read-only, so the container
cannot repair permissions itself. Mode 0644 works and was used here. If the
file carries a secret — `web.password`, or a `web.password_hash` from a
restored export — give it to that uid instead:
```sh
chown 65532:65532 deploy/docker/etc-nxdns/config.zon
chmod 0600 deploy/docker/etc-nxdns/config.zon
```
> Not verified on this host: `chown` to a uid you do not own needs root. What
> was verified is the failure it prevents — a seed file at 0600 owned by
> another uid makes the container log `nxdns run failed: AccessDenied` and
> restart in a loop. See [Troubleshoot nxdns](troubleshoot.md).
## 3. Run it
```sh
docker compose -f deploy/docker/compose.yaml up -d
docker compose -f deploy/docker/compose.yaml logs -f
```
A healthy first start logs the seeding and the bound sockets:
```
info(config_bootstrap): seeded the database from '/etc/nxdns/config.zon'
info(nxdns): nxdns 0.1.0-dev serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
info(web_server): web interface listening on 0.0.0.0:8080
```
Confirm it answers and that the admin interface is up:
```sh
dig @127.0.0.1 example.com A +short
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
```
> On the machine that wrote this page, host port 8080 was already taken by an
> unrelated process, so the container was verified with the host side of the
> port mappings moved to 25353 and 28088 — everything inside the container was
> unchanged, and the log still reads `serving on udp [::]:53`. Against those
> ports `dig` returned the A records for `example.com` and `curl` returned 200.
> `docker compose up -d` fails with
> `failed to bind host port 0.0.0.0:8080/tcp: address already in use` when a
> host port is occupied; free the port or edit the `ports:` list.
The compose file publishes 53/udp, 53/tcp and 8080, keeps `/var/lib/nxdns` in
the named volume `nxdns-data`, and sets the per-namespace sysctl
`net.ipv4.ip_unprivileged_port_start=0` so uid 65532 can bind port 53 without
any capability. Uncomment the 443 and 853 mappings when you enable the DoH or
DoT listener; see [Enable DoH and DoT](enable-doh-and-dot.md).
## 4. Do not point the host at the container
The container resolves its own upstream DoH and DoT hostnames through the
host's DNS configuration. If you set the host's `/etc/resolv.conf` to the nxdns
container, the container's startup lookups depend on the service that is trying
to start. Point LAN clients at nxdns; leave the container's host on its own
resolver.
## Build for a Raspberry Pi 5
The Dockerfile maps buildx's `TARGETARCH` onto the cross-target directory, so
the aarch64 image comes from the same `zig-out/cross` tree with no second
compile:
```sh
docker buildx build --platform linux/arm64 -t nxdns:arm64 -f deploy/docker/Dockerfile .
```
This was run here and completed; add `--push` or `--load` to keep the result,
since the default buildx driver leaves it in the build cache.
> Not verified on this host: the arm64 image was not started. Running it needs
> an aarch64 machine or qemu binfmt emulation, neither of which is available
> here.
## Publish the image to a registry
There is no registry push in CI on purpose: credentials and the choice of
registry are infrastructure decisions, not this repository's. Publish by hand
with `docker login <registry>`, then `docker tag nxdns
<registry>/<owner>/nxdns:<tag>`, then `docker push
<registry>/<owner>/nxdns:<tag>`.
> Not verified on this host: pushing needs credentials for a registry.
+262
View File
@@ -0,0 +1,262 @@
# Install nxdns with systemd
Installs nxdns as a system service on a Linux host with systemd, including a
Raspberry Pi 5. At the end the service answers DNS on port 53 and starts on
boot.
For what each flag does, see [the CLI reference](../reference/cli.md); for what
each configuration field means, see
[the configuration reference](../reference/configuration.md).
> Verification: the build steps and `systemd-analyze verify` were run on the
> machine that wrote this page. `nxdns check` and `nxdns run` were run there
> too, but against a scratch `--data-dir` and `--config` on an unprivileged
> port, because that machine is not a deploy target and has no `/etc/nxdns`,
> no `/var/lib/nxdns` and no root. The steps that need root on a target host —
> `install`, `systemd-sysusers`, `systemctl` — were not run; they are marked
> where they appear.
## 1. Build the binary
Requires Zig 0.16.0 and Node.js. From the repository root:
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
```
The first command builds the admin interface into `web/dist`; the second
embeds that directory in the binary. Build the interface every time, before the
binary: a stale `web/dist` ships an admin UI that does not match the API it
talks to.
Two static musl binaries come out, one per deploy target:
- `zig-out/cross/x86_64-linux-musl/nxdns`
- `zig-out/cross/aarch64-linux-musl/nxdns`
Both are statically linked and need nothing installed on the target host.
## 2. Copy the files to the target
```sh
scp zig-out/cross/x86_64-linux-musl/nxdns target:/tmp/nxdns
scp deploy/systemd/nxdns.service deploy/systemd/sysusers.conf target:/tmp/
```
For a Raspberry Pi 5, copy `zig-out/cross/aarch64-linux-musl/nxdns` instead —
see [Raspberry Pi 5](#raspberry-pi-5) below.
> Not verified on this host: `target` is a placeholder for your server's
> hostname, and the machine that wrote this page has no second host to copy to.
> What was verified is that both source paths exist after step 1 and that the
> aarch64 file is a statically linked aarch64 ELF executable.
Before copying, you can confirm the unit file parses:
```sh
systemd-analyze verify deploy/systemd/nxdns.service
```
Off the target host this prints one complaint and exits 1:
```
nxdns.service: Command /usr/local/bin/nxdns is not executable: No such file or directory
```
That is the ExecStart path check finding no binary yet. Any other message is a
real problem with the unit. On the target, after step 3, the same command
should print nothing.
## 3. Install the binary, the user and the unit
Run as root on the target:
```sh
install -m 0755 /tmp/nxdns /usr/local/bin/nxdns
install -m 0644 /tmp/sysusers.conf /usr/lib/sysusers.d/nxdns.conf
systemd-sysusers
install -m 0644 /tmp/nxdns.service /etc/systemd/system/nxdns.service
systemctl daemon-reload
mkdir -p -m 0755 /etc/nxdns
```
> Not verified on this host: these commands need root on a target machine. The
> files they install were read at HEAD and the unit was checked with
> `systemd-analyze verify`.
The service user is a static one, not `DynamicUser`: a TLS key for the DoH or
DoT listener has to be chown-able to a uid that survives a restart.
Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's
`StateDirectory` and `LogsDirectory` settings make systemd create them on first
start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
`/etc/nxdns` is the one directory the `mkdir` above is for. The unit's
`ConfigurationDirectory=nxdns` also creates it, but not until the first start
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
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:
```zon
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
.web = .{ .password = "choose-a-real-password" },
}
```
That file holds a password in plain text, so restrict it as soon as you have
written it:
```sh
chown root:nxdns /etc/nxdns/config.zon
chmod 0640 /etc/nxdns/config.zon
```
Root's umask is 022 on most distributions, so a freshly written
`/etc/nxdns/config.zon` is mode 0644 and every account on the host can read the
password out of it. The unit's `UMask=0077` does not help here: it applies to
files the service creates once it is running, and never re-chmods a file that
was written before the first start.
0640 with group `nxdns` rather than 0600: `/etc/nxdns` is a
`ConfigurationDirectory`, which systemd leaves owned by root, and the service
runs as `nxdns` and has to read this file on the first start. A root-owned 0600
file would be unreadable to it.
Do not expect `nxdns check` to catch a permissive mode here. Its only
permission warning is for a TLS private key
(`WARN doh_server.key_path: ... is mode 644; a TLS key must be readable by its
owner only`, from `checkTlsFiles` in `src/cli.zig`); it never stats the
configuration file. A mode 0644 `config.zon` passes `check` in silence, so the
`chmod` above is yours to remember.
Check it before you start the service:
```sh
nxdns check --config /etc/nxdns/config.zon
```
A good file prints the source it checked, one `OK` line per upstream, and
`OK: no problems found`:
```
checking configuration file /etc/nxdns/config.zon
OK https://cloudflare-dns.com/dns-query
OK: no problems found
```
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.
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
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.
> 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.
## 5. Start it
```sh
systemctl enable --now nxdns
journalctl -u nxdns -f
```
> Not verified on this host: needs root and an installed unit.
A healthy start logs a line naming every socket it bound:
```
info(nxdns): nxdns 0.1.0-dev serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
```
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`.
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.
## 6. Confirm it answers
From another machine on the LAN:
```sh
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
availability and disk state without a login.
> Not verified on this host as written: `<server-ip>` is a placeholder, and a
> LAN client to run it from is a second machine this host does not have. What
> was verified is the same two checks against a local nxdns started from a
> scratch data directory on an unprivileged port — `dig @127.0.0.1 -p 15353
> example.com A +short` returned the A records, and `curl` against the web
> port returned 200. Only the address and the port differ from the lines
> above.
## Raspberry Pi 5
The Pi 5 is aarch64. Nothing about the procedure changes except which binary
you copy — the cross build needs no toolchain on the Pi and no toolchain beyond
Zig on the build machine:
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
scp zig-out/cross/aarch64-linux-musl/nxdns pi:/tmp/nxdns
scp deploy/systemd/nxdns.service deploy/systemd/sysusers.conf pi:/tmp/
```
Then follow steps 3 to 6 on the Pi.
The build was run on the machine that wrote this page and
`zig-out/cross/aarch64-linux-musl/nxdns` is a statically linked aarch64 ELF
executable.
> Not verified on this host: the two `scp` lines. `pi` is a placeholder for
> your Pi's hostname, and this page was written on an x86_64 machine with no Pi
> attached. The build steps above it were run; the copy was not.
> Not verified on this host: the aarch64 binary was not executed. This host is
> x86_64 and has no `qemu-aarch64` to run it under. Running it needs a
> Raspberry Pi 5 or another aarch64 machine.
+163
View File
@@ -0,0 +1,163 @@
# Measure performance
`tools/bench.zig` measures the three things nxdns can measure in-process:
blocklist lookup latency, cache-hit latency, and blocklist compile throughput.
Sustained query rate is not one of them — that one is end-to-end and needs a
load generator pointed at a running server.
The numbers this project treats as targets, and the numbers measured so far,
are in the [performance reference](../reference/performance.md). Why those
targets exist and why CI does not gate on them is in
[performance and testing](../explanation/performance-and-testing.md).
## Run the whole bench
```sh
zig build bench -Doptimize=ReleaseFast
```
That runs all three suites with the defaults: 1,000,000 domains, 200,000
iterations per suite, seed `0x5eed`. It takes minutes, most of it generating and
loading the million-domain list.
`-Doptimize=ReleaseFast` is not optional if you want the numbers to mean
anything. A Debug build says so before it prints:
```
warning: Debug build; run with -Doptimize=ReleaseFast for meaningful numbers
```
## Run one suite, smaller
Everything after `--` goes to the harness. A suite name selects one of
`filter`, `cache`, `compile` (the default is `all`), and `--domains` /
`--iters` shrink the load:
```sh
zig build bench -Doptimize=ReleaseFast -- filter --domains=100000 --iters=20000
```
```
nxdns bench suite=filter domains=100000 iters=20000 seed=0x5eed optimize=ReleaseFast
suite ops p50(us) p95(us) p99(us) max(us)
filter 20000 0.14 0.25 0.27 0.51
blocked 6670/20000, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.4 MiB
target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS
```
A reduced run is good for checking the harness works and for a rough
regression signal. It is not a result: the memory figure scales with
`--domains`, so 100,000 domains says nothing about the 1,000,000-domain memory
target.
The other two suites:
```sh
zig build bench -Doptimize=ReleaseFast -- cache --iters=20000
```
```
suite ops p50(us) p95(us) p99(us) max(us)
cache 20000 0.12 0.21 0.23 0.54
hits 10000/20000, DnsCache.memoryBytes 4.3 MiB, VmRSS 6.2 MiB
target p95 < 5ms: PASS
```
```sh
zig build bench -Doptimize=ReleaseFast -- compile --domains=100000
```
```
suite ops p50(us) p95(us) p99(us) max(us)
compile 100000 wall 11.512ms, 8686215 lines/s, 100000 domains kept (informational)
```
`--seed=N` changes the generated domains and the query order; the default is
`0x5eed`, so two runs on the same machine are comparable. `--domains` caps at
4,000,000, and the `compile` suite additionally refuses more than 2,000,000 —
the compiler's own limit.
An argument the harness does not recognise stops it before any measuring:
```
error: unknown argument 'nosuch'
usage: zig build bench -Doptimize=ReleaseFast -- [filter|cache|compile|all] [--domains=N] [--iters=N] [--seed=N] [--assert]
```
## Read the output
- `p50`/`p95`/`p99`/`max` are per-operation microseconds, nearest-rank over
every iteration. What one operation means differs per suite: for `filter` it
is normalising a name plus evaluating it against the snapshot; for `cache` it
is building the key, getting the entry and stamping the response id.
- `blocked N/M` and `hits N/M` are sanity counters. The harness aborts if either
is zero — a suite that never hits its own path measures nothing.
- Two memory figures appear on purpose. `Snapshot.memoryBytes` and
`DnsCache.memoryBytes` are the in-repo accounting of those structures; `VmRSS`
is what the kernel holds resident for the whole process, allocator slack and
code included. The truth is between them, and the memory target is judged on
`VmRSS`.
- `target ...: PASS` / `FAIL` lines appear for the targets a suite covers. On a
plain run they are informational and the exit code stays 0.
## Fail the run when a target is missed
`--assert` turns those lines into an exit code — 1 when any target was
exceeded, 0 otherwise. This is meant for an acceptance run on hardware you
control, not for CI:
```sh
zig build bench -Doptimize=ReleaseFast -- --assert
```
The full-scale form is the one worth asserting on, because the memory target
only means something at a million domains. On this development host the reduced
form was used to check the flag itself:
```sh
zig build bench -Doptimize=ReleaseFast -- filter --domains=100000 --iters=20000 --assert
```
```
filter 20000 0.14 0.26 0.27 2.42
blocked 6670/20000, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.4 MiB
target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS
```
**Not verified on this host at full scale:** the plain
`zig build bench -Doptimize=ReleaseFast -- --assert` above was not run during
the writing of this page — the default run takes minutes. The reduced runs
shown were all executed as written. The full-scale numbers already recorded for
this host are in the [performance reference](../reference/performance.md).
## Measure sustained query rate
The bench harness cannot do this. Query rate is a property of the whole server
— sockets, upstreams, the query log writer — so it has to be driven from
outside, against the real binary, on the machine you care about.
Start nxdns with real blocklists configured, then drive it from another host on
the LAN with a DNS load generator such as `dnsperf`:
```sh
dnsperf -s 192.168.1.10 -p 53 -d queries.txt -c 20 -Q 200 -l 60
```
Read the client's own rate and the server's `/metrics` together: a load
generator that reports 200 qps while the server counts fewer has lost queries
somewhere, and that is the interesting number.
**Not verified on this host:** `dnsperf` is not installed here and the target
platform is a Raspberry Pi 5, not this development machine. The command above
is the shape of the measurement, not a transcript.
## Where to run it
The target platform is a Raspberry Pi 5. Numbers from a development x86_64 box
do not transfer — the Pi's Cortex-A76 is far slower — so a passing run here is
evidence the harness works and a baseline for spotting regressions on the
machine development happens on, and nothing more. Run `--assert` on the Pi,
where the numbers mean something.
+268
View File
@@ -0,0 +1,268 @@
# Set up admin authentication
The admin interface and its API are protected by a single operator password.
With no password set, every route is open to anything that can reach the web
port. Set one.
The commands below run against the scratch lab from
[enable DoH and DoT](enable-doh-and-dot.md): data directory
`/tmp/nxdns-lab/data`, web listener on `127.0.0.1:8451`. On a real install the
data directory is `/var/lib/nxdns` and the web port is 8080.
## 1. Set the password
Put it in the seed configuration file, under `web`:
```zon
.{
.groups = .{.{ .name = "default" }},
.upstreams = .{.{ .url = "https://cloudflare-dns.com/dns-query" }},
.web = .{ .bind = "127.0.0.1", .port = 8451, .password = "lab-password" },
}
```
At import time the plaintext is hashed with argon2id into `web.password_hash`
and discarded. It becomes no database row and appears in no log line. Setting
both `password` and `password_hash` in one file is refused:
```
web.password: password and password_hash are both set; ambiguity in a security setting is refused
import failed: PasswordAndHashBothSet
```
The seed file is read only while the database is empty. On a server that
already has a database, use step 4 or step 5 instead.
## 2. Log in
Login is `POST /api/auth/login` with a JSON body. Without a session, the API
answers 401:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8451/api/stats
```
```
401
```
Log in and keep the cookie:
```sh
curl -sS -c /tmp/nxdns-lab/cookies.txt \
-X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' \
-d '{"password":"lab-password"}'
```
```json
{"authenticated":true,"auth_required":true}
```
The session token comes back in a `Set-Cookie` header, not in the body. In the
jar it looks like this (value redacted here):
```
#HttpOnly_127.0.0.1 FALSE / FALSE 1785770178 nxdns_session <redacted>
```
The cookie is named `nxdns_session` and carries `HttpOnly; SameSite=Lax;
Path=/`. Its `Max-Age` comes from `web.session_ttl_hours`. Send it back on every
later call:
```sh
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w '%{http_code}\n' \
http://127.0.0.1:8451/api/stats
```
```
200
```
A wrong password and an unknown one are the same answer, so a guess learns
nothing:
```sh
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"wrong"}' \
-w ' (http %{http_code})\n'
```
```
{"error":"invalid password"} (http 401)
```
The server logs the address and the outcome, never the password:
```
info(web_auth): web login accepted for 127.0.0.1:34040
warning(web_auth): web login refused for 127.0.0.1:59670
```
Sessions live in memory only. A restart logs everyone out. Thirty-two
concurrent sessions are kept; a thirty-third login evicts the least recently
used one.
## 3. Log out
```sh
curl -sS -b /tmp/nxdns-lab/cookies.txt -c /tmp/nxdns-lab/cookies.txt \
-X POST http://127.0.0.1:8451/api/auth/logout
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w 'stats: %{http_code}\n' \
http://127.0.0.1:8451/api/stats
```
```
{"authenticated":false}
stats: 401
```
Logging out with a stale cookie, or with none, answers the same way. The point
of logging out is to end up logged out, and that is where such a request
already is.
## 4. Change the password on a running server
Send the new one to `PUT /api/settings` as `web.password`. The response is the
full settings document; `password` is write-only and `password_hash` is neither
readable nor directly writable, so neither value comes back.
```sh
curl -sS -c /tmp/nxdns-lab/c2.txt -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"lab-password"}'
curl -sS -b /tmp/nxdns-lab/c2.txt -X PUT http://127.0.0.1:8451/api/settings \
-H 'content-type: application/json' \
-d '{"web":{"password":"a-new-password"}}'
```
Changing the password ends every session, including the one that made the
change:
```sh
curl -sS -b /tmp/nxdns-lab/c2.txt -o /dev/null -w 'old session: %{http_code}\n' \
http://127.0.0.1:8451/api/stats
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"lab-password"}' \
-w ' (old password)\n'
curl -sS -c /tmp/nxdns-lab/c3.txt -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"a-new-password"}' \
-w ' (new password)\n'
```
```
old session: 401
{"error":"invalid password"} (old password)
{"authenticated":true,"auth_required":true} (new password)
```
Log back in with the new password. That is the whole rotation.
## 5. Change the password without the API
If you have lost the password, the admin interface cannot help — go through the
database instead. Export, edit, import. `nxdns export` always writes
`.password = ""` and carries the hash, so an exported file re-imports without
anyone knowing the password. To install a new one, put it in `.password` and
clear `.password_hash`:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/rekeyed.zon
```
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon` so it reads:
```zon
.password = "offline-password",
.password_hash = "",
```
Stop the server before importing. `import` rewrites the stored hash underneath a
process that read it at startup; a running server keeps verifying against the
old one, so skipping the stop leaves the new password not working until the next
restart. In the lab the server is a foreground `nxdns run`, so Ctrl-C in its
terminal stops it, and it goes back up with the same command:
```sh
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
nxdns import /tmp/nxdns-lab/rekeyed.zon --force --data-dir /tmp/nxdns-lab/data
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
```
```
imported /tmp/nxdns-lab/rekeyed.zon
```
On a real install the stop and start are `systemctl stop nxdns` and
`systemctl start nxdns` around the same `import` — **not verified on this
host**, which has no installed nxdns systemd unit (`systemctl status nxdns`
answers `Unit nxdns.service could not be found.`) and where `systemctl` needs
root. See [back up and restore](back-up-and-restore.md).
Once it is back up the old password is refused and the new one works:
```sh
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"a-new-password"}' \
-w ' (old password, http %{http_code})\n'
curl -sS -c /tmp/nxdns-lab/c5.txt -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"offline-password"}' \
-w ' (new password, http %{http_code})\n'
curl -sS -b /tmp/nxdns-lab/c5.txt -o /dev/null -w 'stats: %{http_code}\n' \
http://127.0.0.1:8451/api/stats
```
```
{"error":"invalid password"} (old password, http 401)
{"authenticated":true,"auth_required":true} (new password, http 200)
stats: 200
```
The next export shows the new hash and an empty `password` again:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data | grep password
```
```
.password = "",
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$kvlRj1tdGul3MlfbvzLncLKWirNpJRJ3howFA9/ysgg$7elW7PPQ3WXHwI4YOmOpZ/1KNEQo7ZDLRJhnYOPMjqw",
```
`--force` is required because the database already has content. See
[back up and restore](back-up-and-restore.md).
## What happens with no password set
Authentication is off. Every route is open, and a login attempt succeeds
without minting anything — there is nothing to log in to, and a session that
authorises nothing would be a lie for the browser to store:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8453/api/stats
curl -sS -X POST http://127.0.0.1:8453/api/auth/login \
-H 'content-type: application/json' -d '{"password":"anything"}'
```
```
200
{"authenticated":true,"auth_required":false}
```
`auth_required: false` is how the admin interface knows to stop showing a login
form. Treat this as a lab-only state: bind the web listener to a trusted
interface at the very least, and preferably set a password.
## Notes
- A stored hash this build cannot parse is a 500, not a 401. Answering 401 would
tell an operator with a corrupted `web.password_hash` that their password is
wrong, and they would retype a password that can never verify.
- Requests from the box itself skip the API rate limit by default
(`web.api_localhost_exempt`).
- `web.session_ttl_hours`, `web.api_rate_limit_per_min` and the rest are in the
[configuration reference](../reference/configuration.md); the routes are in
the [API reference](../reference/api.md).
Every command on this page was executed on this host as written, except the
`systemctl` stop and start named in step 5 and marked **not verified on this
host** there.
+295
View File
@@ -0,0 +1,295 @@
# Troubleshoot nxdns
Symptoms an nxdns install actually produces, what to run to identify each one,
and what to change. Every symptom on this page was reproduced on the machine
that wrote it, and every diagnosis command was run there. Two details differ
from a real install and cannot be otherwise on that machine: it has no
installed service, so the log lines were read from a foreground run instead of
`journalctl -u nxdns`, and ports 53 and 8080 were occupied, so DNS and the API
were exercised on unprivileged ports. Fixes that need root are marked.
The exit codes themselves are listed in
[the CLI reference](../reference/cli.md).
## The service exits with code 2
**Symptom.** The process stops immediately. The last two lines are the error
and a pointer:
```
nxdns run failed: NoUsableUpstreams
run `nxdns check` to see the configuration in full
```
Exit 2 is reserved for a small set of faults `run` raises itself:
`NoUsableUpstreams`, `BadBindAddress`, `BadRateLimit` and `BadCertificate`.
**Diagnosis.**
```sh
nxdns check
```
`check` prints every problem it finds, not the first, and names the source it
checked on its first line.
**Fixes by cause.**
- `NoUsableUpstreams` — the database has no enabled upstream. On a fresh
install this means the seed file was missing or in the wrong place; the start
log says `no configuration file at '/etc/nxdns/config.zon'; using the
database as it is`. Write the seed file and start again against the still
empty database, or `nxdns import <file> --force`.
- `BadCertificate` — a DoH or DoT listener is enabled and its certificate or
key is unreadable, too large, unparseable, or the key does not belong to the
certificate. `run` names both paths before it exits:
`doh_server: '<cert>' + '<key>': certificate file is not readable`.
**`check` does not catch most of this.** It tests only that each file is
readable, and warns when the key is readable beyond its owner; it never opens
the PEM. Parsing and the key/certificate pairing happen when `run` builds the
TLS context, so `check` can print `OK: no problems found` on a configuration
`run` then refuses. Reproduced here with a self-signed pair and the key from a
second, unrelated pair:
```
$ nxdns check --config config.zon
checking configuration file config.zon
OK https://cloudflare-dns.com/dns-query
OK: no problems found # exit 0
$ nxdns run --config config.zon --data-dir ./data
warning(tls_server): mbedtls_pk_check_pair failed: RSA - Key failed to pass the validity check of the library (-16896)
doh_server: 'cert.pem' + 'mismatched-key.pem': private key does not belong to the certificate
nxdns run failed: BadCertificate # exit 2
```
A cert file containing `not a certificate` behaves the same way — `check`
exits 0, `run` exits 2 with `certificate PEM could not be parsed`. So a
successful `check` means the paths and permissions are right, not that the
certificate is usable; the only test of that is starting the service. Fix the
path, the ownership, or the pair; see
[Enable DoH and DoT](enable-doh-and-dot.md).
- `BadRateLimit` — a rate limit or window is zero. `import` refuses such a
configuration, so this only reaches a database that was edited by hand.
- `BadBindAddress` — `dns.bind_ipv4` or `dns.bind_ipv6` is not an address of
that family.
## The service exits with code 1 on a seed file you just wrote
**Symptom.** A first start against an empty database prints the validation
problem and stops, but with exit code 1, not 2:
```
groups: no group named 'default'; every unknown client is assigned to it
nxdns run failed: MissingDefaultGroup
```
A syntax error behaves the same way:
```
config: 2:42: error: expected ',' after initializer
nxdns run failed: ParseZon
```
So does a seed file whose upstream list is empty or all disabled:
```
upstreams: at least one upstream must be enabled
nxdns run failed: NoUpstreams
```
`NoUpstreams` from a seed file is not the same fault as `NoUsableUpstreams`
above: the first is a file `run` refused, the second is a database `run`
accepted and found empty. Only the second is exit 2.
**Diagnosis.** Run the same file through `check`, which reports it as a
configuration problem and exits 2:
```sh
nxdns check --config /etc/nxdns/config.zon
```
**Fix.** Correct the file the diagnostics name and start again. The database is
still empty after a failed seed, so the next start re-reads the file. Note that
`nxdns check` and `nxdns import` of the same bad file exit 2 while `nxdns run`
exits 1 — the exit code differs by command, the diagnostics do not. All three
commands were run here against a file missing its `default` group, one with a
syntax error and one with no enabled upstream, and every pair came out that
way.
## Port 53 is already taken
**Symptom.** The process exits 1, having named the socket it could not have:
```
cannot bind udp [::1]:53: AddressInUse
nxdns run failed: AddressInUse
```
A bind conflict is a runtime failure, not a configuration fault, so this is
exit 1 and `nxdns check` will not find it.
**Diagnosis.**
```sh
ss -lnup 'sport = :53'
ss -lntp 'sport = :53'
systemctl is-active systemd-resolved
```
On most systemd distributions the holder is `systemd-resolved`, which runs a
stub listener on `127.0.0.53:53` and on some setups binds `0.0.0.0:53`.
**Fix.** Turn off the stub listener and keep resolved for the host's own
lookups:
```sh
mkdir -p /etc/systemd/resolved.conf.d
printf '[Resolve]\nDNSStubListener=no\n' > /etc/systemd/resolved.conf.d/nxdns.conf
systemctl restart systemd-resolved
```
If `/etc/resolv.conf` is a symlink to `/run/systemd/resolve/stub-resolv.conf`,
repoint it at `/run/systemd/resolve/resolv.conf` so the host still resolves.
> Not verified on this host: this needs root, and `systemd-resolved` is
> inactive here with port 53 free, so the conflict could not be reproduced
> against it. The bind failure itself was reproduced by starting a second nxdns
> on a port the first already held, which is the same error path.
Do not fix this by pointing the host's `/etc/resolv.conf` at nxdns when that
host is where nxdns resolves its own upstream DoH and DoT hostnames. That is a
startup cycle, not a fix.
## The container restarts in a loop
**Symptom.** `docker compose ps` shows the container restarting, and the log is
one line repeated:
```
nxdns run failed: AccessDenied
```
**Diagnosis.**
```sh
docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}} restarts={{.RestartCount}}' docker-nxdns-1
stat -c '%a %u:%g %n' deploy/docker/etc-nxdns/config.zon
```
Exit 1 with `AccessDenied` means the container could not read the seed file.
The container runs as uid 65532 and `/etc/nxdns` is mounted read-only, so a
file at mode 0600 owned by your own uid is unreadable to it and the container
cannot repair it.
**Fix.** Either make the file world-readable, when it holds no secret:
```sh
chmod 0644 deploy/docker/etc-nxdns/config.zon
```
or give it to the container's uid:
```sh
chown 65532:65532 deploy/docker/etc-nxdns/config.zon
chmod 0600 deploy/docker/etc-nxdns/config.zon
```
The 0644 path was verified here, including the recovery: after the `chmod` the
container started and answered queries. The `chown` needs root and was not run
here.
A container that exits 2 instead — `nxdns run failed: NoUsableUpstreams` after
`no configuration file at '/etc/nxdns/config.zon'` — has no seed file at all on
a fresh volume. Create `deploy/docker/etc-nxdns/config.zon` and bring it up
again; see [Install with Docker](install-with-docker.md).
## The container cannot reach its upstreams
**Symptom.** The container starts, but every query fails and `nxdns check`
inside it reports each upstream as unreachable.
**Diagnosis.** Look at what the host resolves with:
```sh
cat /etc/resolv.conf
```
**Fix.** If it points at the nxdns container, repoint it at a real resolver.
The container resolves its upstream DoH and DoT hostnames through the host's
DNS configuration, so pointing that at nxdns makes nxdns depend on itself to
start. LAN clients point at nxdns; the container's own host does not.
## The disk is filling up
**Symptom.** Writes stop but DNS keeps answering. The journal shows the
transition:
```
warning(disk_monitor): disk state ok -> critical: 33349095424 bytes free on /var/lib/nxdns
```
**Diagnosis.**
```sh
curl -s http://127.0.0.1:8080/api/health
```
`/api/health` needs no login and reports the state and what has been gated:
```json
{"status":"degraded","disk":{"state":"critical","free_bytes":33349079040,"db_bytes":180224,"log_bytes":0,"sample_failures":0},"upstreams":{"available":1,"total":1},"queries_dropped":0,"writer_failed":false,"refreshes_gated":1,"snapshot_generation":2}
```
`/metrics` carries the same free, database and log byte gauges as
`nxdns_disk_free_bytes`, `nxdns_disk_db_bytes` and `nxdns_disk_log_bytes`; the
state itself is on `/api/health`, not in the metrics output.
**What the state means.** The monitor samples free space and database sizes
once a minute. Below `disk.warn_free_mb` it logs the transition. Below
`disk.min_free_mb` it gates every non-essential write: the query logger holds
its batches, the client tracker stops persisting, and blocklist refreshes are
skipped and counted in `refreshes_gated`. Resolution never degrades because the
disk is full — this was verified by setting the thresholds above the free space
on the volume: the state went critical, a refresh was gated, and queries kept
being answered.
**Fix.** Recover space — lower `logging.retention_days`, or stop the service
and delete `querylog.db` — and writes resume on the next sample.
## Blocklists are not filtering
**Symptom.** Domains that should be blocked resolve normally.
**Diagnosis.** Read the startup line:
```sh
journalctl -u nxdns | grep 'serving on'
```
It ends in either `blocklist generation N` or
`unfiltered (no blocklist snapshot)`.
**Fix.** `unfiltered` means no snapshot loaded at all; the download or compile
warning that explains it is earlier in the same start. nxdns serves anyway on
purpose — a household loses more from DNS that refuses to start than from a
window of unfiltered answers.
A generation number with nothing being blocked is a different problem: the
snapshot loaded but has no sources in it. The line
`blocklist snapshot generation 1: 0 of 0 sources loaded` says exactly that. Add
a source in the admin interface, or in the seed file before the first start.
## A database stamped by a newer binary
**Symptom.** After putting an older binary back, it will not start:
```
warning(migrations): config.db is at schema version 99; this nxdns binary supports 2
nxdns run failed: SchemaTooNew
```
**Fix.** There is no downgrade. Import the export you took before upgrading
into a fresh data directory with the older binary; see
[Upgrade nxdns](upgrade.md).
+214
View File
@@ -0,0 +1,214 @@
# Upgrade nxdns
Replaces a running nxdns with a newer build without losing its configuration.
The database is migrated in place on the first start of the new binary.
> Verification: the export, the migration behaviour and the `version`/`check`
> steps below were run on the machine that wrote this page, against a
> populated scratch data directory and with an explicit `--data-dir`, since
> that machine has no `/var/lib/nxdns`. The commands were not run exactly as
> printed — the page uses the defaults and placeholders a real operator would
> have (`/var/lib/nxdns`, `/some/backup`, a `target` host), and every block
> where the substitution matters, or which was not run at all, carries its own
> note. Nothing here was verified except where a note says so.
## 1. Take an export first
There is no downgrade path, so the export is what you fall back to:
```sh
nxdns export --out /some/backup/nxdns-config.zon
```
`/some/backup` is a stand-in for a directory you keep backups in, and the
command relies on the default `--data-dir /var/lib/nxdns` that a systemd
install has.
> Verified on this host with both paths substituted, since it has neither
> `/var/lib/nxdns` nor `/some/backup`. `SCRATCH` below is a scratch directory,
> and its `data/` was populated beforehand with `nxdns import`:
>
> ```
> $ nxdns export --data-dir $SCRATCH/data --out $SCRATCH/nxdns-config.zon
> wrote /…/scratchpad/nxdns-config.zon
> $ stat -c '%a %n' $SCRATCH/nxdns-config.zon
> 600 /…/scratchpad/nxdns-config.zon
> $ grep password $SCRATCH/nxdns-config.zon
> .password = "",
> .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$mCNEo…$i3DMz…",
> ```
>
> The shell umask was 022, so the 0600 is `export` setting it, not the umask.
> Only the two paths differ from the command above.
The file is written atomically at mode 0600 and carries
`web.password_hash`, so treat it as a secret. See
[Back up and restore](back-up-and-restore.md) for the full backup story. The
query log is deliberately not part of it.
## 2. Build the new binary
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
```
Rebuild `web/dist` before the binary on every upgrade. The admin interface is
embedded at build time, and an old bundle against a new API is a broken
settings page.
## 3. Replace the binary
### systemd
Step 2 leaves the new binary under `zig-out/cross`, one per target. Copy the
one that matches the host — `aarch64-linux-musl` for a Raspberry Pi 5:
```sh
scp zig-out/cross/x86_64-linux-musl/nxdns target:/tmp/nxdns
```
> Not run on this host: `target` is a placeholder for the machine running
> nxdns, and this host has no such second machine to copy to. What exists here
> is the local half — `zig build cross` produced
> `zig-out/cross/x86_64-linux-musl/nxdns`.
Then, as root on the target:
```sh
install -m 0755 /tmp/nxdns /usr/local/bin/nxdns
systemctl restart nxdns
journalctl -u nxdns -f
```
> Not run on this host: all three lines need root and an installed service.
> The migration half of what a restart does is checkable without either, and
> was — see the note under
> [What happens to the database](#what-happens-to-the-database). The swap of
> an older binary for a newer one on a live service was not reproduced here.
### Docker
```sh
cd deploy/docker
docker compose build
docker compose up -d
```
Compose recreates the container against the same `nxdns-data` volume. The seed
file in `etc-nxdns` is not read again; the database in the volume is the
configuration.
> Verified on this host for the first two lines: `docker compose config -q`
> exited 0, and `docker compose build` finished with `Image nxdns Built`.
> `docker compose up -d` was not run — it publishes host ports 53/udp, 53/tcp
> and 8080, which this workstation is not a deploy target for.
## 4. Confirm the upgrade
```sh
nxdns version
nxdns check
dig @127.0.0.1 example.com A +short
```
`nxdns check` with no `--config` checks the database, which is what you want
after an upgrade — it names its source on the first line and migrates a
database that is one schema version behind before checking it:
```
checking database /var/lib/nxdns/config.db
OK https://cloudflare-dns.com/dns-query
OK: no problems found
```
> Verified on this host for the first two commands, with `--data-dir` pointing
> at the scratch data directory instead of `/var/lib/nxdns`:
>
> ```
> $ nxdns version
> nxdns 0.1.0-dev (unknown)
> zig 0.16.0
> $ nxdns check --data-dir $SCRATCH/data
> checking database /…/scratchpad/data/config.db
> OK https://cloudflare-dns.com/dns-query
> OK: no problems found
> ```
>
> The database path on the first line is the only difference from the block
> above. The `dig` line was not run in this round: nothing is listening on
> 127.0.0.1:53 here, and port 53 needs root.
## What happens to the database
Migrations run at startup, and also before `check`, `export` and `import`, so
whichever of those you run first performs the upgrade. A fresh database is
created at the current schema version; an older one is stepped up to it. The
log line names both versions:
```
info(migrations): config.db migrated from schema version 0 to 2
```
> Verified on this host: that exact line is what `nxdns import` printed when it
> created the scratch database used throughout this page. An empty data
> directory is schema version 0, which is why a first run reports a migration
> rather than nothing. The step from a populated older schema to 2 was not
> reproduced here — it needs a database written by an older binary, which this
> host does not have.
Rolling back is the case that has no answer. A database stamped by a newer
binary refuses to open, so an older binary against an upgraded data directory
fails to start:
```
warning(migrations): config.db is at schema version 99; this nxdns binary supports 2
nxdns run failed: SchemaTooNew
```
> Not reproduced on this host: the same missing ingredient as above, a
> database at a schema version this binary does not support. The two lines
> are the messages `src/storage/migrations.zig` emits, not a run captured
> here.
That run exits 1. Recovering means importing the export you took in step 1 into
a fresh data directory with the older binary.
## Changing settings, not the binary
An upgrade never re-reads `/etc/nxdns/config.zon`. After the first successful
seed the file is ignored, and the start log says so:
```
info(config_bootstrap): configuration file ignored; the database is already configured
```
Change settings through the admin interface, through the API, or with an
exporteditimport cycle against a stopped server:
```sh
nxdns export --out config-backup.zon
$EDITOR config-backup.zon
systemctl stop nxdns
nxdns import config-backup.zon --force
systemctl start nxdns
```
`--force` is required here. A plain `import` into a database that already has
content fails with `import failed: DatabaseNotEmpty` and exits 2, so it cannot
clobber a configured server by accident.
> Verified on this host for the two `nxdns` lines, against a populated scratch
> data directory:
>
> ```
> $ nxdns import $SCRATCH/nxdns-config.zon --data-dir $SCRATCH/data
> import failed: DatabaseNotEmpty
> (exit 2)
> $ nxdns import $SCRATCH/nxdns-config.zon --data-dir $SCRATCH/data --force
> imported /…/scratchpad/nxdns-config.zon
> (exit 0)
> ```
>
> The `systemctl stop`/`start` lines around them need root and an installed
> service and were not run; `$EDITOR` is yours to run.