docs: unwrap hand-wrapped prose repo-wide
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s
This commit is contained in:
@@ -1,27 +1,15 @@
|
||||
# Back up and restore
|
||||
|
||||
The configuration 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 configuration 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.
|
||||
|
||||
**Which authority the service runs under decides what the backup *is*.** Check
|
||||
the start log:
|
||||
**Which authority the service runs under decides what the backup *is*.** Check the start log:
|
||||
|
||||
- `authority: database` — `config.db` holds the configuration. Back it up with
|
||||
`nxdns export`, and restore with `nxdns import` or by replacing the database
|
||||
file.
|
||||
- `authority: file (<path>)` — that file holds the configuration, and it is
|
||||
already a text file you can keep in git. **The file is the backup.** Restoring
|
||||
means putting the file back and restarting; the database rebuilds itself from
|
||||
it. `config.db` is a cache of the file in this mode, not the thing to preserve.
|
||||
- `authority: database` — `config.db` holds the configuration. Back it up with `nxdns export`, and restore with `nxdns import` or by replacing the database file.
|
||||
- `authority: file (<path>)` — that file holds the configuration, and it is already a text file you can keep in git. **The file is the backup.** Restoring means putting the file back and restarting; the database rebuilds itself from it. `config.db` is a cache of the file in this mode, not the thing to preserve.
|
||||
|
||||
The rest of this page covers database mode unless it says otherwise.
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -35,8 +23,7 @@ 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:
|
||||
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
|
||||
@@ -57,15 +44,9 @@ grep password /tmp/nxdns-lab/backup.zon
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$hOjjnTrTZ6kU8XrwQuJ7ZFC3B5LumaB4hRe7kBbzJ6Q$YsC2rCTDKv96eEwPhs+D6vbDliLogEZph8EkagKSuy8",
|
||||
```
|
||||
|
||||
Treat backups as secrets. `.password` is always exported as `null` — the
|
||||
plaintext is never stored anywhere — so the file re-imports without anyone
|
||||
knowing the password. `null` and `""` are different statements here: `null`
|
||||
means the file says nothing about the password, while an empty `password_hash`
|
||||
would disable authentication. See
|
||||
[Password and hash](../reference/configuration.md#password-and-hash).
|
||||
Treat backups as secrets. `.password` is always exported as `null` — the plaintext is never stored anywhere — so the file re-imports without anyone knowing the password. `null` and `""` are different statements here: `null` means the file says nothing about the password, while an empty `password_hash` would disable authentication. See [Password and hash](../reference/configuration.md#password-and-hash).
|
||||
|
||||
Without `--out` the export goes to stdout, where the file mode is your
|
||||
redirect's problem:
|
||||
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 -10
|
||||
@@ -84,15 +65,11 @@ nxdns export --data-dir /tmp/nxdns-lab/data | head -10
|
||||
.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.
|
||||
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.
|
||||
Nothing to overwrite, so nothing to authorise.
|
||||
This is the normal restore: new machine, new disk, empty data directory. Nothing to overwrite, so nothing to authorise.
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data-restored
|
||||
@@ -103,15 +80,11 @@ info(migrations): config.db migrated from schema version 0 to 1
|
||||
imported /tmp/nxdns-lab/backup.zon
|
||||
```
|
||||
|
||||
The migration line is expected: `import` creates and migrates the database
|
||||
before writing to it.
|
||||
The migration line is expected: `import` creates and migrates the database before writing to it.
|
||||
|
||||
## Restore over an existing database
|
||||
|
||||
**Stop the server first.** `import` rewrites configuration underneath a process
|
||||
that read it at startup, and a running server picks up only part of it —
|
||||
filtering follows the new rows at the next reload, while upstreams, listeners
|
||||
and settings stay at their boot values until a restart.
|
||||
**Stop the server first.** `import` rewrites configuration underneath a process that read it at startup, and a running server picks up only part of it — filtering follows the new rows at the next reload, while upstreams, listeners and settings stay at their boot values until a restart.
|
||||
|
||||
```sh
|
||||
systemctl stop nxdns
|
||||
@@ -119,10 +92,7 @@ nxdns import /var/backups/nxdns-config.zon
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
`import` converges the database onto the file. Rows the file still names are
|
||||
matched and updated in place; rows it no longer names are deleted. That last
|
||||
part is what a restore of an old backup does to everything added since, so it
|
||||
takes a flag:
|
||||
`import` converges the database onto the file. Rows the file still names are matched and updated in place; rows it no longer names are deleted. That last part is what a restore of an old backup does to everything added since, so it takes a flag:
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/old-backup.zon --data-dir /tmp/nxdns-lab/data
|
||||
@@ -133,10 +103,7 @@ FAIL import: this file would delete rows the database holds (upstreams 1); re-ru
|
||||
import failed: DestructiveImport
|
||||
```
|
||||
|
||||
That exits 2 and rolls the transaction back, so nothing is half-applied. The
|
||||
message names every table that would lose rows, which is usually enough to tell
|
||||
an intended restore from the wrong file. Say `--allow-delete` when deleting is
|
||||
what you mean:
|
||||
That exits 2 and rolls the transaction back, so nothing is half-applied. The message names every table that would lose rows, which is usually enough to tell an intended restore from the wrong file. Say `--allow-delete` when deleting is what you mean:
|
||||
|
||||
```sh
|
||||
nxdns import /tmp/nxdns-lab/old-backup.zon --allow-delete --data-dir /tmp/nxdns-lab/data
|
||||
@@ -146,20 +113,9 @@ nxdns import /tmp/nxdns-lab/old-backup.zon --allow-delete --data-dir /tmp/nxdns-
|
||||
imported /tmp/nxdns-lab/old-backup.zon
|
||||
```
|
||||
|
||||
A restore that only puts back what is already there needs no flag at all, and
|
||||
neither does one that only adds rows. The flag is about deletion specifically —
|
||||
including deletion in disguise: renaming a group, or correcting a typo in an
|
||||
upstream URL, changes the row's identity, so the engine sees one row gone and
|
||||
one arrived.
|
||||
A restore that only puts back what is already there needs no flag at all, and neither does one that only adds rows. The flag is about deletion specifically — including deletion in disguise: renaming a group, or correcting a typo in an upstream URL, changes the row's identity, so the engine sees one row gone and one arrived.
|
||||
|
||||
What survives a restore is worth knowing before you take an old backup out of
|
||||
the drawer. Blocklist download state is kept for every source whose URL the file
|
||||
still names — checksum, counters, compiled files, and the row id they are keyed
|
||||
by — so restoring does not cost a re-download. Devices the server discovered
|
||||
from traffic are kept whole, and one the backup names keeps the first-seen and
|
||||
last-seen the database already held, so a restore never restamps your network as
|
||||
newly arrived. What does go is a client the backup does not name and that was
|
||||
named by hand: that row is declarative, and it is deleted with the rest.
|
||||
What survives a restore is worth knowing before you take an old backup out of the drawer. Blocklist download state is kept for every source whose URL the file still names — checksum, counters, compiled files, and the row id they are keyed by — so restoring does not cost a re-download. Devices the server discovered from traffic are kept whole, and one the backup names keeps the first-seen and last-seen the database already held, so a restore never restamps your network as newly arrived. What does go is a client the backup does not name and that was named by hand: that row is declarative, and it is deleted with the rest.
|
||||
|
||||
> The three `systemctl` lines are the only commands on this page that were not
|
||||
> run: this machine has no installed nxdns unit (`systemctl status nxdns`
|
||||
@@ -169,17 +125,10 @@ named by hand: that row is declarative, and it is deleted with the rest.
|
||||
|
||||
## Restoring the database file itself
|
||||
|
||||
Copying `config.db` back into place works too, and it is the fastest restore on
|
||||
a machine that still has one. Two rules, and the second one is where restores go
|
||||
wrong:
|
||||
Copying `config.db` back into place works too, and it is the fastest restore on a machine that still has one. Two rules, and the second one is where restores go wrong:
|
||||
|
||||
1. **Take the copy from a stopped instance**, or use `nxdns export` instead. A
|
||||
copy taken while nxdns is running catches the main file without the changes
|
||||
sitting in its write-ahead log.
|
||||
2. **Delete any stale `config.db-wal` and `config.db-shm` beside the file you
|
||||
restore.** SQLite silently discards a write-ahead log that does not match the
|
||||
database it sits next to. It does not warn, and it does not fail — it just
|
||||
answers from the main file, so a restore quietly loses its own tail.
|
||||
1. **Take the copy from a stopped instance**, or use `nxdns export` instead. A copy taken while nxdns is running catches the main file without the changes sitting in its write-ahead log.
|
||||
2. **Delete any stale `config.db-wal` and `config.db-shm` beside the file you restore.** SQLite silently discards a write-ahead log that does not match the database it sits next to. It does not warn, and it does not fail — it just answers from the main file, so a restore quietly loses its own tail.
|
||||
|
||||
```sh
|
||||
systemctl stop nxdns
|
||||
@@ -193,15 +142,11 @@ systemctl start nxdns
|
||||
> Not verified on this host: these need root, an installed unit and
|
||||
> `/var/lib/nxdns`, none of which exist here.
|
||||
|
||||
None of this applies in file mode. There `config.db` is derived state — restore
|
||||
the configuration file and start the service, and the first reconcile rebuilds
|
||||
the database from it.
|
||||
None of this applies in file mode. There `config.db` is derived state — restore the configuration file and start the service, and the first reconcile rebuilds the database from it.
|
||||
|
||||
## 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.
|
||||
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
|
||||
@@ -214,8 +159,7 @@ 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:
|
||||
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
|
||||
@@ -233,20 +177,12 @@ restored database exports identically
|
||||
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.
|
||||
- `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).
|
||||
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 `export` and `import`; nothing walks them back, and `nxdns
|
||||
check` does not run them at all. Take an export before installing a new binary —
|
||||
see [upgrade](upgrade.md).
|
||||
There is no downgrade path. Schema migrations run forward automatically at startup and before `export` and `import`; nothing walks them back, and `nxdns check` does not run them at all. Take an export before installing a new binary — see [upgrade](upgrade.md).
|
||||
|
||||
Every `nxdns` command on this page was executed on this host as written. The
|
||||
`systemctl`, `cp`, `chown` and `chmod` lines were not: they need root and an
|
||||
installed unit, and both blocks holding them say so.
|
||||
Every `nxdns` command on this page was executed on this host as written. The `systemctl`, `cp`, `chown` and `chmod` lines were not: they need root and an installed unit, and both blocks holding them say so.
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
# 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.
|
||||
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.
|
||||
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).
|
||||
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:
|
||||
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
|
||||
@@ -28,16 +20,11 @@ openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
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.
|
||||
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:
|
||||
`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
|
||||
.{
|
||||
@@ -62,12 +49,7 @@ to:
|
||||
}
|
||||
```
|
||||
|
||||
Write that to `/tmp/nxdns-lab/etc/config.zon`. The lab runs
|
||||
`nxdns run --config`, which makes that file the configuration: every start
|
||||
reconciles the database onto it, so editing the file and restarting is how these
|
||||
settings change here. A real install may instead run bare `nxdns run` and keep
|
||||
the configuration in the database — see
|
||||
[the configuration model](../explanation/configuration-model.md).
|
||||
Write that to `/tmp/nxdns-lab/etc/config.zon`. The lab runs `nxdns run --config`, which makes that file the configuration: every start reconciles the database onto it, so editing the file and restarting is how these settings change here. A real install may instead run bare `nxdns run` and keep the configuration in the database — see [the configuration model](../explanation/configuration-model.md).
|
||||
|
||||
## 3. Check the files before starting
|
||||
|
||||
@@ -81,17 +63,14 @@ OK upstreams[0] https://cloudflare-dns.com
|
||||
OK: no problems found
|
||||
```
|
||||
|
||||
`check` loads both endpoints' certificate and key the same way the listeners do,
|
||||
so what passes here will start. An unreadable file is a failure and exits 2:
|
||||
`check` loads both endpoints' certificate and key the same way the listeners do, so what passes here will start. An unreadable file is a failure and exits 2:
|
||||
|
||||
```
|
||||
FAIL doh_server.cert_path: '/tmp/nxdns-lab/etc/cert.pem': certificate file is not readable
|
||||
FAIL dot_server.cert_path: '/tmp/nxdns-lab/etc/cert.pem': certificate file is not readable
|
||||
```
|
||||
|
||||
So is a key that does not belong to the certificate, which is the mistake worth
|
||||
catching before a restart — the two files are individually valid and only their
|
||||
pairing is wrong. mbedTLS writes its own line to stderr as it rejects the pair:
|
||||
So is a key that does not belong to the certificate, which is the mistake worth catching before a restart — the two files are individually valid and only their pairing is wrong. mbedTLS writes its own line to stderr as it rejects the pair:
|
||||
|
||||
```
|
||||
warning(tls_server): mbedtls_pk_check_pair failed: RSA - Key failed to pass the validity check of the library (-16896)
|
||||
@@ -102,9 +81,7 @@ FAIL dot_server.key_path: '/tmp/nxdns-lab/etc/other.pem': private key does not b
|
||||
OK upstreams[0] https://cloudflare-dns.com
|
||||
```
|
||||
|
||||
A key readable by anyone but its owner is a warning instead. It does not change
|
||||
the exit code, because the service still starts, and the summary line counts it
|
||||
rather than claiming nothing was found:
|
||||
A key readable by anyone but its owner is a warning instead. It does not change the exit code, because the service still starts, and the summary line counts it rather than claiming nothing was found:
|
||||
|
||||
```
|
||||
WARN doh_server.key_path: '/tmp/nxdns-lab/etc/key.pem' is mode 644; a TLS key must be readable by its owner only
|
||||
@@ -126,9 +103,7 @@ 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:
|
||||
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
|
||||
@@ -138,8 +113,7 @@ 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):
|
||||
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
|
||||
@@ -152,13 +126,9 @@ dig @127.0.0.1 -p 8853 +tls example.com A +short
|
||||
|
||||
## 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 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:
|
||||
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' \
|
||||
@@ -186,8 +156,7 @@ http 200, 72 bytes
|
||||
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 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:
|
||||
|
||||
@@ -202,16 +171,11 @@ curl -sS --insecure --http1.1 -o /tmp/nxdns-lab/get.bin \
|
||||
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.
|
||||
`--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.
|
||||
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:
|
||||
|
||||
@@ -232,10 +196,7 @@ 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`.
|
||||
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
|
||||
@@ -245,9 +206,7 @@ curl -sS -b /tmp/nxdns-lab/cookies.txt -X POST http://127.0.0.1:8451/api/certs/r
|
||||
{"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:
|
||||
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
|
||||
@@ -269,8 +228,7 @@ dig @127.0.0.1 -p 8853 +tls example.com A +short
|
||||
172.66.147.243
|
||||
```
|
||||
|
||||
Undo it with `chmod 0600 /tmp/nxdns-lab/etc/key.pem` and reload again. The
|
||||
counters are on `/metrics`:
|
||||
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
|
||||
@@ -291,10 +249,7 @@ 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:
|
||||
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
|
||||
@@ -303,13 +258,8 @@ 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.
|
||||
**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).
|
||||
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).
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
# Install nxdns with Docker
|
||||
|
||||
Runs the published nxdns image with Docker Compose. At the end a container
|
||||
answers DNS on port 53 and keeps its data in a named volume.
|
||||
Runs the published nxdns image with Docker Compose. At the end a container answers DNS on port 53 and keeps its data in a named volume.
|
||||
|
||||
The image is multi-architecture — `linux/amd64` and `linux/arm64` — so the same
|
||||
tag works on a PC and on a Raspberry Pi 5. Building the image yourself is still
|
||||
supported and is the last section of this page.
|
||||
The image is multi-architecture — `linux/amd64` and `linux/arm64` — so the same tag works on a PC and on a Raspberry Pi 5. Building the image yourself is still supported and is the last section of this page.
|
||||
|
||||
For what each configuration field means, see
|
||||
[the configuration reference](../reference/configuration.md).
|
||||
For what each configuration field means, see [the configuration reference](../reference/configuration.md).
|
||||
|
||||
> Verification: the failure modes, the run and the two checks in step 3 were run
|
||||
> on the machine that wrote an earlier revision of this page, against an image
|
||||
@@ -43,16 +39,9 @@ VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
|
||||
docker pull git.mial.net/mokhtar/nxdns:$VERSION
|
||||
```
|
||||
|
||||
Pin a version. `:latest` exists and moves, which is what you want when you are
|
||||
trying it out and not what you want on a machine your household's DNS depends
|
||||
on. The lookup above asks the server for the current release rather than
|
||||
hardcoding a number that goes stale one release later — Gitea redirects
|
||||
`releases/latest` to the newest published release's tag page. To take a
|
||||
particular version instead, set `VERSION=<version>` yourself.
|
||||
Pin a version. `:latest` exists and moves, which is what you want when you are trying it out and not what you want on a machine your household's DNS depends on. The lookup above asks the server for the current release rather than hardcoding a number that goes stale one release later — Gitea redirects `releases/latest` to the newest published release's tag page. To take a particular version instead, set `VERSION=<version>` yourself.
|
||||
|
||||
Verify what you pulled before you run it. The release publishes an
|
||||
`IMAGE-DIGEST.txt` asset naming the digest of the image index, and that file is
|
||||
covered by the signed `SHA256SUMS.txt`:
|
||||
Verify what you pulled before you run it. The release publishes an `IMAGE-DIGEST.txt` asset naming the digest of the image index, and that file is covered by the signed `SHA256SUMS.txt`:
|
||||
|
||||
```sh
|
||||
curl -fLO "$BASE/releases/download/v$VERSION/IMAGE-DIGEST.txt"
|
||||
@@ -65,11 +54,7 @@ docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
|
||||
cut -d@ -f2 IMAGE-DIGEST.txt
|
||||
```
|
||||
|
||||
The last two have to print the same string — `IMAGE-DIGEST.txt` holds a whole
|
||||
pinned reference, `name:tag@sha256:…`, so the `cut` is what reduces it to the
|
||||
digest `imagetools` prints. [Verify a release](verify-a-release.md)
|
||||
covers the key, the fingerprint, every failure message, and what the signature
|
||||
does and does not prove.
|
||||
The last two have to print the same string — `IMAGE-DIGEST.txt` holds a whole pinned reference, `name:tag@sha256:…`, so the `cut` is what reduces it to the digest `imagetools` prints. [Verify a release](verify-a-release.md) covers the key, the fingerprint, every failure message, and what the signature does and does not prove.
|
||||
|
||||
> Not verified on this host: no image and no release are published yet, so
|
||||
> `docker pull` and every URL here fail today, and the `releases/latest` lookup
|
||||
@@ -80,10 +65,7 @@ does and does not prove.
|
||||
|
||||
## 2. Get the compose file and write the configuration
|
||||
|
||||
Every path on this page is relative to a checkout of the repository, because
|
||||
that is how it was verified. Running the published image needs no checkout,
|
||||
though — one file is enough. Fetch it for the version you pulled and work in
|
||||
its directory instead, dropping `deploy/docker/` from the paths below:
|
||||
Every path on this page is relative to a checkout of the repository, because that is how it was verified. Running the published image needs no checkout, though — one file is enough. Fetch it for the version you pulled and work in its directory instead, dropping `deploy/docker/` from the paths below:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/nxdns && cd ~/nxdns
|
||||
@@ -94,16 +76,14 @@ curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
|
||||
> `<repo>/raw/tag/<tag>/<path>` was run here against `gitea.com/gitea/tea` on
|
||||
> Gitea `1.27.0+dev` and returned the file with a 200.
|
||||
|
||||
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create
|
||||
it and put the configuration in it:
|
||||
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create it and put the configuration 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:
|
||||
The smallest file that starts is one group named `default` and one enabled upstream:
|
||||
|
||||
```zon
|
||||
.{
|
||||
@@ -113,15 +93,9 @@ upstream:
|
||||
}
|
||||
```
|
||||
|
||||
**The compose file ships file mode**, with
|
||||
`command: ["run", "--config=/etc/nxdns/config.zon"]`. That file is the
|
||||
configuration: the container reconciles its database onto it at every start, and
|
||||
the admin interface answers 403 to configuration edits. To change anything, edit
|
||||
the file and restart the container. It also means a fresh or recreated
|
||||
`nxdns-data` volume rebuilds itself from the mounted file with no extra step.
|
||||
**The compose file ships file mode**, with `command: ["run", "--config=/etc/nxdns/config.zon"]`. That file is the configuration: the container reconciles its database onto it at every start, and the admin interface answers 403 to configuration edits. To change anything, edit the file and restart the container. It also means a fresh or recreated `nxdns-data` volume rebuilds itself from the mounted file with no extra step.
|
||||
|
||||
The file is therefore required, and its absence is a hard failure rather than a
|
||||
start with defaults:
|
||||
The file is therefore required, and its absence is a hard failure rather than a start with defaults:
|
||||
|
||||
```
|
||||
FAIL /etc/nxdns/config.zon: no such file
|
||||
@@ -129,9 +103,7 @@ nxdns run failed: ManagedConfigUnreadable
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
A file that is present but rejected is a different failure with the same exit
|
||||
code. No `default` group, no enabled upstream, a syntax error — `run` prints the
|
||||
diagnostic and exits 2 as well. A file whose only group was named `other`:
|
||||
A file that is present but rejected is a different failure with the same exit code. No `default` group, no enabled upstream, a syntax error — `run` prints the diagnostic and exits 2 as well. A file whose only group was named `other`:
|
||||
|
||||
```
|
||||
FAIL groups: no group named 'default'; every unknown client is assigned to it
|
||||
@@ -139,24 +111,17 @@ nxdns run failed: MissingDefaultGroup
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
Under `restart: unless-stopped` any of these is a restart loop — Docker has no
|
||||
start limit and will retry forever. Read the lines above the failure, which name
|
||||
the fault. See [Troubleshoot nxdns](troubleshoot.md).
|
||||
Under `restart: unless-stopped` any of these is a restart loop — Docker has no start limit and will retry forever. Read the lines above the failure, which name the fault. See [Troubleshoot nxdns](troubleshoot.md).
|
||||
|
||||
### Database mode in Docker instead
|
||||
|
||||
Drop the `command:` line from `compose.yaml` and the container runs
|
||||
`nxdns run`, with the database as the configuration and the file read by nothing.
|
||||
On a fresh volume that database is empty and the container exits 2 with
|
||||
`NoUsableUpstreams`, so load it once before bringing the service up:
|
||||
Drop the `command:` line from `compose.yaml` and the container runs `nxdns run`, with the database as the configuration and the file read by nothing. On a fresh volume that database is empty and the container exits 2 with `NoUsableUpstreams`, so load it once before bringing the service up:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
The file is positional; add `--allow-delete` when re-running it against a
|
||||
populated volume and the diff deletes rows. Without this step, `restart:
|
||||
unless-stopped` plus exit 2 is a crash loop with no way out.
|
||||
The file is positional; add `--allow-delete` when re-running it against a populated volume and the diff deletes rows. Without this step, `restart: unless-stopped` plus exit 2 is a crash loop with no way out.
|
||||
|
||||
> Not run in a container on this host, for the reason in the verification note
|
||||
> at the top: no image could be staged here. The `nxdns import <file>` and
|
||||
@@ -165,10 +130,7 @@ unless-stopped` plus exit 2 is a crash loop with no way out.
|
||||
> exited 0, the second was required after a plain `import` refused a
|
||||
> row-deleting file with `DestructiveImport` and exited 2.
|
||||
|
||||
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:
|
||||
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
|
||||
@@ -190,10 +152,7 @@ NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
|
||||
docker compose -f deploy/docker/compose.yaml logs -f
|
||||
```
|
||||
|
||||
`compose.yaml` reads the image from two variables:
|
||||
`${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}`. Set
|
||||
`NXDNS_VERSION` to pin a release; set `NXDNS_IMAGE` to run something else
|
||||
entirely, which is what the build-from-source section at the bottom does.
|
||||
`compose.yaml` reads the image from two variables: `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}`. Set `NXDNS_VERSION` to pin a release; set `NXDNS_IMAGE` to run something else entirely, which is what the build-from-source section at the bottom does.
|
||||
|
||||
> Verified on this host with `docker compose -f deploy/docker/compose.yaml
|
||||
> config`, which resolves the variables without contacting a registry: no
|
||||
@@ -201,12 +160,7 @@ entirely, which is what the build-from-source section at the bottom does.
|
||||
> gives `git.mial.net/mokhtar/nxdns:0.0.1`, and `NXDNS_IMAGE=nxdns` gives
|
||||
> `nxdns`.
|
||||
|
||||
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 `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`.
|
||||
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 `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`.
|
||||
|
||||
A healthy first start logs the reconcile, the authority and the bound sockets:
|
||||
|
||||
@@ -220,8 +174,7 @@ info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53;
|
||||
info(web_server): web interface listening on 0.0.0.0:8080
|
||||
```
|
||||
|
||||
Every later start on an unchanged file reports `reconciled
|
||||
'/etc/nxdns/config.zon': no changes` and writes nothing to the database.
|
||||
Every later start on an unchanged file reports `reconciled '/etc/nxdns/config.zon': no changes` and writes nothing to the database.
|
||||
|
||||
Confirm it answers and that the admin interface is up:
|
||||
|
||||
@@ -240,29 +193,17 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
|
||||
> host port is occupied; free the port or edit the `ports:` list. The image
|
||||
> under test was built locally, not pulled: there is nothing published to pull.
|
||||
|
||||
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).
|
||||
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.
|
||||
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.
|
||||
|
||||
## Raspberry Pi 5
|
||||
|
||||
Nothing changes. The published tag is a multi-architecture index, so
|
||||
`docker pull` on the Pi selects the `linux/arm64` image on its own. The
|
||||
platform list is one of the things
|
||||
[Verify a release](verify-a-release.md) has you check.
|
||||
Nothing changes. The published tag is a multi-architecture index, so `docker pull` on the Pi selects the `linux/arm64` image on its own. The platform list is one of the things [Verify a release](verify-a-release.md) has you check.
|
||||
|
||||
To pull the arm64 image from an x86_64 machine — to inspect it, or to save and
|
||||
copy it — name the platform:
|
||||
To pull the arm64 image from an x86_64 machine — to inspect it, or to save and copy it — name the platform:
|
||||
|
||||
```sh
|
||||
docker pull --platform linux/arm64 git.mial.net/mokhtar/nxdns:$VERSION
|
||||
@@ -274,9 +215,7 @@ docker pull --platform linux/arm64 git.mial.net/mokhtar/nxdns:$VERSION
|
||||
|
||||
## Build the image from source instead
|
||||
|
||||
The Dockerfile does not compile anything. It assembles a filesystem around
|
||||
binaries you build first, so build the admin interface and the release
|
||||
artifacts from the repository root:
|
||||
The Dockerfile does not compile anything. It assembles a filesystem around binaries you build first, so build the admin interface and the release artifacts from the repository root:
|
||||
|
||||
```sh
|
||||
(cd web && npm ci && npm run build)
|
||||
@@ -286,19 +225,11 @@ zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)"
|
||||
DOCKER_BUILDKIT=1 docker build -t nxdns -f deploy/docker/Dockerfile .
|
||||
```
|
||||
|
||||
Take the version from `build.zig.zon` rather than inventing one: `verify-dist`
|
||||
asserts the two agree, so a made-up string builds but fails verification.
|
||||
BuildKit is required — the Dockerfile pins its builder stage to
|
||||
`$BUILDPLATFORM`, which the classic builder does not define.
|
||||
Take the version from `build.zig.zon` rather than inventing one: `verify-dist` asserts the two agree, so a made-up string builds but fails verification. BuildKit is required — the Dockerfile pins its builder stage to `$BUILDPLATFORM`, which the classic builder does not define.
|
||||
|
||||
Build `web/dist` every time, before the binaries. A stale bundle is embedded
|
||||
silently and ships an admin interface that does not match its API — which is
|
||||
why `dist` refuses to build against the `web/dist-placeholder` default at all.
|
||||
Build `web/dist` every time, before the binaries. A stale bundle is embedded silently and ships an admin interface that does not match its API — which is why `dist` refuses to build against the `web/dist-placeholder` default at all.
|
||||
|
||||
The context has to be the repository root, because the Dockerfile copies
|
||||
`zig-out/dist/bin` and `zig-out/dist/stage`. The result is a `scratch` image
|
||||
holding the binary, a CA bundle, `/LICENSE`, `/THIRD-PARTY-NOTICES` and two
|
||||
empty directories.
|
||||
The context has to be the repository root, because the Dockerfile copies `zig-out/dist/bin` and `zig-out/dist/stage`. The result is a `scratch` image holding the binary, a CA bundle, `/LICENSE`, `/THIRD-PARTY-NOTICES` and two empty directories.
|
||||
|
||||
Run that image instead of the published one by naming it:
|
||||
|
||||
@@ -306,16 +237,13 @@ Run that image instead of the published one by naming it:
|
||||
NXDNS_IMAGE=nxdns docker compose -f deploy/docker/compose.yaml up -d
|
||||
```
|
||||
|
||||
For an arm64 image on an x86_64 machine, use buildx. The Dockerfile's builder
|
||||
stage is pinned to `$BUILDPLATFORM` and only copies files, so no emulation is
|
||||
involved:
|
||||
For an arm64 image on an x86_64 machine, use buildx. The Dockerfile's builder stage is pinned to `$BUILDPLATFORM` and only copies files, so no emulation is involved:
|
||||
|
||||
```sh
|
||||
docker buildx build --platform linux/arm64 -t nxdns:arm64 -f deploy/docker/Dockerfile .
|
||||
```
|
||||
|
||||
Add `--push` or `--load` to keep the result; the default buildx driver leaves
|
||||
it in the build cache.
|
||||
Add `--push` or `--load` to keep the result; the default buildx driver leaves it in the build cache.
|
||||
|
||||
> Verified on this host, except the two buildx lines. `zig build dist` was run
|
||||
> to completion with the version read out of `build.zig.zon` and exited 0, and
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
# 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.
|
||||
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.
|
||||
|
||||
The normal path is to download a released tarball, verify it, and install what
|
||||
is inside it. Building from source is still supported and is the last section
|
||||
of this page.
|
||||
The normal path is to download a released tarball, verify it, and install what is inside it. Building from source is still supported and is the last section of this page.
|
||||
|
||||
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).
|
||||
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: `systemd-analyze verify` was run on the machine that wrote this
|
||||
> page. `nxdns check`, `nxdns import`, `nxdns export` and `nxdns run` were run
|
||||
@@ -33,9 +27,7 @@ each configuration field means, see
|
||||
|
||||
## 1. Download and verify
|
||||
|
||||
Two static musl tarballs are published per release, one per architecture. Pick
|
||||
`x86_64-linux-musl` for a normal PC or server and `aarch64-linux-musl` for a
|
||||
Raspberry Pi 5.
|
||||
Two static musl tarballs are published per release, one per architecture. Pick `x86_64-linux-musl` for a normal PC or server and `aarch64-linux-musl` for a Raspberry Pi 5.
|
||||
|
||||
```sh
|
||||
BASE=https://git.mial.net/mokhtar/nxdns
|
||||
@@ -47,15 +39,9 @@ curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
|
||||
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
|
||||
```
|
||||
|
||||
The first line asks the server which release is current instead of hardcoding a
|
||||
number that goes stale one release later — Gitea redirects `releases/latest` to
|
||||
the newest published release's tag page. To install a particular version
|
||||
instead, set `VERSION=<version>` yourself with the one you want; the tarball
|
||||
filenames carry the version either way, so there is no version-free download
|
||||
URL for them.
|
||||
The first line asks the server which release is current instead of hardcoding a number that goes stale one release later — Gitea redirects `releases/latest` to the newest published release's tag page. To install a particular version instead, set `VERSION=<version>` yourself with the one you want; the tarball filenames carry the version either way, so there is no version-free download URL for them.
|
||||
|
||||
Verify before you extract. The signature is over `SHA256SUMS.txt`, and
|
||||
`SHA256SUMS.txt` is over the tarballs:
|
||||
Verify before you extract. The signature is over `SHA256SUMS.txt`, and `SHA256SUMS.txt` is over the tarballs:
|
||||
|
||||
```sh
|
||||
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
|
||||
@@ -63,13 +49,9 @@ sha256sum -c --ignore-missing SHA256SUMS.txt
|
||||
tar -xzf "nxdns-$VERSION-x86_64-linux-musl.tar.gz"
|
||||
```
|
||||
|
||||
[Verify a release](verify-a-release.md) has the whole procedure: where the
|
||||
public key comes from, what fingerprint to expect, what each failure means, and
|
||||
what the signature does and does not prove. Read it once before your first
|
||||
install.
|
||||
[Verify a release](verify-a-release.md) has the whole procedure: where the public key comes from, what fingerprint to expect, what each failure means, and what the signature does and does not prove. Read it once before your first install.
|
||||
|
||||
The extracted directory `nxdns-$VERSION-x86_64-linux-musl/` holds everything
|
||||
this page installs:
|
||||
The extracted directory `nxdns-$VERSION-x86_64-linux-musl/` holds everything this page installs:
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
@@ -92,8 +74,7 @@ cd "nxdns-$VERSION-x86_64-linux-musl"
|
||||
scp nxdns nxdns.service nxdns.conf target:/tmp/
|
||||
```
|
||||
|
||||
For a Raspberry Pi 5, extract the `aarch64-linux-musl` tarball instead — see
|
||||
[Raspberry Pi 5](#raspberry-pi-5) below.
|
||||
For a Raspberry Pi 5, extract the `aarch64-linux-musl` tarball 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
|
||||
@@ -111,9 +92,7 @@ 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.
|
||||
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.
|
||||
|
||||
> Verified on this host against `deploy/systemd/nxdns.service` in a checkout,
|
||||
> which is the same file the tarball ships — the path is the only difference.
|
||||
@@ -134,31 +113,21 @@ systemctl daemon-reload
|
||||
mkdir -p -m 0755 /etc/nxdns
|
||||
```
|
||||
|
||||
The sysusers fragment ships under the name it is installed as, so there is no
|
||||
rename to get wrong.
|
||||
The sysusers fragment ships under the name it is installed as, so there is no rename to get wrong.
|
||||
|
||||
> 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.
|
||||
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`.
|
||||
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.
|
||||
`/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 configuration
|
||||
|
||||
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`.
|
||||
The smallest file that starts is one group named `default` and one enabled
|
||||
upstream:
|
||||
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`. The smallest file that starts is one group named `default` and one enabled upstream:
|
||||
|
||||
```zon
|
||||
.{
|
||||
@@ -168,36 +137,20 @@ upstream:
|
||||
}
|
||||
```
|
||||
|
||||
That file holds a password in plain text, so restrict it as soon as you have
|
||||
written it:
|
||||
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.
|
||||
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`. A root-owned 0600 file would be unreadable to it.
|
||||
0640 with group `nxdns` rather than 0600: `/etc/nxdns` is a `ConfigurationDirectory`, which systemd leaves owned by root, and the service runs as `nxdns`. A root-owned 0600 file would be unreadable to it.
|
||||
|
||||
Keep that group read bit for good, not just for the first boot. Under
|
||||
`run --config` the service reads this file on **every** start, so tightening
|
||||
the mode later breaks the next restart. Under database authority it is
|
||||
`nxdns import` that reads the file, as whoever runs that command, and a bare
|
||||
`nxdns run` never reads it at all.
|
||||
Keep that group read bit for good, not just for the first boot. Under `run --config` the service reads this file on **every** start, so tightening the mode later breaks the next restart. Under database authority it is `nxdns import` that reads the file, as whoever runs that command, and a bare `nxdns run` never reads it at all.
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
@@ -205,8 +158,7 @@ Check it before you start the service:
|
||||
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`:
|
||||
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
|
||||
@@ -214,9 +166,7 @@ OK upstreams[0] https://cloudflare-dns.com
|
||||
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 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.
|
||||
|
||||
Now load it into the database:
|
||||
|
||||
@@ -229,28 +179,17 @@ info(migrations): config.db migrated from schema version 0 to 1
|
||||
imported /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
The plaintext password is hashed into `web.password_hash` and never stored as
|
||||
plaintext; `nxdns export` writes `.password = null` beside the hash. Nothing
|
||||
downstream reads the plaintext again, so once step 6 confirms you can log in you
|
||||
can delete the file:
|
||||
The plaintext password is hashed into `web.password_hash` and never stored as plaintext; `nxdns export` writes `.password = null` beside the hash. Nothing downstream reads the plaintext again, so once step 6 confirms you can log in you can delete the file:
|
||||
|
||||
```sh
|
||||
rm /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
A kept file is not a backup — `nxdns export` is (see
|
||||
[Back up and restore](back-up-and-restore.md)), and the export carries the
|
||||
password hash rather than the password. If you keep it, leave it at 0640
|
||||
root:nxdns.
|
||||
A kept file is not a backup — `nxdns export` is (see [Back up and restore](back-up-and-restore.md)), and the export carries the password hash rather than the password. If you keep it, leave it at 0640 root:nxdns.
|
||||
|
||||
That is the **database mode** install, which is what the packaged unit runs:
|
||||
`ExecStart=/usr/local/bin/nxdns run`, no `--config`, so nothing reads a file
|
||||
after this step. Change settings afterwards through the admin interface, the
|
||||
API, or an export–edit–import cycle.
|
||||
That is the **database mode** install, which is what the packaged unit runs: `ExecStart=/usr/local/bin/nxdns run`, no `--config`, so nothing reads a file after this step. Change settings afterwards through the admin interface, the API, or an export–edit–import cycle.
|
||||
|
||||
If you would rather keep `/etc/nxdns/config.zon` in git and have every restart
|
||||
converge onto it, do not delete the file — go to
|
||||
[Run in file mode](#run-in-file-mode) instead, and skip the `rm`.
|
||||
If you would rather keep `/etc/nxdns/config.zon` in git and have every restart converge onto it, do not delete the file — go to [Run in file mode](#run-in-file-mode) instead, and skip the `rm`.
|
||||
|
||||
> Verified on this host, with a scratch `--config` and `--data-dir` in place of
|
||||
> `/etc/nxdns` and `/var/lib/nxdns` — those two paths are the only difference
|
||||
@@ -277,20 +216,11 @@ A healthy start logs a line naming every socket it bound:
|
||||
info(nxdns): nxdns <version> 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`.
|
||||
nxdns writes to stderr and systemd captures that into the journal; logging needs no further configuration. Port 53 is privileged, and the unit grants `CAP_NET_BIND_SERVICE` through `AmbientCapabilities`.
|
||||
|
||||
The unit does not restart the service after exit 2 or exit 64
|
||||
(`RestartPreventExitStatus=2 64`). Those are a wrong configuration and a wrong
|
||||
command line, and neither clears on a retry — restarting every two seconds until
|
||||
`StartLimitBurst` gives up would only bury the diagnostics that are already in
|
||||
the journal. `systemctl status nxdns` shows the failed state; fix the cause and
|
||||
start it again.
|
||||
The unit does not restart the service after exit 2 or exit 64 (`RestartPreventExitStatus=2 64`). Those are a wrong configuration and a wrong command line, and neither clears on a retry — restarting every two seconds until `StartLimitBurst` gives up would only bury the diagnostics that are already in the journal. `systemctl status nxdns` shows the failed state; fix the cause and start it again.
|
||||
|
||||
If the start fails, read [Troubleshoot nxdns](troubleshoot.md). The two common
|
||||
first-install failures are a port 53 already held by `systemd-resolved` and a
|
||||
configuration file that does not parse.
|
||||
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 configuration file that does not parse.
|
||||
|
||||
## 6. Confirm it answers
|
||||
|
||||
@@ -300,9 +230,7 @@ From another machine on the LAN:
|
||||
dig @<server-ip> example.com A +short
|
||||
```
|
||||
|
||||
The admin interface is on port 8080 by default; log in with the password from
|
||||
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
|
||||
availability and disk state without a login.
|
||||
The admin interface is on port 8080 by default; log in with the password from the configuration file. `http://<server-ip>:8080/api/health` reports upstream availability and disk state without a login.
|
||||
|
||||
> Not verified on this host as written: `<server-ip>` is a placeholder, and a
|
||||
> LAN client to run it from is a second machine this host does not have. What
|
||||
@@ -314,25 +242,15 @@ availability and disk state without a login.
|
||||
|
||||
## Run in file mode
|
||||
|
||||
In file mode `/etc/nxdns/config.zon` is the configuration: every start converges
|
||||
the database onto it, and the admin interface refuses configuration edits with a
|
||||
403 naming the file. Use it when you want the file in git and deployed by
|
||||
Ansible. Stay in database mode when you want the UI to be the way things change.
|
||||
In file mode `/etc/nxdns/config.zon` is the configuration: every start converges the database onto it, and the admin interface refuses configuration edits with a 403 naming the file. Use it when you want the file in git and deployed by Ansible. Stay in database mode when you want the UI to be the way things change.
|
||||
|
||||
The packaged unit is flagless on purpose — it is correct as shipped, and a
|
||||
commented-out alternative `ExecStart` in a unit file is documentation
|
||||
masquerading as configuration. File mode is a drop-in.
|
||||
The packaged unit is flagless on purpose — it is correct as shipped, and a commented-out alternative `ExecStart` in a unit file is documentation masquerading as configuration. File mode is a drop-in.
|
||||
|
||||
### Adopt file mode on a box that is already running
|
||||
|
||||
Run these in order. **Stop first**, and do not skip that: any edit made through
|
||||
the UI between an export and the restart would be silently reverted by the first
|
||||
reconcile, and `nxdns check` against a live database refuses to grade it (below).
|
||||
Run these in order. **Stop first**, and do not skip that: any edit made through the UI between an export and the restart would be silently reverted by the first reconcile, and `nxdns check` against a live database refuses to grade it (below).
|
||||
|
||||
If you are arriving here from an upgrade, the binary must already be the new
|
||||
one before you export. An export written by 0.0.1 carries a `.password = ""`
|
||||
line this binary refuses; see
|
||||
[the order trap](upgrade.md#the-order-trap-export-with-the-new-binary-not-the-old-one).
|
||||
If you are arriving here from an upgrade, the binary must already be the new one before you export. An export written by 0.0.1 carries a `.password = ""` line this binary refuses; see [the order trap](upgrade.md#the-order-trap-export-with-the-new-binary-not-the-old-one).
|
||||
|
||||
```sh
|
||||
systemctl stop nxdns
|
||||
@@ -360,12 +278,9 @@ systemctl daemon-reload
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
The empty `ExecStart=` is required. Without it systemd appends a second command
|
||||
to the list rather than replacing the first, and the unit tries to run nxdns
|
||||
twice.
|
||||
The empty `ExecStart=` is required. Without it systemd appends a second command to the list rather than replacing the first, and the unit tries to run nxdns twice.
|
||||
|
||||
The first start after adoption changes nothing, because the file was rendered
|
||||
from the database it is now governing:
|
||||
The first start after adoption changes nothing, because the file was rendered from the database it is now governing:
|
||||
|
||||
```
|
||||
reconciled '/etc/nxdns/config.zon': no changes
|
||||
@@ -373,14 +288,9 @@ info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
|
||||
```
|
||||
|
||||
`authority: file` is the line that confirms the drop-in took. Blocklists,
|
||||
compiled snapshots and client history all survive, and every later start on an
|
||||
unchanged file writes nothing either.
|
||||
`authority: file` is the line that confirms the drop-in took. Blocklists, compiled snapshots and client history all survive, and every later start on an unchanged file writes nothing either.
|
||||
|
||||
The file now carries `web.password_hash`, so restrict it the same way step 4
|
||||
does — `chown root:nxdns`, `chmod 0640`. The unit's `ReadOnlyPaths=/etc/nxdns`
|
||||
denies the service write access to that directory, so the process that reads the
|
||||
file cannot modify it.
|
||||
The file now carries `web.password_hash`, so restrict it the same way step 4 does — `chown root:nxdns`, `chmod 0640`. The unit's `ReadOnlyPaths=/etc/nxdns` denies the service write access to that directory, so the process that reads the file cannot modify it.
|
||||
|
||||
### Change the configuration from now on
|
||||
|
||||
@@ -392,11 +302,7 @@ nxdns check --config /etc/nxdns/config.zon
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
Make `nxdns check --config` the precondition of any Ansible handler that
|
||||
restarts nxdns. A file-mode start reads the file on **every** boot, so a bad
|
||||
push that skips its handler does not fail at deploy time — it detonates at the
|
||||
next power cut. Validating before restarting turns that into a failed deploy at
|
||||
noon.
|
||||
Make `nxdns check --config` the precondition of any Ansible handler that restarts nxdns. A file-mode start reads the file on **every** boot, so a bad push that skips its handler does not fail at deploy time — it detonates at the next power cut. Validating before restarting turns that into a failed deploy at noon.
|
||||
|
||||
The restart prints what it changed:
|
||||
|
||||
@@ -407,9 +313,7 @@ settings keys changed: dns.port web.port
|
||||
|
||||
### Leave file mode
|
||||
|
||||
Remove the drop-in and restart. The database already holds the last reconciled
|
||||
state, so nothing else is needed and the server comes back serving the same
|
||||
configuration:
|
||||
Remove the drop-in and restart. The database already holds the last reconciled state, so nothing else is needed and the server comes back serving the same configuration:
|
||||
|
||||
```sh
|
||||
rm /etc/systemd/system/nxdns.service.d/file-mode.conf
|
||||
@@ -448,8 +352,7 @@ info(nxdns): authority: database
|
||||
|
||||
## Raspberry Pi 5
|
||||
|
||||
The Pi 5 is aarch64. Nothing about the procedure changes except which tarball
|
||||
you take:
|
||||
The Pi 5 is aarch64. Nothing about the procedure changes except which tarball you take:
|
||||
|
||||
```sh
|
||||
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-aarch64-linux-musl.tar.gz"
|
||||
@@ -467,12 +370,7 @@ Then follow steps 3 to 6 on the Pi.
|
||||
|
||||
## Build from source instead
|
||||
|
||||
You do not need this to install nxdns, and it gets you a binary nobody has
|
||||
signed. It is here for two cases: you want to run something other than a
|
||||
tagged release, or you want to build the release yourself and compare it
|
||||
against the published one. For the second case, follow
|
||||
[Verify a release](verify-a-release.md) rather than this section — it says what
|
||||
the comparison is and is not worth.
|
||||
You do not need this to install nxdns, and it gets you a binary nobody has signed. It is here for two cases: you want to run something other than a tagged release, or you want to build the release yourself and compare it against the published one. For the second case, follow [Verify a release](verify-a-release.md) rather than this section — it says what the comparison is and is not worth.
|
||||
|
||||
Requires Zig 0.16.0 and Node.js. From the repository root:
|
||||
|
||||
@@ -483,29 +381,18 @@ zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)"
|
||||
-Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
```
|
||||
|
||||
The first command builds the admin interface into `web/dist`; the last one
|
||||
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. `dist` refuses to run against the `web/dist-placeholder` default for
|
||||
exactly that reason, so there is no way to skip it by accident.
|
||||
The first command builds the admin interface into `web/dist`; the last one 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. `dist` refuses to run against the `web/dist-placeholder` default for exactly that reason, so there is no way to skip it by accident.
|
||||
|
||||
`-Dversion-string` is required and has no default. It is what `nxdns version`
|
||||
prints. Take it from `build.zig.zon` rather than inventing one: `verify-dist`
|
||||
asserts that the version under build equals `.version` there, so a made-up
|
||||
string like `0.0.0-local` builds but then fails verification. `-Dgit-commit`
|
||||
is what distinguishes your build from the published one of the same version.
|
||||
`-Dversion-string` is required and has no default. It is what `nxdns version` prints. Take it from `build.zig.zon` rather than inventing one: `verify-dist` asserts that the version under build equals `.version` there, so a made-up string like `0.0.0-local` builds but then fails verification. `-Dgit-commit` is what distinguishes your build from the published one of the same version.
|
||||
|
||||
What comes out under `zig-out/dist/` is the same set a release publishes,
|
||||
minus the signature and the image digest:
|
||||
What comes out under `zig-out/dist/` is the same set a release publishes, minus the signature and the image digest:
|
||||
|
||||
- `bin/<triple>/nxdns` — the stripped static binary, one per target
|
||||
- `stage/nxdns-<version>-<triple>/` — the staged payload, one per target
|
||||
- `nxdns-<version>-<triple>.tar.gz` — one tarball per target
|
||||
- `SHA256SUMS` — the two tarball hashes. The release publishes this as
|
||||
`SHA256SUMS.txt`, with a third line for the image digest appended
|
||||
- `SHA256SUMS` — the two tarball hashes. The release publishes this as `SHA256SUMS.txt`, with a third line for the image digest appended
|
||||
|
||||
The two targets are `x86_64-linux-musl` and `aarch64-linux-musl`. Both binaries
|
||||
are statically linked and need nothing installed on the target host.
|
||||
The two targets are `x86_64-linux-musl` and `aarch64-linux-musl`. Both binaries are statically linked and need nothing installed on the target host.
|
||||
|
||||
Check the result the same way the release pipeline does:
|
||||
|
||||
@@ -514,10 +401,7 @@ zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse
|
||||
-Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
```
|
||||
|
||||
`verify-dist` extracts each archive and asserts the ELF is static and within
|
||||
the size budget, that the layout and file modes are exactly what step 1 lists,
|
||||
and that `nxdns version` prints what was built. It exits non-zero on any
|
||||
failure.
|
||||
`verify-dist` extracts each archive and asserts the ELF is static and within the size budget, that the layout and file modes are exactly what step 1 lists, and that `nxdns version` prints what was built. It exits non-zero on any failure.
|
||||
|
||||
> Verified on this host: `zig build dist` and `zig build verify-dist` were both
|
||||
> run to completion with the version taken from `build.zig.zon`. `dist`
|
||||
@@ -529,13 +413,11 @@ failure.
|
||||
> `FAIL zon-version: build.zig.zon says '0.0.1', the build says '0.0.0-local'`,
|
||||
> which is why this section reads the version out of `build.zig.zon`.
|
||||
|
||||
From here, join the page at step 2 with the staged directory in place of the
|
||||
extracted one:
|
||||
From here, join the page at step 2 with the staged directory in place of the extracted one:
|
||||
|
||||
```sh
|
||||
cd "zig-out/dist/stage/nxdns-$VERSION-x86_64-linux-musl"
|
||||
scp nxdns nxdns.service nxdns.conf target:/tmp/
|
||||
```
|
||||
|
||||
The aarch64 binary is built by the same command and needs no toolchain on the
|
||||
Pi.
|
||||
The aarch64 binary is built by the same command and needs no toolchain on the Pi.
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
# 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.
|
||||
`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).
|
||||
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
|
||||
|
||||
@@ -16,12 +10,9 @@ targets exist and why CI does not gate on them is in
|
||||
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.
|
||||
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:
|
||||
`-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
|
||||
@@ -29,9 +20,7 @@ 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:
|
||||
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
|
||||
@@ -47,10 +36,7 @@ filter 20000 2.38 2.76 2.88 20.32
|
||||
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.
|
||||
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:
|
||||
|
||||
@@ -74,10 +60,7 @@ suite ops p50(us) p95(us) p99(us) max(us)
|
||||
compile 100000 wall 15.623ms, 6400464 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.
|
||||
`--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:
|
||||
|
||||
@@ -88,36 +71,21 @@ usage: zig build bench -Doptimize=ReleaseFast -- [filter|cache|compile|all] [--d
|
||||
|
||||
## 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.
|
||||
- `32 regex rules` on the `filter` line is the rule set the suite loads. No
|
||||
generated query matches any of them, so every operation runs all 32 programs
|
||||
to their end, which is the costly case and the one worth measuring.
|
||||
- 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.
|
||||
- `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.
|
||||
- `32 regex rules` on the `filter` line is the rule set the suite loads. No generated query matches any of them, so every operation runs all 32 programs to their end, which is the costly case and the one worth measuring.
|
||||
- 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:
|
||||
`--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:
|
||||
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
|
||||
@@ -130,37 +98,22 @@ filter 20000 2.34 2.71 2.85 15.06
|
||||
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).
|
||||
**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.
|
||||
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`:
|
||||
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.
|
||||
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.
|
||||
**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.
|
||||
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.
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
# 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 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.
|
||||
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
|
||||
|
||||
@@ -21,17 +16,14 @@ Put it in the configuration file, under `web`:
|
||||
}
|
||||
```
|
||||
|
||||
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:
|
||||
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
|
||||
```
|
||||
|
||||
Applying that file — with `nxdns import`, or with a `nxdns run --config` start —
|
||||
announces the change:
|
||||
Applying that file — with `nxdns import`, or with a `nxdns run --config` start — announces the change:
|
||||
|
||||
```
|
||||
web authentication is now enabled
|
||||
@@ -39,9 +31,7 @@ web authentication is now enabled
|
||||
|
||||
### Absent, empty, and set are three different things
|
||||
|
||||
The two fields are optional, and the difference between leaving one out and
|
||||
setting it to `""` is the difference between keeping your password and removing
|
||||
it:
|
||||
The two fields are optional, and the difference between leaving one out and setting it to `""` is the difference between keeping your password and removing it:
|
||||
|
||||
| The file says | Effect on the stored password |
|
||||
| --- | --- |
|
||||
@@ -51,11 +41,7 @@ it:
|
||||
| `.password_hash = "$argon2id$…"` | Installs that hash, for example from an export. |
|
||||
| `.password_hash = ""` | **Removes the password.** Authentication is off. |
|
||||
|
||||
Absence has to mean "keep", because the alternative is a foot-gun with a live
|
||||
round in it. An export carries the full PHC string, which is long and ugly, and
|
||||
sooner or later someone trims that line out of a file before committing it —
|
||||
meaning "leave the password alone". If absence meant "no password", that edit
|
||||
would open the admin interface to the whole LAN without a word.
|
||||
Absence has to mean "keep", because the alternative is a foot-gun with a live round in it. An export carries the full PHC string, which is long and ugly, and sooner or later someone trims that line out of a file before committing it — meaning "leave the password alone". If absence meant "no password", that edit would open the admin interface to the whole LAN without a word.
|
||||
|
||||
So removing the password takes the explicit empty string:
|
||||
|
||||
@@ -63,23 +49,17 @@ So removing the password takes the explicit empty string:
|
||||
web authentication is now disabled
|
||||
```
|
||||
|
||||
And an empty plaintext is refused outright, because hashing the empty string
|
||||
would switch authentication *on* while making every login impossible — the login
|
||||
handler rejects empty passwords:
|
||||
And an empty plaintext is refused outright, because hashing the empty string would switch authentication *on* while making every login impossible — the login handler rejects empty passwords:
|
||||
|
||||
```
|
||||
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
|
||||
```
|
||||
|
||||
Which of steps 4 and 5 applies to your server depends on its authority. Under
|
||||
`nxdns run --config FILE` the file is the password: edit it and restart, and the
|
||||
API refuses the change with a 403. Under bare `nxdns run` the database holds it,
|
||||
and step 4 or step 5 is how it moves.
|
||||
Which of steps 4 and 5 applies to your server depends on its authority. Under `nxdns run --config FILE` the file is the password: edit it and restart, and the API refuses the change with a 403. Under bare `nxdns run` the database holds it, and step 4 or step 5 is how it moves.
|
||||
|
||||
## 2. Log in
|
||||
|
||||
Login is `POST /api/auth/login` with a JSON body. Without a session, the API
|
||||
answers 401:
|
||||
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
|
||||
@@ -102,16 +82,13 @@ curl -sS -c /tmp/nxdns-lab/cookies.txt \
|
||||
{"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):
|
||||
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 1786559938 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:
|
||||
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' \
|
||||
@@ -122,8 +99,7 @@ curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w '%{http_code}\n' \
|
||||
200
|
||||
```
|
||||
|
||||
A wrong password and an unknown one are the same answer, so a guess learns
|
||||
nothing:
|
||||
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 \
|
||||
@@ -142,9 +118,7 @@ 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.
|
||||
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
|
||||
|
||||
@@ -160,18 +134,13 @@ curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w 'stats: %{http_code}\n' \
|
||||
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.
|
||||
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
|
||||
|
||||
This is a database-mode procedure. In file mode `PUT /api/settings` answers 403
|
||||
naming the file; edit `web.password` there and restart instead.
|
||||
This is a database-mode procedure. In file mode `PUT /api/settings` answers 403 naming the file; edit `web.password` there and restart instead.
|
||||
|
||||
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.
|
||||
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 \
|
||||
@@ -181,8 +150,7 @@ curl -sS -b /tmp/nxdns-lab/c2.txt -X PUT http://127.0.0.1:8451/api/settings \
|
||||
-d '{"web":{"password":"a-new-password"}}'
|
||||
```
|
||||
|
||||
Changing the password ends every session, including the one that made the
|
||||
change:
|
||||
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' \
|
||||
@@ -205,39 +173,26 @@ 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 = null` 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`:
|
||||
If you have lost the password, the admin interface cannot help — go through the database instead. Export, edit, import. `nxdns export` always writes `.password = null` 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`: set `.password` to the
|
||||
new value and **delete the `.password_hash` line entirely**, so the `web` block
|
||||
carries one password field and not two:
|
||||
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon`: set `.password` to the new value and **delete the `.password_hash` line entirely**, so the `web` block carries one password field and not two:
|
||||
|
||||
```zon
|
||||
.password = "offline-password",
|
||||
```
|
||||
|
||||
Deleting the line is the part to get right. Setting `.password_hash = ""`
|
||||
alongside a plaintext password does not clear the way for it — an empty string
|
||||
is a present value meaning "no password", so the file then states two
|
||||
contradictory things and is refused:
|
||||
Deleting the line is the part to get right. Setting `.password_hash = ""` alongside a plaintext password does not clear the way for it — an empty string is a present value meaning "no password", so the file then states two contradictory things and is refused:
|
||||
|
||||
```
|
||||
FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused
|
||||
import failed: PasswordAndHashBothSet
|
||||
```
|
||||
|
||||
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:
|
||||
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
|
||||
@@ -249,14 +204,9 @@ nxdns run --data-dir /tmp/nxdns-lab/data
|
||||
imported /tmp/nxdns-lab/rekeyed.zon
|
||||
```
|
||||
|
||||
No flag is needed: replacing a password edits a settings value and deletes no
|
||||
rows.
|
||||
No flag is needed: replacing a password edits a settings value and deletes no rows.
|
||||
|
||||
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).
|
||||
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:
|
||||
|
||||
@@ -288,14 +238,11 @@ nxdns export --data-dir /tmp/nxdns-lab/data | grep password
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$xqzK66LgiWGvyCmCl6ZRa3GHH0nS5qZnRgVfWmeGadc$1mafhflKFIg3vcHjJaDMAXGQiOjtym2UADZsPW1xkfw",
|
||||
```
|
||||
|
||||
See [back up and restore](back-up-and-restore.md) for when `import` does need
|
||||
`--allow-delete`.
|
||||
See [back up and restore](back-up-and-restore.md) for when `import` does need `--allow-delete`.
|
||||
|
||||
## 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:
|
||||
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
|
||||
@@ -308,24 +255,13 @@ curl -sS -X POST http://127.0.0.1:8453/api/auth/login \
|
||||
{"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.
|
||||
`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`).
|
||||
- **If you put a reverse proxy in front of the admin interface, configure
|
||||
`web.trusted_proxies` or turn `web.api_localhost_exempt` off.** A proxy on the
|
||||
same box connects from loopback, so every request arrives exempt and the API
|
||||
limiter — the only brake on guessing the admin password — stops applying to
|
||||
anyone. Listing the proxy's address in `web.trusted_proxies` makes nxdns read
|
||||
the client's address from the `X-Forwarded-For` the proxy appends, so the
|
||||
limiter and the SSE connection cap bind each real client again:
|
||||
- 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`).
|
||||
- **If you put a reverse proxy in front of the admin interface, configure `web.trusted_proxies` or turn `web.api_localhost_exempt` off.** A proxy on the same box connects from loopback, so every request arrives exempt and the API limiter — the only brake on guessing the admin password — stops applying to anyone. Listing the proxy's address in `web.trusted_proxies` makes nxdns read the client's address from the `X-Forwarded-For` the proxy appends, so the limiter and the SSE connection cap bind each real client again:
|
||||
|
||||
```zig
|
||||
.web = .{
|
||||
@@ -333,17 +269,7 @@ interface at the very least, and preferably set a password.
|
||||
},
|
||||
```
|
||||
|
||||
The proxy must append its own entry to that header. A proxy that forwards a
|
||||
client-supplied `X-Forwarded-For` unchanged is not one to trust.
|
||||
- `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).
|
||||
The proxy must append its own entry to that header. A proxy that forwards a client-supplied `X-Forwarded-For` unchanged is not one to trust.
|
||||
- `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, against the
|
||||
lab described at the top, except the `systemctl` stop and start named in step 5
|
||||
and marked **not verified on this host** there. That includes the whole of
|
||||
steps 2 to 5, re-run for this revision: the login, logout and rate-limit
|
||||
transcripts reproduced exactly as printed, the both-set refusal in step 5 was
|
||||
reproduced by leaving `.password_hash = ""` in the file, and the rekey then
|
||||
succeeded once that line was deleted. The cookie jar's expiry timestamp is the
|
||||
one that run produced and will differ on yours.
|
||||
Every command on this page was executed on this host as written, against the lab described at the top, except the `systemctl` stop and start named in step 5 and marked **not verified on this host** there. That includes the whole of steps 2 to 5, re-run for this revision: the login, logout and rate-limit transcripts reproduced exactly as printed, the both-set refusal in step 5 was reproduced by leaving `.password_hash = ""` in the file, and the rekey then succeeded once that line was deleted. The cookie jar's expiry timestamp is the one that run produced and will differ on yours.
|
||||
|
||||
+49
-159
@@ -1,29 +1,19 @@
|
||||
# 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.
|
||||
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 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:
|
||||
**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 means the configuration is wrong and you can fix it. Every subcommand
|
||||
uses the same definition, so a file `run` exits 2 on exits 2 from `check` and
|
||||
`import` too.
|
||||
Exit 2 means the configuration is wrong and you can fix it. Every subcommand uses the same definition, so a file `run` exits 2 on exits 2 from `check` and `import` too.
|
||||
|
||||
**Diagnosis.**
|
||||
|
||||
@@ -31,14 +21,11 @@ uses the same definition, so a file `run` exits 2 on exits 2 from `check` and
|
||||
nxdns check
|
||||
```
|
||||
|
||||
`check` prints every problem it finds, not the first, and names the source it
|
||||
checked on its first line.
|
||||
`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 in database mode this is simply an empty database, and the run says
|
||||
what to do about it on the next line:
|
||||
- `NoUsableUpstreams` — the database has no enabled upstream. On a fresh install in database mode this is simply an empty database, and the run says what to do about it on the next line:
|
||||
|
||||
```
|
||||
nxdns run failed: NoUsableUpstreams
|
||||
@@ -46,22 +33,11 @@ checked on its first line.
|
||||
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
|
||||
```
|
||||
|
||||
Write a configuration file and take either exit: `nxdns import <file>` to load
|
||||
it into the database once, or add `--config <file>` to `ExecStart` to make the
|
||||
file the configuration from then on.
|
||||
- `ManagedConfigUnreadable` — the service runs `run --config FILE` and that file
|
||||
is missing or the process may not read it. The path is in the FAIL line above
|
||||
the failure. File mode never falls back to the database, on purpose: a
|
||||
fallback would turn a bad deploy into a silently stale configuration.
|
||||
- `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`.
|
||||
Write a configuration file and take either exit: `nxdns import <file>` to load it into the database once, or add `--config <file>` to `ExecStart` to make the file the configuration from then on.
|
||||
- `ManagedConfigUnreadable` — the service runs `run --config FILE` and that file is missing or the process may not read it. The path is in the FAIL line above the failure. File mode never falls back to the database, on purpose: a fallback would turn a bad deploy into a silently stale configuration.
|
||||
- `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` catches this without starting a listener. It loads both PEM files and
|
||||
tests the key against the certificate through the same code `run` uses, so it
|
||||
fails on exactly what `run` would fail on. Reproduced here with a self-signed
|
||||
pair and the key from a second, unrelated pair:
|
||||
`check` catches this without starting a listener. It loads both PEM files and tests the key against the certificate through the same code `run` uses, so it fails on exactly what `run` would fail on. Reproduced here with a self-signed pair and the key from a second, unrelated pair:
|
||||
|
||||
```
|
||||
$ nxdns check --config config.zon
|
||||
@@ -77,24 +53,15 @@ checked on its first line.
|
||||
nxdns run failed: BadCertificate # exit 2
|
||||
```
|
||||
|
||||
The `warning(tls_server)` line comes from mbedTLS on stderr and can appear
|
||||
before the `checking` line, which is on stdout. A cert file containing
|
||||
`not a certificate` fails the same way, with
|
||||
`FAIL doh_server.cert_path: 'junk.pem': certificate PEM could not be parsed`.
|
||||
An unreadable file reads
|
||||
`FAIL doh_server.cert_path: '<path>': certificate file is not readable`.
|
||||
The `warning(tls_server)` line comes from mbedTLS on stderr and can appear before the `checking` line, which is on stdout. A cert file containing `not a certificate` fails the same way, with `FAIL doh_server.cert_path: 'junk.pem': certificate PEM could not be parsed`. An unreadable file reads `FAIL doh_server.cert_path: '<path>': certificate file is not readable`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## A configuration file you just wrote is rejected
|
||||
|
||||
**Symptom.** `nxdns run --config`, `nxdns check --config` or `nxdns import`
|
||||
prints the validation problem and stops with exit 2:
|
||||
**Symptom.** `nxdns run --config`, `nxdns check --config` or `nxdns import` prints the validation problem and stops with exit 2:
|
||||
|
||||
```
|
||||
FAIL groups: no group named 'default'; every unknown client is assigned to it
|
||||
@@ -118,53 +85,32 @@ nxdns run failed: NoUpstreams
|
||||
run `nxdns check` to see the configuration in full
|
||||
```
|
||||
|
||||
`NoUpstreams` from a 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. Both are exit 2.
|
||||
`NoUpstreams` from a 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. Both are exit 2.
|
||||
|
||||
**Diagnosis.** Run the same file through `check`, which reports the same
|
||||
problems and exits 2:
|
||||
**Diagnosis.** Run the same file through `check`, which reports the same problems and exits 2:
|
||||
|
||||
```sh
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
**Fix.** Correct the file the diagnostics name and start again. Nothing was
|
||||
applied — a file-mode reconcile happens in one transaction that rolls back, and
|
||||
a failed `import` leaves the database untouched. The exit code does not depend
|
||||
on which command read the file: all three of these files were run through `run`,
|
||||
`check` and `import` here, and every one of the nine combinations exited 2 with
|
||||
the same diagnostic.
|
||||
**Fix.** Correct the file the diagnostics name and start again. Nothing was applied — a file-mode reconcile happens in one transaction that rolls back, and a failed `import` leaves the database untouched. The exit code does not depend on which command read the file: all three of these files were run through `run`, `check` and `import` here, and every one of the nine combinations exited 2 with the same diagnostic.
|
||||
|
||||
Under the shipped systemd unit an exit 2 stops the service rather than
|
||||
restarting it (`RestartPreventExitStatus=2 64`), so the journal holds the
|
||||
diagnostics instead of drowning them in a restart loop. `systemctl start nxdns`
|
||||
once the file is fixed.
|
||||
Under the shipped systemd unit an exit 2 stops the service rather than restarting it (`RestartPreventExitStatus=2 64`), so the journal holds the diagnostics instead of drowning them in a restart loop. `systemctl start nxdns` once the file is fixed.
|
||||
|
||||
Make `nxdns check --config <file>` the precondition in whatever pushes the file.
|
||||
In file mode every boot reads it, so an unvalidated bad push does not fail at
|
||||
deploy time — it fails at the next restart, which may be a power cut at 3am.
|
||||
Make `nxdns check --config <file>` the precondition in whatever pushes the file. In file mode every boot reads it, so an unvalidated bad push does not fail at deploy time — it fails at the next restart, which may be a power cut at 3am.
|
||||
|
||||
## `nxdns check` fails on a server that is running fine
|
||||
|
||||
**Symptom.** The service is up and answering, but `nxdns check` on the same
|
||||
machine exits 2 with one long line about a write-ahead log:
|
||||
**Symptom.** The service is up and answering, but `nxdns check` on the same machine exits 2 with one long line about a write-ahead log:
|
||||
|
||||
```
|
||||
checking database /var/lib/nxdns/config.db
|
||||
FAIL /var/lib/nxdns/config.db: uncheckpointed changes are waiting in /var/lib/nxdns/config.db-wal, and reading without writing would answer from the older settings in the main file; `nxdns run` applies them. A running nxdns normally holds this log, which is the usual reason to see this line.
|
||||
```
|
||||
|
||||
Nothing is damaged. `check` opens `config.db` immutable so that it can never
|
||||
write to it, and an immutable open ignores the write-ahead log. When that log
|
||||
holds bytes, the newest settings are in it and the main file holds older ones,
|
||||
so `check` refuses rather than grade stale values.
|
||||
Nothing is damaged. `check` opens `config.db` immutable so that it can never write to it, and an immutable open ignores the write-ahead log. When that log holds bytes, the newest settings are in it and the main file holds older ones, so `check` refuses rather than grade stale values.
|
||||
|
||||
The log holds bytes after a configuration write that has not been checkpointed
|
||||
yet, which on a running server means someone changed something through the web
|
||||
interface or the API. A server that has only been answering queries has an empty
|
||||
`config.db-wal` and `check` reads it normally — so this line comes and goes, and
|
||||
its absence is not proof that nothing is running.
|
||||
The log holds bytes after a configuration write that has not been checkpointed yet, which on a running server means someone changed something through the web interface or the API. A server that has only been answering queries has an empty `config.db-wal` and `check` reads it normally — so this line comes and goes, and its absence is not proof that nothing is running.
|
||||
|
||||
**Fix.** Check the exported configuration instead of the live file:
|
||||
|
||||
@@ -173,9 +119,7 @@ nxdns export --data-dir /var/lib/nxdns --out /tmp/current.zon
|
||||
nxdns check --config /tmp/current.zon
|
||||
```
|
||||
|
||||
`export` opens the database read/write and does see the log, so it renders the
|
||||
settings that are actually in force. Stopping the service and checking again
|
||||
works too: a clean shutdown checkpoints the log away.
|
||||
`export` opens the database read/write and does see the log, so it renders the settings that are actually in force. Stopping the service and checking again works too: a clean shutdown checkpoints the log away.
|
||||
|
||||
> Reproduced here on a scratch data directory rather than `/var/lib/nxdns` —
|
||||
> that path is the only substitution in the output above. nxdns was started on
|
||||
@@ -194,8 +138,7 @@ 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.
|
||||
A bind conflict is a runtime failure, not a configuration fault, so this is exit 1 and `nxdns check` will not find it.
|
||||
|
||||
**Diagnosis.**
|
||||
|
||||
@@ -205,11 +148,9 @@ 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`.
|
||||
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:
|
||||
**Fix.** Turn off the stub listener and keep resolved for the host's own lookups:
|
||||
|
||||
```sh
|
||||
mkdir -p /etc/systemd/resolved.conf.d
|
||||
@@ -217,22 +158,18 @@ printf '[Resolve]\nDNSStubListener=no\n' > /etc/systemd/resolved.conf.d/nxdns.co
|
||||
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.
|
||||
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.
|
||||
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
|
||||
the same failure repeated. Docker has no start limit, so this goes on forever.
|
||||
**Symptom.** `docker compose ps` shows the container restarting, and the log is the same failure repeated. Docker has no start limit, so this goes on forever.
|
||||
|
||||
```
|
||||
FAIL /etc/nxdns/config.zon: not readable
|
||||
@@ -246,10 +183,7 @@ docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}} restarts={{.Restar
|
||||
stat -c '%a %u:%g %n' deploy/docker/etc-nxdns/config.zon
|
||||
```
|
||||
|
||||
Exit 2 naming the configuration path means the container could not read the
|
||||
file the shipped `command:` makes its configuration. 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.
|
||||
Exit 2 naming the configuration path means the container could not read the file the shipped `command:` makes its configuration. 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:
|
||||
|
||||
@@ -264,17 +198,11 @@ chown 65532:65532 deploy/docker/etc-nxdns/config.zon
|
||||
chmod 0600 deploy/docker/etc-nxdns/config.zon
|
||||
```
|
||||
|
||||
The 0644 path was verified against an earlier revision of this page, including
|
||||
the recovery: after the `chmod` the container started and answered queries. The
|
||||
`chown` needs root and was not run here.
|
||||
The 0644 path was verified against an earlier revision of this page, including the recovery: after the `chmod` the container started and answered queries. The `chown` needs root and was not run here.
|
||||
|
||||
`FAIL /etc/nxdns/config.zon: no such file` instead of `not readable` means there
|
||||
is no configuration file at all. Create `deploy/docker/etc-nxdns/config.zon` and
|
||||
bring it up again; see [Install with Docker](install-with-docker.md).
|
||||
`FAIL /etc/nxdns/config.zon: no such file` instead of `not readable` means there is no configuration file at all. Create `deploy/docker/etc-nxdns/config.zon` and bring it up again; see [Install with Docker](install-with-docker.md).
|
||||
|
||||
A container that exits 2 with `NoUsableUpstreams` is in database mode — the
|
||||
`command:` line naming `--config` was removed — on a volume whose database is
|
||||
still empty. Load one and bring it back up:
|
||||
A container that exits 2 with `NoUsableUpstreams` is in database mode — the `command:` line naming `--config` was removed — on a volume whose database is still empty. Load one and bring it back up:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
|
||||
@@ -293,9 +221,7 @@ docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/co
|
||||
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
|
||||
```
|
||||
|
||||
This is not a fault. The service runs `nxdns run --config`, which makes that
|
||||
file the configuration, and configuration writes through the API are refused so
|
||||
the file and the running server cannot drift apart.
|
||||
This is not a fault. The service runs `nxdns run --config`, which makes that file the configuration, and configuration writes through the API are refused so the file and the running server cannot drift apart.
|
||||
|
||||
**Diagnosis.** The start log names the authority:
|
||||
|
||||
@@ -315,19 +241,13 @@ nxdns check --config /etc/nxdns/config.zon
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
Or, if you want the interface to be how this box is configured, leave file mode:
|
||||
drop `--config` from `ExecStart` and restart. The database already holds the
|
||||
last reconciled state, so nothing is lost. See
|
||||
[Run in file mode](install-with-systemd.md#run-in-file-mode).
|
||||
Or, if you want the interface to be how this box is configured, leave file mode: drop `--config` from `ExecStart` and restart. The database already holds the last reconciled state, so nothing is lost. See [Run in file mode](install-with-systemd.md#run-in-file-mode).
|
||||
|
||||
Pausing blocking, refreshing blocklists and reloading certificates are not
|
||||
configuration and keep working in file mode. Deleting a client works too, unless
|
||||
the file names that client's address.
|
||||
Pausing blocking, refreshing blocklists and reloading certificates are not configuration and keep working in file mode. Deleting a client works too, unless the file names that client's address.
|
||||
|
||||
## The container cannot reach its upstreams
|
||||
|
||||
**Symptom.** The container starts, but every query fails and `nxdns check`
|
||||
inside it reports each upstream as unreachable.
|
||||
**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:
|
||||
|
||||
@@ -335,15 +255,11 @@ inside it reports each upstream as unreachable.
|
||||
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.
|
||||
**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:
|
||||
**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
|
||||
@@ -361,21 +277,11 @@ curl -s http://127.0.0.1:8080/api/health
|
||||
{"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.
|
||||
`/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.
|
||||
**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.
|
||||
**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
|
||||
|
||||
@@ -387,23 +293,13 @@ and delete `querylog.db` — and writes resume on the next sample.
|
||||
journalctl -u nxdns | grep 'serving on'
|
||||
```
|
||||
|
||||
It ends in either `blocklist generation N` or
|
||||
`unfiltered (no blocklist snapshot)`.
|
||||
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.
|
||||
**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 a `blocklist_sources` entry to the
|
||||
configuration file with a `group_sources` link naming a group.
|
||||
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 a `blocklist_sources` entry to the configuration file with a `group_sources` link naming a group.
|
||||
|
||||
One name resolving while its neighbours are blocked is a third case, and
|
||||
`/api/lookup` answers it directly: it reports which level of the filtering
|
||||
ladder decided, and against what.
|
||||
One name resolving while its neighbours are blocked is a third case, and `/api/lookup` answers it directly: it reports which level of the filtering ladder decided, and against what.
|
||||
|
||||
```sh
|
||||
curl -s 'http://127.0.0.1:8080/api/lookup?domain=api.ads.tvb.com'
|
||||
@@ -413,11 +309,7 @@ curl -s 'http://127.0.0.1:8080/api/lookup?domain=api.ads.tvb.com'
|
||||
{"domain":"api.ads.tvb.com","group_id":1,"local_records":false,"forward_zone":null,"blocked":false,"reason":"blocklist_exception","matched":"api.ads.tvb.com","source_url":"https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt","safe_search_rewrite":null}
|
||||
```
|
||||
|
||||
`blocklist_exception` means a downloaded list lifted that name with an `@@`
|
||||
line, and `source_url` names the list that did it. Nothing is broken, and the
|
||||
list is not overruling you: an exception cancels only what another list blocks.
|
||||
Your own rule wins over it. Adding an exact block rule for the same name and
|
||||
asking again reports `rule_block_exact`, `blocked` true and a null `source_url`.
|
||||
`blocklist_exception` means a downloaded list lifted that name with an `@@` line, and `source_url` names the list that did it. Nothing is broken, and the list is not overruling you: an exception cancels only what another list blocks. Your own rule wins over it. Adding an exact block rule for the same name and asking again reports `rule_block_exact`, `blocked` true and a null `source_url`.
|
||||
|
||||
## A database stamped by a newer binary
|
||||
|
||||
@@ -428,6 +320,4 @@ warning(migrations): config.db is at schema version 99; this nxdns binary suppor
|
||||
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).
|
||||
**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).
|
||||
|
||||
+45
-151
@@ -1,11 +1,8 @@
|
||||
# Upgrade nxdns
|
||||
|
||||
Replaces a running nxdns with a newer release without losing its
|
||||
configuration. The database is migrated in place on the first start of the new
|
||||
binary.
|
||||
Replaces a running nxdns with a newer release without losing its configuration. The database is migrated in place on the first start of the new binary.
|
||||
|
||||
The normal path is to download the new release, verify it, and swap the binary.
|
||||
Upgrading a build you made yourself is the last section of this page.
|
||||
The normal path is to download the new release, verify it, and swap the binary. Upgrading a build you made yourself is the last section of this page.
|
||||
|
||||
> Verification: the export, the migration behaviour and the `version`/`check`
|
||||
> steps below were run on the machine that wrote this page, against a
|
||||
@@ -23,38 +20,22 @@ Upgrading a build you made yourself is the last section of this page.
|
||||
|
||||
## Breaking change: `run --config` now means file authority
|
||||
|
||||
**Read this before upgrading if anything on your box passes `--config` to
|
||||
`nxdns run`** — a systemd drop-in, a wrapper script, or a `command:` in a
|
||||
compose file.
|
||||
**Read this before upgrading if anything on your box passes `--config` to `nxdns run`** — a systemd drop-in, a wrapper script, or a `command:` in a compose file.
|
||||
|
||||
`run --config FILE` used to mean *seed once*: the file was read only while the
|
||||
database was still empty, and ignored on every start after that. It now means
|
||||
*the file is the configuration*: every start reconciles the database onto it.
|
||||
`run --config FILE` used to mean *seed once*: the file was read only while the database was still empty, and ignored on every start after that. It now means *the file is the configuration*: every start reconciles the database onto it.
|
||||
|
||||
For a box that was seeded once and then configured through the admin interface,
|
||||
the first start after the upgrade converges the database back to that old seed
|
||||
file. **Every change made through the UI since seeding is deleted.**
|
||||
For a box that was seeded once and then configured through the admin interface, the first start after the upgrade converges the database back to that old seed file. **Every change made through the UI since seeding is deleted.**
|
||||
|
||||
There are two ways out, and you pick before you restart:
|
||||
|
||||
- **Keep the database.** Drop the flag. `nxdns run` with no `--config` serves
|
||||
the database exactly as it did before, and nothing reads a file. This is the
|
||||
right answer if the UI is how you change things.
|
||||
- **Adopt file mode cleanly.** Install the new binary, stop the service, export
|
||||
the current database over the file path, check it, then start with the flag.
|
||||
The first reconcile is then a no-op, because the file was rendered from the
|
||||
database it governs. **Install the new binary first** — see the order trap
|
||||
below. The full procedure is
|
||||
[Adopt file mode](install-with-systemd.md#adopt-file-mode-on-a-box-that-is-already-running).
|
||||
- **Keep the database.** Drop the flag. `nxdns run` with no `--config` serves the database exactly as it did before, and nothing reads a file. This is the right answer if the UI is how you change things.
|
||||
- **Adopt file mode cleanly.** Install the new binary, stop the service, export the current database over the file path, check it, then start with the flag. The first reconcile is then a no-op, because the file was rendered from the database it governs. **Install the new binary first** — see the order trap below. The full procedure is [Adopt file mode](install-with-systemd.md#adopt-file-mode-on-a-box-that-is-already-running).
|
||||
|
||||
`nxdns check --config FILE` is unchanged: it graded that file before and it
|
||||
grades that file now.
|
||||
`nxdns check --config FILE` is unchanged: it graded that file before and it grades that file now.
|
||||
|
||||
### The order trap: export with the new binary, not the old one
|
||||
|
||||
Take the export **after** you have replaced the binary, with the service
|
||||
stopped. Exporting first — the instinctive order, and the one step 1 of this
|
||||
page tells you to take for a backup — produces a file the new binary refuses.
|
||||
Take the export **after** you have replaced the binary, with the service stopped. Exporting first — the instinctive order, and the one step 1 of this page tells you to take for a backup — produces a file the new binary refuses.
|
||||
|
||||
A 0.0.1 `nxdns export` writes both fields:
|
||||
|
||||
@@ -63,9 +44,7 @@ A 0.0.1 `nxdns export` writes both fields:
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…",
|
||||
```
|
||||
|
||||
An empty `password_hash` used to mean "unset". It now means "disable
|
||||
authentication", so it is a *present* value — and a file that carries both
|
||||
fields states two different things about the password and is refused:
|
||||
An empty `password_hash` used to mean "unset". It now means "disable authentication", so it is a *present* value — and a file that carries both fields states two different things about the password and is refused:
|
||||
|
||||
```
|
||||
FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused
|
||||
@@ -73,27 +52,18 @@ FAIL web.password: password is set to the empty string; omit the field to keep t
|
||||
nxdns run failed: PasswordAndHashBothSet
|
||||
```
|
||||
|
||||
The old `nxdns check` passes that file, because the old binary agreed with the
|
||||
old rule. So the failure lands at the first start after the upgrade, with the
|
||||
resolver stopped and the unit refusing to retry it
|
||||
(`RestartPreventExitStatus=2 64`). A new `nxdns export` writes
|
||||
`.password = null` instead and has no such problem.
|
||||
The old `nxdns check` passes that file, because the old binary agreed with the old rule. So the failure lands at the first start after the upgrade, with the resolver stopped and the unit refusing to retry it (`RestartPreventExitStatus=2 64`). A new `nxdns export` writes `.password = null` instead and has no such problem.
|
||||
|
||||
**If you already have an old export you want to adopt**, you do not need to
|
||||
redo it. Delete the empty-password line and the file is valid:
|
||||
**If you already have an old export you want to adopt**, you do not need to redo it. Delete the empty-password line and the file is valid:
|
||||
|
||||
```sh
|
||||
sed -i '/^ \.password = "",$/d' /etc/nxdns/config.zon
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
Keep the `.password_hash` line — that is the password, and deleting it as well
|
||||
would leave the file saying nothing about authentication, which means "keep
|
||||
whatever is stored" rather than anything you would notice.
|
||||
Keep the `.password_hash` line — that is the password, and deleting it as well would leave the file saying nothing about authentication, which means "keep whatever is stored" rather than anything you would notice.
|
||||
|
||||
The same trap has nothing to do with file mode as such: it is any 0.0.1 export
|
||||
fed to the new binary, so it also applies to a restore through `nxdns import`.
|
||||
Backups taken with 0.0.1 need that one line removed before they will load.
|
||||
The same trap has nothing to do with file mode as such: it is any 0.0.1 export fed to the new binary, so it also applies to a restore through `nxdns import`. Backups taken with 0.0.1 need that one line removed before they will load.
|
||||
|
||||
> Verified on this host, with one substitution stated: the repository has no
|
||||
> 0.0.1 binary to hand, so the old export was **simulated** by taking a current
|
||||
@@ -107,16 +77,9 @@ Backups taken with 0.0.1 need that one line removed before they will load.
|
||||
> `git show v0.0.1:src/config/export.zig` line 71 is `cfg.web.password = "";` —
|
||||
> not from running that binary.
|
||||
|
||||
A database-mode install that never passed `--config` needs nothing. Under
|
||||
Docker, a fresh database-mode install must either take the new compose file or
|
||||
run `import` once — see
|
||||
[Database mode in Docker](install-with-docker.md#database-mode-in-docker-instead).
|
||||
A database-mode install that never passed `--config` needs nothing. Under Docker, a fresh database-mode install must either take the new compose file or run `import` once — see [Database mode in Docker](install-with-docker.md#database-mode-in-docker-instead).
|
||||
|
||||
Two smaller renames in the same release: `nxdns import --force` is now
|
||||
`--allow-delete`, and it is required only when the file's diff would delete
|
||||
rows rather than whenever the database is non-empty. `nxdns check` no longer
|
||||
falls back to a default file path when there is no database; it reports the
|
||||
absent database and names the two ways to get one.
|
||||
Two smaller renames in the same release: `nxdns import --force` is now `--allow-delete`, and it is required only when the file's diff would delete rows rather than whenever the database is non-empty. `nxdns check` no longer falls back to a default file path when there is no database; it reports the absent database and names the two ways to get one.
|
||||
|
||||
There is no schema migration in this change.
|
||||
|
||||
@@ -128,16 +91,9 @@ There is no downgrade path, so the export is what you fall back to:
|
||||
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.
|
||||
`/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.
|
||||
|
||||
This export is a fallback, not a file to deploy. If you are adopting file mode,
|
||||
take a *second* export after the binary swap and use that one — an export
|
||||
written by 0.0.1 carries a `.password = ""` line the new binary refuses, as
|
||||
[the order trap](#the-order-trap-export-with-the-new-binary-not-the-old-one)
|
||||
explains. The same line has to come out of this backup before the new binary
|
||||
will import it.
|
||||
This export is a fallback, not a file to deploy. If you are adopting file mode, take a *second* export after the binary swap and use that one — an export written by 0.0.1 carries a `.password = ""` line the new binary refuses, as [the order trap](#the-order-trap-export-with-the-new-binary-not-the-old-one) explains. The same line has to come out of this backup before the new binary will import it.
|
||||
|
||||
> Verified on this host with both paths substituted, since it has neither
|
||||
> `/var/lib/nxdns` nor `/some/backup`. `SCRATCH` below is a scratch directory,
|
||||
@@ -156,15 +112,11 @@ will import it.
|
||||
> 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.
|
||||
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. Download and verify the new release
|
||||
|
||||
Read the release notes for the version you are moving to before you take it —
|
||||
the `CHANGELOG.md` section for that version is the release body.
|
||||
Read the release notes for the version you are moving to before you take it — the `CHANGELOG.md` section for that version is the release body.
|
||||
|
||||
```sh
|
||||
BASE=https://git.mial.net/mokhtar/nxdns
|
||||
@@ -179,18 +131,11 @@ sha256sum -c --ignore-missing SHA256SUMS.txt
|
||||
tar -xzf "nxdns-$VERSION-x86_64-linux-musl.tar.gz"
|
||||
```
|
||||
|
||||
The first line asks the server which release is current, so this block does not
|
||||
carry a version number that goes stale — Gitea redirects `releases/latest` to
|
||||
the newest published release's tag page. To move to a particular version rather
|
||||
than the newest, set `VERSION=<version>` yourself. Check it against what you are
|
||||
running (`nxdns version`) before you download anything.
|
||||
The first line asks the server which release is current, so this block does not carry a version number that goes stale — Gitea redirects `releases/latest` to the newest published release's tag page. To move to a particular version rather than the newest, set `VERSION=<version>` yourself. Check it against what you are running (`nxdns version`) before you download anything.
|
||||
|
||||
Take `aarch64-linux-musl` for a Raspberry Pi 5. Verify every time, not only on
|
||||
the first install — an upgrade is a fresh download of a fresh artifact.
|
||||
[Verify a release](verify-a-release.md) is the full procedure.
|
||||
Take `aarch64-linux-musl` for a Raspberry Pi 5. Verify every time, not only on the first install — an upgrade is a fresh download of a fresh artifact. [Verify a release](verify-a-release.md) is the full procedure.
|
||||
|
||||
Under Docker there is nothing to download: step 3 pulls the image, and the
|
||||
`IMAGE-DIGEST.txt` asset is what you verify instead.
|
||||
Under Docker there is nothing to download: step 3 pulls the image, and the `IMAGE-DIGEST.txt` asset is what you verify instead.
|
||||
|
||||
> Not verified on this host: no release exists yet, so the `releases/latest`
|
||||
> lookup returns 404 and leaves `VERSION` empty, and every `curl` below it is a
|
||||
@@ -201,8 +146,7 @@ Under Docker there is nothing to download: step 3 pulls the image, and the
|
||||
|
||||
### systemd
|
||||
|
||||
Step 2 leaves the new binary in the extracted directory. Copy the one that
|
||||
matches the host — `aarch64-linux-musl` for a Raspberry Pi 5:
|
||||
Step 2 leaves the new binary in the extracted directory. Copy the one that matches the host — `aarch64-linux-musl` for a Raspberry Pi 5:
|
||||
|
||||
```sh
|
||||
scp "nxdns-$VERSION-x86_64-linux-musl/nxdns" target:/tmp/nxdns
|
||||
@@ -212,9 +156,7 @@ scp "nxdns-$VERSION-x86_64-linux-musl/nxdns" target:/tmp/nxdns
|
||||
> nxdns, and this host has no such second machine to copy to. There is also no
|
||||
> release to have extracted.
|
||||
|
||||
The tarball also carries `nxdns.service` and `nxdns.conf`. An upgrade does not
|
||||
normally reinstall them, but compare them against what is on the target when
|
||||
the release notes say the unit changed.
|
||||
The tarball also carries `nxdns.service` and `nxdns.conf`. An upgrade does not normally reinstall them, but compare them against what is on the target when the release notes say the unit changed.
|
||||
|
||||
Then, as root on the target:
|
||||
|
||||
@@ -237,15 +179,9 @@ NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml pull
|
||||
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
|
||||
```
|
||||
|
||||
Compose recreates the container against the same `nxdns-data` volume. What
|
||||
happens to the file in `etc-nxdns` depends on the `command:` in your compose
|
||||
file: with the shipped `run --config=/etc/nxdns/config.zon` the file is the
|
||||
configuration and the restart reconciles onto it; without it, the database in
|
||||
the volume is the configuration and the file is read by nothing.
|
||||
Compose recreates the container against the same `nxdns-data` volume. What happens to the file in `etc-nxdns` depends on the `command:` in your compose file: with the shipped `run --config=/etc/nxdns/config.zon` the file is the configuration and the restart reconciles onto it; without it, the database in the volume is the configuration and the file is read by nothing.
|
||||
|
||||
Set `NXDNS_VERSION` on both lines, or export it. Without it the compose file
|
||||
falls back to `:latest`, and `pull` and `up` could then land on different
|
||||
images if a release happens between them.
|
||||
Set `NXDNS_VERSION` on both lines, or export it. Without it the compose file falls back to `:latest`, and `pull` and `up` could then land on different images if a release happens between them.
|
||||
|
||||
> Not run on this host: `pull` needs a published image, and there is none.
|
||||
> What was run is `docker compose -f deploy/docker/compose.yaml config`, which
|
||||
@@ -262,19 +198,13 @@ nxdns check --config /tmp/after-upgrade.zon
|
||||
dig @127.0.0.1 example.com A +short
|
||||
```
|
||||
|
||||
The restart in step 3 is what migrated the database, so by now the schema is
|
||||
current and the service is answering. Confirming with `nxdns check` alone would
|
||||
not work here, and the reason is worth knowing: `check` opens `config.db`
|
||||
immutable so it can never write to it, and the migration you just performed is
|
||||
sitting in `config.db-wal` waiting to be checkpointed. Rather than read around
|
||||
the log and grade older settings, `check` reports it:
|
||||
The restart in step 3 is what migrated the database, so by now the schema is current and the service is answering. Confirming with `nxdns check` alone would not work here, and the reason is worth knowing: `check` opens `config.db` immutable so it can never write to it, and the migration you just performed is sitting in `config.db-wal` waiting to be checkpointed. Rather than read around the log and grade older settings, `check` reports it:
|
||||
|
||||
```
|
||||
FAIL /var/lib/nxdns/config.db: uncheckpointed changes are waiting in /var/lib/nxdns/config.db-wal, and reading without writing would answer from the older settings in the main file; `nxdns run` applies them. A running nxdns normally holds this log, which is the usual reason to see this line.
|
||||
```
|
||||
|
||||
`export` opens the database read/write and does see the log, so exporting and
|
||||
then checking the export validates what is actually in force:
|
||||
`export` opens the database read/write and does see the log, so exporting and then checking the export validates what is actually in force:
|
||||
|
||||
```
|
||||
checking configuration file /tmp/after-upgrade.zon
|
||||
@@ -301,20 +231,15 @@ OK: no problems found
|
||||
|
||||
## What happens to the database
|
||||
|
||||
Migrations run at startup, and also before `export` and `import`, so whichever
|
||||
of those you run first performs the upgrade. `nxdns check` is the exception: it
|
||||
opens the database immutable and never migrates, so on a database still one
|
||||
version behind it reports the mismatch and exits 2 rather than fixing it:
|
||||
Migrations run at startup, and also before `export` and `import`, so whichever of those you run first performs the upgrade. `nxdns check` is the exception: it opens the database immutable and never migrates, so on a database still one version behind it reports the mismatch and exits 2 rather than fixing it:
|
||||
|
||||
```
|
||||
FAIL /var/lib/nxdns/config.db: schema version 0, this nxdns expects 1; `nxdns run` migrates it, `check` will not
|
||||
```
|
||||
|
||||
That line was reproduced here against a database stamped at version 0; the path
|
||||
and the version numbers are what vary.
|
||||
That line was reproduced here against a database stamped at version 0; the path and the version numbers are what vary.
|
||||
|
||||
A fresh database is created at the current schema version; an older one is
|
||||
stepped up to it. The log line names both versions:
|
||||
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 1
|
||||
@@ -326,9 +251,7 @@ info(migrations): config.db migrated from schema version 0 to 1
|
||||
> rather than nothing. Version 1 is the only schema nxdns has published, so an
|
||||
> upgrade from a populated older one is not a case that exists yet.
|
||||
|
||||
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:
|
||||
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 1
|
||||
@@ -340,39 +263,26 @@ nxdns run failed: SchemaTooNew
|
||||
> hand and `nxdns run` was pointed at it. The two lines above are that run's
|
||||
> output.
|
||||
|
||||
That run exits 1. Recovering means importing the export you took in step 1 into
|
||||
a fresh data directory with the older binary.
|
||||
That run exits 1. Recovering means importing the export you took in step 1 into a fresh data directory with the older binary.
|
||||
|
||||
### Rolling back from file mode
|
||||
|
||||
Putting an older binary back needs no unit edit. The old binary accepts
|
||||
`run --config` — it just reads it as the old seed-once flag — and against a
|
||||
database that already holds configuration it ignores the file entirely and
|
||||
serves the last state the new binary reconciled. So the service comes back up
|
||||
on the configuration it was running.
|
||||
Putting an older binary back needs no unit edit. The old binary accepts `run --config` — it just reads it as the old seed-once flag — and against a database that already holds configuration it ignores the file entirely and serves the last state the new binary reconciled. So the service comes back up on the configuration it was running.
|
||||
|
||||
The consequence is worth stating plainly: **file edits stop applying.** The old
|
||||
binary will not re-read the file, so every change made to `config.zon` after the
|
||||
rollback does nothing at all, silently, until the newer binary is back. If you
|
||||
have to stay on the old binary, use `nxdns import` to apply file changes, or drop
|
||||
the flag so the invocation matches what the binary actually does.
|
||||
The consequence is worth stating plainly: **file edits stop applying.** The old binary will not re-read the file, so every change made to `config.zon` after the rollback does nothing at all, silently, until the newer binary is back. If you have to stay on the old binary, use `nxdns import` to apply file changes, or drop the flag so the invocation matches what the binary actually does.
|
||||
|
||||
The schema note above still governs: a database stamped by a newer binary
|
||||
refuses to open, whatever mode either binary runs in.
|
||||
The schema note above still governs: a database stamped by a newer binary refuses to open, whatever mode either binary runs in.
|
||||
|
||||
## Changing settings, not the binary
|
||||
|
||||
How you change a setting depends on which authority the service runs under.
|
||||
`nxdns run` in `ExecStart` means the database; `nxdns run --config FILE` means
|
||||
the file. The start log names it either way:
|
||||
How you change a setting depends on which authority the service runs under. `nxdns run` in `ExecStart` means the database; `nxdns run --config FILE` means the file. The start log names it either way:
|
||||
|
||||
```
|
||||
info(nxdns): authority: database
|
||||
info(nxdns): authority: file (/etc/nxdns/config.zon)
|
||||
```
|
||||
|
||||
**In file mode**, edit the file, validate it, restart. The admin interface will
|
||||
refuse the change with a 403 naming the file, so there is nothing to get wrong:
|
||||
**In file mode**, edit the file, validate it, restart. The admin interface will refuse the change with a 403 naming the file, so there is nothing to get wrong:
|
||||
|
||||
```sh
|
||||
$EDITOR /etc/nxdns/config.zon
|
||||
@@ -380,8 +290,7 @@ nxdns check --config /etc/nxdns/config.zon
|
||||
systemctl restart nxdns
|
||||
```
|
||||
|
||||
**In database mode**, change settings through the admin interface, through the
|
||||
API, or with an export–edit–import cycle against a stopped server:
|
||||
**In database mode**, 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
|
||||
@@ -391,19 +300,14 @@ nxdns import config-backup.zon
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
`import` needs no flag to add rows or to edit them. It needs `--allow-delete`
|
||||
only when applying the file would delete rows the database holds — including the
|
||||
case where you renamed something, since changing a group's name or an upstream's
|
||||
URL is a delete and an insert to the engine, not an edit. The refusal names the
|
||||
tables and rolls back:
|
||||
`import` needs no flag to add rows or to edit them. It needs `--allow-delete` only when applying the file would delete rows the database holds — including the case where you renamed something, since changing a group's name or an upstream's URL is a delete and an insert to the engine, not an edit. The refusal names the tables and rolls back:
|
||||
|
||||
```
|
||||
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
|
||||
import failed: DestructiveImport
|
||||
```
|
||||
|
||||
Stop the server first either way. `import` rewrites configuration underneath a
|
||||
process that read it at startup, and a running server picks up only some of it.
|
||||
Stop the server first either way. `import` rewrites configuration underneath a process that read it at startup, and a running server picks up only some of it.
|
||||
|
||||
> Verified on this host against a populated scratch data directory, with
|
||||
> `--data-dir` pointing at it — that path is the only difference from the blocks
|
||||
@@ -429,8 +333,7 @@ process that read it at startup, and a running server picks up only some of it.
|
||||
|
||||
## Upgrading to a build of your own
|
||||
|
||||
If you are running something you built rather than a release, step 2 is a
|
||||
build instead of a download:
|
||||
If you are running something you built rather than a release, step 2 is a build instead of a download:
|
||||
|
||||
```sh
|
||||
(cd web && npm ci && npm run build)
|
||||
@@ -439,18 +342,9 @@ zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)"
|
||||
-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. `dist` refuses the `web/dist-placeholder` default outright, so
|
||||
the only way to ship a stale bundle is to leave an old `web/dist` in place.
|
||||
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. `dist` refuses the `web/dist-placeholder` default outright, so the only way to ship a stale bundle is to leave an old `web/dist` in place.
|
||||
|
||||
The staged payload for each target is under
|
||||
`zig-out/dist/stage/nxdns-<version>-<triple>/`, and step 3 continues from there
|
||||
with that path in place of the extracted one. The version string has to equal
|
||||
`.version` in `build.zig.zon` — `verify-dist` asserts it, so a made-up one
|
||||
builds and then fails verification. What tells your build apart from the
|
||||
published release of the same version is `-Dgit-commit`, which `nxdns version`
|
||||
prints beside the version.
|
||||
The staged payload for each target is under `zig-out/dist/stage/nxdns-<version>-<triple>/`, and step 3 continues from there with that path in place of the extracted one. The version string has to equal `.version` in `build.zig.zon` — `verify-dist` asserts it, so a made-up one builds and then fails verification. What tells your build apart from the published release of the same version is `-Dgit-commit`, which `nxdns version` prints beside the version.
|
||||
|
||||
Under Docker, build the image and name it instead of pulling:
|
||||
|
||||
|
||||
+44
-149
@@ -1,12 +1,8 @@
|
||||
# Verify a release
|
||||
|
||||
Checks that a downloaded nxdns release is the one the project published and
|
||||
that it arrived intact. It also gives the recipe for rebuilding the same
|
||||
version from source, and says plainly what that does and does not settle.
|
||||
Checks that a downloaded nxdns release is the one the project published and that it arrived intact. It also gives the recipe for rebuilding the same version from source, and says plainly what that does and does not settle.
|
||||
|
||||
Do this before you run the binary, not after. The whole point of the checksum
|
||||
file is that it is signed, so a tampered mirror cannot hand you a matching
|
||||
tarball and a matching checksum at the same time.
|
||||
Do this before you run the binary, not after. The whole point of the checksum file is that it is signed, so a tampered mirror cannot hand you a matching tarball and a matching checksum at the same time.
|
||||
|
||||
> Verification: every command on this page was run on 2026-08-09 against the
|
||||
> published `v0.0.1` release, from a clean directory, with a clean `GNUPGHOME`
|
||||
@@ -17,8 +13,7 @@ tarball and a matching checksum at the same time.
|
||||
|
||||
## What a release contains
|
||||
|
||||
Five assets, on the release page at
|
||||
`https://git.mial.net/mokhtar/nxdns/releases`:
|
||||
Five assets, on the release page at `https://git.mial.net/mokhtar/nxdns/releases`:
|
||||
|
||||
| Asset | What it is |
|
||||
| --- | --- |
|
||||
@@ -28,28 +23,15 @@ Five assets, on the release page at
|
||||
| `SHA256SUMS.txt.asc` | A detached OpenPGP signature over `SHA256SUMS.txt` |
|
||||
| `IMAGE-DIGEST.txt` | The container image reference this version pushed, pinned by digest |
|
||||
|
||||
Each tarball holds one top-level directory, `nxdns-<version>-<triple>/`, with
|
||||
six files in it: the `nxdns` binary at mode 0755, and `nxdns.service`,
|
||||
`nxdns.conf`, `LICENSE`, `THIRD-PARTY-NOTICES` and `INSTALL.md` at 0644.
|
||||
Each tarball holds one top-level directory, `nxdns-<version>-<triple>/`, with six files in it: the `nxdns` binary at mode 0755, and `nxdns.service`, `nxdns.conf`, `LICENSE`, `THIRD-PARTY-NOTICES` and `INSTALL.md` at 0644.
|
||||
|
||||
`SHA256SUMS.txt` covers `IMAGE-DIGEST.txt` rather than the image, because the
|
||||
image digest does not exist until the push has happened and cannot be computed
|
||||
by the build. Signing the file that names the digest gets you the same
|
||||
guarantee in one signature.
|
||||
`SHA256SUMS.txt` covers `IMAGE-DIGEST.txt` rather than the image, because the image digest does not exist until the push has happened and cannot be computed by the build. Signing the file that names the digest gets you the same guarantee in one signature.
|
||||
|
||||
The `.txt` on three of the five names is not decoration. Gitea decides what an
|
||||
attachment may be by its file extension, and whether it accepts an
|
||||
extensionless upload at all is untested against this instance, so the release
|
||||
uses names it is known to accept. On disk, `zig build dist` still writes a file
|
||||
called `SHA256SUMS`; the release job copies it to `SHA256SUMS.txt` and appends
|
||||
the image line before signing.
|
||||
The `.txt` on three of the five names is not decoration. Gitea decides what an attachment may be by its file extension, and whether it accepts an extensionless upload at all is untested against this instance, so the release uses names it is known to accept. On disk, `zig build dist` still writes a file called `SHA256SUMS`; the release job copies it to `SHA256SUMS.txt` and appends the image line before signing.
|
||||
|
||||
## 1. Pick a version
|
||||
|
||||
Every URL below takes the version from one shell variable. Ask the server
|
||||
rather than typing a number that goes stale: Gitea redirects `releases/latest`
|
||||
to the tag page of the newest published release — newest by publication time,
|
||||
and drafts and pre-releases are excluded.
|
||||
Every URL below takes the version from one shell variable. Ask the server rather than typing a number that goes stale: Gitea redirects `releases/latest` to the tag page of the newest published release — newest by publication time, and drafts and pre-releases are excluded.
|
||||
|
||||
```sh
|
||||
BASE=https://git.mial.net/mokhtar/nxdns
|
||||
@@ -58,8 +40,7 @@ VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
|
||||
echo "$VERSION"
|
||||
```
|
||||
|
||||
To take a particular version instead, set it yourself — substitute the one you
|
||||
want for the placeholder:
|
||||
To take a particular version instead, set it yourself — substitute the one you want for the placeholder:
|
||||
|
||||
```sh
|
||||
VERSION=<version>
|
||||
@@ -68,8 +49,7 @@ VERSION=<version>
|
||||
> Verified: the two-command form, run against this repository, printed `0.0.1`
|
||||
> with `v0.0.1` published.
|
||||
|
||||
Pin the version in anything you script or automate. `latest` is convenient for
|
||||
a person at a terminal and a liability in a machine that upgrades itself.
|
||||
Pin the version in anything you script or automate. `latest` is convenient for a person at a terminal and a liability in a machine that upgrades itself.
|
||||
|
||||
## 2. Download
|
||||
|
||||
@@ -81,8 +61,7 @@ curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
|
||||
curl -fLO "$BASE/releases/download/v$VERSION/IMAGE-DIGEST.txt"
|
||||
```
|
||||
|
||||
`-L` is not optional: Gitea answers an asset URL with a 303 to wherever the
|
||||
attachment is actually stored.
|
||||
`-L` is not optional: Gitea answers an asset URL with a 303 to wherever the attachment is actually stored.
|
||||
|
||||
For the aarch64 tarball, or for both, swap or add the filename:
|
||||
|
||||
@@ -90,8 +69,7 @@ For the aarch64 tarball, or for both, swap or add the filename:
|
||||
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-aarch64-linux-musl.tar.gz"
|
||||
```
|
||||
|
||||
Gitea also accepts the literal word `latest` in place of the tag, so the three
|
||||
assets whose names carry no version can be fetched without one:
|
||||
Gitea also accepts the literal word `latest` in place of the tag, so the three assets whose names carry no version can be fetched without one:
|
||||
|
||||
```sh
|
||||
curl -fLO "$BASE/releases/download/latest/SHA256SUMS.txt"
|
||||
@@ -99,10 +77,7 @@ curl -fLO "$BASE/releases/download/latest/SHA256SUMS.txt.asc"
|
||||
curl -fLO "$BASE/releases/download/latest/IMAGE-DIGEST.txt"
|
||||
```
|
||||
|
||||
That is the Gitea spelling, and it is not GitHub's. `releases/latest/download/`
|
||||
— the form GitHub uses — is a 404 on Gitea; the alias goes in the tag
|
||||
position, as `releases/download/latest/`. The tarball filenames contain the
|
||||
version, so this alias never saves you from knowing it for those two.
|
||||
That is the Gitea spelling, and it is not GitHub's. `releases/latest/download/` — the form GitHub uses — is a 404 on Gitea; the alias goes in the tag position, as `releases/download/latest/`. The tarball filenames contain the version, so this alias never saves you from knowing it for those two.
|
||||
|
||||
> Verified against `v0.0.1`: all five assets downloaded through the versioned
|
||||
> path, `SHA256SUMS.txt` downloaded again through the `latest` alias and hashed
|
||||
@@ -110,9 +85,7 @@ version, so this alias never saves you from knowing it for those two.
|
||||
|
||||
## 3. Check the signature over `SHA256SUMS.txt`
|
||||
|
||||
Get the public key first. It is a signing subkey of the key that signs every
|
||||
commit in this repository, so you can confirm the fingerprint against a clone
|
||||
you already have with `git log --show-signature` or `git verify-tag v$VERSION`:
|
||||
Get the public key first. It is a signing subkey of the key that signs every commit in this repository, so you can confirm the fingerprint against a clone you already have with `git log --show-signature` or `git verify-tag v$VERSION`:
|
||||
|
||||
```
|
||||
A2061F6AB24DF2C0E92346FD1509B54946D08A95
|
||||
@@ -143,32 +116,18 @@ Primary key fingerprint: A206 1F6A B24D F2C0 E923 46FD 1509 B549 46D0 8A95
|
||||
Subkey fingerprint: 019D 00DF 8417 EBFD A547 1E5E F731 9CC0 24FB 5A96
|
||||
```
|
||||
|
||||
The *structure* is what to read: three lines, not one. `using EDDSA key` and
|
||||
`Subkey fingerprint` name the signing subkey that actually made the signature;
|
||||
`Primary key fingerprint` names the certificate it hangs off, and that is the
|
||||
one published above. The subkey fingerprint can change — a signing subkey is
|
||||
revoked and replaced on its own — but the primary fingerprint is the
|
||||
project's identity and stays.
|
||||
The *structure* is what to read: three lines, not one. `using EDDSA key` and `Subkey fingerprint` name the signing subkey that actually made the signature; `Primary key fingerprint` names the certificate it hangs off, and that is the one published above. The subkey fingerprint can change — a signing subkey is revoked and replaced on its own — but the primary fingerprint is the project's identity and stays.
|
||||
|
||||
Exit status 0, and `Good signature`. That warning is normal and is not a
|
||||
failure: it says you have not told GnuPG you believe the key belongs to the
|
||||
person it claims to.
|
||||
Exit status 0, and `Good signature`. That warning is normal and is not a failure: it says you have not told GnuPG you believe the key belongs to the person it claims to.
|
||||
|
||||
Now compare the `Primary key fingerprint` line with the fingerprint in this
|
||||
page. GnuPG prints it as ten space-separated groups of four hex digits, with a
|
||||
double space in the middle, while the fingerprint above is the same 40
|
||||
characters unspaced — so compare the hex digits in order and ignore the
|
||||
spacing, or strip it and let the shell do it:
|
||||
Now compare the `Primary key fingerprint` line with the fingerprint in this page. GnuPG prints it as ten space-separated groups of four hex digits, with a double space in the middle, while the fingerprint above is the same 40 characters unspaced — so compare the hex digits in order and ignore the spacing, or strip it and let the shell do it:
|
||||
|
||||
```sh
|
||||
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt 2>&1 |
|
||||
sed -n 's/^Primary key fingerprint: //p' | tr -d ' '
|
||||
```
|
||||
|
||||
That prints the 40-character form, ready to compare with
|
||||
`A2061F6AB24DF2C0E92346FD1509B54946D08A95`. Do not skip the comparison —
|
||||
`gpg --verify` exits 0 for a good signature from *any* key in your keyring,
|
||||
including one an attacker talked you into importing.
|
||||
That prints the 40-character form, ready to compare with `A2061F6AB24DF2C0E92346FD1509B54946D08A95`. Do not skip the comparison — `gpg --verify` exits 0 for a good signature from *any* key in your keyring, including one an attacker talked you into importing.
|
||||
|
||||
A tampered `SHA256SUMS.txt` looks like this, and exits 1:
|
||||
|
||||
@@ -197,9 +156,7 @@ nxdns-<version>-x86_64-linux-musl.tar.gz: OK
|
||||
IMAGE-DIGEST.txt: OK
|
||||
```
|
||||
|
||||
`--ignore-missing` is what makes this work when you downloaded one tarball out
|
||||
of the two. Without it, `sha256sum` treats every line it cannot read as a
|
||||
failure and exits 1:
|
||||
`--ignore-missing` is what makes this work when you downloaded one tarball out of the two. Without it, `sha256sum` treats every line it cannot read as a failure and exits 1:
|
||||
|
||||
```
|
||||
nxdns-<version>-x86_64-linux-musl.tar.gz: OK
|
||||
@@ -209,8 +166,7 @@ IMAGE-DIGEST.txt: OK
|
||||
sha256sum: WARNING: 1 listed file could not be read
|
||||
```
|
||||
|
||||
A file that is present but does not match is the case that matters, and it
|
||||
says `FAILED` with no `open or read`:
|
||||
A file that is present but does not match is the case that matters, and it says `FAILED` with no `open or read`:
|
||||
|
||||
```
|
||||
nxdns-<version>-x86_64-linux-musl.tar.gz: FAILED
|
||||
@@ -218,9 +174,7 @@ IMAGE-DIGEST.txt: OK
|
||||
sha256sum: WARNING: 1 computed checksum did NOT match
|
||||
```
|
||||
|
||||
Check the signature before the hashes, not after. An attacker who can replace
|
||||
the tarball can replace `SHA256SUMS.txt` next to it; the signature is the only
|
||||
thing in the set they cannot forge.
|
||||
Check the signature before the hashes, not after. An attacker who can replace the tarball can replace `SHA256SUMS.txt` next to it; the signature is the only thing in the set they cannot forge.
|
||||
|
||||
> Verified against `v0.0.1`: with both tarballs present, `sha256sum -c` printed
|
||||
> three `OK` lines. The three transcripts above are the same command over
|
||||
@@ -234,12 +188,7 @@ thing in the set they cannot forge.
|
||||
tar -tvzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
|
||||
```
|
||||
|
||||
Expect exactly one top-level directory and the six files listed above, with
|
||||
mode `-rwxr-xr-x` on `nxdns` and `-rw-r--r--` on the rest, no symlinks, and no
|
||||
path that begins with `/` or contains `..`. `zig build verify-dist` asserts all
|
||||
of that on the extracted archive before a release is ever published, so this is
|
||||
a second opinion rather than the only check — but it costs nothing and it is
|
||||
the step that catches a tarball that is not the one you think it is.
|
||||
Expect exactly one top-level directory and the six files listed above, with mode `-rwxr-xr-x` on `nxdns` and `-rw-r--r--` on the rest, no symlinks, and no path that begins with `/` or contains `..`. `zig build verify-dist` asserts all of that on the extracted archive before a release is ever published, so this is a second opinion rather than the only check — but it costs nothing and it is the step that catches a tarball that is not the one you think it is.
|
||||
|
||||
Then extract:
|
||||
|
||||
@@ -248,9 +197,7 @@ tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
|
||||
./nxdns-$VERSION-x86_64-linux-musl/nxdns version
|
||||
```
|
||||
|
||||
`version` prints the version and the git commit it was built from, then the
|
||||
Zig version. The version has to match the tag you downloaded, and the commit
|
||||
has to match the commit the tag points at.
|
||||
`version` prints the version and the git commit it was built from, then the Zig version. The version has to match the tag you downloaded, and the commit has to match the commit the tag points at.
|
||||
|
||||
> Verified against `v0.0.1`: both tarballs listed exactly the one directory and
|
||||
> six files with the stated modes, no symlinks and no absolute or `..` paths,
|
||||
@@ -260,16 +207,13 @@ has to match the commit the tag points at.
|
||||
|
||||
## 6. Verify the container image
|
||||
|
||||
`IMAGE-DIGEST.txt` holds one line: the image reference this version pushed,
|
||||
pinned by the digest of its index, in the form
|
||||
`IMAGE-DIGEST.txt` holds one line: the image reference this version pushed, pinned by the digest of its index, in the form
|
||||
|
||||
```
|
||||
git.mial.net/mokhtar/nxdns:<version>@sha256:<64 hex digits>
|
||||
```
|
||||
|
||||
`SHA256SUMS.txt` covers `IMAGE-DIGEST.txt`, so the signature you already
|
||||
checked covers that line too. Confirm the tag in the registry still resolves to
|
||||
that digest:
|
||||
`SHA256SUMS.txt` covers `IMAGE-DIGEST.txt`, so the signature you already checked covers that line too. Confirm the tag in the registry still resolves to that digest:
|
||||
|
||||
```sh
|
||||
cut -d@ -f2 IMAGE-DIGEST.txt
|
||||
@@ -277,8 +221,7 @@ docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
|
||||
--format '{{.Manifest.Digest}}'
|
||||
```
|
||||
|
||||
The two have to be the same string. A registry tag is mutable; the digest is
|
||||
not, so pull the whole pinned reference rather than the tag when you care:
|
||||
The two have to be the same string. A registry tag is mutable; the digest is not, so pull the whole pinned reference rather than the tag when you care:
|
||||
|
||||
```sh
|
||||
docker pull "$(cat IMAGE-DIGEST.txt)"
|
||||
@@ -291,12 +234,9 @@ docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
|
||||
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}'
|
||||
```
|
||||
|
||||
`linux/amd64 linux/arm64`. The build passes `--provenance=false --sbom=false`,
|
||||
so there are no `unknown/unknown` attestation entries in the list; seeing any
|
||||
means the image did not come from this pipeline.
|
||||
`linux/amd64 linux/arm64`. The build passes `--provenance=false --sbom=false`, so there are no `unknown/unknown` attestation entries in the list; seeing any means the image did not come from this pipeline.
|
||||
|
||||
The binary inside the image is the same file as the one in the matching
|
||||
tarball, and the release checks that before publishing. To check it yourself:
|
||||
The binary inside the image is the same file as the one in the matching tarball, and the release checks that before publishing. To check it yourself:
|
||||
|
||||
```sh
|
||||
docker create --name nxdns-verify git.mial.net/mokhtar/nxdns:$VERSION
|
||||
@@ -317,35 +257,18 @@ sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
|
||||
|
||||
It proves two things:
|
||||
|
||||
- The release was produced by this project's release pipeline, using a key
|
||||
only that pipeline holds.
|
||||
- What you have on disk is byte for byte what that pipeline uploaded. A
|
||||
corrupted download, a modified mirror or a tampered proxy all break the
|
||||
check.
|
||||
- The release was produced by this project's release pipeline, using a key only that pipeline holds.
|
||||
- What you have on disk is byte for byte what that pipeline uploaded. A corrupted download, a modified mirror or a tampered proxy all break the check.
|
||||
|
||||
It does not prove that the binary in the tarball was built from the source in
|
||||
this repository. The machine that ran the build also held the signing key, so
|
||||
a compromise of that machine produces an artifact that is signed, verifies
|
||||
cleanly, and contains whatever the attacker put in it. The signature is a
|
||||
statement about origin and integrity in transit. It is not a statement about
|
||||
provenance from source.
|
||||
It does not prove that the binary in the tarball was built from the source in this repository. The machine that ran the build also held the signing key, so a compromise of that machine produces an artifact that is signed, verifies cleanly, and contains whatever the attacker put in it. The signature is a statement about origin and integrity in transit. It is not a statement about provenance from source.
|
||||
|
||||
Closing that gap needs a reproducibility gate — an independent build, run
|
||||
somewhere else, that lands on the same bytes — and this project does not have
|
||||
one. It is a recorded deferral, not an oversight: see `specs/milestone-14.md`
|
||||
ruling 12. Until it exists, nothing here claims the build is reproducible,
|
||||
because nobody has measured whether it is.
|
||||
Closing that gap needs a reproducibility gate — an independent build, run somewhere else, that lands on the same bytes — and this project does not have one. It is a recorded deferral, not an oversight: see `specs/milestone-14.md` ruling 12. Until it exists, nothing here claims the build is reproducible, because nobody has measured whether it is.
|
||||
|
||||
The signing key is a subkey rather than the primary key, which limits the
|
||||
damage of the case above: a leaked release subkey is revoked on its own and
|
||||
the identity, the commit signatures and everyone's existing trust in the key
|
||||
survive.
|
||||
The signing key is a subkey rather than the primary key, which limits the damage of the case above: a leaked release subkey is revoked on its own and the identity, the commit signatures and everyone's existing trust in the key survive.
|
||||
|
||||
## Rebuild it yourself
|
||||
|
||||
You can still build the same version from source and compare. That gets you a
|
||||
binary whose provenance you know, and the comparison is worth making — read
|
||||
the paragraph after the recipe before you draw a conclusion from it.
|
||||
You can still build the same version from source and compare. That gets you a binary whose provenance you know, and the comparison is worth making — read the paragraph after the recipe before you draw a conclusion from it.
|
||||
|
||||
```sh
|
||||
git clone https://git.mial.net/mokhtar/nxdns
|
||||
@@ -358,30 +281,13 @@ zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)"
|
||||
sha256sum zig-out/dist/nxdns-"$VERSION"-*.tar.gz
|
||||
```
|
||||
|
||||
`git verify-tag` is the check that the tag itself is signed by the key from
|
||||
step 3, and it is the one part of this section that stands on its own: it ties
|
||||
the source you just checked out to the same identity that signed the release.
|
||||
`git verify-tag` is the check that the tag itself is signed by the key from step 3, and it is the one part of this section that stands on its own: it ties the source you just checked out to the same identity that signed the release.
|
||||
|
||||
`zig build dist` writes `zig-out/dist/`: the two tarballs, a staging directory
|
||||
per target under `stage/`, the stripped binaries under `bin/<triple>/`, and a
|
||||
`SHA256SUMS` covering the two tarballs. The published `SHA256SUMS.txt` is that
|
||||
file with a third line for `IMAGE-DIGEST.txt` appended by the release job, so
|
||||
the two tarball lines should match and the local file has no third line to
|
||||
compare.
|
||||
`zig build dist` writes `zig-out/dist/`: the two tarballs, a staging directory per target under `stage/`, the stripped binaries under `bin/<triple>/`, and a `SHA256SUMS` covering the two tarballs. The published `SHA256SUMS.txt` is that file with a third line for `IMAGE-DIGEST.txt` appended by the release job, so the two tarball lines should match and the local file has no third line to compare.
|
||||
|
||||
Now the caveat, and it is the whole reason this section is last. **A hash that
|
||||
differs does not mean the release was tampered with.** Nothing in this project
|
||||
measures whether two builds of the same commit on two different machines
|
||||
produce the same bytes, and there are several ordinary reasons they would not:
|
||||
a different Zig patch release, a different Node version, a different path to
|
||||
the build directory, a different npm lockfile resolution. A hash that matches
|
||||
is real evidence. A hash that does not match tells you only that something
|
||||
about the two builds differed, and finding out what is on you.
|
||||
Now the caveat, and it is the whole reason this section is last. **A hash that differs does not mean the release was tampered with.** Nothing in this project measures whether two builds of the same commit on two different machines produce the same bytes, and there are several ordinary reasons they would not: a different Zig patch release, a different Node version, a different path to the build directory, a different npm lockfile resolution. A hash that matches is real evidence. A hash that does not match tells you only that something about the two builds differed, and finding out what is on you.
|
||||
|
||||
If you want the comparison to mean as much as it can, match the toolchain the
|
||||
release used. The Zig version is the second line of `nxdns version`, and both
|
||||
it and the Node version are pinned to exact patch releases at the top of
|
||||
`.gitea/workflows/gates.yml`, which is the workflow the release runs.
|
||||
If you want the comparison to mean as much as it can, match the toolchain the release used. The Zig version is the second line of `nxdns version`, and both it and the Node version are pinned to exact patch releases at the top of `.gitea/workflows/gates.yml`, which is the workflow the release runs.
|
||||
|
||||
> Verified against `v0.0.1`, and the result is the caveat above in action. The
|
||||
> whole recipe ran from a fresh clone: `git verify-tag v0.0.1` printed
|
||||
@@ -398,25 +304,14 @@ it and the Node version are pinned to exact patch releases at the top of
|
||||
|
||||
Stop and do not run the binary.
|
||||
|
||||
- `sha256sum` says `FAILED` but the signature was good — you have a damaged or
|
||||
substituted download. Delete it and fetch it again over a different network
|
||||
before assuming anything worse.
|
||||
- `gpg` says `BAD signature` — `SHA256SUMS.txt` and `SHA256SUMS.txt.asc` do not
|
||||
belong together. Re-download both from the release page; a stale
|
||||
`SHA256SUMS.txt.asc` left over from a previous version is the boring
|
||||
explanation.
|
||||
- `gpg` says `Can't check signature: No public key` — you have not imported the
|
||||
key, or you imported a different one.
|
||||
- The fingerprint does not match the one in step 3 — that is the case to take
|
||||
seriously. Do not extract the tarball, and do not import more keys trying to
|
||||
make it pass.
|
||||
- `sha256sum` says `FAILED` but the signature was good — you have a damaged or substituted download. Delete it and fetch it again over a different network before assuming anything worse.
|
||||
- `gpg` says `BAD signature` — `SHA256SUMS.txt` and `SHA256SUMS.txt.asc` do not belong together. Re-download both from the release page; a stale `SHA256SUMS.txt.asc` left over from a previous version is the boring explanation.
|
||||
- `gpg` says `Can't check signature: No public key` — you have not imported the key, or you imported a different one.
|
||||
- The fingerprint does not match the one in step 3 — that is the case to take seriously. Do not extract the tarball, and do not import more keys trying to make it pass.
|
||||
|
||||
## Related
|
||||
|
||||
- [Install with systemd](install-with-systemd.md) — where the verified tarball
|
||||
goes next.
|
||||
- [Install with systemd](install-with-systemd.md) — where the verified tarball goes next.
|
||||
- [Install with Docker](install-with-docker.md) — the published image.
|
||||
- [Upgrade nxdns](upgrade.md) — the same verification, on the way to a newer
|
||||
version.
|
||||
- [Performance targets and what the tests prove](../explanation/performance-and-testing.md)
|
||||
— the other place this project writes down what its checks do not cover.
|
||||
- [Upgrade nxdns](upgrade.md) — the same verification, on the way to a newer version.
|
||||
- [Performance targets and what the tests prove](../explanation/performance-and-testing.md) — the other place this project writes down what its checks do not cover.
|
||||
|
||||
Reference in New Issue
Block a user