milestone 13: restructure docs to diataxis, tutorial, every command executed
This commit is contained in:
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
export–edit–import 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.
|
||||
Reference in New Issue
Block a user