milestone 11: systemd and docker packaging, operator and architecture docs, config and api reference, docs drift guards
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
# Operating nxdns
|
||||
|
||||
How to install, configure, back up, upgrade and troubleshoot an nxdns server.
|
||||
For the meaning of every configuration field, see
|
||||
[config-reference.md](config-reference.md); for the HTTP API,
|
||||
[api.md](api.md).
|
||||
|
||||
## Install: systemd
|
||||
|
||||
nxdns ships as one static musl binary. Build it (see
|
||||
[Building](#building-the-binary)) or take it from CI, then:
|
||||
|
||||
```sh
|
||||
# 1. The binary.
|
||||
install -m 0755 nxdns /usr/local/bin/nxdns
|
||||
|
||||
# 2. The service user. Static, not DynamicUser: the TLS key for DoH/DoT
|
||||
# must be chown-able to a stable uid.
|
||||
install -m 0644 deploy/systemd/sysusers.conf /usr/lib/sysusers.d/nxdns.conf
|
||||
systemd-sysusers
|
||||
|
||||
# 3. The unit.
|
||||
install -m 0644 deploy/systemd/nxdns.service /etc/systemd/system/nxdns.service
|
||||
systemctl daemon-reload
|
||||
```
|
||||
|
||||
Do not create directories by hand. The unit's `StateDirectory=nxdns`,
|
||||
`LogsDirectory=nxdns` and `ConfigurationDirectory=nxdns` make systemd create
|
||||
`/var/lib/nxdns` (mode 0700, owned by `nxdns`), `/var/log/nxdns` and
|
||||
`/etc/nxdns` on first start.
|
||||
|
||||
Write a seed configuration to `/etc/nxdns/config.zon`. The minimum that
|
||||
starts is one group named `default` and one enabled upstream:
|
||||
|
||||
```zon
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
|
||||
.web = .{ .password = "choose-a-real-password" },
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
systemctl enable --now nxdns
|
||||
journalctl -u nxdns -f
|
||||
```
|
||||
|
||||
nxdns logs to stderr by default and systemd captures that into the journal;
|
||||
nothing else needs configuring for logs. Port 53 needs
|
||||
`CAP_NET_BIND_SERVICE`, which the unit grants via `AmbientCapabilities`.
|
||||
|
||||
If port 53 is already taken, see
|
||||
[Port 53 conflicts](#port-53-is-taken-systemd-resolved).
|
||||
|
||||
### Building the binary
|
||||
|
||||
Requires Zig 0.16.0 and Node.js 24 (for the web UI). From the repository
|
||||
root:
|
||||
|
||||
```sh
|
||||
(cd web && npm ci && npm run build)
|
||||
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
```
|
||||
|
||||
This produces static binaries for both deploy targets:
|
||||
|
||||
- `zig-out/cross/x86_64-linux-musl/nxdns`
|
||||
- `zig-out/cross/aarch64-linux-musl/nxdns`
|
||||
|
||||
### Raspberry Pi 5 recipe
|
||||
|
||||
The Pi 5 is aarch64. Build on any machine (the cross build needs no
|
||||
toolchain beyond Zig itself), copy the binary over, then follow the systemd
|
||||
steps above on the Pi:
|
||||
|
||||
```sh
|
||||
(cd web && npm ci && npm run build)
|
||||
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
scp zig-out/cross/aarch64-linux-musl/nxdns pi:/tmp/nxdns
|
||||
scp deploy/systemd/nxdns.service deploy/systemd/sysusers.conf pi:/tmp/
|
||||
|
||||
# on the Pi, as root:
|
||||
install -m 0755 /tmp/nxdns /usr/local/bin/nxdns
|
||||
install -m 0644 /tmp/sysusers.conf /usr/lib/sysusers.d/nxdns.conf
|
||||
systemd-sysusers
|
||||
install -m 0644 /tmp/nxdns.service /etc/systemd/system/nxdns.service
|
||||
systemctl daemon-reload
|
||||
# write /etc/nxdns/config.zon, then:
|
||||
systemctl enable --now nxdns
|
||||
```
|
||||
|
||||
The binary is statically linked against musl; it has no runtime
|
||||
dependencies on the Pi.
|
||||
|
||||
## Install: Docker
|
||||
|
||||
The image is built from a binary you compile first; the Dockerfile only
|
||||
assembles the filesystem. From the repository root:
|
||||
|
||||
```sh
|
||||
(cd web && npm ci && npm run build)
|
||||
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
docker build -t nxdns -f deploy/docker/Dockerfile .
|
||||
```
|
||||
|
||||
or, with compose (which runs the same build with the repository root as
|
||||
context):
|
||||
|
||||
```sh
|
||||
cd deploy/docker
|
||||
docker compose build
|
||||
```
|
||||
|
||||
The Dockerfile maps buildx's `TARGETARCH` onto the cross-target directory,
|
||||
so `docker buildx build --platform linux/arm64` produces the aarch64 image
|
||||
from the same `zig-out/cross` tree.
|
||||
|
||||
Before the first `docker compose up`, create the seed configuration the
|
||||
compose file bind-mounts read-only at `/etc/nxdns`:
|
||||
|
||||
```sh
|
||||
cd deploy/docker
|
||||
mkdir -p etc-nxdns
|
||||
$EDITOR etc-nxdns/config.zon # the same minimal seed as the systemd path
|
||||
```
|
||||
|
||||
The seed must be readable by uid 65532, the fixed container user; the bind
|
||||
mount is read-only, so the container cannot adjust permissions itself.
|
||||
World-readable (0644) is fine when the seed carries no secret; if it holds
|
||||
`web.password` or `web.password_hash` (a restored export), restrict it instead:
|
||||
`chown 65532:65532 etc-nxdns/config.zon && chmod 0600 etc-nxdns/config.zon`.
|
||||
|
||||
Without a valid seed — at least a `default` group and one enabled
|
||||
upstream — the container exits with code 2, because a fresh volume holds an
|
||||
empty database and an empty database has no upstream to forward to.
|
||||
|
||||
The compose file publishes 53/udp, 53/tcp and 8080, keeps the data in a
|
||||
named volume mounted at `/var/lib/nxdns`, and sets the per-network-namespace
|
||||
sysctl `net.ipv4.ip_unprivileged_port_start=0` so the nonroot user
|
||||
(uid 65532) can bind port 53. Uncomment the 443/853 port mappings when you
|
||||
enable the DoH or DoT listener.
|
||||
|
||||
Do not point the host's `/etc/resolv.conf` at the nxdns container. The
|
||||
container resolves its upstream DoH/DoT hostnames through the host's DNS
|
||||
configuration; pointing that at nxdns itself makes the container's own
|
||||
lookups depend on the service they are trying to start.
|
||||
|
||||
### Publishing the image to a private registry
|
||||
|
||||
There is deliberately no registry push in CI — credentials and registry
|
||||
choice are an infrastructure decision, not this repository's. To publish
|
||||
manually, log in to your registry, tag the local image with the registry's
|
||||
name, and push: `docker login <registry>`, then
|
||||
`docker tag nxdns <registry>/<owner>/nxdns:<tag>`, then
|
||||
`docker push <registry>/<owner>/nxdns:<tag>`. The same works for a
|
||||
self-hosted Gitea registry such as git.mial.net.
|
||||
|
||||
## First boot and configuration semantics
|
||||
|
||||
The ZON file at `/etc/nxdns/config.zon` (or `--config`) seeds the database
|
||||
exactly once:
|
||||
|
||||
- **No file:** normal steady state; the database is used as it is.
|
||||
- **File present, database empty:** the file is imported. A file that is
|
||||
unreadable, unparseable or invalid is an error (exit 2, every problem
|
||||
printed) — nxdns never falls back to silent defaults over a file you
|
||||
wrote.
|
||||
- **File present, database already configured:** the file is ignored. The
|
||||
database is the truth from the first successful seed onward.
|
||||
|
||||
After the first boot, editing `config.zon` changes nothing. Change the
|
||||
configuration through the web UI, the REST API, or the export→edit→import
|
||||
cycle:
|
||||
|
||||
```sh
|
||||
nxdns export --out config-backup.zon
|
||||
$EDITOR config-backup.zon
|
||||
systemctl stop nxdns
|
||||
nxdns import config-backup.zon --force
|
||||
systemctl start nxdns
|
||||
```
|
||||
|
||||
`import` without `--force` refuses a database that already has content
|
||||
(exit 2), so a plain `import` can never clobber a configured server by
|
||||
accident.
|
||||
|
||||
## Authentication setup
|
||||
|
||||
Set `web.password` in the seed file (or in a file you `import`). At import
|
||||
time it is hashed with argon2id into `web.password_hash` and discarded; the
|
||||
plaintext is never stored anywhere. `nxdns export` always writes
|
||||
`.password = ""` and carries the hash instead, so an exported file
|
||||
re-imports without knowing the password. Setting both `password` and
|
||||
`password_hash` in one file is an error (exit 2). To change the password,
|
||||
export, set `.password` to the new value, clear `.password_hash` to `""`,
|
||||
and import with `--force`.
|
||||
|
||||
## TLS for the DoH/DoT listeners
|
||||
|
||||
Both listeners are disabled by default. To enable one, set
|
||||
`doh_server.enabled` / `dot_server.enabled` and point `cert_path` and
|
||||
`key_path` at a PEM certificate chain and key, conventionally under
|
||||
`/etc/nxdns`. Ownership depends on how you deploy.
|
||||
|
||||
Under systemd, the service runs as the `nxdns` user:
|
||||
|
||||
```sh
|
||||
chown nxdns:nxdns /etc/nxdns/cert.pem /etc/nxdns/key.pem
|
||||
chmod 0644 /etc/nxdns/cert.pem
|
||||
chmod 0600 /etc/nxdns/key.pem
|
||||
```
|
||||
|
||||
Under Docker, the container runs as uid 65532 (fixed in the image) and
|
||||
`/etc/nxdns` is a read-only bind mount, so the container cannot fix
|
||||
permissions itself — the host-side files must already be readable by that
|
||||
uid. It has no name on the host or in the scratch image, so chown it
|
||||
numerically:
|
||||
|
||||
```sh
|
||||
cd deploy/docker
|
||||
chown 65532:65532 etc-nxdns/cert.pem etc-nxdns/key.pem
|
||||
chmod 0644 etc-nxdns/cert.pem
|
||||
chmod 0600 etc-nxdns/key.pem
|
||||
```
|
||||
|
||||
The key must be readable by the user nxdns runs as and only by its owner:
|
||||
`nxdns check` prints a WARN for a key with any group or other permission
|
||||
bits, and a FAIL (exit 2) for a cert or key the user cannot read. A
|
||||
certificate that fails to load at boot while its listener is enabled exits 2.
|
||||
|
||||
Renewals need no restart. A watcher polls both files every 30 seconds and
|
||||
swaps the new pair in atomically; in-flight connections finish on the old
|
||||
certificate. To pick up a renewal immediately — for example from a certbot
|
||||
deploy hook — call `POST /api/certs/reload` (session-authenticated; see
|
||||
[api.md](api.md)). A reload that fails to parse leaves the old certificate
|
||||
serving and reports the error.
|
||||
|
||||
## Backup, restore and upgrades
|
||||
|
||||
**Backup** is one command against a stopped or running server:
|
||||
|
||||
```sh
|
||||
nxdns export --out /some/backup/nxdns-config.zon
|
||||
```
|
||||
|
||||
The write is atomic (temp file + rename) and mode 0600, because the file
|
||||
carries `web.password_hash` — treat backups as secrets. Without `--out` the
|
||||
export goes to stdout, where file permissions are your redirect's problem.
|
||||
The query log is deliberately not part of the backup; it is expendable
|
||||
history.
|
||||
|
||||
**Restore** onto a fresh data directory or over an existing one:
|
||||
|
||||
```sh
|
||||
nxdns import /some/backup/nxdns-config.zon --force
|
||||
```
|
||||
|
||||
**Upgrades:** install the new binary, restart the service. Schema
|
||||
migrations run automatically at startup (and before `check`, `export` and
|
||||
`import`), so a database one schema version behind is upgraded in place.
|
||||
There is no downgrade path; take an export before upgrading.
|
||||
|
||||
## Data directory layout
|
||||
|
||||
Everything lives under the data directory (default `/var/lib/nxdns`,
|
||||
override with `--data-dir`), mode 0700:
|
||||
|
||||
| Path | What it is |
|
||||
| --- | --- |
|
||||
| `config.db` (+ `-wal`, `-shm`) | The configuration database — the single source of truth, including `web.password_hash`. Mode 0600. Back it up via `nxdns export`. |
|
||||
| `querylog.db` (+ `-wal`, `-shm`) | The query log. Mode 0600 — it records every domain every client asked for. Expendable: if it is missing or unusable it is recreated empty. |
|
||||
| `blocklists/` | Compiled blocklist snapshots, two files per source: `<id>.list` (exact domains) and `<id>.wild` (wildcards). `.raw.tmp` / `.list.tmp` / `.wild.tmp` files are transient refresh state. |
|
||||
|
||||
## CLI reference
|
||||
|
||||
```
|
||||
nxdns <command> [options]
|
||||
```
|
||||
|
||||
Flags take both spellings: `--flag value` and `--flag=value`.
|
||||
|
||||
### `run`
|
||||
|
||||
Serves DNS until SIGINT or SIGTERM.
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at 0700 if missing. |
|
||||
| `--config FILE` | Seed configuration file (default `/etc/nxdns/config.zon`). Read only when the database is empty. |
|
||||
| `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
|
||||
|
||||
### `check`
|
||||
|
||||
Validates the configuration and probes the upstreams. Exit 0 when clean,
|
||||
2 when it found problems, and it always reports every problem, not just the
|
||||
first. What it checks, in order:
|
||||
|
||||
1. Which source to check (see [source selection](#check-source-selection)).
|
||||
2. Full validation — the same rules `import` enforces.
|
||||
3. For each enabled DoH/DoT listener: cert and key are readable (FAIL if
|
||||
not), key permissions are owner-only (WARN if not).
|
||||
4. A live probe: one real A query for `example.com` through every enabled
|
||||
upstream, using the same failover machinery the server uses. A FAIL line
|
||||
names the upstream and the concrete cause. This probe leaves the machine,
|
||||
so `check` needs network access to pass.
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory to look for `config.db` in. |
|
||||
| `--config FILE` | Check this file instead of the database. |
|
||||
|
||||
#### check source selection
|
||||
|
||||
- `--config FILE` given explicitly: check that file, nothing else.
|
||||
- Otherwise, if `<data-dir>/config.db` exists: check the database — the
|
||||
right default, since the database is the truth on a configured server.
|
||||
- Otherwise, if the default config file exists: check it.
|
||||
- Otherwise: "nothing to check", exit 2.
|
||||
|
||||
The first line of output always names which source was checked.
|
||||
|
||||
### `export`
|
||||
|
||||
Writes the configuration as ZON to stdout, or atomically at mode 0600 to
|
||||
`--out FILE`. `--out` paths are relative to the shell's working directory.
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory holding `config.db`. |
|
||||
| `--out FILE` | Write to FILE instead of stdout. |
|
||||
|
||||
### `import FILE`
|
||||
|
||||
Validates FILE and replaces the configuration with it. Prints every
|
||||
validation problem on failure. Refuses a non-empty database without
|
||||
`--force`.
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
|
||||
| `--force` | Replace a database that already has content. |
|
||||
|
||||
### `version`
|
||||
|
||||
Prints the nxdns version, git commit and Zig version.
|
||||
|
||||
### `help`
|
||||
|
||||
Prints usage. `--help` and `-h` do the same.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| 0 | Success. |
|
||||
| 1 | Runtime failure — I/O, database, out of memory. |
|
||||
| 2 | A configuration problem the operator can fix, or a `check` that found one. |
|
||||
| 64 | Usage error — unknown command or flag, missing argument. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### The service exits with code 2
|
||||
|
||||
Exit 2 always means a configuration you can fix; `nxdns run` prints the
|
||||
cause and suggests `nxdns check`, which shows the full list. The usual
|
||||
causes:
|
||||
|
||||
- Empty database and no seed file, or a seed file with no `default` group
|
||||
or no enabled upstream. On a fresh install this means `config.zon` is
|
||||
missing, in the wrong place, or invalid.
|
||||
- A seed or imported file that fails validation — every problem is printed
|
||||
with its field name.
|
||||
- `dns.bind_ipv4` / `dns.bind_ipv6` is not an IP address, or a rate limit
|
||||
is zero (possible only in a hand-edited database; `import` refuses both).
|
||||
- A DoH/DoT listener is enabled but its certificate or key is unreadable or
|
||||
unparseable at boot.
|
||||
- `import` into a non-empty database without `--force`.
|
||||
|
||||
### Port 53 is taken (systemd-resolved)
|
||||
|
||||
On most systemd distributions, `systemd-resolved` owns a stub listener on
|
||||
`127.0.0.53:53`, and on some setups binds `0.0.0.0:53`. Turn the stub off
|
||||
and keep resolved for the host's own lookups:
|
||||
|
||||
```sh
|
||||
mkdir -p /etc/systemd/resolved.conf.d
|
||||
printf '[Resolve]\nDNSStubListener=no\n' > /etc/systemd/resolved.conf.d/nxdns.conf
|
||||
systemctl restart systemd-resolved
|
||||
```
|
||||
|
||||
If `/etc/resolv.conf` is a symlink to the stub
|
||||
(`/run/systemd/resolve/stub-resolv.conf`), repoint it at
|
||||
`/run/systemd/resolve/resolv.conf` so the host still resolves. Do not point
|
||||
the host running nxdns at nxdns itself if that host is where nxdns resolves
|
||||
its upstream DoH/DoT hostnames — that is a bootstrap cycle.
|
||||
|
||||
### Disk is filling up
|
||||
|
||||
The disk monitor samples free space and database sizes once a minute and
|
||||
classifies the state against `disk.warn_free_mb` and `disk.min_free_mb`.
|
||||
Below the warn threshold it logs the transition; below `min_free_mb` it
|
||||
gates every non-essential write: the query logger holds its batches, the
|
||||
client tracker stops persisting, and the blocklist scheduler skips its
|
||||
refresh passes. DNS keeps answering throughout — resolution never degrades
|
||||
because the disk is full. The state and the size gauges are visible on
|
||||
`/metrics` and in the web UI. Recover space (lower
|
||||
`logging.retention_days`, or delete `querylog.db` with the service
|
||||
stopped) and writes resume on the next sample.
|
||||
|
||||
### Blocklists are not filtering
|
||||
|
||||
Serving starts even when no blocklist snapshot loads — a household loses
|
||||
more from DNS that refuses to start than from a window of unfiltered
|
||||
answers. The startup journal line says either `blocklist generation N` or
|
||||
`unfiltered (no blocklist snapshot)`. If it says unfiltered, check the
|
||||
journal for the download or compile warning that preceded it.
|
||||
Reference in New Issue
Block a user