docs: rewrite for people running nxdns; query-history refusals name their remedy and a refused start gives no wrong hints

This commit is contained in:
2026-10-21 17:03:00 +02:00
parent fbc8fd7882
commit c21e5a9542
31 changed files with 1186 additions and 3877 deletions
+11
View File
@@ -4,6 +4,17 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
## [Unreleased]
### Fixed
- **A refused query history says what to do.** When nxdns refuses to open `querylog.db`, because a newer release wrote it or it cannot be converted, the message now gives the remedy: run the newer release again, put back the `querylog.db.pre-migrate-*` copy, or move the file and its `-wal` and `-shm` files aside to start an empty history. It used to point at a documentation page.
- **A refused start no longer gives advice that does not apply.** After a query history refusal, `nxdns run` also printed "run `nxdns check`" and "load one with `nxdns import <file>`", though the configuration was fine. The import hint now appears only when the database has no usable upstream, and neither hint follows a database refusal.
### Changed
- **The documentation is written for people running nxdns.** It starts from Get started, which downloads a release instead of building from source, and drops the API reference, the troubleshooting page and the performance pages. It is published at <https://nxdns.sh/docs/>, and `INSTALL.md` in the tarball links there.
## [0.0.24] - 2026-10-21
### Fixed
+7 -9
View File
@@ -13,7 +13,7 @@ This directory is an nxdns release for one architecture. It holds:
The binary is statically linked against musl and needs nothing installed on the target host.
Verify the download before you trust it. `docs/how-to/verify-a-release.md` in the repository covers where the public key comes from, what fingerprint to expect, and what the signature does and does not prove.
Verify the download before you trust it: <https://nxdns.sh/docs/how-to/verify-a-release/> covers where the public key comes from, what fingerprint to expect, and what the signature does and does not prove.
## 1. Install the binary, the user and the unit
@@ -78,7 +78,7 @@ rm /etc/nxdns/config.zon
A kept file is not a backup. `nxdns export` is.
To keep the file as the configuration instead — converged at every start, with the UI refusing configuration edits — do not delete it, and add a drop-in that appends `--config=/etc/nxdns/config.zon` to `ExecStart`. See `docs/how-to/install-with-systemd.md`.
To keep the file as the configuration instead — converged at every start, with the UI refusing configuration edits — do not delete it, and add a drop-in that appends `--config=/etc/nxdns/config.zon` to `ExecStart`. See <https://nxdns.sh/docs/how-to/install-with-systemd/#run-in-file-mode>.
## 3. Start it
@@ -101,11 +101,9 @@ The admin interface is on port 8080 by default; log in with the password from th
## More
The full documentation lives in the repository at
<https://git.mial.net/mokhtar/nxdns>:
The documentation is at <https://nxdns.sh/docs/>:
- `docs/how-to/install-with-systemd.md` — this procedure with the reasoning
- `docs/how-to/verify-a-release.md` — verifying the download
- `docs/how-to/troubleshoot.md` — first-install failures
- `docs/reference/configuration.md` — every configuration field
- `docs/reference/cli.md` — every command and flag
- <https://nxdns.sh/docs/how-to/install-with-systemd/> — this procedure in full, including file mode and a busy port 53
- <https://nxdns.sh/docs/how-to/upgrade/> — moving to a new release
- <https://nxdns.sh/docs/reference/configuration/> — every configuration setting
- <https://nxdns.sh/docs/reference/cli/> — every command and flag
+2 -2
View File
@@ -92,7 +92,7 @@ Two SQLite files with opposite write profiles, isolated from each other:
### 3.7 Upgrades: Auto-Migration (Decision J)
- `config.db`: numbered, sequential SQL migration steps compiled into the binary. At startup: read schema version row, apply newer steps inside a transaction, continue. Operator upgrade = install binary, restart. Before v0.1 the list holds one step — the baseline of §11.2, edited in place — because nxdns has no installs and a step exists only to reconcile a database somebody already has.
- `querylog.db`: a logical version in `PRAGMA user_version`, migrated **in place** at startup by the same shape of compiled step list, inside one transaction and behind one `querylog.db.pre-migrate-<epoch>` backup (only the newest is kept). A healthy file is never renamed aside: a version this build cannot reach refuses the startup with instructions, and only corruption recreates. A deliberate break is still allowed, but it must be versioned, refused at startup, and disclosed in the changelog — the cut gate enforces that. See `docs/reference/query-log-lifecycle.md`.
- `querylog.db`: a logical version in `PRAGMA user_version`, migrated **in place** at startup by the same shape of compiled step list, inside one transaction and behind one `querylog.db.pre-migrate-<epoch>` backup (only the newest is kept). A healthy file is never renamed aside: a version this build cannot reach refuses the startup with instructions, and only corruption recreates. A deliberate break is still allowed, but it must be versioned, refused at startup, and disclosed in the changelog — the cut gate enforces that.
### 3.8 Blocklist Storage (Decision A)
@@ -554,7 +554,7 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
### 13.2 OpenAPI
Hand-maintained `openapi.yaml`, served at `GET /api/openapi.yaml` and mirrored by hand in `docs/reference/api.md`. No renderer is vendored. Two drift guards fail the build instead: `src/web/openapi.zig` asserts every served route appears in the spec, and `src/docs_drift_test.zig` asserts every served route has its own table row in the reference page.
Hand-maintained `openapi.yaml`, served at `GET /api/openapi.yaml`. No renderer is vendored, and the user docs carry no API reference: the API serves the admin UI. A drift guard fails the build instead: `src/web/openapi.zig` asserts every served route appears in the spec.
---
+4 -6
View File
@@ -80,15 +80,13 @@ On the toolchain versions pinned at the top of `.gitea/workflows/gates.yml`, you
## Documentation
Start at [docs/README.md](docs/README.md), which splits the documentation into a tutorial, how-to guides, reference and explanation.
The documentation is published at [nxdns.sh/docs](https://nxdns.sh/docs/). Its source is [docs/](docs/README.md).
- [docs/tutorial/first-run.md](docs/tutorial/first-run.md) — build it, resolve a name, block a domain, on a scratch directory
- [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) — a real install, including the Raspberry Pi 5
- [docs/tutorial/get-started.md](docs/tutorial/get-started.md) — download it, resolve a name, block a domain, on a scratch directory
- [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) — a real install, including a Raspberry Pi
- [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) — checking the hashes and the signature before you install
- [docs/reference/configuration.md](docs/reference/configuration.md) — every configuration field
- [docs/reference/api.md](docs/reference/api.md) — REST API, auth and SSE
- [docs/reference/cli.md](docs/reference/cli.md) — subcommands, flags and exit codes
- [docs/explanation/architecture.md](docs/explanation/architecture.md) — module map and design
- [docs/reference/cli.md](docs/reference/cli.md) — commands, flags and exit codes
- [PLAN.md](PLAN.md) and [specs/](specs/) — scope, design decisions and per-milestone contracts
## Licence
+14 -38
View File
@@ -1,52 +1,28 @@
# nxdns documentation
The pages are split by what you are trying to do, following [Diátaxis](https://diataxis.fr/). Each page serves one of four purposes, and knowing which one you want is the fastest way to the right page.
| Mode | For | Read it when |
| --- | --- | --- |
| [Tutorial](#tutorial) | Someone who has never run nxdns | You want to learn what it does by making it work once |
| [How-to guides](#how-to-guides) | An operator with a job to do | You know what you want and need the steps |
| [Reference](#reference) | Anyone who needs an exact answer | You want a field, a flag, a route or an exit code |
| [Explanation](#explanation) | Anyone deciding or debugging | You want to know why it works the way it does |
New to nxdns? Start with [Get started](tutorial/get-started.md). It runs nxdns from a scratch directory and blocks a real ad domain, and you can delete everything afterwards.
## Tutorial
A lesson, not a procedure: one path with one outcome, on a scratch directory you can delete afterwards.
- [tutorial/first-run.md](tutorial/first-run.md) — build nxdns, resolve a name, block a domain from a real blocklist, open the web interface, stop cleanly.
- [Get started](tutorial/get-started.md) — download nxdns, resolve a name, add a blocklist and watch it block, then clean up.
## How-to guides
Steps for a goal you already have. They assume you know what nxdns is.
- [how-to/verify-a-release.md](how-to/verify-a-release.md) — check the signature and the checksums before you run anything, and what they prove.
- [how-to/install-with-systemd.md](how-to/install-with-systemd.md) — a real install as a system service, including the Raspberry Pi 5 aarch64 binary.
- [how-to/install-with-docker.md](how-to/install-with-docker.md) — the published container image and the compose file.
- [how-to/install-with-nix.md](how-to/install-with-nix.md) — the flake input pinned to a release tag, the `services.nxdns` NixOS module, and Renovate for tag bumps.
- [how-to/upgrade.md](how-to/upgrade.md) — move to a new release without losing state.
- [how-to/troubleshoot.md](how-to/troubleshoot.md) — what to do when it does not answer, does not block, or will not start.
- [how-to/enable-doh-and-dot.md](how-to/enable-doh-and-dot.md) — serve encrypted DNS with certificates.
- [how-to/set-up-admin-authentication.md](how-to/set-up-admin-authentication.md) — put a password on the web interface and the API.
- [how-to/back-up-and-restore.md](how-to/back-up-and-restore.md) — export and import the configuration, and what to copy.
- [how-to/measure-performance.md](how-to/measure-performance.md) — run the benchmark harness on your own hardware.
- [Verify a release](how-to/verify-a-release.md) — check the signature and checksums before you run anything.
- [Install with systemd](how-to/install-with-systemd.md) — a system service on Linux, including a Raspberry Pi.
- [Install with Docker](how-to/install-with-docker.md) — the published image with Docker Compose.
- [Install with Nix](how-to/install-with-nix.md) — the flake and its `services.nxdns` NixOS module.
- [Upgrade](how-to/upgrade.md) — move to a new release, and back if you must.
- [Enable DoH and DoT](how-to/enable-doh-and-dot.md) — serve encrypted DNS to your devices.
- [Set up admin authentication](how-to/set-up-admin-authentication.md) — put a password on the web interface.
- [Back up and restore](how-to/back-up-and-restore.md) — export the configuration and load it back.
## Reference
Descriptions of what is there. No procedures, no advice.
- [reference/configuration.md](reference/configuration.md) — every configuration section, field, default and range.
- [reference/api.md](reference/api.md) — every REST route, authentication and the event stream.
- [reference/cli.md](reference/cli.md) — the six subcommands, every flag, every exit code.
- [reference/files-and-directories.md](reference/files-and-directories.md) — the data directory layout and file modes.
- [reference/query-log-lifecycle.md](reference/query-log-lifecycle.md) — how `querylog.db` is versioned, migrated, backed up and, rarely, recreated.
- [reference/performance.md](reference/performance.md) — the targets and the measured numbers.
- [Configuration](reference/configuration.md) — every setting and list, with defaults and allowed values.
- [Command line](reference/cli.md) — every command, flag and exit code.
- [Files and directories](reference/files-and-directories.md) — what nxdns keeps where, and the query history database.
## Explanation
Background. Nothing here is needed to operate nxdns; it is here so the decisions are inspectable.
- [explanation/architecture.md](explanation/architecture.md) — the module map and the design it comes from.
- [explanation/configuration-model.md](explanation/configuration-model.md) — why there are two authority modes, how each one is selected, and what each is for.
- [explanation/performance-and-testing.md](explanation/performance-and-testing.md) — why the targets exist, why CI does not gate on them, and what the hermetic tests do and do not prove.
Scope and per-milestone contracts live outside this directory, in [../PLAN.md](../PLAN.md) and [../specs/](../specs/).
- [The configuration model](explanation/configuration-model.md) — database mode and file mode, and which to choose.
+8 -9
View File
@@ -2,23 +2,23 @@
//! `docs_files` anonymous import (test builds only) — @embedFile paths resolve
//! relative to this file.
pub const reference_api_md = @embedFile("reference/api.md");
pub const reference_configuration_md = @embedFile("reference/configuration.md");
pub const reference_cli_md = @embedFile("reference/cli.md");
pub const reference_files_and_directories_md = @embedFile("reference/files-and-directories.md");
pub const tutorial_first_run_md = @embedFile("tutorial/first-run.md");
pub const tutorial_get_started_md = @embedFile("tutorial/get-started.md");
pub const howto_back_up_and_restore_md = @embedFile("how-to/back-up-and-restore.md");
pub const howto_enable_doh_and_dot_md = @embedFile("how-to/enable-doh-and-dot.md");
pub const howto_install_with_docker_md = @embedFile("how-to/install-with-docker.md");
pub const howto_install_with_nix_md = @embedFile("how-to/install-with-nix.md");
pub const howto_install_with_systemd_md = @embedFile("how-to/install-with-systemd.md");
pub const howto_measure_performance_md = @embedFile("how-to/measure-performance.md");
pub const howto_set_up_admin_authentication_md = @embedFile("how-to/set-up-admin-authentication.md");
pub const howto_troubleshoot_md = @embedFile("how-to/troubleshoot.md");
pub const howto_upgrade_md = @embedFile("how-to/upgrade.md");
pub const howto_verify_a_release_md = @embedFile("how-to/verify-a-release.md");
pub const explanation_configuration_model_md = @embedFile("explanation/configuration-model.md");
pub const Page = struct {
/// Repo-relative path, so a failing assertion names the file to edit.
path: []const u8,
@@ -28,27 +28,26 @@ pub const Page = struct {
/// Every page this module embeds. A guard that must hold for all of them walks
/// this table instead of naming each decl.
pub const pages: []const Page = &.{
.{ .path = "docs/reference/api.md", .text = reference_api_md },
.{ .path = "docs/reference/configuration.md", .text = reference_configuration_md },
.{ .path = "docs/reference/cli.md", .text = reference_cli_md },
.{ .path = "docs/tutorial/first-run.md", .text = tutorial_first_run_md },
.{ .path = "docs/reference/files-and-directories.md", .text = reference_files_and_directories_md },
.{ .path = "docs/tutorial/get-started.md", .text = tutorial_get_started_md },
.{ .path = "docs/how-to/back-up-and-restore.md", .text = howto_back_up_and_restore_md },
.{ .path = "docs/how-to/enable-doh-and-dot.md", .text = howto_enable_doh_and_dot_md },
.{ .path = "docs/how-to/install-with-docker.md", .text = howto_install_with_docker_md },
.{ .path = "docs/how-to/install-with-nix.md", .text = howto_install_with_nix_md },
.{ .path = "docs/how-to/install-with-systemd.md", .text = howto_install_with_systemd_md },
.{ .path = "docs/how-to/measure-performance.md", .text = howto_measure_performance_md },
.{ .path = "docs/how-to/set-up-admin-authentication.md", .text = howto_set_up_admin_authentication_md },
.{ .path = "docs/how-to/troubleshoot.md", .text = howto_troubleshoot_md },
.{ .path = "docs/how-to/upgrade.md", .text = howto_upgrade_md },
.{ .path = "docs/how-to/verify-a-release.md", .text = howto_verify_a_release_md },
.{ .path = "docs/explanation/configuration-model.md", .text = explanation_configuration_model_md },
};
/// The pages that paste a transcript naming the running binary's version. Each
/// one must print the `<version>` placeholder rather than the version of the
/// day the page was written.
pub const version_transcript_pages: []const Page = &.{
.{ .path = "docs/tutorial/first-run.md", .text = tutorial_first_run_md },
.{ .path = "docs/tutorial/get-started.md", .text = tutorial_get_started_md },
.{ .path = "docs/how-to/install-with-docker.md", .text = howto_install_with_docker_md },
.{ .path = "docs/how-to/install-with-systemd.md", .text = howto_install_with_systemd_md },
.{ .path = "docs/how-to/upgrade.md", .text = howto_upgrade_md },
-144
View File
@@ -1,144 +0,0 @@
# Architecture
nxdns is a self-hosted DNS sinkhole for a household LAN: one static Zig binary that answers DNS on UDP/TCP 53 (optionally DoH and DoT), filters against blocklists, and serves an embedded admin SPA over HTTP. This page maps the source tree and explains the few design rules that hold everywhere, and why they are the rules.
For what the configuration fields, API routes and CLI flags actually are, see [reference/configuration.md](../reference/configuration.md), [reference/api.md](../reference/api.md) and [reference/cli.md](../reference/cli.md). This page does not repeat them.
## Module map
Top-level files:
| File | Role |
|---|---|
| `src/main.zig` | Process shell: writers, argv, dispatch, exit code. |
| `src/cli.zig` | Every command body (`run`, `check`, `export`, `import`, `version`, `help`); takes its writers as parameters so tests capture output without a process. |
| `src/app.zig` | The composition root: everything `nxdns run` owns, built in order. Nothing else constructs a collaborator. |
| `src/version.zig` | Build-time version strings. |
| `src/tests.zig` | Test root; imports each file directly. |
| `src/docs_drift_test.zig` | Guards that keep the reference pages in step with the code. |
Directories:
| Directory | Role |
|---|---|
| `src/dns/` | Pure DNS wire format: header, names, questions, records, whole packets, EDNS(0)/ECS (`edns.zig`), enums and limits (`types.zig`). No allocation, no `std.Io` beyond writing to a caller's writer. |
| `src/filter/` | Blocklist pipeline: line parsers (hosts, domains, ABP), the compiler that turns a downloaded list into `.list`/`.wild`/`.allow` bodies, `domain_set.zig` (exact-match set, no Bloom filter), `matcher.zig` (the immutable snapshot every query evaluates against), per-group `rules.zig`, `wildcard.zig`, `regex.zig` (a Pike VM for the operator's regex rules, linear-time by construction), `safesearch.zig`, blocked-response synthesis (`response.zig`). Two I/O edges live here too: `fetcher.zig` (HTTP download) and `manager.zig` (files + DB + snapshot swap). |
| `src/local/` | Local DNS records and conditional forward zones: immutable lookup tables built once from DB rows (`records.zig`, `forward_zones.zig`), plus the plain UDP/TCP client for LAN resolvers (`forward_client.zig`). |
| `src/cache/` | `dns_cache.zig`: bounded in-memory TTL cache of whole response messages, keyed by the question. The clock arrives as a parameter. |
| `src/upstream/` | Upstream resolution: shared vocabulary and the `Client` interface (`transport.zig`), DoH client (RFC 8484), DoT client (RFC 7858), per-endpoint health and backoff (`health.zig`), and `pool.zig` — priority-ordered failover that is itself a `transport.Client`, so the handler sees one interface. |
| `src/server/` | The serving side: UDP/TCP/DoH/DoT listeners, `handler.zig` (the whole query pipeline), `cert_store.zig` (refcounted TLS cert holder), `rate_limiter.zig`, `pause.zig`, `clients.zig` (client auto-materialisation), `local_tables.zig` (published local-answer tables), `query_sink.zig` (log and SSE fanout), `shutdown.zig` (SIGINT/SIGTERM into one `std.Io.Event`). |
| `src/storage/` | SQLite ownership: `db.zig` is the only file that calls SQLite, `config_schema.zig` + `migrations.zig` for `config.db`, `querylog_schema.zig` + `querylog_versions.zig` + `querylog_migrations.zig` for `querylog.db`, async query `logger.zig`, `retention.zig`, `disk_monitor.zig`, and one repository per table under `repositories/`. |
| `src/config/` | The one configuration model (`model.zig`), the pure validator (`validate.zig`), `import.zig`/`export.zig` (ZON to and from `config.db`, byte-stable round trip), `loader.zig` (read/parse/validate a named file, with the shared fault mapping), `reconcile.zig` (converge the database onto a parsed config by row identity). |
| `src/web/` | The admin HTTP layer: `server.zig` (listener), `router.zig`/`routes.zig`, one file per resource under `handlers/`, `auth.zig` (sessions), `sse.zig` (live query fanout), `static.zig` (embedded SPA), `metrics.zig` (Prometheus), `openapi.zig` (served contract), `api_limiter.zig`, `http_util.zig`. |
| `src/platform/` | OS and TLS edges: IP address values, the `std.log` sink (`logging.zig`), `statfs.zig` (free-space query via libc), client TLS over `std.crypto.tls` (`tls_client.zig`), server TLS over vendored Mbed TLS (`tls_server.zig`). |
The SPA source lives in `admin/` at the repo root; the build embeds its `dist/` output as the `admin_assets` module (`-Dadmin-dist`).
```
main.zig ── cli.zig ── app.zig (composition root)
│ injects std.Io + collaborators
┌──────────────────────┴───────────────────────┐
│ server/ web/ upstream/ storage/ │ I/O edge
│ platform/ config/{loader,reconcile,import} │
├──────────────────────────────────────────────┤
│ dns/ filter/* local/* cache/ │ pure core:
│ config/{model,validate} │ bytes in, bytes out
└──────────────────────────────────────────────┘
* except filter/{fetcher,manager}.zig and local/forward_client.zig,
which are those directories' named I/O edges
```
## The purity rule
`dns/`, `filter/`, `local/` and `cache/` take bytes and return bytes: no `std.Io`, no sockets, no clocks hidden inside (AGENTS.md). Anything that needs a timestamp takes it as a parameter — the cache, the rate limiter and the pause flag all work this way, so every decision is testable without a backend.
The point is not purity for its own sake. A decision that depends on a hidden clock or a hidden socket can only be tested by arranging the world around it; one that takes the clock as an argument is tested by passing a number. The whole filtering and caching pipeline can therefore be exercised in the plain, network-free test suite, which is what makes that suite worth gating CI on.
The exceptions are deliberate, few, and named: `filter/fetcher.zig` downloads lists, `filter/manager.zig` owns the compiled files, the DB columns and the snapshot swap, and `local/forward_client.zig` speaks UDP/TCP to a LAN resolver. Those three files are the only ones under those four directories that take a `std.Io`. The decision path a query takes through them allocates nothing and opens nothing.
The honest cost of the exceptions shows up in [performance-and-testing.md](performance-and-testing.md): `fetcher.zig` is where the one production crash came from, precisely because it is the file the pure suite cannot reach.
## std.Io injection
There is one `std.Io` in the process. `main` receives it through `std.process.Init` — on the standard start path this is the Threaded backend (`std.Io.Threaded`, constructed in the stdlib's start code) — and hands it to `cli.Runner`, from which `app.zig` threads it into every collaborator as a parameter. No module constructs its own event loop or reads an ambient clock; tests build their own `std.Io.Threaded` instance and pass it the same way.
The one deliberate exception is `storage/db.zig`: SQLite performs its own file I/O through its VFS, so that file takes no `std.Io` at all. Wrapping SQLite's VFS to route through `std.Io` would be a large amount of C-boundary code to make one dependency match a convention it does not need.
## Life of one query
The pipeline lives in `src/server/handler.zig` — `Handler.handle` does validation and setup, then `Context.run` decides the answer. Its order is PLAN §4; the stages below are the code's actual call chain:
```
UDP/53 TCP/53 DoH DoT (src/server/{udp,tcp,doh,dot}_server.zig)
└──────┴──────┴────┘
│ raw query bytes, listener-owned buffers
▼
handler.handle
├─ header parse (too short / QR set → counted drop)
├─ rate limit (over budget → REFUSED)
├─ packet + EDNS validation (FORMERR / NOTIMP, qdcount != 1 → FORMERR)
├─ one clock read, client tracking, snapshot + local-table acquire
├─ group lookup (snapshot.groupForClient)
│
└─ Context.run
├─ qclass != IN ──► upstream, unfiltered and uncached
├─ local records ────────────────► authoritative answer
├─ forward zones ─► cache ─► LAN resolver ─► cache put ─► answer
│
└─ upstream path (filtering off while paused)
├─ filter snapshot evaluate ─► blocked? synthesized block reply
├─ safe-search rewrite (per group)
├─ cache get ─► hit? answer
├─ upstream pool: priority failover across DoH/DoT endpoints
├─ CNAME uncloak: walk the answer's chain, re-evaluate each target
└─ cache put (skipped for safe-search answers)
▼
reply: UDP size check ─► TC=1 if over the limit ─► listener sends
│
└─► QuerySink ─► SSE hub (GET /api/queries/live)
└─► async logger ─► querylog.db
```
Local records win over forward zones, and both win over filtering: a name nxdns answers itself never reaches a blocklist. Pause suspends filtering only; local records, forward zones, cache, upstream and the query log keep running, which is what makes pause safe to hand to a household member. A question whose class is not IN bypasses local answers, filtering and the cache entirely and goes straight upstream — nxdns has no opinion about CHAOS or HESIOD names and declines to cache answers it does not model.
`handle` returns no error union. Every failure is either a DNS response the client can act on or a counted drop, because there is no caller above it that could do anything useful with a Zig error. Two consequences are worth knowing: answers synthesized from a safe-search rewrite are never cached (the rewrite is per group, and the cache is not), and a SERVFAIL reply short-circuits before the query sink, so it appears in the counters but not in the query log.
The query path never waits on the database. `QuerySink` copies the entry, the SSE hub gets it first, and one writer task owns the `querylog.db` handle behind an `std.Io.Queue`. A slow disk delays logging, never resolution.
Two smaller decisions in the same spirit: the upstream pool makes a second pass that ignores backoff, so "every endpoint is in backoff" degrades to trying anyway rather than to a blanket SERVFAIL; and a handler that has no filter snapshot yet answers unfiltered rather than refusing. Both prefer a working resolver over a correct-looking failure.
## Storage
Two databases with opposite contracts, in one data directory (see [reference/files-and-directories.md](../reference/files-and-directories.md)).
**`config.db` is what the server reads.** Its schema is versioned: `migrations.zig` holds an ordered list of steps, step 1 being the verbatim DDL from `config_schema.zig`, each applied inside one transaction. `nxdns import` replaces the whole content atomically under `BEGIN IMMEDIATE`, so a failed import changes nothing; `nxdns export` renders it back as canonical ZON, byte-identical across round trips.
Which of the file and the database is *authoritative* is chosen by the invocation, not by state: bare `nxdns run` serves the database, and `nxdns run --config FILE` makes the file authoritative and reconciles the database onto it at every start. `reconcile.zig` is that convergence, matching rows by identity and writing only differences, so runtime state — blocklist checksums, compiled snapshots, client history — survives. Why it works that way is [configuration-model.md](configuration-model.md).
**`querylog.db` is the expendable one, but its history is not thrown away.** It carries a logical version in `PRAGMA user_version`, and an older supported version is migrated in place at open: one transaction, behind one `querylog.db.pre-migrate-<epoch>` backup, of which only the newest is kept. Only real damage recreates the file — missing, corrupt, not a database, or failing `quick_check` — and then the old file is kept under a new name rather than deleted, so an operator can still look at it. A healthy file this build cannot read is neither migrated nor moved aside: the startup refuses and says why, because losing months of history to a rollback is worse than a server that will not start. Retention deletes old rows daily and periodically rewrites the file to reclaim space. The whole contract is [reference/query-log-lifecycle.md](../reference/query-log-lifecycle.md).
The split exists so that the churn of the second database can never endanger the first. Query logs are high-volume, disposable, and the thing most likely to be corrupted by a power cut on an SD card; configuration is small, irreplaceable, and the thing an operator would have to reconstruct by hand. Giving them one file would force the careful contract onto the noisy data or the loose contract onto the valuable data.
## Web stack
`web/server.zig` runs one `std.http.Server` per connection over its own accept loop, with a fixed set of pre-allocated connection slots, optionally behind TLS. Over capacity it answers 503 rather than queueing without bound — the admin UI is not the product, and it must not be able to starve DNS.
The SPA is embedded at build time: `static.zig` serves the `admin_assets` module — bytes, content type, strong ETag, and a pre-compressed `.gz` sibling where it paid off — via a linear scan with no filesystem access at runtime. (The one exception is `nxdns run --admin-dev DIR`, which serves from disk with no cache headers, for developing the SPA against a running server.) `GET /api/queries/live` is server-sent events over chunked transfer, fed by the same `QuerySink` the logger reads. Routing is a flat table (`routes.zig`) matched linearly; a few dozen routes do not justify a trie. The OpenAPI YAML is hand-written, embedded and served at `GET /api/openapi.yaml`, kept honest by tests that assert every served route appears in it.
Authentication (`web/auth.zig`): the operator's password is verified against an argon2id PHC string (`web.password_hash`; the plaintext is hashed on import and never stored). A successful login mints a 256-bit token carried in a cookie; the in-memory session table holds only SHA-256 digests of tokens, compared in constant time, capped at 32 sessions with LRU eviction. Nothing is persisted, so a restart logs everyone out — for a household LAN that is a feature, not a gap. Unauthenticated by design: the monitoring endpoints (health, version, metrics), the served OpenAPI contract, login itself, and the static SPA assets, which the router hands to the SPA fallback before any auth check. Everything else requires the cookie, and the API has its own token-bucket rate limiter. See [how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md).
## DoH, DoT and certificate hot-reload
`server/doh_server.zig` (RFC 8484 over HTTP/1.1 and TLS) and `server/dot_server.zig` (RFC 7858) mirror the plain listeners' shape. Server TLS terminates in Mbed TLS (`platform/tls_server.zig`), exposing plaintext as `std.Io.Reader`/`std.Io.Writer`, so the listeners above it do not know whether they are encrypted.
Certificates hot-reload through `server/cert_store.zig`. One refcounted `CertStore` per endpoint owns the published TLS context generation; listeners `acquire` it per connection and `release` it when the connection ends, so a reload never frees a context mid-handshake. Reload publishes nothing on failure: both PEM files are read and a whole new context is built before anything swaps, and any failure leaves the old generation serving. A watcher polls mtime and size of both files every 30 seconds (`cert_store.poll_interval_s`); `POST /api/certs/reload` triggers the same path on demand and reports the per-endpoint outcome as its payload.
The requirement driving all of this is that a certbot renewal must not need a restart and must not be able to break DNS. A half-swapped context or a free-while-in-use would do exactly that, so the store is built so neither is representable. See [how-to/enable-doh-and-dot.md](../how-to/enable-doh-and-dot.md).
## Failure visibility
Every failure mode must be visible, and the surface is counters, not log lines (AGENTS.md). Log lines are a bad primitive for this: they are unbounded, they are only read after someone already suspects a problem, and on an SD card they are a way to fill a disk.
So the handler counts every outcome in atomics — drops, FORMERR, NOTIMP, REFUSED, SERVFAIL, blocked, uncloak-blocked, truncated, cache hits, local and forward-zone answers, safe-search rewrites, paused and unfiltered queries, and the tracker-full condition. Listeners count dropped datagrams instead of queueing them unboundedly. `GET /metrics` renders all of it as Prometheus text 0.0.4, and `GET /api/health` rolls it up for a monitor. Health always answers 200: "degraded" is a fact about the box, not a failed request, and a monitor that cannot distinguish the two is worse than no monitor.
The disk monitor classifies free space against thresholds and gates non-essential writes; the query logger holds its batches while writes are disallowed rather than dropping them silently or writing until the filesystem fills. `std.log` is reserved for failures nobody else records, with upstream-error deduplication so a flapping resolver cannot fill a disk with identical lines.
+37 -81
View File
@@ -1,31 +1,30 @@
# The configuration model
nxdns is configured two ways — a ZON file and a web UI — and only one of them can be the truth at a time. This page explains how that choice is made, what each mode is for, and why the design leaves the fewest ways to lose an operator's work.
You can configure nxdns in its web interface or in a ZON file, but only one of them is the configuration at a time. This page explains how nxdns decides which, what each mode suits, and what nxdns keeps whichever you choose.
For the fields themselves see [reference/configuration.md](../reference/configuration.md); for the commands and their exit codes see [reference/cli.md](../reference/cli.md).
The settings themselves are in the [configuration reference](../reference/configuration.md), and the commands in the [CLI reference](../reference/cli.md).
## The rule
**Authority is the invocation.**
## The command line decides
```
nxdns run the database is the truth
nxdns run --config /etc/nxdns/config.zon the file is the truth
nxdns run database mode: the web interface is the configuration
nxdns run --config /etc/nxdns/config.zon file mode: the file is the configuration
```
That is the entire selection mechanism. There is no mode setting, no default file path, and nothing recorded in the database about which mode last wrote it. A `config.zon` that exists but that no invocation names changes nothing at all.
Nothing else decides: there is no mode setting and no default file path. A `config.zon` that no `--config` names is never read. You can always tell which mode a server runs in from its command line, its systemd unit or its compose file, and the log says so at every start:
Two properties fall out of that, and both were chosen on purpose.
**An operator can read `ExecStart` and know which authority is live.** The alternative — probe a well-known path, and behave differently depending on whether a file happens to be there — is ambient magic. It is also the exact class of rule that produced years of documentation lies in this project: the old design read the file only while the database was empty, which meant the same command did two different things depending on state nobody could see from the command line, and every page that described it eventually described it wrongly.
**A path already expresses a two-state choice, so a mode flag beside it would be redundant and worse.** An earlier draft had `--config-source=db|file`. A mode flag next to a path flag manufactures combinations that cannot mean anything — a path with no mode, a mode with no path — and each one then needs a pairing rule and a usage error to defend it. Presence-of-path has no invalid combinations, so there is nothing to defend.
```
info(nxdns): authority: database
info(nxdns): authority: file (/etc/nxdns/config.zon)
```
## Database mode
`nxdns run`. `config.db` holds the configuration; the UI, the API and `nxdns import` write to it; nothing reads a file. This is the appliance: someone sets the box up once, and afterwards the household member who wants to unblock one domain clicks a button.
nxdns keeps the configuration in `config.db`, and you change it in the web interface. Changes apply as you save them; the few that need a restart, such as a listening port, are marked **needs restart**.
A fresh install in this mode starts from an empty database, which fails validation on its own terms — there is nowhere to forward a query to — and says what to do about it:
This suits a box someone sets up once, after which anyone in the household can unblock a domain without touching a file.
A new database is empty, and an empty configuration has no upstream to send queries to, so the first start needs a configuration loaded with `nxdns import`:
```
nxdns run failed: NoUsableUpstreams
@@ -35,85 +34,42 @@ load one with `nxdns import <file>`, or make a file the source of truth with `nx
## File mode
`nxdns run --config FILE`. The file is the sole declarative source, and the database becomes the runtime substrate: every start reads the file, validates it, converges the database onto it, and serves from there. Configuration writes through the API are refused with a 403.
The file is the configuration. Every start reads it, checks it and makes the database match it. The web interface still shows everything nxdns does, but its configuration pages are read-only and name the file to edit instead.
This is the mode for a file kept in git and pushed by Ansible. What it buys is that the deployed file is what is running — not "was imported once", not "was imported unless someone clicked something since".
This suits a file kept in git and deployed by a tool such as Ansible or NixOS: what is deployed is what runs, and nobody can change it from a browser behind the tool's back.
Three properties make it usable rather than merely correct.
- **A bad file stops the start.** A missing, unreadable or invalid file makes nxdns exit with code 2, rather than run on whatever the database held before. Run `nxdns check --config FILE` in your deploy before the restart, so a typo fails the deploy instead of stopping DNS.
- **An unchanged file changes nothing.** The start logs `reconciled '…': no changes`, and writes nothing to disk.
- **The file is the backup.** Losing the data directory loses only the query history and the list of devices seen; the next start rebuilds the configuration from the file and downloads the blocklists again.
**It fails closed.** A file that is missing, unreadable, unparseable, oversized or invalid stops the start. nxdns never falls back to the database, because a fallback turns a deploy typo into a configuration that is silently months old and looks fine. That failure is exit 2, so `nxdns check --config FILE` is a real pre-restart gate: validate the pushed file in the handler, and a typo is a failed deploy at noon rather than a dead resolver at the next power cut.
## What nxdns keeps in either mode
**It converges rather than replaces.** Reconciling matches rows by identity and writes only what differs. A source whose URL has not changed keeps its row id, its checksum, its counters and its compiled blocklist files — so a restart in file mode downloads nothing, which is the difference between a design that is tolerable to restart and one that costs three minutes and 100 MB every time.
Some things are not configuration, and making the database match a file never removes them:
**An unchanged file writes nothing at all.** Not "writes the same bytes" — performs zero write statements, and reports it:
- **Blocklist downloads.** A list whose URL is unchanged keeps its download, so a restart downloads nothing. A list whose URL changed is a new list, downloaded afresh.
- **Devices nxdns has seen.** Their first-seen and last-seen times stay, also when you give one a name in the file. When the file removes a group, its devices move to `default` instead of disappearing.
- **The password, when the file does not mention it.** See [Passwords](../reference/configuration.md#passwords).
## Moving between the file and the web interface
`nxdns export` writes the database as a file that `nxdns import` and file mode both read. That makes the file useful in database mode too: export it to keep a readable, diffable copy, or edit it and import it back.
```
reconciled '/etc/nxdns/config.zon': no changes
nxdns export → ZON file → edit → nxdns import → config.db
```
That matters beyond elegance. A box whose SD card is full of query log can still restart in file mode, because a no-op reconcile needs no write-ahead-log headroom.
An export lists every setting, defaults included, in a fixed order, with no timestamps. Exporting the same configuration twice gives the same bytes, so a diff between two exports shows only real changes. Importing an export and exporting again gives the same file back.
**Converged at every boot is not a lock between boots.** Nothing stops `nxdns import` or a `runtime action` route from moving the database while the server runs. The contract is that the next start puts it back, and says what it corrected.
To switch a database-mode server to file mode, export its configuration and start it with `--config` on that file; the first start changes nothing. To switch back, drop `--config`: the database already holds the last configuration the file set. [Install with systemd](../how-to/install-with-systemd.md#run-in-file-mode) has the commands.
## What the file cannot take away
## Unknown settings
The file is authoritative over configuration. It is not authoritative over things it has no vocabulary for, and reconciling has to preserve those or the mode is unusable.
A setting nxdns does not know is an error in a file and a warning in the database.
- **Blocklist download state.** Checksums, fetch timestamps and domain counts belong to the network, not the operator. They survive on every matched row.
- **Client history.** Devices nxdns saw on the wire are kept whole. Naming one in the file promotes that row in place — it keeps its first-seen and last-seen and its row id, and counts as an update rather than a delete and an insert.
- **Devices whose group is un-declared.** Remove a group from the file and the observed clients assigned to it move to `default`. The operator un-declared the group, not the devices.
- **The password, when the file does not mention it.** See [the password](#the-password).
In a file, an unknown name is most likely a typo, and ignoring it would leave you believing a setting applies when it does not. So the file is refused, with the line and column.
The one thing identity cannot survive is a change to identity itself. Edit a source's URL and the engine sees one row gone and one row arrived: new id, fresh download, and the old compiled files swept. That is consistent — artifacts are keyed by row id — and it is why the CLI asks for `--allow-delete` when a diff deletes anything.
In the database, an unknown setting almost always means an older nxdns is reading a database a newer one wrote, after you went back a release. Refusing to start would make going back impossible, so nxdns warns, ignores the setting, and picks it up again once you upgrade.
## Why the database is the substrate in both modes
## What is not in the file
Even in file mode the database is where the server reads its effective configuration from. That is not a leftover; it is what lets one read path serve both modes, and it is what makes the runtime state above have somewhere to live.
If the file were read directly on every query path there would be no place to keep a checksum, and no way for the UI to show anything. If both file and database were authoritative there would be a two-way merge, and a merge cannot work here: when the UI writes a rule and the file still says otherwise, nothing in the system knows which of two statements is the newer intention. Every resolution silently destroys work someone meant to keep.
So the modes are exclusive, and the failure mode of each is loud. In database mode, editing the file does nothing, which you find out the first time you look at the UI. In file mode, the UI refuses the edit to your face with a message naming the file to edit instead.
## The round trip
The file stays useful as an editing surface in both modes — text is diffable, reviewable and easy to back up — because `export` renders the database into the same shape `import` and file mode read:
```
nxdns export → canonical ZON → edit → nxdns import → config.db
```
`nxdns export` writes canonical ZON: a fixed two-line header, every default emitted, deterministic ordering from the model's field order and the repositories' `ORDER BY` clauses, and no timestamps or hostnames anywhere. Runtime columns are absent from the configuration model on purpose, so two exports taken from a live, busy server are identical. `export → import → export` is byte-identical, and a test asserts it.
Byte-stability is not cosmetic. It is what makes an exported file usable in version control and what makes a diff of two exports mean something: any difference is a configuration change, never noise from when the export ran.
The output is also identical in both modes — nothing marks a file as coming from a file-mode box. That is deliberate, because it is what makes export the adoption tool: the file you check is the file you deploy, byte for byte. The label an operator wants is in the unit file, where they put it.
Reconciling the same file twice produces a byte-identical database — ids, checksums, `created_at`, the password hash, the whole settings table — including when the file is written in a non-canonical but equivalent form, such as `FD00:0:0:0:0:0:0:1` for an address stored as `fd00::1`. Anything that churns under an unchanged file is a bug in the engine by definition. That single invariant is what forces most of the design above: matching on canonical forms, writing only on difference, treating duplicate rule tuples as a multiset, and verifying a password rather than re-hashing it.
## Unknown keys, and why the asymmetry is deliberate
An unknown key in the **database** is warned about and ignored. An unknown key in the **file** is a hard error.
They are different situations. A settings row the running binary does not recognise is almost always an older binary reading a database written by a newer one — a downgrade, or a rollback after a bad upgrade. Refusing to start there would mean a downgrade bricks the config database, and the operator would have to hand-edit SQLite to recover. Warning and ignoring means the downgrade works, the unknown setting sits inert, and the upgrade back picks it up again.
A key in a file, by contrast, is something a human just typed. The likeliest cause is a typo, and the second likeliest is a field that no longer exists. Silently ignoring it would mean the setting the operator believes they applied was never applied — exactly the silent-divergence failure the whole model is built to avoid. So the ZON parser rejects unknown fields with a line and column.
The tolerance has a boundary worth stating plainly: it covers unknown *keys*, not unparseable *values*. A known key whose stored text does not decode into its type is an error, not a warning.
## The password
`web.password` is a write-only input. It is never a stored value. A non-empty one is hashed with argon2id (PHC encoding, OWASP argon2id parameters) into `web.password_hash`, and the plaintext is cleared before anything is written. There is no settings row that can hold it: the model skips `web.password` in both directions of the settings bridge, so the plaintext has nowhere to go even by accident.
Both fields are optional, and this is the one deliberate carve-out from file-as-sole-truth: **a file that mentions neither leaves the stored hash alone.**
The reason is a trap the design walked into once. An export carries the full PHC string, which is long and ugly, and an operator committing that file to git will sooner or later delete the line — meaning "keep the current password". If absence meant "no password", that edit would reconcile an empty hash over the stored one and open the admin UI to the entire LAN, silently, because authentication is on exactly when the hash is non-empty. Silence has to mean keep. Disabling authentication takes the explicit `password_hash = ""`.
The other end of the same problem is `password = ""`. Hashing the empty string produces a perfectly valid hash, so authentication would be *on* — while the login handler refuses every empty password, so it could never be satisfied. Auth on and unreachable is worse than either alternative, so that file is refused at validation with a diagnostic naming the remedy.
A plaintext password that has not changed is verified against the stored hash and kept rather than re-hashed. That is byte-stability, not a saving: verification recomputes the same argon2id function with the stored salt and costs exactly what hashing costs. Hashing unconditionally would generate a fresh salt on every start and break the invariant above.
Export's canonical form is therefore `password = null` beside the stored `password_hash`. Writing an empty *string* there instead would make every export carry a present-but-empty password next to a hash — tripping the both-set rule on re-import, so export's own output would fail export's own contract.
## What is not configuration
Storage paths are process arguments, not configuration fields: `--data-dir`, `--config`, `--admin-dev`. They cannot live in the file, because the file is found by way of them — a path that told you where to find the thing that told you the path would be circular. They are also the settings a supervisor (systemd, Docker) owns rather than the operator's policy about DNS. See [reference/files-and-directories.md](../reference/files-and-directories.md).
The data directory and the configuration file's own path are command-line arguments, `--data-dir` and `--config`, because nxdns needs them to find everything else. They belong to whatever starts nxdns: the systemd unit, the compose file or the NixOS module. [Files and directories](../reference/files-and-directories.md) lists what lives where.
@@ -1,84 +0,0 @@
# Performance targets and what the tests prove
Two related questions: why the performance numbers are the numbers, and why CI does not enforce them — and then, less comfortably, what a green test suite here does and does not tell you.
For the targets and the measured results as data, see [reference/performance.md](../reference/performance.md); to run the bench yourself, [how-to/measure-performance.md](../how-to/measure-performance.md).
## Where the targets come from
PLAN §18 sets five:
- sustained ≥ 100 qps on a Raspberry Pi 5;
- blocklist lookup p95 < 1 ms;
- cached response p95 < 5 ms;
- memory with ~1M blocked domains < 100 MiB;
- stripped static binary ≤ 10,485,760 bytes per arch, ≤ 15,728,640 bytes with the embedded frontend.
They are household-scale numbers, and they are deliberately unambitious. 100 qps is far more than a house generates; the point of the target is not speed but that a Pi 5 with an SD card never becomes the reason the internet feels broken. The latency targets exist for the same reason: DNS sits in front of every connection anyone makes, so the failure people notice is not throughput but a stall. The memory target is what keeps a 1M-entry blocklist from competing with everything else on a 4 GB board. The binary-size target is about what a static single-binary deployment is for — if it does not fit on a constrained box and copy over a slow link in one step, the packaging decision has not paid for itself.
`tools/bench.zig` (`zig build bench`) measures the three that are measurable in-process: `filter` (normalize plus snapshot evaluate against a ~1M-entry snapshot), `cache` (key build plus cache get plus id patch), and `compile` (the blocklist compiler over a 1M-line body, informational — there is no §18 target for it because no prior datapoint exists). Memory comes from `/proc/self/status` VmRSS. The qps target is not in the harness at all: it is end-to-end against the real binary with a DNS load generator, because a harness number for "queries per second" would measure the harness.
The bench is `tools/`, not `src/`, on purpose: `src/` is the shipped product, and `src/tests.zig` aggregates everything shippable.
## Why CI does not gate on performance
Required CI stays deterministic (AGENTS.md). Latency assertions on shared runners measure the runner's noisy neighbours; the same commit passes and fails depending on what else the host is doing. A gate that flakes does not protect anything — it trains people to re-run the job, and once re-running is routine, a real regression gets re-run too. The flaky gate is worse than no gate, because it also consumes the attention a real gate would need.
So the bench defaults to informational, and `--assert` — which exits non-zero on a missed target — exists for hardware you control. Run it on the Pi, where the numbers describe the machine the software actually has to run on. The x86_64 development-host numbers in [reference/performance.md](../reference/performance.md) are a regression baseline for the machine development happens on, not a claim about the target platform; a Cortex-A76 is far slower and those numbers do not transfer.
CI does gate on the one performance property that *is* deterministic: binary size. The `package` job builds the release artifacts and `zig build verify-dist` asserts both §18 budgets against them. Size is a function of the input, not of the runner's mood, so it is exactly the kind of thing a shared runner can measure honestly.
The budgets are asserted as exact byte counts, and the asset-free budget gets its own build against a generated empty assets directory rather than against `admin/dist-placeholder`. The placeholder is not buildable by `dist` at all — that is the guard against a release shipping a stub admin page — and letting it back in through a size check would have defeated the guard for the sake of one number.
## What the test suite is
Every blocking check lives in `.gitea/workflows/gates.yml`, which is a `workflow_call` workflow with nothing in it but jobs. `ci.yml` calls it on push and pull request for `master`, and `release.yml` calls it before it builds anything publishable. That shape exists for one reason: a check that lived in `ci.yml` alone would be a check a release could skip.
Five jobs, all required:
- `test` — the Zig suite with `-Dintegration`.
- `test-aarch64` — the same suite cross-built for aarch64 and executed under qemu-user, plain tier only.
- `frontend` — format, lint, typecheck, the vitest cases, build.
- `package` — `zig build dist` and `zig build verify-dist`, which is where the size budgets, the ELF static-linkage assert and the archive layout checks are.
- `container` — builds the image, asserts the binary inside it is byte-identical to the one in the matching tarball, and smoke-tests it by booting the container and polling `/api/health`.
The Zig suite has three tiers, gated by build flags:
- **plain `zig build test`** — pure logic. No sockets, no threads, no clock budgets. This is the tier the purity rule ([architecture.md](architecture.md)) exists to make possible.
- **`-Dintegration`** — hermetic integration: loopback sockets, `:memory:` databases, temp directories. Nothing leaves the host.
- **`-Dlive`** — the only tests that reach the public internet (DoH and DoT handshakes against real resolvers). Four tests, and they run in a manual-dispatch workflow, never on push or pull request.
The aarch64 job runs the plain tier only. The integration tests are multithreaded loopback TLS with wall-clock budgets, and qemu-user's slowdown turns those budgets into a flake source — the same reasoning that keeps the bench out of CI. What aarch64 needs to prove is portable correctness of the DNS, filter and cache logic, and the plain tier is exactly that.
At the time of writing, plain `zig build test` is 1175 of 1288 passing with 113 skipped and 0 failed, the skips being the integration-gated tests. Milestone 12 recorded the other two tiers on the same tree: 1280 of 1284 with `-Dintegration` (the 4 skips are the live-network tests) and 1159 passing under qemu, 0 failed in each.
## What it does not prove
The suite is hermetic by design. That is the right default: it is fast, it is deterministic, it can gate merges. But hermetic and correct are different properties, and the gap has already cost this project twice.
**The blocklist download aborted the process on first real use.** The fetcher constructed the HTTP response reader over `transfer_buf` and then read *into* that same buffer. `Reader.readSliceShort` starts by `@memcpy`-ing the reader's already-buffered bytes into the caller's destination — so source and destination were the same allocation, and Zig's `@memcpy` requires them not to overlap. It aborts.
The reason no test caught it is precise and instructive. The copy length is zero whenever the reader has nothing buffered, and a zero-length `@memcpy` is fine. Bytes only accumulate in the reader's own buffer when a read comes back short of filling the destination and the loop goes round again — that is, when the body arrives in more than one stream call. The loopback fixture answers every request with one small in-memory body that lands in a single read, and the one over-size test never streams a byte, because the fetcher refuses an oversized `content-length` on the response head. Every test in the suite was on the zero-length-memcpy side of the branch. The first real download — a multi-megabyte list over TLS across the WAN, arriving in many TCP segments — was on the other side, and took the process down. The fix (commit 35f2324) streams the body straight into the caller's writer, so the reader's buffer is never a destination slice, and it came with four regression tests that put a fully-buffered reader into exactly the state the old code could not survive.
**A stale embedded SPA bundle shipped a settings page that crashed on load, while 121 web tests passed.** `admin/dist/` is gitignored and `-Dadmin-dist=admin/dist` embeds whatever bytes are sitting in that directory. The frontend tests ran against the sources, in jsdom, and were green; the binary carried an older build. The tests were testing something the artifact did not contain.
Note what these two have in common. Neither was a logic bug that a better unit test would have caught. One lived in the seam between the pure core and its one I/O edge; the other lived in the seam between two build systems. Hermetic tests are constructed to exclude exactly those seams — that is what makes them hermetic.
## The lesson, and where it now lives
Green hermetic tests are a floor, not a ceiling. They prove the logic is consistent with itself. They cannot prove the program works, because the things they deliberately exclude — real network reads, real TLS, real file sizes, real build artifacts — are where a program meets reality.
The response is not to make CI non-deterministic. It is to require that the real paths get exercised by a human before work is called done. That is now ruling 3 of `specs/milestone-13.md`: every command block in the tutorial and the how-to pages is executed verbatim, on the host, by the session that writes it, and a command that cannot run there is marked in the page as unverified with the reason. Documentation written from source-reading alone is how both of these shipped; documentation that has been run is a second, independent test suite that exercises precisely the paths the hermetic one skips.
Two honest gaps remain, stated so nobody has to rediscover them:
- Nothing in the suite drives a multi-read HTTP body through the fetcher end to end. The regression tests cover `pumpBody` directly over a pre-buffered reader; the loopback fixture still sends one small body per connection.
- There is no freshness check on `admin/dist`. CI cannot embed a stale bundle, because the jobs that pass `-Dadmin-dist` rebuild the frontend immediately beforehand. A local build can, and will do it without a warning.
## What a signed release does not prove either
The same distinction applies one level out, to the artifacts. A release is signed, and the signature is worth having: it says the artifact came from this project's pipeline and reached you unaltered. It does not say the binary was built from the source in this repository, because the machine that ran the build also held the signing key. An attacker with that machine produces something that verifies cleanly and contains whatever they put in it.
The control that closes that gap is a reproducibility gate — an independent build, in a different directory on a different machine, landing on the same bytes. Every release from 0.0.17 on passes one (milestone 40, which closed the deferral in `specs/milestone-14.md` ruling 12). The cut builds the tarballs on the maintainer's machine and writes their hashes into `flake.nix`; the release job rebuilds them on the CI runner from the tagged tree, and `zig build verify-pins` refuses to let anything be uploaded unless the two builds landed on the same bytes. What makes that hold: the binary embeds no commit sha, the archive is written by the project's own tool with fixed modes, zero timestamps and sorted entries, and Zig, Node and npm are pinned to exact versions and run in a normalized environment.
What the gate does not show: both builds run on machines the maintainer controls, so it proves the pipeline is deterministic across two hosts, not that someone independent of the project has rebuilt it. That is what the rebuild recipe in [Verify a release](../how-to/verify-a-release.md) is for, and anyone can run it. It reproduces the release only on x86_64 Linux with glibc, because the admin bundle is built with host-native Rolldown and Lightning CSS bindings.
+37 -133
View File
@@ -1,90 +1,55 @@
# 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 backing up. The query history is not part of a backup, and the blocklists download again on their own.
**Which authority the service runs under decides what the backup *is*.** Check the start log:
What the backup is depends on the mode nxdns runs in. The start log says which:
- `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`: the configuration lives in `config.db`. Back it up with `nxdns export` and restore it with `nxdns import`. The rest of this page is about this mode.
- `authority: file (<path>)`: the configuration is that file, and the file is the backup. To restore, put the file back and start nxdns; it rebuilds its database from the file.
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 default data directory, `/var/lib/nxdns`. Add `--data-dir DIR` if yours is elsewhere.
## Back up
One command, against a stopped or a running server:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/backup.zon
nxdns export --out /var/backups/nxdns-config.zon
```
```
wrote /tmp/nxdns-lab/backup.zon
wrote /var/backups/nxdns-config.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:
This works while nxdns is running. The file is written in one step, so an interrupted export leaves no half-written backup, and it is created at mode 0600 because it carries the admin password's hash:
```sh
stat -c '%a %n' /tmp/nxdns-lab/backup.zon
```
```
600 /tmp/nxdns-lab/backup.zon
```
That mode is not decoration. The file carries `web.password_hash`:
```sh
grep password /tmp/nxdns-lab/backup.zon
```
```
```zon
.password = null,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$hOjjnTrTZ6kU8XrwQuJ7ZFC3B5LumaB4hRe7kBbzJ6Q$YsC2rCTDKv96eEwPhs+D6vbDliLogEZph8EkagKSuy8",
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…",
```
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).
The plaintext password is never stored, so `.password` is always `null`, and the file restores the same password without anyone knowing it. Keep backups as private as the database.
Without `--out` the export goes to stdout, where the file mode is your redirect's problem:
Without `--out`, the export goes to standard output.
An export holds configuration only: settings, groups, upstreams, devices you named, blocklist sources, rules and local records. It leaves out what the server records while it runs, such as when a device was last seen or how many domains a list held, so two exports of the same configuration are identical.
## Restore onto a new machine
On an empty data directory, `import` loads the backup:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data | head -10
```
```
// nxdns configuration
// generated by `nxdns export` from the running configuration
.{
.upstream = .{
.attempt_timeout_ms = 2500,
.read_timeout_ms = 3000,
.total_timeout_ms = 5000,
},
.dns = .{
.bind_ipv4 = "127.0.0.1",
```
Runtime facts are left out on purpose: client first-seen and last-seen times, rule creation times, per-source domain counts and checksums. They are things a running server produces, not configuration, and including them would make two exports taken minutes apart differ.
## Restore onto a fresh data directory
This is the normal restore: new machine, new disk, empty data directory. Nothing to overwrite, so nothing to authorise.
```sh
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data-restored
nxdns import /var/backups/nxdns-config.zon
```
```
info(migrations): config.db migrated from schema version 0 to 1
imported /tmp/nxdns-lab/backup.zon
imported /var/backups/nxdns-config.zon
```
The migration line is expected: `import` creates and migrates the database before writing to it.
Under systemd, run it as root and then `chown -R nxdns:nxdns /var/lib/nxdns` before you start the service. Under Docker, see [Database mode instead](install-with-docker.md#database-mode-instead).
## Restore over an existing database
## Restore over an existing configuration
**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 nxdns first. A running server read its configuration when it started, and would only pick up part of the change.
```sh
systemctl stop nxdns
@@ -92,97 +57,36 @@ 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:
```sh
nxdns import /tmp/nxdns-lab/old-backup.zon --data-dir /tmp/nxdns-lab/data
```
`import` makes the database match the file. Entries the file names are added or updated. Entries it does not name would be deleted, which is what restoring an old backup does to everything added since, so `import` refuses until you confirm, and changes nothing:
```
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
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:
The message names what would go. If that is what you want, run it again with `--allow-delete`:
```sh
nxdns import /tmp/nxdns-lab/old-backup.zon --allow-delete --data-dir /tmp/nxdns-lab/data
nxdns import /var/backups/nxdns-config.zon --allow-delete
```
```
imported /tmp/nxdns-lab/old-backup.zon
```
Renaming a group or changing an upstream's URL counts as a deletion too: the old entry goes and a new one arrives.
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 keeps what does not belong in a backup. A blocklist whose URL is still in the file keeps its download, so nothing is fetched again. Devices nxdns discovered on the network keep their history, and a device the backup names keeps the first-seen and last-seen times the database already had. A device you named by hand that the backup does not name 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.
## Check a backup
> 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`
> answers `Unit nxdns.service could not be found.`) and `systemctl stop`/`start`
> need root. The `nxdns import` between them is the same command the lab blocks
> above run, which were executed here as written.
## 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:
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.
Exporting the same configuration twice gives the same bytes, so a backup that loads and exports back unchanged is complete. To check one without touching the real data directory, load it into a scratch directory and compare:
```sh
systemctl stop nxdns
rm -f /var/lib/nxdns/config.db-wal /var/lib/nxdns/config.db-shm
cp /var/backups/config.db /var/lib/nxdns/config.db
chown nxdns:nxdns /var/lib/nxdns/config.db
chmod 0600 /var/lib/nxdns/config.db
systemctl start nxdns
nxdns import /var/backups/nxdns-config.zon --data-dir /tmp/nxdns-check
nxdns export --data-dir /tmp/nxdns-check --out /tmp/nxdns-check.zon
diff /var/backups/nxdns-config.zon /tmp/nxdns-check.zon && echo "backup is complete"
```
> Not verified on this host: these need root, an installed unit and
> `/var/lib/nxdns`, none of which exist here.
## What a backup leaves out
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.
- `querylog.db`, the query history. nxdns creates an empty one when it is missing. To keep the history as well, copy `querylog.db` while nxdns is stopped.
- `blocklists/`, the downloaded lists. They come back from the sources the configuration names.
## Verify a backup
The round trip is byte-stable: exporting, importing and exporting again gives an identical file. That is the cheapest check that a backup is complete and that it will load.
```sh
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/backup2.zon
diff /tmp/nxdns-lab/backup.zon /tmp/nxdns-lab/backup2.zon \
&& echo "round trip is byte-identical"
```
```
wrote /tmp/nxdns-lab/backup2.zon
round trip is byte-identical
```
The same check works across data directories — export from the restored copy and diff against the backup you restored from:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data-restored --out /tmp/nxdns-lab/backup3.zon
diff /tmp/nxdns-lab/backup.zon /tmp/nxdns-lab/backup3.zon \
&& echo "restored database exports identically"
```
```
wrote /tmp/nxdns-lab/backup3.zon
restored database exports identically
```
## What is not in the backup
Everything under the data directory other than `config.db`:
- `querylog.db` — expendable history, recreated empty when absent.
- `blocklists/` — compiled snapshots. They are rebuilt from the sources named in the configuration, so restoring the configuration is enough; the first refresh after a restore downloads them again.
The data directory layout is in [files and directories](../reference/files-and-directories.md).
## A backup before every upgrade
There is no downgrade path. Schema migrations run forward automatically at startup and before `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.
[Files and directories](../reference/files-and-directories.md) lists everything in the data directory.
+81 -241
View File
@@ -1,247 +1,23 @@
# 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.
Besides plain DNS on port 53, nxdns can answer encrypted queries from your devices: DNS over HTTPS (DoH) on port 443 and DNS over TLS (DoT) on port 853. Both are off by default, and both need a certificate and its private key, as PEM files.
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.
## 1. Get a certificate
Every field mentioned here is documented in [configuration reference](../reference/configuration.md).
Devices check the certificate against the name you give them, so use a certificate for a name you control, from an ACME client such as certbot, lego or Caddy. Android's Private DNS setting, for example, takes a hostname and needs a certificate a phone trusts.
## 1. Get a certificate and key
For a LAN service the practical options are a certificate from your ACME client (certbot, lego, caddy) for a name you control, or a self-signed pair. The lab below uses a self-signed pair, because it needs no domain:
```sh
mkdir -p /tmp/nxdns-lab/etc
cd /tmp/nxdns-lab
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout etc/key.pem -out etc/cert.pem -days 365 \
-subj "/CN=nxdns.lan" \
-addext "subjectAltName=DNS:nxdns.lan,DNS:localhost,IP:127.0.0.1"
chmod 0600 etc/key.pem
```
A self-signed certificate means every client has to be told to trust it, or told to skip verification. That is why the client commands further down pass `--insecure` and `+tls` without a CA. A real deployment uses a real certificate and drops those flags.
## 2. Turn the listeners on
`cert_path` and `key_path` must be absolute, or relative to the process working directory. Point both endpoints at the same pair unless you have a reason not to:
```zon
.{
.groups = .{.{ .name = "default" }},
.upstreams = .{.{ .url = "https://cloudflare-dns.com/dns-query" }},
.dns = .{ .bind_ipv4 = "127.0.0.1", .bind_ipv6 = "::1", .port = 15400 },
.web = .{ .bind = "127.0.0.1", .port = 8451, .password = "lab-password" },
.doh_server = .{
.enabled = true,
.bind = "127.0.0.1",
.port = 8443,
.cert_path = "/tmp/nxdns-lab/etc/cert.pem",
.key_path = "/tmp/nxdns-lab/etc/key.pem",
},
.dot_server = .{
.enabled = true,
.bind = "127.0.0.1",
.port = 8853,
.cert_path = "/tmp/nxdns-lab/etc/cert.pem",
.key_path = "/tmp/nxdns-lab/etc/key.pem",
},
}
```
Write that to `/tmp/nxdns-lab/etc/config.zon`. The 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
```sh
nxdns check --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
```
```
checking configuration file /tmp/nxdns-lab/etc/config.zon
OK 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:
```
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:
```
warning(tls_server): mbedtls_pk_check_pair failed: RSA - Key failed to pass the validity check of the library (-16896)
warning(tls_server): mbedtls_pk_check_pair failed: RSA - Key failed to pass the validity check of the library (-16896)
checking configuration file /tmp/nxdns-lab/etc/mismatch.zon
FAIL doh_server.key_path: '/tmp/nxdns-lab/etc/other.pem': private key does not belong to the certificate
FAIL dot_server.key_path: '/tmp/nxdns-lab/etc/other.pem': private key does not belong to the certificate
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:
```
WARN doh_server.key_path: '/tmp/nxdns-lab/etc/key.pem' is mode 644; a TLS key must be readable by its owner only
WARN dot_server.key_path: '/tmp/nxdns-lab/etc/key.pem' is mode 644; a TLS key must be readable by its owner only
OK upstreams[0] https://cloudflare-dns.com
OK: no failures found, 2 warnings
```
## 4. Start and confirm the listeners
```sh
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
```
Two lines in the log say the listeners bound:
```
info(nxdns): doh listener on 127.0.0.1:8443
info(nxdns): dot listener on 127.0.0.1:8853
```
If a certificate cannot be loaded while its endpoint is enabled, nxdns refuses to start and exits 2 rather than serving DNS without the listener you asked for:
```
doh_server: '/tmp/nxdns-lab/etc/cert.pem' + '/tmp/nxdns-lab/etc/key.pem': private key file is not readable
nxdns run failed: BadCertificate
run `nxdns check` to see the configuration in full
```
## 5. Query DoT
Recent `dig` speaks DNS over TLS with `+tls` (this page was checked with BIND 9.20.26):
```sh
dig @127.0.0.1 -p 8853 +tls example.com A +short
```
```
172.66.147.243
104.20.23.154
```
## 6. Query DoH
The only path the DoH listener serves is `/dns-query`; anything else is a 404. It accepts both the POST form (the query as an `application/dns-message` body) and the GET form (`?dns=` with base64url of the same bytes).
The body is a raw DNS query in wire format. Build one for `example.com A` — header with the recursion-desired bit, one question, then the QNAME as length-prefixed labels:
```sh
printf '%s' '000001000001000000000000076578616d706c6503636f6d0000010001' \
| xxd -r -p > /tmp/nxdns-lab/query.bin
```
POST it:
```sh
curl -sS --insecure --http1.1 \
-H 'content-type: application/dns-message' \
--data-binary @/tmp/nxdns-lab/query.bin \
--output /tmp/nxdns-lab/answer.bin \
-w 'http %{http_code}, %{size_download} bytes\n' \
https://127.0.0.1:8443/dns-query
xxd /tmp/nxdns-lab/answer.bin
```
```
http 200, 72 bytes
00000000: 0000 8180 0001 0002 0000 0001 0765 7861 .............exa
00000010: 6d70 6c65 0363 6f6d 0000 0100 01c0 0c00 mple.com........
00000020: 0100 0100 0000 0400 04ac 4293 f3c0 0c00 ..........B.....
00000030: 0100 0100 0000 0400 0468 1417 9a00 0029 .........h.....)
00000040: 04d0 0000 0000 0000 ........
```
The second flag byte `80` and the third answer-count field `0002` say: response, no error, two answer records.
The GET form takes the same bytes, base64url-encoded with the padding removed:
```sh
Q=$(base64 -w0 /tmp/nxdns-lab/query.bin | tr '+/' '-_' | tr -d '=')
curl -sS --insecure --http1.1 -o /tmp/nxdns-lab/get.bin \
-w 'GET http %{http_code}, %{size_download} bytes\n' \
"https://127.0.0.1:8443/dns-query?dns=$Q"
```
```
GET http 200, 61 bytes
```
`--http1.1` matters: without it curl offers HTTP/2 over ALPN, and the DoH listener negotiates only what it advertises. `--insecure` is only needed for the self-signed lab certificate.
## 7. Renewals
A watcher polls both files every 30 seconds and compares their modification time and size against the pair currently loaded. When either differs it reloads and swaps the new pair in; connections already open finish on the old certificate. Nothing has to restart.
Replace the pair and wait one poll interval:
To try it out first, a self-signed certificate is enough:
```sh
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout /tmp/nxdns-lab/etc/key.pem -out /tmp/nxdns-lab/etc/cert.pem -days 365 \
-keyout /etc/nxdns/key.pem -out /etc/nxdns/cert.pem -days 365 \
-subj "/CN=nxdns.lan" \
-addext "subjectAltName=DNS:nxdns.lan,DNS:localhost,IP:127.0.0.1"
chmod 0600 /tmp/nxdns-lab/etc/key.pem
-addext "subjectAltName=DNS:nxdns.lan,IP:192.168.1.10"
```
Within 30 seconds the log says so, once per endpoint that watches the file:
Replace `nxdns.lan` and `192.168.1.10` with the name and address your devices will use.
```
info(cert_store): certificate reloaded from /tmp/nxdns-lab/etc/cert.pem
info(cert_store): certificate reloaded from /tmp/nxdns-lab/etc/cert.pem
```
## 8. Reload immediately
To skip the wait — from an ACME deploy hook, for example — call `POST /api/certs/reload`. It needs a session; see [set up admin authentication](set-up-admin-authentication.md) for the login call that fills `cookies.txt`.
```sh
curl -sS -b /tmp/nxdns-lab/cookies.txt -X POST http://127.0.0.1:8451/api/certs/reload
```
```json
{"doh":{"enabled":true,"reloaded":true,"error":null},"dot":{"enabled":true,"reloaded":true,"error":null}}
```
The route always answers 200: the per-endpoint outcome is the payload, not the status code. A disabled endpoint reports `"enabled":false`. A reload that fails names the reason and leaves the old certificate serving:
```sh
chmod 000 /tmp/nxdns-lab/etc/key.pem
curl -sS -b /tmp/nxdns-lab/cookies.txt -X POST http://127.0.0.1:8451/api/certs/reload
```
```json
{"doh":{"enabled":true,"reloaded":false,"error":"private key file is not readable"},"dot":{"enabled":true,"reloaded":false,"error":"private key file is not readable"}}
```
The listeners keep working through that failure:
```sh
dig @127.0.0.1 -p 8853 +tls example.com A +short
```
```
104.20.23.154
172.66.147.243
```
Undo it with `chmod 0600 /tmp/nxdns-lab/etc/key.pem` and reload again. The counters are on `/metrics`:
```
nxdns_cert_reloads_total{endpoint="doh"} 3
nxdns_cert_reloads_total{endpoint="dot"} 3
nxdns_cert_reload_failures_total{endpoint="doh"} 2
nxdns_cert_reload_failures_total{endpoint="dot"} 2
```
## File ownership on a real install
The key must be readable by the user nxdns runs as, and by nobody else.
Under systemd the service runs as the `nxdns` user:
The key must be readable by the user nxdns runs as, and by nobody else. Under systemd that is the `nxdns` user:
```sh
chown nxdns:nxdns /etc/nxdns/cert.pem /etc/nxdns/key.pem
@@ -249,17 +25,81 @@ 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, put both files in `etc-nxdns/` next to `compose.yaml` and give them to uid 65532 the same way, since the container cannot change the read-only mount itself.
```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
## 2. Turn the listeners on
In file mode, add both sections to the configuration file:
```zon
.doh_server = .{
.enabled = true,
.cert_path = "/etc/nxdns/cert.pem",
.key_path = "/etc/nxdns/key.pem",
},
.dot_server = .{
.enabled = true,
.cert_path = "/etc/nxdns/cert.pem",
.key_path = "/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.
In database mode, open **System** in the web interface, tick **enabled** under **DoH Server** and **DoT Server**, check the certificate and key paths, and save.
## Ports 443 and 853
Both listeners bind every address on their standard ports by default; `bind` and `port` change that. The packaged systemd unit already allows binding ports below 1024. Under Docker, uncomment the `443` and `853` lines under `ports:` in `compose.yaml`.
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).
## 3. Check and restart
In file mode, `check` loads the certificate and key the same way the listeners will:
```sh
nxdns check --config /etc/nxdns/config.zon
```
It fails when a file is unreadable or when the key does not belong to the certificate, and warns when the key is readable by others:
```
FAIL doh_server.key_path: '/etc/nxdns/other.pem': private key does not belong to the certificate
WARN dot_server.key_path: '/etc/nxdns/key.pem' is mode 644; a TLS key must be readable by its owner only
```
Then restart nxdns (`systemctl restart nxdns`, or `docker compose restart`). The log names both listeners:
```
info(nxdns): doh listener on 0.0.0.0:443
info(nxdns): dot listener on 0.0.0.0:853
```
If a certificate cannot be loaded, nxdns refuses to start and exits 2 rather than run without the listener you asked for:
```
doh_server: '/etc/nxdns/cert.pem' + '/etc/nxdns/key.pem': private key file is not readable
nxdns run failed: BadCertificate
```
## 4. Test them
DoT, with a `dig` from BIND 9.18 or newer:
```sh
dig @192.168.1.10 +tls example.com A +short
```
DoH, using curl's own DoH resolver to look up `example.com`:
```sh
curl -sS --doh-url https://nxdns.lan/dns-query -o /dev/null \
-w 'resolved to %{remote_ip}\n' https://example.com
```
Add `--doh-insecure` to the curl command while you test with a self-signed certificate. DoH is served at `/dns-query` only, over HTTP/1.1, as both GET and POST.
## Renewing the certificate
nxdns checks both files every 30 seconds and switches to a renewed pair on its own; connections already open finish on the old one. There is nothing to restart. Each reload is logged:
```
info(cert_store): certificate reloaded from /etc/nxdns/cert.pem
```
To load it at once instead, press **Reload certificates** on the **System** page. If a reload fails, nxdns keeps serving the certificate it has and says why.
+60 -173
View File
@@ -1,73 +1,26 @@
# Install nxdns with Docker
# Install 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.
Run 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 built for `linux/amd64` and `linux/arm64`, so the same steps work on a PC and on a Raspberry Pi.
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).
> Verification: on 2026-10-02, steps 1 to 3 were run against the published `v0.0.21` release with an image pulled anonymously from the registry, on the host ports printed below; the notes in those steps carry the detail. The failure modes in step 2 were run on the machine that wrote an earlier revision of this page, against an image built from this checkout. The `chown` to uid 65532 needs root and was not run.
>
> **Earlier, for the file-mode revision.** The compose file now ships
> `command: ["run", "--config=/etc/nxdns/config.zon"]`, and no container was
> started against that command on this host: staging a release image needs
> `zig build dist`, which refuses to run while `admin/dist` is stale, and the web
> bundle was being rebuilt by other work in the same tree at the time. What was
> checked instead is `docker compose -f deploy/docker/compose.yaml config`,
> which resolves the file without contacting a registry and prints the `command`
> and the `:ro` bind mount as written, and the same
> `run --config=<file>` invocation driven directly against the locally built
> binary: it printed the reconcile summary, `authority: file (<path>)`, and
> `no changes` on the second start. The log lines quoted in step 3 come from
> that run, with the paths and ports the container uses.
## 1. Pull and verify the image
```sh
BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
sed 's#.*/releases/tag/v##')
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.
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"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txt
docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
--format '{{.Manifest.Digest}}'
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.
> Verified on 2026-10-02 against `v0.0.21`, with an empty `DOCKER_CONFIG`, so no registry credentials: the lookup printed `0.0.21`, `docker pull` succeeded without a login, `gpg --verify` printed `Good signature` with the primary fingerprint above, `sha256sum -c` printed `OK` for all three files, and `imagetools inspect` and the `cut` both printed `sha256:ce1fef32c7ee6c23656abd94e53e4d4d6b1d34fe88a2c842b82c7202d8b904c3`.
## 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:
## 1. Get the compose file
```sh
mkdir -p ~/nxdns && cd ~/nxdns
curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
curl -fLO https://git.mial.net/mokhtar/nxdns/raw/tag/v<version>/deploy/docker/compose.yaml
```
> Verified on 2026-10-02: the `curl` returned `compose.yaml` from the `v0.0.21` tag with a 200.
The compose file runs `git.mial.net/mokhtar/nxdns:<version>`, the release it was tagged with. It publishes ports 53 (UDP and TCP) and 8080, keeps the data directory in the volume `nxdns-data`, and mounts `./etc-nxdns` read-only as `/etc/nxdns`.
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create it and put the configuration in it:
To check the image against the signed release before you run it, follow [Verify a release](verify-a-release.md#6-verify-the-container-image).
## 2. Write the configuration
```sh
mkdir -p deploy/docker/etc-nxdns
$EDITOR deploy/docker/etc-nxdns/config.zon
mkdir -p etc-nxdns
$EDITOR etc-nxdns/config.zon
```
The smallest file that starts is one group named `default` and one enabled upstream:
The smallest configuration that starts is a group named `default` and one upstream resolver, with a password for the web interface:
```zon
.{
@@ -77,160 +30,94 @@ The smallest file that starts is one group named `default` and one enabled upstr
}
```
**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:
```
FAIL /etc/nxdns/config.zon: no such file
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`:
```
FAIL groups: no group named 'default'; every unknown client is assigned to it
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).
### 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:
The container runs as uid 65532 and cannot change the mounted files, so make the file readable by that uid and nobody else:
```sh
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
sudo chown 65532:65532 etc-nxdns/config.zon
sudo chmod 0600 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 compose file runs nxdns in file mode: this file is the configuration. Every start applies it, and the web interface refuses configuration changes and names the file instead. To change anything, edit the file and restart the container. A new or recreated `nxdns-data` volume rebuilds itself from the file.
> 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
> `nxdns import <file> --allow-delete` commands inside it were run directly
> against the locally built binary — the first applied an additive file and
> 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:
## 3. Start it
```sh
chown 65532:65532 deploy/docker/etc-nxdns/config.zon
chmod 0600 deploy/docker/etc-nxdns/config.zon
docker compose up -d
docker compose logs -f
```
> Not verified on this host: `chown` to a uid you do not own needs root. What
> was verified is the failure it prevents — a configuration file at 0600 owned
> by another uid makes the container refuse to start and restart in a loop. In
> file mode an unreadable file is a configuration fault:
> `FAIL /etc/nxdns/config.zon: not readable` followed by
> `nxdns run failed: ManagedConfigUnreadable`, exit 2. See
> [Troubleshoot nxdns](troubleshoot.md).
## 3. Run it
```sh
docker compose -f deploy/docker/compose.yaml up -d
docker compose -f deploy/docker/compose.yaml logs -f
```
`compose.yaml` names the release it was tagged with: its image is `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-<release>}}`, and `zig build cut` writes `<release>` in the same commit that bumps `build.zig.zon`, so the file from the `v$VERSION` tag runs `$VERSION` with no variable set. Set `NXDNS_VERSION` to run a different release with the same file; set `NXDNS_IMAGE` to run something else entirely, which is what the build-from-source section at the bottom does. Compose files from `v0.0.22` and earlier default to `:latest` instead, so with one of those, set `NXDNS_VERSION=$VERSION` on every command.
> Verified on 2026-10-20 with `docker compose -f deploy/docker/compose.yaml
> config` on a tree whose `build.zig.zon` declares `0.0.22`, which resolves
> the variables without contacting a registry: no variables gives
> `git.mial.net/mokhtar/nxdns:0.0.22`, `NXDNS_VERSION=0.0.21` gives
> `git.mial.net/mokhtar/nxdns:0.0.21`, 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`.
A healthy first start logs the reconcile, the authority and the bound sockets:
A healthy first start applies the file and names every socket it opened. Among its first lines:
```
info(migrations): config.db migrated from schema version 0 to 1
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +46 ~0 -0;
settings keys changed: dns.bind_ipv4 dns.bind_ipv6 dns.port web.bind web.port …
web authentication is now enabled
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
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.
Later starts with an unchanged file log `reconciled '/etc/nxdns/config.zon': no changes`.
Confirm it answers and that the admin interface is up:
If the file is missing, unreadable or invalid, nxdns prints what is wrong and exits 2, for example:
```
FAIL groups: no group named 'default'; every unknown client is assigned to it
nxdns run failed: MissingDefaultGroup
```
With `restart: unless-stopped`, Docker then restarts the container until you fix the file, so read the log. If a host port is already in use, `docker compose up` fails with `address already in use`; free the port or change the `ports:` list. On a systemd host, port 53 is usually held by `systemd-resolved`; [Install with systemd](install-with-systemd.md#if-port-53-is-already-taken) shows how to free it.
## 4. Check it and point your network at it
```sh
dig @127.0.0.1 example.com A +short
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
```
> Verified on 2026-10-02 with the pulled `v0.0.21` image, the configuration above and the unchanged `compose.yaml`, on host ports 53 and 8080: the first start logged the lines above, `dig` returned the A records for `example.com`, `curl` returned 200, and `/api/health` reported `"status":"ok"` with one of one upstreams available. `docker compose up -d` fails with `failed to bind host port 0.0.0.0:8080/tcp: address already in use` when a host port is occupied; free the port or edit the `ports:` list.
Open `http://<host-ip>:8080` and log in. The interface shows everything nxdns does; in file mode its configuration pages are read-only and name the file to edit.
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).
Blocklists go in the file too. Add a source and attach it to the `default` group, then restart:
## 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.
## 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.
To pull the arm64 image from an x86_64 machine — to inspect it, or to save and copy it — name the platform:
```zon
.blocklist_sources = .{
.{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack hosts" },
},
.group_sources = .{
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
},
```
```sh
docker pull --platform linux/arm64 git.mial.net/mokhtar/nxdns:$VERSION
docker compose restart
```
> Verified on 2026-10-02 against `v0.0.21`: `imagetools inspect` lists `linux/amd64` and `linux/arm64`, and the pull above fetched an image whose `docker image inspect` reports `linux/arm64`. It was not started: this host is x86_64 with no emulation.
The restart logs the change and then the download:
## Build the image from source instead
```
reconciled '/etc/nxdns/config.zon': sources +1 ~0 -0; group_sources +1 ~0 -0;
info(blocklist_manager): blocklist snapshot generation 3: 1 of 1 sources loaded, 1997188 bytes
```
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 [configuration reference](../reference/configuration.md#lists) lists everything else the file can hold: more groups, devices, rules and local records.
To make every device use nxdns, set the DNS server your router hands out (its DHCP settings) to the Docker host's address. Leave the Docker host itself on another resolver: the container looks up its upstreams' names through the host, and that should not depend on the container already running.
## Database mode instead
To change settings in the web interface rather than in the file, delete the `command:` line from `compose.yaml`. The database in the volume is then the configuration and the file is not read. Load the file into the empty database once, then start:
```sh
(cd admin && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
DOCKER_BUILDKIT=1 docker build -t nxdns -f deploy/docker/Dockerfile .
docker compose run --rm nxdns import /etc/nxdns/config.zon
docker compose up -d
```
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.
Without that import the empty database has no upstream, and the container exits 2 with `NoUsableUpstreams`.
Build `admin/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 `admin/dist-placeholder` default at all.
## Run another release
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:
`NXDNS_VERSION` overrides the release the compose file names:
```sh
NXDNS_IMAGE=nxdns docker compose -f deploy/docker/compose.yaml up -d
NXDNS_VERSION=<version> docker compose 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:
```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.
> 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
> the `DOCKER_BUILDKIT=1 docker build` above was then run against that
> `zig-out/dist` tree and exited 0. The binary copied out of the resulting
> image with `docker cp` hashed identically to
> `zig-out/dist/stage/nxdns-<version>-x86_64-linux-musl/nxdns`. The
> `NXDNS_IMAGE=nxdns docker compose ... up -d` line was verified as described in
> step 3, with the host ports moved. `docker buildx build --platform
> linux/arm64` was run here too and exited 0 on an x86_64 host with no
> emulation available — the builder stage is pinned to `$BUILDPLATFORM` and the
> final stage is `FROM scratch`, so nothing arm64 ever executes during the
> build. It printed the driver's `No output specified` warning, which is the
> reason the paragraph above tells you to add `--push` or `--load`. See
> [Install with systemd](install-with-systemd.md#build-from-source-instead) for
> the `dist` and `verify-dist` detail.
To move to a new release, see [Upgrade](upgrade.md#docker).
+33 -49
View File
@@ -1,52 +1,33 @@
# Install nxdns with Nix
# Install with Nix
Adds nxdns to a NixOS machine as a flake input pinned to a release tag and runs it with the flake's `services.nxdns` module. At the end the machine serves DNS on port 53 from a configuration file that the pinned binary checked at build time, and a Renovate custom manager opens a pull request when a new tag appears.
Add nxdns to a NixOS machine as a flake input pinned to a release, and run it with the flake's `services.nxdns` module. At the end the machine answers DNS on port 53 from a configuration file that was checked when the system was built.
nxdns publishes its own `flake.nix`. Its packages do not build nxdns from source: each one fetches the release tarball for the target and pins its SHA-256 hash, so a changed byte fails the build. The two supported systems are `aarch64-linux` and `x86_64-linux`, both static musl builds that need nothing on the host.
The flake does not build nxdns from source. Its package fetches the release tarball for `x86_64-linux` or `aarch64-linux` and pins its SHA-256, so a changed byte fails the build.
For the signature and checksum checks a human does once, see [verify a release](verify-a-release.md). For what each configuration field means, see [the configuration reference](../reference/configuration.md).
## 1. Add the input
> Verification: the commands in step 1 and step 2 were run on the machine that wrote this page, against the flake at nxdns 0.0.16. `nix flake check --no-build` passed and `nix build` produced a binary that printed `nxdns 0.0.16`. Step 3 was verified on 2026-10-20 by the flake's own checks, built with Nix 2.35.3 on an x86_64 host with KVM and the module running the 0.0.22 release: `checks.x86_64-linux.nixos-module` boots a NixOS VM with the module and `openFirewall`, gets `nas.lan` from `dig @127.0.0.1`, finds the packaged unit with its drop-in in `systemctl cat`, finds port 53 open over UDP and TCP, and switches to a configuration-only change that restarts the service and changes the answer; `checks.x86_64-linux.nixos-module-rejects-config` fails a system build on a bad regex with `FAIL rules[0].pattern: '(' is not a valid regex pattern`. The Renovate snippet in step 4 is copied from the shape `rpi.mial.net` already uses for another flake input of the same author.
## 1. Add the input, pinned to a tag
In the consuming flake:
In your flake:
```nix
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nxdns = {
url = "git+https://git.mial.net/mokhtar/nxdns.git?ref=refs/tags/v0.0.16";
url = "git+https://git.mial.net/mokhtar/nxdns.git?ref=refs/tags/v<version>";
inputs.nixpkgs.follows = "nixpkgs";
};
};
```
Pin a tag, not a branch. A branch pin moves the version under you at the next `nix flake update`, and the hashes in the flake belong to whatever release that branch last cut.
The `follows` line is not cosmetic. Without it Nix fetches and evaluates nxdns's own nixpkgs as a second nixpkgs, which costs a download and an evaluation for a package that only needs `stdenv`, `fetchurl`, and `lib`.
Write the lock entry:
Pin a tag, not a branch: a tag never moves under you. The `follows` line saves fetching and evaluating a second nixpkgs. Then write the lock entry:
```sh
nix flake lock
```
## 2. Check what you pinned
## 2. Enable the service
```sh
nix flake check --no-build
nix eval .#packages.x86_64-linux.default.outPath
```
Evaluation does not fetch the tarball. The download happens at build time, and the hash in nxdns's flake is what the build asserts the bytes against.
## 3. Run the service
`nixosModules.default` exists from the first release after `v0.0.22`; pin that tag or a later one. A tag up to `v0.0.22` has the package only.
Write the configuration next to the NixOS configuration that uses it. The smallest file that starts is one group named `default` and one enabled upstream:
Write the configuration next to your NixOS configuration, for example as `nxdns-config.zon`. The smallest one that starts is a group named `default` and one upstream resolver:
```zon
.{
@@ -55,7 +36,7 @@ Write the configuration next to the NixOS configuration that uses it. The smalle
}
```
Pass the flake inputs to the module system, import the module, and enable it:
Import the module and enable it:
```nix
{ inputs, ... }:
@@ -70,34 +51,41 @@ Pass the flake inputs to the module system, import the module, and enable it:
}
```
Then `nixos-rebuild switch`. The module has four options:
Then run `nixos-rebuild switch`.
| Option | Default | What it does |
| --- | --- | --- |
| `enable` | `false` | Runs nxdns as `nxdns.service`, as the `nxdns` system user. |
| `package` | the release of the pinned tag | The nxdns release to run. |
| `configFile` | none, required | The configuration, in ZON. It is installed at `/etc/nxdns/config.zon`. |
| `openFirewall` | `false` | Opens port 53 over UDP and TCP. The web interface on port 8080 and the DoH and DoT ports stay closed; open them yourself if you want them reachable from the network. |
| `configFile` | none, required | The configuration, installed as `/etc/nxdns/config.zon`. |
| `openFirewall` | `false` | Opens port 53 over UDP and TCP. The web interface (8080) and the DoH and DoT ports stay closed; open them yourself if other machines should reach them. |
`configFile` is copied into the Nix store, which every local user can read. Do not put a plaintext `web.password` in it: put the `web.password_hash` from an export there instead, as [set up admin authentication](set-up-admin-authentication.md) describes.
`configFile` is copied into the Nix store, which every local user can read. Do not put a plaintext `web.password` in it. Use the `web.password_hash` from an export instead, as [Set up admin authentication](set-up-admin-authentication.md#put-a-hash-in-the-file-instead) shows.
What the module does with them:
What the module does:
- **The build checks the configuration.** `nxdns import` from the pinned `package` reads `configFile` into a throwaway database during the build. A file that `import` rejects, because it does not parse, breaks a field rule or does not reconcile, fails `nixos-rebuild` with the binary's own reason, as in `FAIL rules[0].pattern: ...`, and nothing is deployed. `nxdns check` is not used, because it queries every upstream and the build sandbox has no network. So the build does not load the DoH and DoT certificate and key files, and does not reach the upstreams: a missing certificate still stops the service at start. The build also says nothing about the database already on the machine; see the rollback note below.
- **nxdns runs in file mode.** The service runs `nxdns run --config=/etc/nxdns/config.zon`: every start reconciles the database onto the file, and the web interface refuses configuration edits. Change the file and switch to change anything. A switch that changes only the configuration restarts the service.
- **The unit is the packaged one.** The package installs `deploy/systemd/nxdns.service` from the release tarball, with its sandboxing and its `RestartPreventExitStatus=2 64`, and the module adds a drop-in that changes only the command line and leaves `/etc/nxdns` to NixOS. `systemctl cat nxdns.service` shows both.
- **The build checks the configuration.** The pinned nxdns loads `configFile` into a throwaway database during the build, so a file nxdns would reject fails `nixos-rebuild` with nxdns's own message, for example `FAIL rules[0].pattern: '(' is not a valid regex pattern`, and nothing is deployed. The build has no network, so it cannot check that the upstreams answer or load DoH and DoT certificates; a missing certificate still stops the service when it starts.
- **nxdns runs in file mode.** Every start applies the file, and the web interface shows the configuration read-only. To change anything, change the file and switch. A switch that changes only the configuration restarts the service.
- **The unit is the packaged one**, with its sandboxing. The module only replaces its command line; `systemctl cat nxdns.service` shows both.
The module does not touch the machine's own resolver. If you point `networking.nameservers` at `127.0.0.1`, the machine loses name resolution whenever nxdns is down, including while you repair it; a resolver outside the machine avoids that.
The module does not touch the machine's own resolver. Point your network at nxdns by setting the DNS server your router hands out to this machine's address, and leave `networking.nameservers` on another resolver, so the machine can still resolve names while nxdns is down.
**Rolling back.** A `nixos-rebuild --rollback` or an older generation puts an older binary in front of a database that the newer one may have migrated. That binary refuses the database with `SchemaTooNew`, exit 2, and the unit stops instead of restarting. [Upgrade](upgrade.md#what-happens-to-the-database) describes the recovery.
The package alone is `inputs.nxdns.packages.${pkgs.stdenv.hostPlatform.system}.default`, for running nxdns some other way. It installs `bin/nxdns` and `lib/systemd/system/nxdns.service`, and sets `meta.mainProgram`.
The package alone is also available, for a machine that runs nxdns some other way: `inputs.nxdns.packages.${pkgs.stdenv.hostPlatform.system}.default` installs `bin/nxdns`, `lib/systemd/system/nxdns.service`, and `LICENSE` and `THIRD-PARTY-NOTICES` under `share/doc/nxdns`. It sets `meta.mainProgram`, so `lib.getExe` resolves to the binary.
## 3. Upgrade
## 4. Let Renovate bump the tag
Read the release notes, change the tag in the input URL, then:
Renovate's built-in `nix` manager does not do this. It only advances `flake.lock` along the ref an input already tracks, and a `refs/tags/vX.Y.Z` ref never moves, so it reports no releases for a tag-pinned input. Verified on 2026-09-09 with Renovate 42.99.0 against a Gitea host: all flake inputs, GitHub-hosted ones included, came back with an empty release list.
```sh
nix flake lock --update-input nxdns
nixos-rebuild switch
```
What works is a regex custom manager that treats the tag in the input URL as a version string, with the `gitea-tags` datasource:
Rolling back to an older generation puts an older nxdns in front of a database the newer one may already have upgraded. The older binary then refuses to start; [Upgrade](upgrade.md#going-back-to-an-older-release) explains the way back.
## Let Renovate bump the tag
Renovate's built-in `nix` manager cannot do this: it only moves `flake.lock` along the ref an input already follows, and a tag ref never moves. A regex custom manager that reads the tag from the input URL, with the `gitea-tags` datasource, does:
```json
{
@@ -117,10 +105,6 @@ What works is a regex custom manager that treats the tag in the input URL as a v
}
```
Verified on 2026-09-09: with this manager, Renovate opened the pull request for v0.0.18 on the day of the tag. That pull request rewrites the tag in `flake.nix` only. `flake.lock` still records the old revision, so the same pull request must also refresh the lock, either with a `postUpgradeTasks` command (`nix flake update nxdns`, which needs the self-hosted `allowedCommands` setting) or with a CI job on the Renovate branch that commits the lock. The datasource proposes only tags that already exist, so it never pins a release that has not been cut.
Its pull request changes the tag in `flake.nix` only. The same pull request also needs a refreshed `flake.lock`: run `nix flake update nxdns` as a `postUpgradeTasks` command (self-hosted Renovate, with `allowedCommands`), or have CI commit the lock on the Renovate branch.
Between the tag push and the asset upload there is a window in which the tag exists and the release tarballs do not. A pin written by hand during that window evaluates, then fails at build time with a 404 from the release download URL. Wait for the release to be published, or re-run the build once it is. Renovate's own pull requests are not affected by the window in practice, because it runs on a schedule rather than on the tag push.
## Upgrading
Change the `?ref=refs/tags/vX.Y.Z` in the input, run `nix flake lock --update-input nxdns`, and rebuild. Read the release notes first: nxdns is pre-0.1 and breaks on purpose, and [upgrade](upgrade.md) lists what state a version change touches.
A tag can exist for a short while before its release files are uploaded. A pin made by hand in that window fails the build with a 404 from the download URL; build again once the release is published.
+62 -279
View File
@@ -1,125 +1,44 @@
# Install nxdns with systemd
# Install 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.
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).
> Verification: `systemd-analyze verify` was run on the machine that wrote this
> page. `nxdns check`, `nxdns import`, `nxdns export` and `nxdns run` were run
> there too, but against a scratch `--data-dir` and `--config` on an
> unprivileged port, because that machine is not a deploy target and has no
> `/etc/nxdns`, no `/var/lib/nxdns` and no root. The steps that need root on a
> target host — `install`, `systemd-sysusers`, `systemctl` — were not run; they
> are marked where they appear.
>
> The download in step 1 was run on 2026-10-02 against the published `v0.0.21` release; its note carries the detail.
>
> The `zig build dist` and `zig build verify-dist` blocks in the last section
> were run here, both to completion and both exiting 0; that section carries
> the detail.
Install nxdns as a system service on a Linux machine with systemd, including a Raspberry Pi. At the end it answers DNS on port 53 for your network and starts on boot.
## 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.
Each release has two builds: `x86_64-linux-musl` for a PC or server, and `aarch64-linux-musl` for a Raspberry Pi or another 64-bit ARM machine. Both are static binaries that need nothing installed. On the machine that will run nxdns:
```sh
BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
sed 's#.*/releases/tag/v##')
mkdir -p ~/nxdns-release && cd ~/nxdns-release
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-x86_64-linux-musl.tar.gz"
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.
Verify before you extract. The signature is over `SHA256SUMS.txt`, and `SHA256SUMS.txt` is over the tarballs:
```sh
curl -fLO https://git.mial.net/mokhtar/nxdns/releases/download/v<version>/nxdns-<version>-x86_64-linux-musl.tar.gz
curl -fLO https://git.mial.net/mokhtar/nxdns/releases/download/v<version>/SHA256SUMS.txt
curl -fLO https://git.mial.net/mokhtar/nxdns/releases/download/v<version>/SHA256SUMS.txt.asc
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txt
tar -xzf "nxdns-$VERSION-x86_64-linux-musl.tar.gz"
tar -xzf nxdns-<version>-x86_64-linux-musl.tar.gz
cd nxdns-<version>-x86_64-linux-musl
```
[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 signature covers `SHA256SUMS.txt`, and `SHA256SUMS.txt` covers the tarball. [Verify a release](verify-a-release.md) explains where the public key comes from and which fingerprint to expect; read it before your first install.
The extracted directory `nxdns-$VERSION-x86_64-linux-musl/` holds everything this page installs:
The directory holds the binary `nxdns`, the systemd unit `nxdns.service`, the sysusers file `nxdns.conf` that creates the `nxdns` user, the licences and a short `INSTALL.md`.
| File | What it is |
| --- | --- |
| `nxdns` | The static binary, mode 0755 |
| `nxdns.service` | The systemd unit |
| `nxdns.conf` | The sysusers fragment that creates the `nxdns` user |
| `LICENSE` | EUPL-1.2 |
| `THIRD-PARTY-NOTICES` | Licences of everything compiled or bundled in |
| `INSTALL.md` | A short version of this page |
## 2. Install the binary, the user and the unit
> Verified on 2026-10-02 against `v0.0.21`, from a clean directory with a clean `GNUPGHOME` holding only the key from keys.openpgp.org: the lookup printed `0.0.21`, `gpg --verify` printed `Good signature` over primary fingerprint `A206 1F6A B24D F2C0 E923 46FD 1509 B549 46D0 8A95`, `sha256sum -c` printed `OK` for both tarballs and `IMAGE-DIGEST.txt`, and the extracted directory held the six files in the table below at the modes given, with `./nxdns version` printing `nxdns 0.0.21`.
## 2. Copy the files to the target
As root:
```sh
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.
> Not verified on this host: `target` is a placeholder for your server's
> hostname, and the machine that wrote this page has no second host to copy
> to.
Before copying, you can confirm the unit file parses:
```sh
systemd-analyze verify nxdns.service
```
Off the target host this prints one complaint and exits 1:
```
nxdns.service: Command /usr/local/bin/nxdns is not executable: No such file or directory
```
That is the ExecStart path check finding no binary yet. Any other message is a real problem with the unit. On the target, after step 3, the same command should print nothing.
> 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.
## 3. Install the binary, the user and the unit
Run as root on the target:
```sh
install -m 0755 /tmp/nxdns /usr/local/bin/nxdns
install -m 0644 /tmp/nxdns.conf /usr/lib/sysusers.d/nxdns.conf
install -m 0755 nxdns /usr/local/bin/nxdns
install -m 0644 nxdns.conf /usr/lib/sysusers.d/nxdns.conf
systemd-sysusers
install -m 0644 /tmp/nxdns.service /etc/systemd/system/nxdns.service
install -m 0644 nxdns.service /etc/systemd/system/nxdns.service
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.
Do not create `/var/lib/nxdns` or `/var/log/nxdns`. systemd creates them on the first start, with the right owner and modes.
> 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`.
## 3. Write the configuration
The service user is a static one, not `DynamicUser`: a TLS key for the DoH or DoT listener has to be chown-able to a uid that survives a restart.
Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's `StateDirectory` and `LogsDirectory` settings make systemd create them on first start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
`/etc/nxdns` is the one directory the `mkdir` above is for. The unit's `ConfigurationDirectory=nxdns` also creates it, but not until the first start in step 5, and step 4 has to write a file into it before then. systemd does not mind finding the directory already there; it adjusts the mode and ownership to what the unit asks for.
## 4. Write the 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:
The smallest configuration that starts is a group named `default` and one upstream resolver. Write `/etc/nxdns/config.zon` and give the web interface a password:
```zon
.{
@@ -129,135 +48,99 @@ nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`.
}
```
That file holds a password in plain text, so restrict it as soon as you have written it:
The file holds a password in plain text, so restrict it:
```sh
chown root:nxdns /etc/nxdns/config.zon
chmod 0640 /etc/nxdns/config.zon
```
Root's umask is 022 on most distributions, so a freshly written `/etc/nxdns/config.zon` is mode 0644 and every account on the host can read the password out of it. The unit's `UMask=0077` does not help here: it applies to files the service creates once it is running, and never re-chmods a file that was written before the first start.
0640 with group `nxdns` rather than 0600: `/etc/nxdns` is a `ConfigurationDirectory`, which systemd leaves owned by root, and the service runs as `nxdns`. 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.
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:
Check it, then load it into the database:
```sh
nxdns check --config /etc/nxdns/config.zon
nxdns import /etc/nxdns/config.zon
```
A good file prints the source it checked, one `OK` line per upstream, and `OK: no problems found`:
```
checking configuration file /etc/nxdns/config.zon
OK 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.
Now load it into the database:
```sh
nxdns import /etc/nxdns/config.zon
```
```
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:
`check` sends one real query through each upstream, so the machine needs working DNS when you run it. If it finds a problem it lists every one and exits 2.
The password is stored only as a hash, so once you have logged in (step 5) 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.
From now on you change settings in the web interface. This is database mode, and it is what the packaged unit runs. To keep `config.zon` in git as the configuration instead, keep the file and see [Run in file mode](#run-in-file-mode).
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`.
> 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
> from the blocks above. A file written under umask 022 came out 0644,
> `nxdns check --config` on it printed `OK: no problems found` with no mode
> warning, `nxdns import` of it printed the migration line and `imported <path>`
> and exited 0, and a following `nxdns export` wrote `.password = null` next to
> a populated `.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…"`. The
> `chown`, `chmod` and `rm` lines are ordinary root-owned-file operations and
> were not run against a real `/etc/nxdns`, which this host does not have.
## 5. Start it
## 4. Start it
```sh
systemctl enable --now nxdns
journalctl -u nxdns -f
```
> Not verified on this host: needs root and an installed unit.
A healthy start logs a line naming every socket it bound:
A healthy start names every socket it opened:
```
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`.
The unit grants the capability to bind port 53 and runs nxdns as the `nxdns` user. It does not restart nxdns after exit code 2 (a configuration problem) or 64 (a wrong command line), because a retry cannot fix either; the journal says what is wrong. Fix it and start the service 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 port 53 is already taken
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.
```
cannot bind udp [::1]:53: AddressInUse
nxdns run failed: AddressInUse
```
## 6. Confirm it answers
On most systemd distributions `systemd-resolved` holds port 53 with its stub listener. Turn the stub off and keep resolved for the machine's own lookups:
From another machine on the LAN:
```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 link to `/run/systemd/resolve/stub-resolv.conf`, point it at `/run/systemd/resolve/resolv.conf` so the machine still resolves names. `ss -lnup 'sport = :53'` shows what else holds the port.
## 5. Check it and point your network at it
From another machine on your network:
```sh
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.
Open `http://<server-ip>:8080` and log in with the password from step 3. Then add blocklists on the **Protection** page, as [Get started](../tutorial/get-started.md#5-add-a-blocklist) shows.
> Not verified on this host as written: `<server-ip>` is a placeholder, and a
> LAN client to run it from is a second machine this host does not have. What
> was verified is the same two checks against a local nxdns started from a
> scratch data directory on an unprivileged port — `dig @127.0.0.1 -p 15353
> example.com A +short` returned the A records, and `curl` against the web
> port returned 200. Only the address and the port differ from the lines
> above.
To make every device use nxdns, set the DNS server your router hands out (its DHCP settings) to the nxdns machine's address. Leave the nxdns machine itself on another resolver: nxdns looks up its upstreams' names through it, and that should not depend on nxdns already running.
## 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 applies the file, and the web interface refuses configuration changes and names the file instead. Use it when the file lives in git and a tool deploys it. [The configuration model](../explanation/configuration-model.md) compares the two modes.
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.
### Switch to file mode
### Adopt file mode on a box that is already running
Run these in order. **Stop first**, and do not skip that: any edit made through the UI between an export and the restart would be silently reverted by the first reconcile, and `nxdns check` against a live database refuses to grade it (below).
If you are arriving here from an upgrade, the binary must already be the new one before you export. An export written by 0.0.1 carries a `.password = ""` line this binary refuses; see [the order trap](upgrade.md#the-order-trap-export-with-the-new-binary-not-the-old-one).
Stop the service first, so no change made in the web interface is lost, then write the current configuration out as the file:
```sh
systemctl stop nxdns
nxdns export --out /etc/nxdns/config.zon
chown root:nxdns /etc/nxdns/config.zon
chmod 0640 /etc/nxdns/config.zon
nxdns check --config /etc/nxdns/config.zon
```
```
wrote /etc/nxdns/config.zon
checking configuration file /etc/nxdns/config.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
```
Then add the drop-in and start:
Add a drop-in that passes the file, and start:
```sh
mkdir -p /etc/systemd/system/nxdns.service.d
@@ -270,23 +153,18 @@ systemctl daemon-reload
systemctl start nxdns
```
The empty `ExecStart=` is required. Without it systemd appends a second command to the list rather than replacing the first, and the unit tries to run nxdns twice.
The first start after adoption changes nothing, because the file was rendered from the database it is now governing:
The empty `ExecStart=` line is required: it clears the unit's own command instead of adding a second one. The first start changes nothing, because the file was written from the database it now governs:
```
reconciled '/etc/nxdns/config.zon': no changes
info(nxdns): authority: file (/etc/nxdns/config.zon)
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
```
`authority: file` is the line that confirms the drop-in took. Blocklists, compiled snapshots and client history all survive, and every later start on an unchanged file writes nothing either.
Blocklists, their downloads and the query history all carry over.
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
### Change the configuration from now on
Edit the file, validate it, restart:
Edit the file, check it, restart:
```sh
$EDITOR /etc/nxdns/config.zon
@@ -294,18 +172,18 @@ nxdns check --config /etc/nxdns/config.zon
systemctl restart nxdns
```
Make `nxdns check --config` the precondition of any Ansible handler that restarts nxdns. A file-mode start reads the file on **every** boot, so a bad push that skips its handler does not fail at deploy time — it detonates at the next power cut. Validating before restarting turns that into a failed deploy at noon.
The restart prints what it changed:
The restart logs what it changed:
```
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +0 ~2 -0;
settings keys changed: dns.port web.port
```
### Leave file mode
Because the file is read at every start, a broken file stops the service at its next restart, whenever that is. If a tool deploys the file, run `nxdns check --config` before it restarts nxdns, so a mistake fails the deploy instead.
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:
### Go back to database mode
Remove the drop-in and restart. The database already holds the last configuration the file set:
```sh
rm /etc/systemd/system/nxdns.service.d/file-mode.conf
@@ -316,98 +194,3 @@ systemctl restart nxdns
```
info(nxdns): authority: database
```
> Verified on this host end to end, against a scratch `--data-dir` and a scratch
> configuration path instead of `/var/lib/nxdns` and `/etc/nxdns`, on
> unprivileged ports — this machine has neither of those directories, no root,
> and no installed unit. Every `nxdns` line above was run and produced the output
> shown, with only those paths and the port numbers in the `serving on` line
> differing.
>
> The run: a database-mode instance was started, a blocklist source was added
> through the API to make it UI-configured, then `nxdns check` against the live
> database printed the uncheckpointed-log FAIL and exited 2 (which is why this
> section stops the service first). After the stop, `nxdns export --out` wrote
> the file, `nxdns check --config` on it exited 0 with `OK: no problems found`,
> and the first file-mode start printed `reconciled '<path>': no changes` and
> `authority: file (<path>)`. A second file-mode start printed `no changes`
> again and loaded the 3096006-byte compiled blocklist from disk with no
> download. Dropping the flag printed `authority: database` and served the same
> configuration.
>
> The `systemctl`, `mkdir`, `cat > …/file-mode.conf` and `rm` lines need root and
> an installed unit and were **not** run. What was checked instead:
> `systemd-analyze verify` on `deploy/systemd/nxdns.service` with those exact two
> `ExecStart` lines appended, which reported only the usual
> `Command /usr/local/bin/nxdns is not executable` for the absent binary and
> nothing about the override.
## Raspberry Pi 5
The Pi 5 is aarch64. Nothing about the procedure changes except which tarball you take:
```sh
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-aarch64-linux-musl.tar.gz"
sha256sum -c --ignore-missing SHA256SUMS.txt
tar -xzf "nxdns-$VERSION-aarch64-linux-musl.tar.gz"
cd "nxdns-$VERSION-aarch64-linux-musl"
scp nxdns nxdns.service nxdns.conf pi:/tmp/
```
Then follow steps 3 to 6 on the Pi.
> Partly verified on 2026-10-02: the `v0.0.21` aarch64 tarball downloaded and `sha256sum -c` printed `OK` for it. The `scp` and steps 3 to 6 were not run: `pi` is a placeholder for your Pi's hostname, and this page was written on an x86_64 machine with no Pi attached.
## 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.
Requires Zig 0.16.0 and Node.js. From the repository root:
```sh
(cd admin && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
```
The first command builds the admin interface into `admin/dist`; the last one embeds that directory in the binary. Build the interface every time, before the binary: a stale `admin/dist` ships an admin UI that does not match the API it talks to. `dist` refuses to run against the `admin/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.
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
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:
```sh
zig build verify-dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/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.
> 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`
> produced the two tarballs, `SHA256SUMS` and the staged payloads described
> above; `verify-dist` exited 0 with every check passing and the aarch64
> `nxdns version` check skipped for want of `-fqemu`. Passing a made-up
> `-Dversion-string=0.0.0-local` was also run: `dist` succeeded and
> `verify-dist` then failed with
> `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:
```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.
-139
View File
@@ -1,139 +0,0 @@
# Measure performance
`tools/bench.zig` measures the three things nxdns can measure in-process: blocklist lookup latency, cache-hit latency, and blocklist compile throughput. Sustained query rate is not one of them — that one is end-to-end and needs a load generator pointed at a running server.
The numbers this project treats as targets, and the numbers measured so far, are in the [performance reference](../reference/performance.md). Why those targets exist and why CI does not gate on them is in [performance and testing](../explanation/performance-and-testing.md).
## Run the whole bench
```sh
zig build bench -Doptimize=ReleaseFast
```
That runs all three suites with the defaults: 1,000,000 domains, 200,000 iterations per suite, seed `0x5eed`. It takes minutes, most of it generating and loading the million-domain list.
`-Doptimize=ReleaseFast` is not optional if you want the numbers to mean anything. A Debug build says so before it prints:
```
warning: Debug build; run with -Doptimize=ReleaseFast for meaningful numbers
```
## Run one suite, smaller
Everything after `--` goes to the harness. A suite name selects one of `filter`, `cache`, `compile` (the default is `all`), and `--domains` / `--iters` shrink the load:
```sh
zig build bench -Doptimize=ReleaseFast -- filter --domains=100000 --iters=20000
```
```
nxdns bench suite=filter domains=100000 iters=20000 seed=0x5eed optimize=ReleaseFast
suite ops p50(us) p95(us) p99(us) max(us)
filter 20000 2.38 2.76 2.88 20.32
blocked 6670/20000, 32 regex rules, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.6 MiB
target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS
```
A reduced run is good for checking the harness works and for a rough regression signal. It is not a result: the memory figure scales with `--domains`, so 100,000 domains says nothing about the 1,000,000-domain memory target.
The other two suites:
```sh
zig build bench -Doptimize=ReleaseFast -- cache --iters=20000
```
```
suite ops p50(us) p95(us) p99(us) max(us)
cache 20000 0.14 0.18 0.20 5.14
hits 10000/20000, DnsCache.memoryBytes 4.3 MiB, VmRSS 6.3 MiB
target p95 < 5ms: PASS
```
```sh
zig build bench -Doptimize=ReleaseFast -- compile --domains=100000
```
```
suite ops p50(us) p95(us) p99(us) max(us)
compile 100000 wall 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.
An argument the harness does not recognise stops it before any measuring:
```
error: unknown argument 'nosuch'
usage: zig build bench -Doptimize=ReleaseFast -- [filter|cache|compile|all] [--domains=N] [--iters=N] [--seed=N] [--assert]
```
## Read the output
- `p50`/`p95`/`p99`/`max` are per-operation microseconds, nearest-rank over every iteration. What one operation means differs per suite: for `filter` it is normalising a name plus evaluating it against the snapshot; for `cache` it is building the key, getting the entry and stamping the response id.
- `blocked N/M` and `hits N/M` are sanity counters. The harness aborts if either is zero — a suite that never hits its own path measures nothing.
- `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:
```sh
zig build bench -Doptimize=ReleaseFast -- --assert
```
The full-scale form is the one worth asserting on, because the memory target only means something at a million domains. On this development host the reduced form was used to check the flag itself:
```sh
zig build bench -Doptimize=ReleaseFast -- filter --domains=100000 --iters=20000 --assert
```
```
filter 20000 2.34 2.71 2.85 15.06
blocked 6670/20000, 32 regex rules, Snapshot.memoryBytes 3.0 MiB, VmRSS 5.6 MiB
target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS
```
**Not verified on this host at full scale:** the plain `zig build bench -Doptimize=ReleaseFast -- --assert` above was not run during the writing of this page — the default run takes minutes. The reduced runs shown were all executed as written. The full-scale numbers already recorded for this host are in the [performance reference](../reference/performance.md).
## Measure sustained query rate
The bench harness cannot do this. Query rate is a property of the whole server — sockets, upstreams, the query log writer — so it has to be driven from outside, against the real binary, on the machine you care about.
Start nxdns with real blocklists configured, then drive it from another host on the LAN with a DNS load generator such as `dnsperf`:
```sh
dnsperf -s 192.168.1.10 -p 53 -d queries.txt -c 20 -Q 200 -l 60
```
Read the client's own rate and the server's `/metrics` together: a load generator that reports 200 qps while the server counts fewer has lost queries somewhere, and that is the interesting number.
**Not verified on this host:** `dnsperf` is not installed here and the target platform is a Raspberry Pi 5, not this development machine. The command above is the shape of the measurement, not a transcript.
## Measure memory on a running server
The bench measures one structure at a time. A running server's memory also depends on the most concurrent work it has seen since it started, so read it on the server itself, after it has served a normal day:
```sh
pid=$(pidof nxdns)
grep -E 'VmRSS|VmHWM|Threads' /proc/$pid/status
curl -s http://127.0.0.1:8080/metrics | grep '^nxdns_.*_peak'
```
- `VmRSS` is what the process holds resident now. `VmHWM` is the most it has ever held, which is normally the last blocklist refresh.
- `Threads` is a high-water too: the I/O runtime adds a worker thread only when every existing worker is busy, and never retires one. The count is the largest the worker pool has been since start, plus the main thread.
- `nxdns_dns_in_flight_peak` is the most queries handled at once, every listener together.
- `nxdns_tcp_server_connections_peak`, `nxdns_dot_server_connections_peak` and `nxdns_doh_server_connections_peak` are the most connections one listener has held at once. TCP/53 has one listener per address family; the gauge is the larger of the two.
- `nxdns_querylog_queue_peak` is the most entries that have waited for the query log writer at once. It can overstate the true peak by the entries in transit at that instant, never understate it.
Each worker thread's stack is its own anonymous mapping in `/proc/$pid/smaps`, directly above a one-page `---p` guard, and every worker's mapping has the same size. Its `Rss` is the stack that thread has actually touched.
**Verified on this host** against a static x86_64 musl build under a burst of 300 UDP queries: the gauges and the stack mappings behave as described. Not yet run on the Pi.
## 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.
+61 -224
View File
@@ -1,275 +1,112 @@
# 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 web interface is protected by a single password. With no password set, anyone who can reach the web port can change nxdns's configuration, so set one before you let other machines reach it.
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.
## Set the password in the web interface
## 1. Set the password
In database mode, open **System**, type the new password into **password** and **confirm password** under **Web**, and press **Save**. The interface logs you out at once, and every other session ends with it; log back in with the new password.
Put it in the configuration file, under `web`:
To change the password later, do the same again.
## Set it in the configuration file
In file mode, or for a first configuration you import, put the password under `web`:
```zon
.{
.groups = .{.{ .name = "default" }},
.upstreams = .{.{ .url = "https://cloudflare-dns.com/dns-query" }},
.web = .{ .bind = "127.0.0.1", .port = 8451, .password = "lab-password" },
}
.web = .{ .password = "choose-a-real-password" },
```
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:
nxdns stores only an argon2id hash of it, in `web.password_hash`, and never logs it. Applying the file, at the next start in file mode or with `nxdns import`, logs:
```
web authentication is now enabled
```
### Absent, empty, and set are three different things
### Put a hash in the file instead
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:
A configuration file kept in git or in the Nix store should not hold a plaintext password. Set the password once, then copy the hash out of an export:
| The file says | Effect on the stored password |
```sh
nxdns export | grep password
```
```
.password = null,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…",
```
Put that `.password_hash` line in your file in place of `.password`. The hash installs the same password without revealing it. A file that sets both fields is refused:
```
web.password: password and password_hash are both set; ambiguity in a security setting is refused
```
### What the file's password fields do
| The file says | What happens to the stored password |
| --- | --- |
| Neither field | Nothing. It stays exactly as it was. |
| `.password = "…"` | Installs that password. Unchanged plaintext keeps the existing hash rather than re-hashing it. |
| Neither field | Nothing. It stays as it was. |
| `.password = "…"` | Sets that password. |
| `.password_hash = "$argon2id$…"` | Sets the password that hash belongs to. |
| `.password_hash = ""` | Removes the password and turns authentication off. |
| `.password = ""` | Refused. |
| `.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.
So removing the password takes the explicit empty string:
Leaving both fields out keeps the password, so deleting the hash line from a file before you commit it never opens the interface by accident. Turning authentication off takes the explicit empty hash, and logs:
```
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:
## If you have lost the password
```
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.
## 2. Log in
Login is `POST /api/auth/login` with a JSON body. Without a session, the API answers 401:
The web interface cannot help, so go through the database. Stop nxdns first: a running server keeps checking logins against the hash it read when it started.
```sh
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8451/api/overview
systemctl stop nxdns
nxdns export --out /root/nxdns-rekey.zon
```
```
401
```
Log in and keep the cookie:
```sh
curl -sS -c /tmp/nxdns-lab/cookies.txt \
-X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' \
-d '{"password":"lab-password"}'
```
```json
{"authenticated":true,"auth_required":true}
```
The session token comes back in a `Set-Cookie` header, not in the body. In the jar it looks like this (value redacted here):
```
#HttpOnly_127.0.0.1 FALSE / FALSE 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:
```sh
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w '%{http_code}\n' \
http://127.0.0.1:8451/api/overview
```
```
200
```
A wrong password and an unknown one are the same answer, so a guess learns nothing:
```sh
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"wrong"}' \
-w ' (http %{http_code})\n'
```
```
{"error":"invalid password"} (http 401)
```
The server logs the address and the outcome, never the password:
```
info(web_auth): web login accepted for 127.0.0.1:34040
warning(web_auth): web login refused for 127.0.0.1:59670
```
Sessions live in memory only. A restart logs everyone out. Thirty-two concurrent sessions are kept; a thirty-third login evicts the least recently used one.
## 3. Log out
```sh
curl -sS -b /tmp/nxdns-lab/cookies.txt -c /tmp/nxdns-lab/cookies.txt \
-X POST http://127.0.0.1:8451/api/auth/logout
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w 'overview: %{http_code}\n' \
http://127.0.0.1:8451/api/overview
```
```
{"authenticated":false}
overview: 401
```
Logging out with a stale cookie, or with none, answers the same way. The point of logging out is to end up logged out, and that is where such a request already is.
## 4. Change the password on a running server
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.
```sh
curl -sS -c /tmp/nxdns-lab/c2.txt -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"lab-password"}'
curl -sS -b /tmp/nxdns-lab/c2.txt -X PUT http://127.0.0.1:8451/api/settings \
-H 'content-type: application/json' \
-d '{"web":{"password":"a-new-password"}}'
```
Changing the password ends every session, including the one that made the change:
```sh
curl -sS -b /tmp/nxdns-lab/c2.txt -o /dev/null -w 'old session: %{http_code}\n' \
http://127.0.0.1:8451/api/overview
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"lab-password"}' \
-w ' (old password)\n'
curl -sS -c /tmp/nxdns-lab/c3.txt -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"a-new-password"}' \
-w ' (new password)\n'
```
```
old session: 401
{"error":"invalid password"} (old password)
{"authenticated":true,"auth_required":true} (new password)
```
Log back in with the new password. That is the whole rotation.
## 5. Change the password without the API
If you have lost the password, the admin interface cannot help — go through the database instead. Export, edit, import. `nxdns export` always writes `.password = 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:
In the `web` section of `/root/nxdns-rekey.zon`, set `.password` to the new password and delete the `.password_hash` line, so only one password field is left:
```zon
.password = "offline-password",
.password = "a-new-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:
```
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:
Then load it and start again:
```sh
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
nxdns import /tmp/nxdns-lab/rekeyed.zon --data-dir /tmp/nxdns-lab/data
nxdns run --data-dir /tmp/nxdns-lab/data
nxdns import /root/nxdns-rekey.zon
systemctl start nxdns
rm /root/nxdns-rekey.zon
```
```
imported /tmp/nxdns-lab/rekeyed.zon
imported /root/nxdns-rekey.zon
```
No flag is needed: replacing a password edits a settings value and deletes no rows.
In file mode, change the password in your configuration file and restart instead.
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).
## Sessions
Once it is back up the old password is refused and the new one works:
A login lasts `web.session_ttl_hours`, 24 hours by default. Sessions are kept in memory, so restarting nxdns logs everyone out. Up to 32 sessions are open at once; a 33rd login ends the one used least recently.
```sh
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"lab-password"}' \
-w ' (old password, http %{http_code})\n'
curl -sS -c /tmp/nxdns-lab/c5.txt -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"offline-password"}' \
-w ' (new password, http %{http_code})\n'
curl -sS -b /tmp/nxdns-lab/c5.txt -o /dev/null -w 'overview: %{http_code}\n' \
http://127.0.0.1:8451/api/overview
```
Each login is logged with the address it came from, never with the password:
```
{"error":"invalid password"} (old password, http 401)
{"authenticated":true,"auth_required":true} (new password, http 200)
overview: 200
info(web_auth): web login accepted for 192.168.1.20:34040
warning(web_auth): web login refused for 192.168.1.20:59670
```
The next export shows the new hash and a null `password` again:
Logins count against `web.api_rate_limit_per_min`, which limits how fast anyone can guess. Requests from the nxdns machine itself are exempt by default (`web.api_localhost_exempt`).
```sh
nxdns export --data-dir /tmp/nxdns-lab/data | grep password
## Behind a reverse proxy
A reverse proxy on the same machine connects from `127.0.0.1`, so every request it forwards would be exempt from the rate limit. List the proxy in `web.trusted_proxies`, and nxdns reads each client's real address from the `X-Forwarded-For` header the proxy adds:
```zon
.web = .{ .trusted_proxies = "127.0.0.1" },
```
```
.password = null,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$xqzK66LgiWGvyCmCl6ZRa3GHH0nS5qZnRgVfWmeGadc$1mafhflKFIg3vcHjJaDMAXGQiOjtym2UADZsPW1xkfw",
```
The proxy must add its own entry to that header rather than pass on whatever the client sent. If it cannot, turn `web.api_localhost_exempt` off instead.
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:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8453/api/overview
curl -sS -X POST http://127.0.0.1:8453/api/auth/login \
-H 'content-type: application/json' -d '{"password":"anything"}'
```
```
200
{"authenticated":true,"auth_required":false}
```
`auth_required: false` is how the admin interface knows to stop showing a login form. Treat this as a lab-only state: bind the web listener to a trusted interface at the very least, and preferably set a password.
## Notes
- A stored hash this build cannot parse is a 500, not a 401. Answering 401 would tell an operator with a corrupted `web.password_hash` that their password is wrong, and they would retype a password that can never verify.
- Requests from the box itself skip the API rate limit by default (`web.api_localhost_exempt`).
- **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 = .{
.trusted_proxies = "127.0.0.1",
},
```
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.
The [configuration reference](../reference/configuration.md#web) lists every `web` setting.
-397
View File
@@ -1,397 +0,0 @@
# Troubleshoot nxdns
Symptoms an nxdns install actually produces, what to run to identify each one, and what to change. Every symptom on this page was reproduced on the machine that wrote it, and every diagnosis command was run there. Two details differ from a real install and cannot be otherwise on that machine: it has no installed service, so the log lines were read from a foreground run instead of `journalctl -u nxdns`, and ports 53 and 8080 were occupied, so DNS and the API were exercised on unprivileged ports. Fixes that need root are marked.
The exit codes themselves are listed in [the CLI reference](../reference/cli.md).
## The service exits with code 2
**Symptom.** The process stops immediately. The last two lines are the error and a pointer:
```
nxdns run failed: NoUsableUpstreams
run `nxdns check` to see the configuration in full
```
Exit 2 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.**
```sh
nxdns check
```
`check` prints every problem it finds, not the first, and names the source it checked on its first line.
**Fixes by cause.**
- `NoUsableUpstreams` — the database has no enabled upstream. On a fresh install 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
run `nxdns check` to see the configuration in full
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`.
`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
warning(tls_server): mbedtls_pk_check_pair failed: RSA - Key failed to pass the validity check of the library (-16896)
checking configuration file config.zon
FAIL doh_server.key_path: 'mismatched-key.pem': private key does not belong to the certificate
OK upstreams[0] https://cloudflare-dns.com
# exit 2
$ nxdns run --config config.zon --data-dir ./data
warning(tls_server): mbedtls_pk_check_pair failed: RSA - Key failed to pass the validity check of the library (-16896)
doh_server: 'cert.pem' + 'mismatched-key.pem': private key does not belong to the certificate
nxdns run failed: BadCertificate # exit 2
```
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.
## 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:
```
FAIL groups: no group named 'default'; every unknown client is assigned to it
nxdns run failed: MissingDefaultGroup
run `nxdns check` to see the configuration in full
```
A syntax error behaves the same way:
```
FAIL config: 3:16: error: expected ',' after initializer
nxdns run failed: ParseZon
run `nxdns check` to see the configuration in full
```
So does a file whose upstream list is empty or all disabled:
```
FAIL upstreams: at least one upstream must be enabled
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.
**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.
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.
## `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:
```
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.
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:
```sh
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.
> 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
> unprivileged ports; `config.db-wal` was 0 bytes and `check` exited 0; one
> `POST /api/blocklists` took it to 8272 bytes and `check` then printed the line
> above and exited 2; `export` from the same live directory succeeded and its
> output checked clean; and after a clean shutdown `check --data-dir` exited 0
> again.
## Port 53 is already taken
**Symptom.** The process exits 1, having named the socket it could not have:
```
cannot bind udp [::1]:53: AddressInUse
nxdns run failed: AddressInUse
```
A bind conflict is a runtime failure, not a configuration fault, so this is exit 1 and `nxdns check` will not find it.
**Diagnosis.**
```sh
ss -lnup 'sport = :53'
ss -lntp 'sport = :53'
systemctl is-active systemd-resolved
```
On most systemd distributions the holder is `systemd-resolved`, which runs a stub listener on `127.0.0.53:53` and on some setups binds `0.0.0.0:53`.
**Fix.** Turn off the stub listener and keep resolved for the host's own lookups:
```sh
mkdir -p /etc/systemd/resolved.conf.d
printf '[Resolve]\nDNSStubListener=no\n' > /etc/systemd/resolved.conf.d/nxdns.conf
systemctl restart systemd-resolved
```
If `/etc/resolv.conf` is a symlink to `/run/systemd/resolve/stub-resolv.conf`, repoint it at `/run/systemd/resolve/resolv.conf` so the host still resolves.
> Not verified on this host: this needs root, and `systemd-resolved` is
> inactive here with port 53 free, so the conflict could not be reproduced
> against it. The bind failure itself was reproduced by starting a second nxdns
> on a port the first already held, which is the same error path.
Do not fix this by pointing the host's `/etc/resolv.conf` at nxdns when that host is where nxdns resolves its own upstream DoH and DoT hostnames. That is a startup cycle, not a fix.
## The container restarts in a loop
**Symptom.** `docker compose ps` shows the container restarting, and the log is the same failure repeated. Docker has no start limit, so this goes on forever.
```
FAIL /etc/nxdns/config.zon: not readable
nxdns run failed: ManagedConfigUnreadable
```
**Diagnosis.**
```sh
docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}} restarts={{.RestartCount}}' docker-nxdns-1
stat -c '%a %u:%g %n' deploy/docker/etc-nxdns/config.zon
```
Exit 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:
```sh
chmod 0644 deploy/docker/etc-nxdns/config.zon
```
or give it to the container's uid:
```sh
chown 65532:65532 deploy/docker/etc-nxdns/config.zon
chmod 0600 deploy/docker/etc-nxdns/config.zon
```
The 0644 path was verified 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).
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
```
> Not re-run on this host: staging a release image needs `zig build dist`, which
> could not run here while the web bundle was mid-rebuild by other work in the
> same checkout. The failure text quoted above is what the same binary prints
> outside a container, which was reproduced here, with the container's paths.
## The admin interface refuses an edit with 403
**Symptom.** Saving anything in the admin interface fails, and the API answers:
```json
{"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.
**Diagnosis.** The start log names the authority:
```sh
journalctl -u nxdns | grep 'authority:'
```
```
info(nxdns): authority: file (/etc/nxdns/config.zon)
```
**Fix.** Edit the file, validate it, restart:
```sh
$EDITOR /etc/nxdns/config.zon
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).
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.
**Diagnosis.** Look at what the host resolves with:
```sh
cat /etc/resolv.conf
```
**Fix.** If it points at the nxdns container, repoint it at a real resolver. The container resolves its upstream DoH and DoT hostnames through the host's DNS configuration, so pointing that at nxdns makes nxdns depend on itself to start. LAN clients point at nxdns; the container's own host does not.
## SERVFAIL in bursts, only under concurrent load
**Symptom.** Single queries answer normally, but a burst of unique names — a page load, a device waking up, a `dig` loop — returns SERVFAIL for some of them, and the upstream itself is healthy on `/api/health`.
**Diagnosis.** Scrape the queue counters:
```sh
curl -s http://127.0.0.1:8080/metrics | grep nxdns_upstream_
```
`nxdns_upstream_in_flight` against `nxdns_upstream_slots` is how much of an upstream's concurrency is in use right now, and `nxdns_upstream_queued_total` counts exchanges that had to wait for a slot (with `nxdns_upstream_queued_seconds_total` for how long they waited in total). Both queue counters are approximate — they are sampled when a query is admitted, not measured as a queue length. A `queued_total` climbing with each burst means queries are waiting on the upstream rather than failing at it, and a query that waits past `upstream.total_timeout_ms` is given up on in the queue and answered SERVFAIL. `nxdns_upstream_budget_exhausted_total` counts those give-ups pool-wide — queries whose own budget ran out, in the queue or mid-attempt, before any upstream answered. It carries no `url` label on purpose: running out of budget is a fact about the pool, so the exhausted attempt is never charged to an upstream's health and is never attributed in the query log, although the row can still name the last endpoint whose success or recorded failure preceded it.
`nxdns_upstream_reuse_recoveries_total` is the other half of the picture: it counts DoT connections that went stale between exchanges and were redialed. A few are normal — a resolver is free to close an idle connection. One per query means the connection is never being reused, and every query is paying a full TLS handshake.
**Fix.** Raise `upstream.total_timeout_ms` if the queue drains but drains too slowly for the budget. Otherwise the queue is telling you the upstream is slow: an upstream answering in a few milliseconds does not fill eight concurrent slots at household query rates, so sustained queueing points at the resolver you configured, and a faster one is the fix. Adding a second upstream helps only with saturation, not with latency: failover is strict priority, not load spreading. A query does take the next upstream when the first one's slots are all busy — priority orders the candidates that can be admitted right now — but once every eligible upstream is full it blocks on the highest-priority one, and it still sends one query to one upstream at a time. The per-upstream slot count is compiled, not configured, so there is no knob to widen one upstream either.
## SERVFAIL rows that name no upstream
**Symptom.** The query log shows SERVFAIL rows whose `upstream` is empty, and `/api/health` shows every upstream healthy.
**Diagnosis.** A failed exchange names no upstream only when no attributable attempt happened, and two pool-wide counters say why:
```sh
curl -s http://127.0.0.1:8080/metrics | grep -E 'nxdns_upstream_(budget_exhausted|local_resource)_total'
```
`nxdns_upstream_budget_exhausted_total` is a query whose own budget ran out first; the previous section covers it. `nxdns_upstream_local_resource_total` is an exchange this process ended on its own resources: out of memory, out of file descriptors, or a system error the runtime has no name for. Neither is charged to an upstream, because no upstream caused it, and a local failure does not fail over, because the next upstream would hit the same wall. Both count exchanges, not rows without an upstream: when an earlier upstream had already failed in the same exchange, the row names that one.
**Fix.** For `local_resource_total`, look at the process's limits: `grep -E 'Max open files' /proc/$(pidof nxdns)/limits` against `ls /proc/$(pidof nxdns)/fd | wc -l`, and `VmRSS` in `/proc/$(pidof nxdns)/status` against the memory the host has. A connect that fails for a reason the runtime cannot name — a blackhole route to the upstream is one — is not counted here: it is charged to that upstream as `ConnectFailed (cause Unexpected)`, and the query fails over.
## A DoH upstream fails with `HttpStatus (status 505 HTTP Version Not Supported)`
**Symptom.** Diagnostics shows the upstream failing with that cause, and no query ever succeeds against it.
**Diagnosis.** The server refuses HTTP/1.1. nxdns speaks DoH over HTTP/1.1 only, and some public resolvers (Quad9's `https://dns.quad9.net/dns-query`, for one) require HTTP/2. Other statuses name themselves the same way: a 403 or 404 is usually a wrong URL path.
**Fix.** Use that provider's DoT endpoint instead (for Quad9, `tls://9.9.9.9:853` with `tls_name = "dns.quad9.net"`), or a DoH upstream that accepts HTTP/1.1.
## The disk is filling up
**Symptom.** Writes stop but DNS keeps answering. The journal shows the transition:
```
warning(disk_monitor): disk state ok -> critical: 33349095424 bytes free on /var/lib/nxdns
```
**Diagnosis.**
```sh
curl -s http://127.0.0.1:8080/api/health
```
`/api/health` needs no login. It answers with five condition objects, and `status` is `degraded` when any one of them is in a degrading state:
```json
{"status":"degraded","protection":{"state":"active","until":null},"upstreams":{"state":"ok","available":1,"total":1},"query_history":{"state":"recording","dropped_total":0,"last_drop_s":null},"diagnostics":{"state":"recording","active_warnings":1,"active_errors":0},"disk":{"state":"critical","free_bytes":33349079040}}
```
The disk monitor's `warn` state is reported here as `low`, because `warn` reads as a log level rather than as a quantity of disk.
`/metrics` carries the free, database and log byte gauges as `nxdns_disk_free_bytes`, `nxdns_disk_db_bytes` and `nxdns_disk_log_bytes`, and counts gated refreshes as `nxdns_blocklist_refreshes_gated_total`. The disk 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 `nxdns_blocklist_refreshes_gated_total`. A gate that has already cost query rows shows as `query_history.state = "losing"`. Resolution never degrades because the disk is full — this was verified by setting the thresholds above the free space on the volume: the state went critical, a refresh was gated, and queries kept being answered.
**Fix.** Recover space — lower `logging.retention_days`, or stop the service and delete `querylog.db` — and writes resume on the next sample.
## Blocklists are not filtering
**Symptom.** Domains that should be blocked resolve normally.
**Diagnosis.** Read the startup line:
```sh
journalctl -u nxdns | grep 'serving on'
```
It ends in either `blocklist generation N` or `unfiltered (no blocklist snapshot)`.
**Fix.** `unfiltered` means no snapshot loaded at all; the download or compile warning that explains it is earlier in the same start. nxdns serves anyway on purpose — a household loses more from DNS that refuses to start than from a window of unfiltered answers.
A generation number with nothing being blocked is a different problem: the snapshot loaded but has no sources in it. The line `blocklist snapshot generation 1: 0 of 0 sources loaded` says exactly that. Add a source in the admin interface, or 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.
```sh
curl -s 'http://127.0.0.1:8080/api/lookup?domain=api.ads.tvb.com'
```
```json
{"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`.
## A database stamped by a newer binary
**Symptom.** After putting an older binary back, it will not start:
```
warning(migrations): config.db is at schema version 99; this nxdns binary supports 1
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).
## The server refuses to start over querylog.db
**Symptom.** The process stops at startup naming the query log, and the error is one of four names:
```
error(querylog_schema): refusing to open querylog database '/var/lib/nxdns/querylog.db': it is stamped 7, and this build supports schema versions 1 to 1 (SchemaTooNew). The file is left exactly as it is; see docs/how-to/troubleshoot.md, "The server refuses to start over querylog.db"
nxdns run failed: SchemaTooNew
```
This is a refusal, not damage. nxdns will not replace a healthy query log to get itself started, so the file is left exactly as it was — schema, rows, coverage watermark and version stamp all unchanged — and the startup fails instead. All four exit 2, the code that means an operator has to act, because none of them resolves on a retry — the shipped systemd unit's `RestartPreventExitStatus=2 64` stops the unit on the first refusal instead of restart-looping it. `systemctl status nxdns` shows the refusal. `nxdns check` does not grade the query log at all, so it will not reproduce any of these.
[The query-log lifecycle](../reference/query-log-lifecycle.md) is the full contract behind this page.
**Fixes by name.**
- `SchemaTooNew` — the file was stamped by a newer nxdns than the one you are running, which normally means a binary was rolled back. Put the newer release back and start it: the file is exactly as that release left it. If you mean to stay on the older release, that release cannot read this file, so restore the `querylog.db.pre-migrate-<unix-seconds>` copy the upgrade left beside it — stop the server, move `querylog.db` and its `querylog.db-wal` and `querylog.db-shm` out of the way, rename the backup to `querylog.db`, and start. Starting empty is also an option: with the server stopped, move `querylog.db` and both sidecars aside and the next start creates a fresh log.
- `SchemaUnsupported` — the stamp is not a version this build can reach. Either the file predates 0.0.12, or it came from somewhere else, or a release since deliberately broke the schema; the changelog section for the release you are running says so when it is the third case. There is no migration path, by contract. Keep the file if the history matters — copy it somewhere and read it with the `sqlite3` shell — and if starting with an empty log is acceptable, stop the server, move `querylog.db`, `querylog.db-wal` and `querylog.db-shm` out of the data directory by hand, and start again.
- `MigrationBackupFailed` — a migration was due and the pre-migration backup could not be written, so nothing was migrated. The line above names the destination and the reason, which is almost always a full or read-only data directory. Free space or fix the permissions and start again.
- `MigrationFailed` — read the log line above it, because two different states wear this one name.
**Which `MigrationFailed` you have.** The distinction is in the line the migration logged, and it decides whether you do anything at all:
- Before the commit: `querylog migration 1 -> 2 failed before commit (...); the database is unchanged`. Nothing was applied. The file still carries its old version and every row, and this run's backup was deleted because the original is intact. Restarting will attempt the same migration and fail the same way, so this needs the underlying cause — the log line names it — or a report.
- After the commit: `querylog migration 1 -> 2 COMMITTED and the database IS at version 2, but the connection could not be restored: ...; the backup '...' is kept and the next start will open the migrated file normally`. The migration DID complete. Only that one startup is refused, the pre-migration backup is kept, and the next start opens the migrated file on the ordinary current-version path. Start the server again.
In neither case does the server start with an empty log on its own. Recreating a query log automatically is reserved for real corruption; see [why a query log is moved aside](../reference/files-and-directories.md#why-a-query-log-is-moved-aside).
> Not reproduced against a running service: the four refusals are covered by the
> test suite rather than by a hand-driven install, and the released chain has no
> migration step in it yet, so no upgrade produces a `pre-migrate` backup today.
> The messages above are the ones `src/storage/querylog_schema.zig` and
> `src/storage/querylog_migrations.zig` emit, with a data directory path and
> example version numbers filled in.
+45 -314
View File
@@ -1,376 +1,107 @@
# Upgrade nxdns
# Upgrade
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.
Move a running nxdns to a newer release without losing its configuration or its history. The new binary upgrades the databases in place the first time it starts.
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.
## 1. Read the release notes
> Verification: the export, the migration behaviour and the `version`/`check`
> steps below were run on the machine that wrote this page, against a
> populated scratch data directory and with an explicit `--data-dir`, since
> that machine has no `/var/lib/nxdns`. The commands were not run exactly as
> printed — the page uses the defaults and placeholders a real operator would
> have (`/var/lib/nxdns`, `/some/backup`, a `target` host), and every block
> where the substitution matters, or which was not run at all, carries its own
> note. Nothing here was verified except where a note says so.
>
> The download in step 2 was run on 2026-10-02 against the published `v0.0.21` release; its note carries the detail.
Each release's notes are its section of the [changelog](../../CHANGELOG.md). nxdns is not at 0.1 yet, and a release can still change behaviour or settings on purpose; the notes say what changed and what to do about it.
## Breaking change: `run --config` now means file authority
## 2. Take a backup
**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.
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).
`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.
A 0.0.1 `nxdns export` writes both fields:
```zon
.password = "",
.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:
```
FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused
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
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.
**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:
In database mode, export the configuration first:
```sh
sed -i '/^ \.password = "",$/d' /etc/nxdns/config.zon
nxdns check --config /etc/nxdns/config.zon
nxdns export --out /var/backups/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.
The export is written at mode 0600 and carries the admin password's hash, so keep it as private as the database. In file mode your configuration file is already the backup. [Back up and restore](back-up-and-restore.md) has the details.
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
> `nxdns export` and rewriting `.password = null` to `.password = ""`, which is
> the one byte-level difference between the two formats. Against that file,
> `nxdns check --config` printed both FAIL lines above and exited 2, and
> `nxdns run --config` printed them and failed `PasswordAndHashBothSet`. After
> the `sed` above, `nxdns check --config` exited 0 with `OK: no problems found`
> and `nxdns import` of the same file exited 0, keeping the hash. The claim
> about what 0.0.1's `export` emitted is read from that release's source —
> `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).
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.
## 1. Take an export first
There is no downgrade path, so the export is what you fall back to:
```sh
nxdns export --out /some/backup/nxdns-config.zon
```
`/some/backup` is a stand-in for a directory you keep backups in, and the command relies on the default `--data-dir /var/lib/nxdns` that a systemd install has.
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,
> and its `data/` was populated beforehand with `nxdns import`:
>
> ```
> $ nxdns export --data-dir $SCRATCH/data --out $SCRATCH/nxdns-config.zon
> wrote /…/scratchpad/nxdns-config.zon
> $ stat -c '%a %n' $SCRATCH/nxdns-config.zon
> 600 /…/scratchpad/nxdns-config.zon
> $ grep password $SCRATCH/nxdns-config.zon
> .password = "",
> .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$mCNEo…$i3DMz…",
> ```
>
> The shell umask was 022, so the 0600 is `export` setting it, not the umask.
> Only the two paths differ from the command above.
The file is written atomically at mode 0600 and carries `web.password_hash`, so treat it as a secret. See [Back up and restore](back-up-and-restore.md) for the full backup story. The query log is deliberately not part of it.
## 2. 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.
```sh
BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
sed 's#.*/releases/tag/v##')
mkdir -p ~/nxdns-release && cd ~/nxdns-release
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-x86_64-linux-musl.tar.gz"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
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.
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.
> Verified on 2026-10-02 against `v0.0.21`: the lookup printed `0.0.21`, every `curl` succeeded, `gpg --verify` printed `Good signature`, `sha256sum -c` printed `OK`, and the tarball extracted. [Install with systemd](install-with-systemd.md) has the detail.
## 3. Replace the binary
## 3. Replace nxdns
### 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:
Download and verify the new release as in [Install with systemd](install-with-systemd.md#1-download-and-verify), taking `aarch64` for a Raspberry Pi. Then, as root, from the unpacked directory:
```sh
scp "nxdns-$VERSION-x86_64-linux-musl/nxdns" target:/tmp/nxdns
```
> Not run on this host: `target` is a placeholder for the machine running
> nxdns, and this host has no such second machine to copy to. 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.
Then, as root on the target:
```sh
install -m 0755 /tmp/nxdns /usr/local/bin/nxdns
install -m 0755 nxdns /usr/local/bin/nxdns
systemctl restart nxdns
journalctl -u nxdns -f
```
> Not run on this host: all three lines need root and an installed service.
> The migration half of what a restart does is checkable without either, and
> was — see the note under
> [What happens to the database](#what-happens-to-the-database). The swap of
> an older binary for a newer one on a live service was not reproduced here.
The tarball also carries `nxdns.service` and `nxdns.conf`. Reinstall them only when the release notes say they changed.
### Docker
The compose file names the release it belongs to, so upgrading the image is upgrading the file. A new release's compose file can change more than the image — the `command:` line or a port — so both ways below replace yours: if you edited it by hand, carry your edits over to the new one. To keep your own file as it is, skip the `curl` or the `git checkout` and set `NXDNS_VERSION=$VERSION` on the `pull` and `up` lines instead: it overrides the release the file names. A compose file from `v0.0.22` or earlier defaults to `:latest`, so with one of those the variable is needed on both lines, or `pull` and `up` can land on different images if a release happens between them.
With the single downloaded file of [Install with Docker](install-with-docker.md), fetch the new release's copy over it in its directory, then pull and recreate:
The compose file names the release it belongs to, so fetch the new release's copy over yours, then pull and recreate the container:
```sh
cd ~/nxdns
curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
curl -fLO https://git.mial.net/mokhtar/nxdns/raw/tag/v<version>/deploy/docker/compose.yaml
docker compose pull
docker compose up -d
```
With a checkout of the repository, fetch the tag and move to it, from the repository root:
If you edited your compose file, carry your edits over to the new one. To keep your file exactly as it is, skip the `curl` and set the release on both commands instead: `NXDNS_VERSION=<version> docker compose pull`, then `NXDNS_VERSION=<version> docker compose up -d`.
```sh
git fetch origin tag "v$VERSION"
git checkout "v$VERSION"
docker compose -f deploy/docker/compose.yaml pull
docker compose -f deploy/docker/compose.yaml up -d
```
The container is recreated on the same `nxdns-data` volume, so the configuration and history stay.
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.
### Nix
> Not run on this host: `pull` and `up` were not run against a new release.
> What was run on 2026-10-20 is `docker compose -f deploy/docker/compose.yaml
> config` on a tree whose `build.zig.zon` declares `0.0.22`, which resolves
> the variables without contacting a registry: no variable gave
> `image: git.mial.net/mokhtar/nxdns:0.0.22`, and `NXDNS_VERSION=0.0.21` gave
> `image: git.mial.net/mokhtar/nxdns:0.0.21`.
> The fetch half was run the same day against `v0.0.22`: the `curl` URL
> returned 200, and `git fetch --dry-run origin tag v0.0.22` exited 0.
Change the tag in the flake input and switch; see [Install with Nix](install-with-nix.md#3-upgrade).
## 4. Confirm the upgrade
## 4. Confirm
```sh
nxdns version
nxdns export --out /tmp/after-upgrade.zon
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:
`version` prints the release you installed. The start log names it too:
```
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.
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
```
`export` opens the database read/write and does see the log, so exporting and then checking the export validates what is actually in force:
## What happens to the databases
```
checking configuration file /tmp/after-upgrade.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
```
> Verified on this host for the first three commands, with `--data-dir`
> pointing at a scratch data directory instead of `/var/lib/nxdns` — that path
> is the only difference from the blocks above:
>
> ```
> $ nxdns version
> nxdns <version>
> zig 0.16.0
> ```
>
> Against a running server whose database had just taken a configuration write
> through the API, `nxdns check --data-dir` printed the uncheckpointed-log line
> above and exited 2, while `nxdns export --out` followed by
> `nxdns check --config` on the result exited 0 with `OK: no problems found`.
> Both were re-run for this revision. The `dig` line was not run in this round:
> nothing is listening on 127.0.0.1:53 here, and port 53 needs root.
## What happens to the database
Migrations run at startup, and also before `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.
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 release that changes how data is stored upgrades `config.db` and `querylog.db` when it first starts, and logs it:
```
info(migrations): config.db migrated from schema version 0 to 1
```
> Verified on this host: that exact line is what `nxdns import` printed when it
> created the scratch database used throughout this page. An empty data
> directory is schema version 0, which is why a first run reports a migration
> rather than nothing. Version 1 is the only schema nxdns has published, so an
> upgrade from a populated older one is not a case that exists yet.
Before it upgrades the query history, nxdns copies it to `querylog.db.pre-migrate-<unix-seconds>` beside the original and keeps that copy. An upgrade either completes or leaves the file as it was; if it fails, nxdns refuses to start and says so, and the history is untouched.
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:
A release can also be unable to convert the old query history. Its release notes then say `resets your query history` and explain how to keep the old file. That release refuses to start until you move the old file aside, and its message says how; [Files and directories](../reference/files-and-directories.md#the-query-history-database) has the commands.
## Going back to an older release
Upgrades only go forward. An older nxdns refuses a database a newer one has already upgraded, and exits 2:
```
warning(migrations): config.db is at schema version 99; this nxdns binary supports 1
nxdns run failed: SchemaTooNew
```
> Reproduced on this host, with one substitution: no binary from the future was
> available, so the scratch database's `schema_version` row was set to 99 by
> hand and `nxdns run` was pointed at it. The two lines above are that run's
> output.
That run exits 2, and the shipped unit stops rather than restart-loops it. 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.
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.
## 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:
```
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:
To go back anyway, install the older `nxdns` and give it a fresh data directory with the configuration you exported in step 2. As root:
```sh
$EDITOR /etc/nxdns/config.zon
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:
```sh
nxdns export --out config-backup.zon
$EDITOR config-backup.zon
systemctl stop nxdns
nxdns import config-backup.zon
mv /var/lib/nxdns /var/lib/nxdns.newer
nxdns import /var/backups/nxdns-config.zon
```
If the upgrade converted the query history, nxdns kept a copy of it from before; restore that copy to keep the history:
```sh
cp /var/lib/nxdns.newer/querylog.db.pre-migrate-* /var/lib/nxdns/querylog.db
```
Then hand the directory to the service and start it:
```sh
chown -R nxdns:nxdns /var/lib/nxdns
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:
```
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.
> 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
> above:
>
> ```
> $ nxdns import $SCRATCH/etc/config.zon --data-dir $SCRATCH/dbmode
> imported /…/config.zon
> (exit 0)
> $ nxdns import $SCRATCH/etc/smaller.zon --data-dir $SCRATCH/dbmode
> FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
> import failed: DestructiveImport
> (exit 2)
> $ nxdns import $SCRATCH/etc/smaller.zon --data-dir $SCRATCH/dbmode --allow-delete
> imported /…/smaller.zon
> (exit 0)
> ```
>
> The first of those three is the additive case that needs no flag; the second
> file replaced the upstream, which is an identity change and therefore a
> delete. The `systemctl stop`/`start` lines need root and an installed service
> and were not run; `$EDITOR` is yours to run.
## 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:
```sh
(cd admin && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
```
Rebuild `admin/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 System page. `dist` refuses the `admin/dist-placeholder` default outright, so the only way to ship a stale bundle is to leave an old `admin/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. Your build reports the same two lines as the published release of that version, so compare the tarball hashes rather than the `nxdns version` output when you need to tell them apart.
Under Docker, build the image and name it instead of pulling:
```sh
DOCKER_BUILDKIT=1 docker build -t nxdns -f deploy/docker/Dockerfile .
NXDNS_IMAGE=nxdns docker compose -f deploy/docker/compose.yaml up -d
```
See [Install with Docker](install-with-docker.md) for what that build needs.
> Verified on this host for the two build commands in this section — the
> `zig build dist` block above and the `docker build` here. `dist` was run to
> completion with the version read out of `build.zig.zon` and exited 0, and the
> image was built from the resulting `zig-out/dist` tree, also exiting 0. The
> `docker compose ... up -d` line was not run in this round — the run itself is
> covered in [Install with Docker](install-with-docker.md#3-run-it), where a
> host port had to be moved to do it. See
> [Install with systemd](install-with-systemd.md#build-from-source-instead) for
> the `dist` and `verify-dist` detail.
Blocklists download again on the first start. Anything recorded after the upgrade stays in `/var/lib/nxdns.newer`.
+62 -207
View File
@@ -1,91 +1,37 @@
# 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.
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`
> holding only the key fetched from keys.openpgp.org. Every transcript below is
> that run's output. Where a block shows a failure — a `BAD signature`, a
> `FAILED` hash — the failure was produced deliberately by tampering with a
> copy of the real file, and the surrounding text says how.
Check that an nxdns release you downloaded is the one the project published, and that it arrived intact. Do it before you run the binary.
## What a release contains
Five assets, on the release page at `https://git.mial.net/mokhtar/nxdns/releases`:
The [release page](https://git.mial.net/mokhtar/nxdns/releases) has five files for each version:
| Asset | What it is |
| File | What it is |
| --- | --- |
| `nxdns-<version>-x86_64-linux-musl.tar.gz` | The x86_64 tarball |
| `nxdns-<version>-aarch64-linux-musl.tar.gz` | The aarch64 tarball, for a Raspberry Pi 5 |
| `SHA256SUMS.txt` | One `sha256sum` line each for the two tarballs and for `IMAGE-DIGEST.txt` |
| `SHA256SUMS.txt.asc` | A detached OpenPGP signature over `SHA256SUMS.txt` |
| `IMAGE-DIGEST.txt` | The container image reference this version pushed, pinned by digest |
| `nxdns-<version>-x86_64-linux-musl.tar.gz` | nxdns for x86_64 Linux |
| `nxdns-<version>-aarch64-linux-musl.tar.gz` | nxdns for 64-bit ARM Linux, such as a Raspberry Pi |
| `SHA256SUMS.txt` | The SHA-256 of both tarballs and of `IMAGE-DIGEST.txt` |
| `SHA256SUMS.txt.asc` | An OpenPGP signature over `SHA256SUMS.txt` |
| `IMAGE-DIGEST.txt` | The container image of this version, 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.
The signature covers `SHA256SUMS.txt`, and `SHA256SUMS.txt` covers everything else. Someone who can replace a tarball on its way to you can replace the checksum file next to it, but cannot sign it.
`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.
## 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.
```sh
BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
sed 's#.*/releases/tag/v##')
echo "$VERSION"
```
To take a particular version instead, set it yourself — substitute the one you want for the placeholder:
```sh
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.
## 2. Download
## 1. Download
```sh
mkdir -p ~/nxdns-release && cd ~/nxdns-release
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-x86_64-linux-musl.tar.gz"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
curl -fLO "$BASE/releases/download/v$VERSION/IMAGE-DIGEST.txt"
BASE=https://git.mial.net/mokhtar/nxdns/releases/download/v<version>
curl -fLO "$BASE/nxdns-<version>-x86_64-linux-musl.tar.gz"
curl -fLO "$BASE/SHA256SUMS.txt"
curl -fLO "$BASE/SHA256SUMS.txt.asc"
curl -fLO "$BASE/IMAGE-DIGEST.txt"
```
`-L` is not optional: Gitea answers an asset URL with a 303 to wherever the attachment is actually stored.
Take the `aarch64` tarball instead for a Raspberry Pi.
For the aarch64 tarball, or for both, swap or add the filename:
## 2. Import the signing key
```sh
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:
```sh
curl -fLO "$BASE/releases/download/latest/SHA256SUMS.txt"
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.
> Verified against `v0.0.1`: all five assets downloaded through the versioned
> path, `SHA256SUMS.txt` downloaded again through the `latest` alias and hashed
> identical, and GitHub's spelling answered 404.
## 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`:
Releases are signed by a subkey of this key:
```
A2061F6AB24DF2C0E92346FD1509B54946D08A95
@@ -96,18 +42,16 @@ curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346
gpg --import
```
> Verified: the key is published, and that exact `curl | gpg --import` reported
> `key 1509B54946D08A95: public key "Mokhtar Mial (pc) <…>"
> imported` into a clean `GNUPGHOME`.
The same key signs every commit and tag in the repository, so `git verify-tag v<version>` in a clone confirms it independently.
Then verify:
## 3. Check the signature
```sh
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
```
```
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
gpg: Signature made <date and time of the release>
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
gpg: Good signature from "Mokhtar Mial (pc) <…>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
@@ -116,34 +60,14 @@ 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.
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:
Two things must hold: the output says `Good signature`, and the `Primary key fingerprint` is the fingerprint in step 2. GnuPG prints it in groups of four; this prints it unspaced for comparison:
```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.
A tampered `SHA256SUMS.txt` looks like this, and exits 1:
```
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
gpg: BAD signature from "Mokhtar Mial (pc) <…>" [unknown]
```
> Verified against `v0.0.1`, from a clean `GNUPGHOME` holding only the imported
> public key — which is why the `[unknown]` trust marker and the warning are
> there rather than being written in by hand. The good-signature transcript is
> the real release's; the `BAD signature` transcript is the same command
> against a copy of `SHA256SUMS.txt` with one newline appended, and it exited
> 1. The `sed`/`tr` pipeline printed
> `A2061F6AB24DF2C0E92346FD1509B54946D08A95`, matching the fingerprint above.
Do not skip the fingerprint. `gpg --verify` accepts a good signature from any key in your keyring. The `WARNING` lines are normal: they only mean you have not told GnuPG that you trust the key.
## 4. Check the hashes
@@ -156,135 +80,92 @@ 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` skips the tarball you did not download. A file that does not match prints `FAILED` and `sha256sum` exits 1.
```
nxdns-<version>-x86_64-linux-musl.tar.gz: OK
sha256sum: nxdns-<version>-aarch64-linux-musl.tar.gz: No such file or directory
nxdns-<version>-aarch64-linux-musl.tar.gz: FAILED open or read
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`:
```
nxdns-<version>-x86_64-linux-musl.tar.gz: FAILED
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.
> Verified against `v0.0.1`: with both tarballs present, `sha256sum -c` printed
> three `OK` lines. The three transcripts above are the same command over
> copies of the real assets, with the aarch64 tarball absent for the first two
> and one byte appended to the x86_64 tarball for the third. Only the version
> in the filenames was replaced with `<version>`.
## 5. Look inside before extracting
## 5. Look inside the tarball
```sh
tar -tvzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
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.
```
drwxr-xr-x 0/0 0 1970-01-01 01:00 nxdns-<version>-x86_64-linux-musl
-rw-r--r-- 0/0 4229 1970-01-01 01:00 nxdns-<version>-x86_64-linux-musl/INSTALL.md
-rw-r--r-- 0/0 14210 1970-01-01 01:00 nxdns-<version>-x86_64-linux-musl/LICENSE
-rw-r--r-- 0/0 72967 1970-01-01 01:00 nxdns-<version>-x86_64-linux-musl/THIRD-PARTY-NOTICES
-rwxr-xr-x 0/0 7257952 1970-01-01 01:00 nxdns-<version>-x86_64-linux-musl/nxdns
-rw-r--r-- 0/0 31 1970-01-01 01:00 nxdns-<version>-x86_64-linux-musl/nxdns.conf
-rw-r--r-- 0/0 1547 1970-01-01 01:00 nxdns-<version>-x86_64-linux-musl/nxdns.service
```
Then extract:
Expect one directory and these six files, with no links and no path that starts with `/` or contains `..`. The sizes differ from release to release; the dates are always the epoch, shown in your time zone. Then:
```sh
tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
./nxdns-$VERSION-x86_64-linux-musl/nxdns version
tar -xzf nxdns-<version>-x86_64-linux-musl.tar.gz
./nxdns-<version>-x86_64-linux-musl/nxdns version
```
`version` prints the version, then the Zig version the binary was built with. The version has to match the tag you downloaded. The binary carries no commit sha, so the check that ties a release to its source is the rebuild below rather than a string in this output.
> 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,
> and the extracted binary printed `nxdns 0.0.1` then `zig 0.16.0`.
The first line it prints must be `nxdns <version>`, the version you downloaded.
## 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` names the image this version published, pinned by digest:
```
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:
```sh
cut -d@ -f2 IMAGE-DIGEST.txt
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 signature you checked covers this line. A tag in a registry can be moved; a digest cannot. Pull the pinned reference:
```sh
docker pull "$(cat IMAGE-DIGEST.txt)"
```
The index should carry exactly two platforms and nothing else:
Or check that the tag still points at it, comparing the two lines it prints:
```sh
docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}'
cut -d@ -f2 IMAGE-DIGEST.txt
docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:<version> --format '{{.Manifest.Digest}}'
```
`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 image holds exactly `linux/amd64` and `linux/arm64`, and its binary is the same file as the one in the x86_64 tarball:
```sh
docker create --name nxdns-verify git.mial.net/mokhtar/nxdns:$VERSION
docker create --name nxdns-verify "$(cat IMAGE-DIGEST.txt)"
docker cp nxdns-verify:/nxdns ./nxdns-from-image
docker rm nxdns-verify
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
sha256sum ./nxdns-from-image ./nxdns-<version>-x86_64-linux-musl/nxdns
```
> Verified against `v0.0.1`: the digest in `IMAGE-DIGEST.txt` and the digest
> the `:0.0.1` tag resolves to were the same string
> (`sha256:f2945fbf6c1e16509f0e33e3d62da9a9cd7dc706718d333ce4edf95c80dbb00e`,
> and `:latest` resolved to it too), the platform form printed exactly
> `linux/amd64 linux/arm64` with no attestation entries, `docker pull` of the
> pinned reference succeeded, and the binary copied out of that pulled image
> hashed identical to the `nxdns` in the x86_64 tarball.
## If a check fails
## What the signature proves, and what it does not
Do not run the binary.
It proves two things:
- `sha256sum` says `FAILED` after a good signature: the download is damaged or was replaced. Delete it and download it again, over another network if you can.
- `gpg` says `BAD signature`: `SHA256SUMS.txt` and its signature do not belong together. Download both again; a signature left over from another version is the usual cause.
- `gpg` says `No public key`: import the key from step 2.
- The fingerprint does not match step 2: take this one seriously. Do not extract the tarball, and do not import other keys to make it pass.
- 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.
## What the signature does not prove
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.
The signature proves the files came from the project's release pipeline and that you have the bytes it uploaded. It does not prove that the binary was built from the published source: the machine that built it also signed it.
Closing that gap needs a reproducible build — an independent build, run somewhere else, that lands on the same bytes. The release tarballs are reproducible: the binary carries no commit sha, the archive is written by the project's own tool with fixed modes, zero timestamps and sorted entries, and CI rebuilds the same bytes and compares them against the pinned hashes before it uploads anything. The rebuild below is how you check that for yourself.
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
Build the same version from source and compare the hashes. Match the toolchain first: `ZIG_VERSION`, `NODE_VERSION` and `NPM_VERSION` at the top of `.gitea/workflows/gates.yml` are the exact versions the release used, and a different patch release of any of them changes the bytes.
Zig has to be the official ziglang.org tarball for that version, not your distribution's package of it. A distro package of `0.16.0` is built against the system LLVM and emits different machine code than the official build, and both print `0.16.0`, so the version string tells you nothing. This is not hypothetical: the first cut of `flake.nix` pinned hashes CI could not reproduce for exactly that reason, and the release cut now downloads the official tarball itself rather than trusting `PATH`. Fetch and verify it the same way, against the `ZIG_TARBALL_SHA256` pin that sits next to `ZIG_VERSION` in `gates.yml` (CI installs the same bytes through setup-zig, which checks them by minisign):
The release builds are reproducible, so you can check that yourself by building the same version from source and comparing hashes. It takes the exact toolchain the release used, named at the top of `.gitea/workflows/gates.yml`: `ZIG_VERSION` with its `ZIG_TARBALL_SHA256`, `NODE_VERSION` and `NPM_VERSION`. Use the official Zig tarball from ziglang.org, not a distribution's package of the same version: a distribution build of Zig produces different machine code while printing the same version. The rebuild reproduces the release only on x86_64 Linux with glibc.
```sh
git clone https://git.mial.net/mokhtar/nxdns
cd nxdns
git checkout "v$VERSION"
git verify-tag "v$VERSION"
git checkout v<version>
git verify-tag v<version>
ZIG_VERSION=$(sed -n 's/^ ZIG_VERSION: "\(.*\)"$/\1/p' .gitea/workflows/gates.yml)
ZIG_TARBALL_SHA256=$(sed -n 's/^ ZIG_TARBALL_SHA256: "\(.*\)"$/\1/p' .gitea/workflows/gates.yml)
curl -fsSL -o ../zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-x86_64-linux-$ZIG_VERSION.tar.xz"
(cd .. && echo "$ZIG_TARBALL_SHA256 zig.tar.xz" | sha256sum -c - && tar -xJf zig.tar.xz)
ZIG="$(cd .. && pwd)/zig-x86_64-linux-$ZIG_VERSION/zig"
"$ZIG" version
```
The host matters too: the admin bundle is built with host-native Rolldown and Lightning CSS bindings, which the lockfile ships per platform and libc, so the recipe reproduces the release only on x86_64 Linux with glibc, the runner CI uses. The two `npm_config_*` paths must not exist on your machine; the recipe relies on npm finding no config file there. The environment matters as much as the toolchain. The release cut and CI both build the bundle under exactly nine variables and a `022` umask, so the recipe below does the same: a locale, a time zone, a build timestamp or a file mode picked up from your shell each move the bytes. The Zig build gets a private `--cache-dir` so a stale local cache cannot leak into them; the global Zig cache stays shared, because it is content-addressed and a fresh one refetches every dependency (see `specs/release-cut.md`).
Then build in a clean environment. A locale, a time zone or a umask picked up from your shell changes the output, and the two `npm_config_*` paths must not exist:
```sh
env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global \
@@ -292,37 +173,11 @@ env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=t
env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global \
sh -c 'umask 022 && exec "'"$ZIG"'" build dist \
-Dversion-string="'"$VERSION"'" \
-Dversion-string=<version> \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe \
--cache-dir "$(mktemp -d)"'
sha256sum zig-out/dist/nxdns-"$VERSION"-*.tar.gz
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.
`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.
The tarball is written by `zig build dist` itself rather than by the host's `tar` and `gzip`, so its layout is fixed and a rebuild on a matching toolchain reproduces it byte for byte. Entries are sorted by their full path as bytes, with the payload directory first. The payload directory and `nxdns` carry mode `0755`; every other file carries `0644`. Every entry has a zero modification time, uid 0, gid 0, and no user or group name. The gzip wrapper carries no original filename and a zero header timestamp, which is what makes two archives of the same tree compare equal.
Compare your two tarball hashes against two things: the published `SHA256SUMS.txt`, and the `hashes` block of `flake.nix` at the tag, which carries the same digests in SRI form. All three agree on a matching toolchain, and the release pipeline fails before it uploads anything if they do not.
A hash that differs is a signal to check the toolchain and the environment first. A distribution's Zig package instead of the official tarball is the first thing to rule out; an unpinned Node or npm version is the next ordinary explanation, and a build run outside the normalized environment above is the next one. Rule both out before you conclude anything about the release itself.
> Verified on 2026-10-19 against `v0.0.21`, on x86_64 Linux with glibc, Node 24.19.0 and npm 11.17.0: from a fresh clone, `git verify-tag v0.0.21` printed `Good signature`, the freshly downloaded Zig tarball passed its `sha256sum -c`, and both rebuilt tarball hashes matched the published `SHA256SUMS.txt` and the `flake.nix` pins at the tag (`26615c4b…b3b4ce` for x86_64, `29746c9a…7f19af` for aarch64). The two `sh -c` wrappers were replaced by the same nine-variable `env -i` and a `022` umask set in the calling shell. The host was the maintainer's own machine, in a fresh directory with a private `--cache-dir`, so this run shows the recipe works; it is not a third-party reproduction. An earlier run against `v0.0.1`, before the build was reproducible, did not match.
## If a check fails
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.
## Related
- [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.
Both hashes must match the tarball lines of the published `SHA256SUMS.txt`. If they do not, check the toolchain and the environment first: a distribution's Zig, a different Node or npm, or a build outside `env -i` are the usual causes.
-222
View File
@@ -1,222 +0,0 @@
# REST API reference
nxdns serves its admin API itself, on `web.bind:web.port` (default port 8080), as plain HTTP. TLS termination, where an operator wants it, belongs to a reverse proxy in front; the session cookie deliberately omits the `Secure` attribute so the supported plain-HTTP LAN deployment works.
The machine-readable contract is `src/web/openapi.yaml`, which the running server hands out unauthenticated at `GET /api/openapi.yaml`. Request and response schemas for every operation live there. When this page and the YAML disagree, the YAML wins.
The route table is `src/web/routes.zig`; the [Operations](#operations) table below carries all 64 of its entries.
## Conventions
- All request and response bodies are JSON (`application/json`), except `/metrics` (Prometheus text format), `/api/openapi.yaml` (YAML) and `/api/queries/live` (`text/event-stream`).
- Field names are snake_case, matching settings keys and SQL column names.
- Every error response carries the envelope `{"error": "<message>"}`. The message is operator-facing text; internal detail never reaches the wire — a 500 body is generic and the cause goes to the server log.
- Request bodies are strict: an unknown field is a 400, a body over 1 MiB is a
413.
- A request whose path matches but whose method does not answers 405 with an `Allow` header. An unknown `/api` path is a JSON 404; unknown non-`/api` paths fall through to the embedded SPA (`index.html`), so client-side routing works.
- Item routes (`{id}`) match a positive integer id only.
- Mutations take effect live, upstreams and `/api/settings` included: a write rebuilds or reconfigures the owner it belongs to in-process. The exceptions are the settings keys that create or destroy a socket — the DNS, web, DoH and DoT bind addresses, ports and enabled flags. Writing one of those commits the row and reports `restart_pending: true` on `GET /api/config/status`; the settings envelope lists exactly those keys under `restart_required`.
- Every route has a policy class — `read`, `config_write` or `runtime_action` — and in file mode the `config_write` routes are refused. See [Configuration authority](#configuration-authority).
## Authentication
Cookie sessions, in memory, no accounts — one operator password. Setting that password is [set up admin authentication](../how-to/set-up-admin-authentication.md).
- Authentication is on exactly when `web.password_hash` is set. When no password is set, every route is open and `POST /api/auth/login` answers `{"authenticated": true, "auth_required": false}` without setting a cookie.
- `POST /api/auth/login` takes `{"password": "..."}`. A correct password answers 200 with a `Set-Cookie` for `nxdns_session` (`HttpOnly; SameSite=Lax; Path=/`, `Max-Age` = the session TTL). A wrong password is a 401; a stored hash the server cannot read is a 500, never a 401. Login attempts spend rate-limit tokens like any other request, and argon2id verification is deliberately slow.
- Every route whose auth policy is `session` answers 401 `{"error": "authentication required"}` without a valid cookie.
- Sessions live `web.session_ttl_hours` (default 24) from login; use does not extend the lifetime. The table holds 32 sessions; a 33rd login evicts the least recently used. Nothing is persisted — a server restart logs every operator out.
- Changing the password through `PUT /api/settings` revokes every live session immediately; the new password applies without a restart.
- `POST /api/auth/logout` ends the cookie's session and clears the cookie. The route is `.session` like any other, so with a password configured the router answers 401 before the handler runs when the cookie is missing, expired, revoked or already logged out; only a live session gets the 200. With no password configured every session route is open and logout answers 200.
## Rate limiting
A token bucket per client address: capacity and refill are both `web.api_rate_limit_per_min` (default 300) per minute, so a page-load burst up to the capacity is admitted and the long-run rate holds.
- An over-budget request answers 429 `{"error": "rate limited"}` with a `Retry-After` header giving the seconds until a token is available (rounded up, never zero).
- Loopback addresses (127.0.0.0/8 and ::1) are exempt while `web.api_localhost_exempt` is true (the default).
- The address a bucket keys on is the socket peer, unless that peer is listed in `web.trusted_proxies`. For a listed peer the address is instead the **last** entry of the request's `X-Forwarded-For` — the entry the proxy appended, which is the only one a client cannot write. A request from a trusted proxy with no such header keys on the proxy itself; one whose last entry is not an IP literal is answered 400, because the alternative is granting the proxy's own loopback exemption to whoever sent it. Only `X-Forwarded-For` is read; `Forwarded` (RFC 7239) and the PROXY protocol are not.
- Without `web.trusted_proxies`, a same-box reverse proxy makes every request loopback, so the default exemption disables the limiter for all remote clients. Set the proxy's address there, or set `web.api_localhost_exempt = false`.
- Exempt routes, which never consult a bucket: `/metrics` and `/api/health` (a Prometheus scrape must never see 429) and `/api/queries/live` (one long-lived stream must not drain its address's bucket; it is bounded by the SSE connection cap instead).
- The limiter tracks at most 4096 addresses. When the table is full and no slot is reclaimable, requests from unknown addresses are refused with 429.
## Live query stream (SSE)
`GET /api/queries/live` is server-sent events over chunked transfer, `Content-Type: text/event-stream`, `Cache-Control: no-store`.
- The stream opens with `retry: 3000`, so a browser `EventSource` reconnects on its own after a drop.
- Each query is one frame: `event: query` and a single `data:` line of JSON. The payload is the `Provenance` object — the body of `GET /api/queries/{id}` without its `id`, which does not exist yet because a live entry precedes its own insert. Its six groups are `request`, `group`, `policy`, `rewrites`, `route` and `response`.
- A `: ping` comment heartbeat goes out after 15 s of quiet, keeping middleboxes from reaping the idle connection.
- Each subscriber buffers up to 64 entries. A client too slow for the query rate overflows its buffer and the server ends the stream cleanly after delivering what the buffer held — queries are never held back for a slow reader. There is no gap marker: on reconnect, re-sync through `GET /api/queries`, which has the missed rows.
- Connections per client address are capped at `web.sse_max_connections_per_ip` (default 3); over the cap is a 429. The cap binds loopback too. The server holds at most 32 concurrent streams in total; when all slots are taken, the answer is a 503.
## Configuration authority
Which authority is live decides whether the API may write configuration. Under `nxdns run` the database is authority and every route behaves as it always has. Under `nxdns run --config FILE` the file is authority, and the routes that would edit configuration are refused: the file is the only place configuration changes, and a restart is what applies them.
### The refusal
A `config write` route in file mode answers **403** with the ordinary error envelope:
```json
{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}
```
There is no `code` field and no richer body. 403 is used for nothing else in this API, so the status alone is the machine-readable part, and a client that wants to know the mode in advance reads it from `GET /api/config/status` rather than probing for errors.
**401 comes first.** The router matches the path, spends a rate-limit token, checks the session, and only then checks the policy. So an unauthenticated request to a `config write` route in file mode is a 401, not a 403 — answering 403 first would tell an anonymous caller which routes exist.
`runtime action` and `read` routes are unaffected in both modes. Pausing blocking, refreshing blocklists, reloading certificates and logging in are operations on a running process, not statements about configuration, so a file-mode box still does all of them.
`DELETE /api/clients/{id}` is the one route whose answer depends on the row. Deleting a client the file does not declare is a runtime action and succeeds: without it, a mis-identified or departed device would be immortal in file mode, since the file can add addresses but never remove one it has never named. Deleting a client the file *does* declare contradicts the file, and answers the same 403.
### Discovering the authority
`GET /api/config/status` answers with the authority and the process's restart state:
| Field | Meaning |
| --- | --- |
| `authority` | `"database"` or `"managed_file"`. |
| `path` | The managed file's path, or `null` in database mode. |
| `reconciled_at` | Unix seconds when this process loaded the file, or `null` in database mode. |
| `restart_pending` | True once this process has committed a configuration change that only a restart applies. |
All four keys are always present; the two nullable ones carry `null` rather than being omitted, so a client can read `authority` without probing.
```json
{"authority": "database", "path": null, "reconciled_at": null, "restart_pending": false}
{"authority": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016, "restart_pending": false}
```
The route requires a session, which is why the filesystem path is here rather than on the open `/api/version` and `/api/health`.
`restart_pending` is per-process state and nothing but process exit clears it. It rises for exactly one kind of change: a settings key that creates or destroys a socket — the DNS, web, DoH and DoT bind addresses, ports and enabled flags. Every other write, upstreams included, is applied in-process and leaves the flag alone. It is never persisted, so a `false` read after a restart means the restart happened, not that the flag was cleared. In file mode it stays false: those writes are refused before any handler runs.
`reconciled_at` answers exactly one question: **when did this process last read the file?** Compare it against the file's mtime to spot a restart that has not happened yet. It is a hint and not a verdict, in both directions — a clock that stepped, or a copy that preserved mtimes (`git checkout`, `rsync -a`), can make a newer file look older, and the database can change without either timestamp moving. It does not tell you whether the file and the running configuration agree; answering that would take content hashing, which nxdns deliberately does not do.
## Operations
Auth `open` means no session is required; `session` means a valid session cookie is required whenever a password is set. Rate limit `counted` spends a token; `exempt` never consults the limiter. Policy `config write` is the class refused in file mode; `read` and `runtime action` are always served.
| Method | Path | Auth | Rate limit | Policy | Purpose |
|---|---|---|---|---|---|
| GET | `/metrics` | open | exempt | read | Prometheus metrics |
| GET | `/api/health` | open | exempt | read | Health rollup |
| GET | `/api/version` | open | counted | read | Build and uptime |
| GET | `/api/openapi.yaml` | open | counted | read | This API's OpenAPI document |
| POST | `/api/auth/login` | open | counted | runtime action | Log in |
| POST | `/api/auth/logout` | session | counted | runtime action | Log out |
| GET | `/api/queries` | session | counted | read | Query log rows for the Activity page |
| GET | `/api/queries/{id}` | session | counted | read | One query, fully explained |
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
| GET | `/api/overview` | session | counted | read | Everything the Overview page draws, for one period |
| GET | `/api/lookup` | session | counted | read | Explain a domain |
| GET | `/api/diagnostics` | session | counted | read | Operational event log |
| DELETE | `/api/diagnostics` | session | counted | runtime action | Purge every resolved event |
| GET | `/api/diagnostics/{id}` | session | counted | read | One operational event |
| DELETE | `/api/diagnostics/{id}` | session | counted | runtime action | Purge one resolved event |
| GET | `/api/groups` | session | counted | read | List groups |
| POST | `/api/groups` | session | counted | config write | Create a group |
| GET | `/api/groups/{id}` | session | counted | read | Read a group |
| PUT | `/api/groups/{id}` | session | counted | config write | Update a group |
| DELETE | `/api/groups/{id}` | session | counted | config write | Delete a group |
| GET | `/api/groups/{id}/sources` | session | counted | read | Blocklist sources assigned to a group |
| PUT | `/api/groups/{id}/sources` | session | counted | config write | Replace the assignment |
| GET | `/api/blocklists` | session | counted | read | List blocklist sources |
| POST | `/api/blocklists` | session | counted | config write | Add a blocklist source |
| POST | `/api/blocklists/update` | session | counted | runtime action | Refresh every enabled source now |
| GET | `/api/blocklists/{id}` | session | counted | read | Read a blocklist source |
| PUT | `/api/blocklists/{id}` | session | counted | config write | Update a blocklist source |
| DELETE | `/api/blocklists/{id}` | session | counted | config write | Delete a blocklist source |
| GET | `/api/rules` | session | counted | read | List rules |
| POST | `/api/rules` | session | counted | config write | Create a rule |
| GET | `/api/rules/{id}` | session | counted | read | Read a rule |
| PUT | `/api/rules/{id}` | session | counted | config write | Update a rule |
| DELETE | `/api/rules/{id}` | session | counted | config write | Delete a rule |
| GET | `/api/local-records` | session | counted | read | List local DNS records |
| POST | `/api/local-records` | session | counted | config write | Create a local record |
| GET | `/api/local-records/{id}` | session | counted | read | Read a local record |
| PUT | `/api/local-records/{id}` | session | counted | config write | Update a local record |
| DELETE | `/api/local-records/{id}` | session | counted | config write | Delete a local record |
| GET | `/api/forward-zones` | session | counted | read | List forward zones |
| POST | `/api/forward-zones` | session | counted | config write | Create a forward zone |
| GET | `/api/forward-zones/{id}` | session | counted | read | Read a forward zone |
| PUT | `/api/forward-zones/{id}` | session | counted | config write | Update a forward zone |
| DELETE | `/api/forward-zones/{id}` | session | counted | config write | Delete a forward zone |
| GET | `/api/clients` | session | counted | read | List clients |
| GET | `/api/clients/{id}` | session | counted | read | Read a client |
| PUT | `/api/clients/{id}` | session | counted | config write | Rename or regroup a client |
| DELETE | `/api/clients/{id}` | session | counted | runtime action | Forget a client |
| GET | `/api/client-prefixes` | session | counted | read | List client prefixes |
| PUT | `/api/client-prefixes` | session | counted | config write | Replace the prefix table |
| GET | `/api/upstreams` | session | counted | read | List upstream resolvers |
| POST | `/api/upstreams` | session | counted | config write | Add an upstream |
| GET | `/api/upstreams/{id}` | session | counted | read | Read an upstream |
| PUT | `/api/upstreams/{id}` | session | counted | config write | Update an upstream |
| DELETE | `/api/upstreams/{id}` | session | counted | config write | Delete an upstream |
| GET | `/api/pause` | session | counted | read | Read the pause state |
| POST | `/api/pause` | session | counted | runtime action | Pause or resume blocking |
| GET | `/api/settings` | session | counted | read | Read the scalar settings |
| PUT | `/api/settings` | session | counted | config write | Update settings |
| GET | `/api/config/status` | session | counted | read | Read the configuration authority and restart state |
| POST | `/api/certs/reload` | session | counted | runtime action | Reload the TLS certificates from disk |
There is no `POST /api/clients`: client rows come from DNS activity or import, never from the API.
The two diagnostics deletes purge history only. `DELETE /api/diagnostics/{id}` answers 204 for a resolved event, 409 for one that is still active — an open episode is the current state of the box, not history — and 404 for an id no row holds. `DELETE /api/diagnostics` removes every resolved event and answers `{"purged": N}`, leaving the active ones. Events still resolve on their own; these only decide when the resolved rows go.
Static assets are not routes. The router sends unmatched non-`/api` paths to the embedded SPA before any auth or rate-limit check.
## Settings keys
The envelope both operations answer with is `{settings, restart_required}`: the stored values, and the list of keys a restart applies — the bind addresses, ports and enabled flags of the four listeners, and nothing else. Every other key is applied by the PUT that changes it. It says nothing about the live authority or a restart already owed — those are per-process facts, and `GET /api/config/status` is their one home.
`GET /api/settings` and `PUT /api/settings` speak the `section.field` keys of [the configuration reference](configuration.md), with the values in their database spelling — notably `logging.level` is `"error"`, not `"err"`. Two keys behave differently over the API than in the file: `web.password` is write-only (accepted on a `PUT`, never returned, hashed before storage), and `web.password_hash` is neither readable nor directly writable, because a client that could install a hash could install one whose password it already knows.
In file mode `PUT /api/settings` is refused with the 403 above, password changes included. The password then lives where the rest of the configuration lives: set `web.password` in the file and restart. See [Password and hash](configuration.md#password-and-hash).
## Schemas
Request and response schemas for every operation live in the OpenAPI document: `src/web/openapi.yaml` in the repository, or `GET /api/openapi.yaml` from a running server.
### Policy reasons
Two places carry the same closed set of tags: `policy_reason` on a `GET /api/queries` row and on a `GET /api/queries/{id}` body (where it is `policy.reason`, and where the live stream sends the same field), and `reason` on a `GET /api/lookup` answer. The tag names what decided the query.
The first nine are the matcher's own verdicts, listed in the order they are consulted — the first that matches wins, so a rule always outranks a list.
| Tag | Decided by |
| --- | --- |
| `rule_allow_exact` | An `exact` rule with action `allow` |
| `rule_block_exact` | An `exact` rule with action `block` |
| `rule_allow_wildcard` | A `wildcard` rule with action `allow` |
| `rule_block_wildcard` | A `wildcard` rule with action `block` |
| `rule_allow_regex` | A `regex` rule with action `allow` |
| `rule_block_regex` | A `regex` rule with action `block` |
| `blocklist_exception` | An `@@` exception line in a downloaded list |
| `blocklist_domain` | A plain name in a downloaded list |
| `blocklist_wildcard` | A domain anchor (`||name^`) in a downloaded list |
The rest name a pipeline step that answered the query without consulting the matcher, and appear on a query row only.
| Tag | Decided by |
| --- | --- |
| `local_record` | A configured local record, answered before filtering |
| `forward_zone` | A configured forward zone, answered before filtering |
| `non_in_class` | The question was not class IN, so no rule could apply |
| `paused` | Filtering was paused |
| `snapshot_unavailable` | No filter snapshot was published yet, so the query went unfiltered |
| `no_match` | The matcher evaluated the name and nothing matched |
| `protocol_error` | A parsed request refused on protocol grounds — BADVERS, NOTIMP, a malformed EDNS OPT |
`policy_action` says which way the verdict went: `block`, `allow`, or `not_evaluated` for a query answered before any policy could apply. `/api/lookup` answers `none` when nothing matched, where a query row says `no_match`.
`route_kind` says where the answer came from: `blocked`, `local`, `forward_zone`, `upstream`, `cache` or `rejected`.
A non-empty `rewrites.cname_target` on a query detail means the decision landed on a CNAME target rather than on the name the client asked for; `policy.reason` is then the target's own reason. `/api/lookup` does not follow CNAMEs.
### Coverage
Every window-bounded read — `GET /api/queries` and `GET /api/overview` — answers with a `coverage` object: `available_since` is the oldest instant the query log is still complete for, and `complete` is true only when the window the request asked about starts at or after it. Retention deletes rows and advances the watermark in one transaction, so a client can tell an empty window from a pruned one instead of charting the gap as zero. A request with no lower bound at all asks about the whole of history, and is never complete.
Each of these responses reads its rows and its watermark inside one SQLite read transaction, so retention cannot prune between the two and hand back pre-prune rows tagged with a post-prune `available_since`. `GET /api/overview` puts every Overview panel inside that one transaction, so its totals and its four breakdowns describe one database state. Coherence stops there: two separate requests are two separate reads, and queries logged between them can move the counts.
+65 -135
View File
@@ -4,55 +4,46 @@
nxdns <command> [options]
```
Six subcommands: `run`, `check`, `export`, `import`, `version`, `help`. Source of truth: `src/cli.zig`.
nxdns has six commands: `run`, `check`, `export`, `import`, `version` and `help`.
Every flag takes both spellings, `--flag value` and `--flag=value`. An attached value that is empty (`--config=`) is a missing value, not an empty path. `--allow-delete` is boolean and takes no value at all, so `--allow-delete=1` is not a spelling of any flag this program has. A flag is rejected by the subcommand that has no use for it: `--admin-dev` outside `run` is an unknown flag, not a no-op.
Flags take their value either way, `--data-dir /srv/nxdns` or `--data-dir=/srv/nxdns`. A command rejects a flag it has no use for.
## `run`
Serves DNS until SIGINT or SIGTERM.
Serves DNS and the web interface until it receives SIGINT or SIGTERM.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. |
| `--config FILE` | Make FILE the sole source of configuration and reconcile the database onto it at every start. No default: without this flag the database is the configuration and no file is read. |
| `--admin-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
| `--data-dir DIR` | The data directory, `/var/lib/nxdns` by default. Created at mode 0700 if missing. |
| `--config FILE` | Run in file mode, with FILE as the configuration. |
| `--admin-dev DIR` | Serve the web interface from DIR instead of the copy built into the binary. For developing the interface. |
### Which authority the invocation selects
Without `--config`, nxdns runs in database mode: the configuration is what `config.db` holds, and you change it in the web interface. With `--config FILE`, the file is the configuration: every start makes the database match it, and the web interface shows the configuration read-only. A file at `/etc/nxdns/config.zon` that no `--config` names is not read. [The configuration model](../explanation/configuration-model.md) compares the two.
The presence of `--config` picks the authority, and nothing else does. There is no default path, no probe of `/etc/nxdns`, and nothing recorded in the database: a configuration file sitting at `/etc/nxdns/config.zon` that no flag names changes nothing at all.
| Invocation | Authority | What a start does |
| --- | --- | --- |
| `nxdns run` | The database | Serves what `config.db` holds. Nothing reads a file. |
| `nxdns run --config FILE` | FILE | Reads and validates FILE, reconciles the database onto it, then serves. |
The first log line after the migrations names the mode, so a journal says which authority was live:
The log names the mode at every start:
```
info(nxdns): authority: database
info(nxdns): authority: file (/etc/nxdns/config.zon)
```
In file mode the reconcile prints what it changed before that line — per-table inserted (`+`), updated (`~`) and deleted (`-`) counts, the settings keys whose values changed, and any change to whether the admin password is set:
In file mode it first says what the file changed: entries added (`+`), changed (`~`) and removed (`-`) per list, and the settings that changed.
```
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~0 -0;
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +46 ~0 -0;
settings keys changed: dns.bind_ipv4 dns.port web.bind web.port …
web authentication is now enabled
```
A start whose file matches the database writes nothing and says so:
A start whose file changed nothing says so:
```
reconciled '/etc/nxdns/config.zon': no changes
```
Blocklist state is not declarative and survives every reconcile: a source whose URL the file still names keeps its row id, its checksum, its counters and its compiled `<id>.list`, `<id>.wild` and `<id>.allow`, so a restart in file mode downloads nothing. Editing a source's URL is a new identity — a new row, a new id, and a fresh download.
A blocklist whose URL the file still names keeps its download across restarts. Changing a list's URL makes it a new list, downloaded afresh.
### Failing to start in file mode
File mode fails closed. A file that is missing, unreadable, unparseable, oversized or invalid stops the start; nxdns never falls back to the database, because a fallback turns a deploy typo into a silently stale configuration.
In file mode, a file that is missing, unreadable or invalid stops the start with exit 2. nxdns never falls back to the database, which would leave you running a configuration you did not deploy:
```
FAIL /etc/nxdns/config.zon: no such file
@@ -60,149 +51,106 @@ nxdns run failed: ManagedConfigUnreadable
run `nxdns check` to see the configuration in full
```
That is exit 2, and `check --config` on the same path agrees. Only path-class open failures map that way — the file is not there, or the process may not read it. An open that fails for a reason a retry could clear, such as file-descriptor exhaustion or an I/O error, is exit 1: the box is wrong, not the configuration. See [exit codes](#exit-codes).
The exit-2 agreement covers `run --config` and `check --config`, which read the managed file through one shared helper. It does **not** extend to `import`'s positional argument: a missing file there is `import failed: FileNotFound`, exit
1. That is deliberate rather than an oversight — the managed file is a declarative input an operator deploys, so its absence is a fact about the configuration, while `import`'s argument is a path typed at a prompt, and a mistyped path is a failed command rather than a verdict on anything.
Run `nxdns check --config FILE` before restarting anything that deploys a file. It grades every declarative fault `run` would hit — read, parse, size, validation — through the same code, which is what makes it a usable precondition in an Ansible handler.
## `check`
Validates the configuration and probes the upstreams. Exit 0 when it found no failures, 2 when it found one. It always reports every problem, not just the first. What it checks, in order:
1. Which source to check (see [source selection](#source-selection)).
2. Full validation — the same rules `import` enforces.
3. For each enabled DoH/DoT listener: both PEM files are read and the key is tested against the certificate, through the same `CertStore.init` the listeners boot with. A file that is missing, unreadable, too large or unparseable, and a key that does not belong to the certificate, are all FAIL. The key's permissions are a separate finding: WARN when any group or other bit is set, which does not change the exit code.
4. A live probe: one real A query for `example.com` through every enabled upstream, driving the same pool and failover machinery the server uses, with the same two deadlines: `upstream.attempt_timeout_ms` bounds one try against one upstream, `upstream.total_timeout_ms` the whole probe. A FAIL line names the upstream and the concrete cause recorded in its health. This probe leaves the machine, so `check` needs network access to pass. It runs from the command line but not from unit tests.
Checks a configuration and tests the upstreams, then exits 0 if nothing failed and 2 if something did. It reports every problem, not just the first.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory to look for `config.db` in. |
| `--config FILE` | Grade this file instead of the database. |
| `--data-dir DIR` | The data directory whose `config.db` to check. |
| `--config FILE` | Check FILE instead of the database. |
### Failures and warnings
Like `run`, it checks the file when you name one and the database when you do not, so `nxdns check --config FILE` checks exactly what `nxdns run --config FILE` would serve. Run it before you restart nxdns on a changed file.
Every finding carries a severity. `FAIL` is a problem that sets exit 2. `WARN` is legal configuration that is almost certainly not what was meant — a blocklist source no group links to, a TLS key readable beyond its owner — and never changes an exit code, because the service starts either way.
It checks, in order:
The last line is a summary, and it never contradicts the lines above it:
1. That the file or database can be read, and the file's syntax.
2. Every rule in the [configuration reference](configuration.md).
3. For each DoH or DoT listener that is on, that the certificate and key load and belong together. A key that others can read is a warning.
4. One real query for `example.com` through each enabled upstream, within the configured timeouts. This needs network access.
| What was found | Last line | Exit |
```
checking configuration file /etc/nxdns/config.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
```
Each problem is a `FAIL` or a `WARN` line. A `WARN` marks something allowed but probably unintended, such as a blocklist no group uses, and does not change the exit code. The last line sums up:
| Found | Last line | Exit |
| --- | --- | --- |
| Nothing | `OK: no problems found` | 0 |
| Warnings only | `OK: no failures found, 2 warnings` — `1 warning` in the singular | 0 |
| At least one failure | No summary line; the FAIL lines are the report | 2 |
| Warnings only | `OK: no failures found, 2 warnings` | 0 |
| A failure | the `FAIL` lines, with no summary | 2 |
### Source selection
The flag decides, exactly as it does for `run`. There is no fallback and no probing of a default path:
- `--config FILE`: grade that file, and never open the database.
- No `--config`: grade `<data-dir>/config.db`. It is opened immutable, so `check` writes nothing to it; see [what `check` does not do](#what-check-does-not-do).
So `nxdns check` and `nxdns check --config FILE` grade what the matching `run` invocation would serve. That is what makes `check` a pre-restart gate rather than an approximation of one.
A bare `check` on a box with no database says so and names both ways out of the state, exit 2:
With no `--config` and no database yet, `check` says how to get one:
```
no config database at /var/lib/nxdns/config.db
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
```
The first line of output otherwise always names which source was checked. A file larger than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is reported with its line and column.
`check` never changes the database. Two of its findings are therefore not damage:
A named file that is missing or unreadable is a finding like any other, not an I/O failure that escapes the run: `FAIL <path>: no such file` or `FAIL <path>: not readable`, exit 2.
What `check --config` cannot see is the reconcile itself. It needs no database, so faults that only a write can produce — a disk that is full, a lock held by a restart that started first — are invisible to it. Those are runtime failures, exit 1, and they are not verdicts on the file.
### What `check` does not do
`check` never writes to `config.db`. It opens the file immutable, which means SQLite refuses every statement that would write and builds no write-ahead log, so no `config.db-wal` and no `config.db-shm` appear beside it. It does not chmod the file and it does not migrate the schema.
Two consequences are worth knowing before you read a FAIL line as damage:
- A database behind this binary's schema is reported, not upgraded. Start the service to migrate it.
```
FAIL <db>: schema version <n>, this nxdns expects <m>; `nxdns run` migrates it, `check` will not
```
- A database with an unapplied write-ahead log cannot be graded without writing, because the newest settings are in the log and the main file holds older ones. `check` says so rather than reading the stale values:
```
FAIL <db>: uncheckpointed changes are waiting in <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.
```
A running nxdns is the usual holder of that log, so this is a common answer when checking a live server rather than a sign of damage. It depends on what is in the log, not on whether a server is up: a configuration write that has not been checkpointed puts bytes there, and a server that has only been answering queries leaves `config.db-wal` empty and reads normally. Check an `export` with `--config`, or stop the service first.
- `FAIL <db>: schema version <n>, this nxdns expects <m>; …`: the database is from an older release. Starting nxdns upgrades it.
- `FAIL <db>: uncheckpointed changes are waiting in <db>-wal, …`: recent changes from a running nxdns are not in the main file yet. Check an export with `--config` instead, or stop nxdns first.
## `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, not to the data directory. The output is canonical: every default emitted, deterministic ordering, no timestamps, so `export` → `import` → `export` is byte-identical.
`export` does not create the data directory; it fails if the directory is not there. A directory that exists without a `config.db` is not that case: the database is opened with create semantics, so an empty `config.db` is created and migrated, and the export is of a default configuration.
The output is the same in both authority modes — nothing marks a file as exported from a file-mode box. That is what lets `export` be the adoption tool: the file you check is the file you deploy.
`web.password` is always written as `null` and `web.password_hash` carries the stored value, so an export re-imports without anyone knowing the password. A `null` password is not the same statement as an empty one: see [`web.password` and `web.password_hash`](configuration.md#password-and-hash).
Writes the configuration to standard output as ZON, or to a file with `--out`.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db`. |
| `--out FILE` | Write to FILE instead of stdout. |
| `--data-dir DIR` | The data directory holding `config.db`. |
| `--out FILE` | Write to FILE, created at mode 0600, instead of standard output. |
See [back up and restore](../how-to/back-up-and-restore.md).
The output lists every setting, defaults included, in a fixed order with no timestamps, so exporting the same configuration twice gives the same bytes. It works while nxdns is running.
The password is written as `.password = null` with its hash in `.password_hash`, so the export restores the same password without revealing it. See [Back up and restore](../how-to/back-up-and-restore.md).
## `import FILE`
Converges the database onto FILE in one transaction — the same reconcile a file-mode `run` performs, done once from the command line. Prints every validation problem; a failed import leaves the database untouched. Creates the data directory at mode 0700 if it is missing.
`FILE` is positional and may appear before or after the flags.
Makes the database match FILE, all at once or not at all. If FILE has any problem, `import` reports every one and changes nothing. It creates the data directory at mode 0700 if it is missing.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
| `--allow-delete` | Apply a file whose diff deletes rows. |
| `--data-dir DIR` | The data directory holding `config.db`. |
| `--allow-delete` | Apply a file that removes entries the database holds. |
**`import` is a stop-first operation.** It rewrites configuration underneath a process that read it at startup, and a running server notices only some of it: filtering picks up the imported rows at the next reload, while upstreams, listeners and settings stay at their boot values until a restart.
Stop nxdns before you import: a running server would pick up only part of the change.
### The delete gate
### Removing entries
Rows the database holds and FILE does not name are deleted. That is the point of a declarative apply, and it is also how a mistaken `nxdns import ./wrong.zon` empties a configured server, so it takes a flag:
Entries the database holds and FILE does not name are removed. That is how a restore undoes later changes, and also how importing the wrong file would empty a configured server, so `import` refuses until you add `--allow-delete`:
```
FAIL import: this file would delete rows the database holds (upstreams 1); re-run with --allow-delete to apply it
import failed: DestructiveImport
```
That is exit 2, and the transaction rolls back. The message names every table with a non-zero delete count, so you can tell an intended pruning from a wrong file before applying anything.
The message says how many entries of each kind would go. Nothing is changed, and the exit code is 2.
An import that only adds rows, or only edits them, needs no flag. "Edit" here means a change to a row nxdns can still recognise as the same row. Each table has one column, or one tuple, that establishes identity:
Adding entries or changing them needs no flag. An entry is recognised by these fields, and changing one of them removes the old entry and adds a new one:
| Table | Identity |
| List | Recognised by |
| --- | --- |
| `blocklist_sources`, `upstreams` | `url` |
| `upstreams`, `blocklist_sources` | `url` |
| `groups` | `name` |
| `clients` | `ip` |
| `client_prefixes` | `prefix` |
| `forward_zones` | `zone` |
| `local_records` | `(name, rtype, value)` |
| `rules` | `(group, pattern, kind, action)` |
| `local_records` | `name`, `rtype` and `value` |
| `rules` | `group`, `pattern`, `kind` and `action` |
Change anything else on a row — a source's name, a group's `safe_search`, a client's group — and it is an edit, applied without a flag. Change the identity itself, such as renaming a group or correcting a typo in an upstream URL, and the engine sees a row that vanished and a row that appeared: that needs `--allow-delete`.
Renaming a group or correcting an upstream's URL therefore needs `--allow-delete`; changing a client's group or a list's name does not.
### What survives an import
### What an import keeps
Runtime state is not declarative and is preserved by identity, not by luck. A blocklist source whose URL is unchanged keeps its row id, its checksum, its counters and its compiled files, so an import costs no downloads. Clients the DNS path materialised from traffic are kept whole; naming one in the file promotes that row in place, keeping its first-seen and last-seen. Observed clients whose group the file no longer declares are moved to the `default` group rather than deleted with it, and none of that ever trips the delete gate.
### `import` against a file-mode box
It behaves like any other import. Nothing in the database records that a file governs it — authority lives in the invocation — so `import` neither detects nor refuses that case. The next restart's reconcile converges the database back to the file and its summary reports what it corrected. A source the import deleted comes back with a new row id, which means a fresh download of the whole list.
If a restart and an import race for the write lock, one of them simply wins: both take `BEGIN IMMEDIATE` under a 5-second busy timeout, so the outcome is an ordering, never a corrupted database.
A blocklist whose URL is unchanged keeps its download. Devices nxdns discovered keep their history, and a device the file names keeps its first-seen and last-seen times. A discovered device whose group the file removes moves to `default`.
## `version`
Prints two lines: the nxdns version, then the Zig version the binary was built with. Takes no flags and no arguments.
Prints the nxdns version, then the Zig version it was built with.
```
nxdns <version>
@@ -211,39 +159,21 @@ zig 0.16.0
## `help`
Prints the usage text to stdout and exits 0. `nxdns --help` and `nxdns -h` do the same. A usage error prints the same text to stderr and exits 64.
Prints the usage text and exits 0. `nxdns --help` and `nxdns -h` do the same.
## Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Success. |
| 1 | Runtime failure — I/O, database, out of memory. A partial diagnostic report caused by an allocation failure is a runtime failure, not a verdict on the configuration. |
| 2 | A configuration problem the operator can fix, a `check` that found one, or a deliberate refusal to run that no retry will clear. |
| 64 | Usage error — unknown command or flag, a flag without its value, a missing or extra argument. |
| 1 | A failure a retry might clear: a disk, network or database error, or running out of memory. |
| 2 | Something only you can fix: a configuration problem, a `check` that found one, a `--config` file that is missing or unreadable, an `import` that would remove entries, or a database nxdns refuses to open. |
| 64 | A wrong command line: an unknown command or flag, or a missing or extra argument. |
Code 2 means the same thing from every subcommand. `src/config/faults.zig` holds the one list of errors that mean "the configuration the operator supplied is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits 2 whichever command read it. The list is every error the validator raises, plus `ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and `ManagedConfigUnreadable`. In practice that covers a file with a syntax error, one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit, an unusable certificate, `password` and `password_hash` set together, and a `--config` path that is absent or unreadable.
The last of those is the one deliberate seam. A file nxdns cannot open is a configuration fault only when the *path* is the problem — the file is missing, permissions deny it, a path component is not a directory. Every other open failure, such as running out of file descriptors, is exit 1. The distinction earns its keep under the shipped systemd unit, which stops the service on exit 2 rather than restarting it: a transient box fault graded as a configuration fault would take the resolver down until someone noticed.
When `run` exits 2 it points at the diagnosis on stderr, whichever authority the fault came from:
The packaged systemd unit restarts nxdns after exit 1 and not after exit 2 or 64, since restarting would only repeat the error. When `run` exits 2 it says what to do next:
```
run `nxdns check` to see the configuration in full
```
On a box with no configuration at all, `run` and `check` add the line that names both ways to get one:
```
load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`
```
`check` exits 2 for those faults and also when a probed upstream failed, when a named configuration file is missing or unreadable, and when the database cannot be read, is absent, or is not at this binary's schema version. Warnings never contribute.
`import` exits 2 for those faults and for `DestructiveImport`. That last one is deliberately not a configuration fault — it reports what applying the file would delete, rather than anything wrong with its content — and `import` decides it for itself; the answer to it is `--allow-delete`, not an edit.
`run` also exits 2 on the four query-log schema refusals — `SchemaTooNew`, `SchemaUnsupported`, `MigrationFailed` and `MigrationBackupFailed` — which are in the same list. They are not a verdict on a file the operator wrote, but they share the property exit 2 exists to signal: the server is refusing on purpose, an operator has to act, and a restart will only repeat the refusal. Exit 1 would put them under the unit's `Restart=on-failure` and loop them. See [the server refuses to start over querylog.db](../how-to/troubleshoot.md#the-server-refuses-to-start-over-querylogdb).
`OutOfMemory` is exit 1 even when problems were recorded, because the report is then incomplete. Every other error is 1.
Where an exit code sends you next: [troubleshoot](../how-to/troubleshoot.md).
`run` also exits 2 when it refuses the query history database, `querylog.db`, rather than risk it. The message names the file and what to do. A database from a newer release, after you went back to an older one, is the usual case; see [Going back to an older release](../how-to/upgrade.md#going-back-to-an-older-release).
+338 -462
View File
@@ -1,428 +1,16 @@
# Configuration reference
Every section, field and collection nxdns accepts, with its type, default, unit, validation rule and the subsystem that consumes it.
Every setting and list nxdns accepts, with its type, default and allowed values.
Source of truth: `src/config/model.zig` (the model and the defaults), `src/config/validate.zig` (the rules), `src/config/{loader,reconcile,import,export}.zig` (the lifecycle).
For how the file, the database and `export`/`import` relate to each other, see [the configuration model](../explanation/configuration-model.md). For the commands that read and write configuration, see [the CLI reference](cli.md).
[The configuration model](../explanation/configuration-model.md) explains how the file, the database and the web interface relate. [The CLI reference](cli.md) covers the commands that read and write configuration.
## File format
The file is ZON: a top-level anonymous struct whose fields are the sections and collections below. Enum values are ZON enum literals (`.level = .err`, `.response = .nxdomain`). Strings are double-quoted. The file may be at most 4 MiB (`max_config_bytes` in `src/config/loader.zig`); beyond that the error is `ConfigTooLarge`. A syntax error is reported with its line and column.
The configuration file is ZON, Zig's literal syntax: one `.{ … }` block whose fields are the sections and lists below. Strings are in double quotes, and choices are written with a leading dot (`.response = .nxdomain`). Comments start with `//`. The file may be at most 4 MiB, and a syntax error is reported with its line and column.
Absent fields keep their defaults, both in the file and in the database. A settings key stored in the database that the running binary does not know is warned about and ignored, never an error.
Anything the file leaves out keeps its default.
### The `logging.level = .err` quirk
The log level `error` is a Zig keyword, so the ZON and model tag is `.err` while the database and the settings API store the operator-facing word `"error"`. The file says `.err`; `GET /api/settings` says `"error"`. `"err"` is not accepted as database text, and `.error` is not a ZON tag.
## What is not in the file
Storage paths are process arguments, not configuration:
| Argument | Default | Meaning |
| --- | --- | --- |
| `--data-dir DIR` | `/var/lib/nxdns` | Holds `config.db` and `querylog.db`; see [files and directories](files-and-directories.md). |
| `--config FILE` | — | On `run`, makes FILE the sole source of configuration; on `check`, grades FILE instead of the database. No default: without it the database is the configuration. |
| `--admin-dev DIR` | — | `run` only; serves the web interface from a directory instead of the embedded assets. |
## Scalar sections
The "Key" column is the settings key as stored in the database (`section.field`); in the file the same field lives inside its section block, for example `.dns = .{ .port = 53 }`.
### upstream
Timeouts for talking to upstream resolvers.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `upstream.attempt_timeout_ms` | u32 | 2500 | ms | 100–120000, and not above `total_timeout_ms` | deadline on one attempt against one upstream inside the pool's failover loop (`src/upstream/pool.zig`), the whole attempt including the connect |
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100–120000 | budget for a whole conditional-forward-zone exchange (`src/local/forward_client.zig`): the UDP attempt, a TC=1 fallback and the TCP retry together |
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100–120000 | per-query budget of the upstream pool (`src/upstream/pool.zig`): every failover attempt together, not one of them; also the `nxdns check` probe deadline |
The two pool budgets nest. `attempt_timeout_ms` bounds one try against one upstream; when it expires the pool records the failure and moves to the next candidate. `total_timeout_ms` bounds the whole loop, so a query against five unreachable upstreams costs the total budget once, not five attempt budgets in a row. When the total expires the in-flight attempt is canceled and the query fails with a timeout.
Budget semantics. The deadline is an instant, computed once when the query enters the pool, and every blocking step spends against it — waiting for a free slot on an upstream as much as the exchange itself. An attempt therefore runs against `min(now + attempt_timeout_ms, deadline)`, which near the end of the budget is *truncated*: shorter than the configured attempt. Attribution follows from that. A truncated attempt that expires is evidence about the budget, not about the upstream, so it leaves that upstream's health and success rate untouched and the query log's `upstream` field unchanged — the row may still name the endpoint of the preceding attributable attempt, and is null only when there was none — and it increments the pool-wide `nxdns_upstream_budget_exhausted_total` metric. An attempt that expires on its full budget, or that fails outright (a refused connection, a bad answer, the peer's own timeout), is evidence about the upstream: it is recorded against that upstream's health and names it in the query log. Either way the client is answered SERVFAIL. Forward-zone queries are not affected — they have one configured resolver, so a timeout there always names it.
`read_timeout_ms` is unrelated to both pool budgets. It bounds a different subsystem — the conditional-forward-zone client — so no cross-check relates it to the pool's budgets, and it is free to sit above either of them. Within that subsystem it is one budget for the whole exchange: a query that goes out over UDP, comes back truncated and is retried over TCP has the two legs and the fallback share `read_timeout_ms`, never one each.
### dns
The plain DNS listener (UDP and TCP).
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `dns.bind_ipv4` | string | `"0.0.0.0"` | IP address | must parse as an IPv4 literal | UDP/TCP listener bind (`src/app.zig`) |
| `dns.bind_ipv6` | string | `"::"` | IP address | must parse as an IPv6 literal | UDP/TCP listener bind (`src/app.zig`) |
| `dns.port` | u16 | 53 | port | 1–65535 (0 is refused) | UDP/TCP listener port |
| `dns.rate_limit` | u32 | 1000 | queries per window | at least 1 | per-client DNS rate limiter (`src/server/rate_limiter.zig`) |
| `dns.rate_window_seconds` | u32 | 60 | seconds | 1–3600 | window of the same limiter |
`dns.bind_ipv4` and `dns.bind_ipv6` each name one socket of the dual-stack pair, so each is required to be a literal of its own family. An IPv4 wildcard in `dns.bind_ipv6` is refused: it would bind IPv4 as the "v6" socket and make the real IPv4 bind fail with `AddressInUse`, silently removing the IPv6 service.
### blocking
What a blocked query gets back.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `blocking.response` | enum `.zero` \| `.nxdomain` | `.zero` | — | one of the two tags | blocked-response synthesis (`src/filter/response.zig`): `.zero` answers 0.0.0.0 / `::`, `.nxdomain` answers NXDOMAIN |
| `blocking.ttl` | u32 | 5 | seconds | at most 86400; 0 allowed | TTL on the synthesized block answer |
### cache
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `cache.size` | u32 | 10000 | entries | 1–1000000 | DNS answer cache capacity (`src/cache/dns_cache.zig`) |
| `cache.negative_ttl_max` | u32 | 3600 | seconds | at most 86400 | cap on cached negative answers; 0 disables negative caching |
The cache's slot array is allocated in full at startup, so `cache.size` carries a ceiling: it is a sanity bound against a typo, not a promise that the value fits in the box's memory. There is no "off" value — to run without a cache, set `cache.size` to 1.
### web
The web interface and REST API.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `web.enabled` | bool | true | — | — | gates the whole web stack: server, sessions, SSE hub, API limiter (`src/app.zig`) |
| `web.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | web listener bind (`src/web/server.zig`) |
| `web.port` | u16 | 8080 | port | 1–65535 (0 is refused) | web listener port |
| `web.password` | optional string | absent | — | must not be set together with `web.password_hash`; the empty string is refused | operator input only; hashed and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
| `web.password_hash` | optional string | absent | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication, absent keeps the stored hash |
| `web.session_ttl_hours` | u16 | 24 | hours | at least 1 | session expiry and cookie `Max-Age` (`src/web/auth.zig`) |
| `web.api_rate_limit_per_min` | u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (`src/web/api_limiter.zig`) |
| `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter |
| `web.sse_max_connections_per_ip` | u16 | 3 | connections | at least 1 | cap on concurrent SSE streams per client address |
| `web.trusted_proxies` | string | `""` | — | comma-separated IP literals | reverse proxies whose `X-Forwarded-For` nxdns believes (`src/web/server.zig`); empty trusts none |
### doh_server
The DNS-over-HTTPS listener (server side, for clients on the LAN). See [enable DoH and DoT](../how-to/enable-doh-and-dot.md).
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `doh_server.enabled` | bool | false | — | — | gates the DoH listener (`src/server/doh_server.zig`) |
| `doh_server.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | DoH listener bind |
| `doh_server.port` | u16 | 443 | port | 1–65535 (0 is refused, enabled or not) | DoH listener port |
| `doh_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate loaded into the hot-reloading `CertStore`; the file itself is loaded and paired with the key by `nxdns check`, not by the validator |
| `doh_server.key_path` | string | `"/etc/nxdns/key.pem"` | path | non-empty when enabled | private key for the same; `nxdns check` warns when it is readable beyond its owner |
### dot_server
The DNS-over-TLS listener. Same shape as `doh_server`; only the default port differs.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `dot_server.enabled` | bool | false | — | — | gates the DoT listener (`src/server/dot_server.zig`) |
| `dot_server.bind` | string | `"0.0.0.0"` | IP address | must parse as an IP address of either family | DoT listener bind |
| `dot_server.port` | u16 | 853 | port | 1–65535 (0 is refused, enabled or not) | DoT listener port |
| `dot_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | path | non-empty when enabled | certificate, shared `CertStore` with hot reload |
| `dot_server.key_path` | string | `"/etc/nxdns/key.pem"` | path | non-empty when enabled | private key for the same |
### edns
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `edns.ecs_mode` | enum `.strip` \| `.forward` | `.strip` | — | one of the two tags | EDNS Client Subnet handling in the query path (`src/server/handler.zig`, `src/dns/edns.zig`): `.strip` removes the client subnet before forwarding, `.forward` passes it through |
### logging
Process log and query log behavior.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `logging.level` | enum `.err` \| `.warn` \| `.info` \| `.debug` | `.info` | — | one of the four tags; stored as `"error"` / `"warn"` / `"info"` / `"debug"` | log threshold (`src/platform/logging.zig`) |
| `logging.retention_days` | u16 | 30 | days | at least 1 | query-log pruning cutoff (`src/storage/retention.zig`) and the client tracker's last-seen cutoff (`src/server/clients.zig`) |
| `logging.query_log_buffer_max` | u32 | 10000 | entries | 1–37449 | in-memory query-log ring size and backpressure cap (`src/storage/logger.zig`); the ceiling is derived at compile time from `@sizeOf(logger.Entry)` so the queue's worst case stays within 64 MiB, and it moves whenever the entry's width does |
| `logging.query_log_flush_interval_s` | u16 | 60 | seconds | 0–3600 | how long the query-log writer gathers entries before committing them in one transaction (`src/storage/logger.zig`); see the note below |
| `logging.hide_domains` | bool | false | — | — | the query log stores a hidden marker instead of the domain |
| `logging.hide_client_ips` | bool | false | — | — | the query log stores a hidden marker instead of the client address |
| `logging.output` | enum `.stderr` \| `.syslog` \| `.file` | `.stderr` | — | one of the three tags | log sink selection (`src/platform/logging.zig`); `.stderr` and `.syslog` both write to stderr (journald captures it), `.file` rotates |
| `logging.file_path` | string | `"/var/log/nxdns/nxdns.log"` | path | when `output` is `.file`: non-empty and starting with `/` | rotating log file; its directory also feeds the disk monitor. The binary does not create the directory |
| `logging.max_size_mb` | u32 | 50 | MiB | at least 1 | rotation trigger for the log file |
| `logging.max_files` | u8 | 5 | files | at least 1 | log files kept in total, the live one included, so the highest rotated generation is `max_files - 1`; the default 5 keeps `nxdns.log` plus `nxdns.log.1` through `nxdns.log.4`, and a value of 1 keeps only the live file, which rotation deletes rather than renames |
#### What `query_log_flush_interval_s` costs and buys
The query-log writer commits one transaction per interval instead of one per query. At a household's query rate — a few queries a second at most, often a fraction of one — a per-query commit writes hundreds of times more bytes to the disk than the rows themselves occupy, because every commit rewrites the WAL frames, the WAL index and the page headers whatever the row size. That write amplification is what wears out an SD card, and the default of 60 seconds is what stops it. It matches Pi-hole's `DBinterval`, which defaults to the same minute for the same reason.
What it costs:
- **Crash-loss window.** A process that dies takes roughly `interval` seconds of query history with it. That is the normal case, not a guaranteed maximum: a batch the disk monitor is holding back (free space below the critical threshold) or one waiting on a database write lock can be considerably older when the process dies. Power loss can additionally lose recent committed transactions, because `querylog.db` runs with WAL and `synchronous=NORMAL` — that was already true at any interval, and setting `0` does not buy per-query durability. Query history is the least valuable data on this box: nothing else depends on it, and it is deleted by retention anyway.
- **Staleness.** Every read backed by the query log — the Activity page's History tab, the Overview totals, the timeseries — lags about `interval` seconds behind, and further behind while writes are gated or slow. Activity's Live tab does not lag: it is fed from the SSE hub before the queue, so queries appear there the moment they are answered.
`0` means "do not wait": the writer commits the entry that woke it together with whatever is already queued, up to 100 rows. Use it when you want Activity's History tab to be current to the second and you do not care what that costs the disk.
Two things do not change with the interval: a batch is capped at 100 rows, so a burst is committed as soon as it fills one rather than waiting out the window, and shutdown writes what the writer is holding instead of waiting for the interval to end.
### disk
Free-space thresholds for the data directory. Below them the query-log writer, the client tracker and the blocklist scheduler are throttled (`src/storage/disk_monitor.zig`); DNS resolution is never gated.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `disk.min_free_mb` | u32 | 200 | MiB | at least 1, and not above `warn_free_mb` | `critical` threshold |
| `disk.warn_free_mb` | u32 | 500 | MiB | at least 1 | `warn` threshold |
### blocklist_update
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `blocklist_update.enabled` | bool | true | — | — | blocklist refresh scheduler (`src/filter/manager.zig`); when false the scheduler stops after the startup pass and only a manual refresh runs |
| `blocklist_update.interval_hours` | u16 | 24 | hours | at least 1 | sleep between refresh passes and the per-source staleness test; each pass is preceded by the [orphan sweep](files-and-directories.md#the-orphan-sweep), which runs even when the pass itself is skipped for disk space |
## Collections
Collections are ZON lists of structs. Fields without a default are required. Runtime columns (first/last seen timestamps, per-source download counters) are deliberately not part of the model: import sets timestamps to the import time and export omits them, which is what keeps the round trip byte-stable.
### groups
Client groups. A group named `default` is required; every client not assigned elsewhere lands in it, and import guarantees it keeps database id 1.
| Field | Type | Default | Validation |
|---|---|---|---|
| `name` | string | required | non-empty, unique |
| `safe_search` | bool | false | — |
Consumed by the filter engine (`src/filter/matcher.zig`); `safe_search` triggers the safe-search rewrite in the query path.
### upstreams
Upstream resolvers. At least one enabled upstream is required.
| Field | Type | Default | Validation |
|---|---|---|---|
| `url` | string | required | an `https://` (DoH) or `tls://` (DoT) endpoint accepted by `transport.Endpoint.parse`; unique; a `tls://` host must be an IP literal |
| `priority` | i32 | 100 | — (lower is tried first) |
| `enabled` | bool | true | — |
| `tls_name` | string | `""` | DoT only — a `tls_name` on an `https://` upstream is an error; when set it must be a valid domain name |
Consumed by the upstream pool (`src/upstream/pool.zig`): entries are sorted by ascending priority and tried in order with failover. `tls_name` sets SNI and the certificate verification name for a `tls://` upstream written as an IP literal; empty means "verify by the URL host" (`src/upstream/dot_client.zig`).
A `tls://` upstream's host must be an IP literal. nxdns does not resolve an upstream's own name — that is a bootstrap problem, and the DoT client refuses a non-literal host on every dial — so the validator rejects the hostname form rather than letting it fail at query time as an unreachable upstream. Write the address and put the name in `tls_name`:
```zig
.{ .url = "tls://9.9.9.9:853", .tls_name = "dns.quad9.net" },
```
A DoH upstream is not affected: `https://` goes through the HTTP client, which resolves normally, so a hostname there is the usual form.
The DoH client speaks HTTP/1.1 only (HTTP/2 is out of scope), so a DoH server that requires HTTP/2 fails every exchange. Quad9 is one: `https://dns.quad9.net/dns-query` answers HTTP/1.1 with `505` (checked 2026-10-19). Reach Quad9 over DoT instead, as in the example above.
### clients
Known clients with a fixed group assignment.
| Field | Type | Default | Validation |
|---|---|---|---|
| `ip` | string | required | an IP address; unique after canonicalization (`FD00::1` and `fd00:0:0:0:0:0:0:1` collide) |
| `name` | string | `""` | — (display only, never read by the resolver) |
| `group` | string | `"default"` | must name a declared group |
Consumed by the filter engine's exact address-to-group lookup (`src/filter/matcher.zig`).
#### Learned names
A client row that carries no name of its own can still show one. Every flush pass the client tracker takes at most 16 unnamed rows that are due, builds each address's reverse name (`192.168.1.10` becomes `10.1.168.192.in-addr.arpa`), and matches it against the declared [`forward_zones`](#forward_zones). On a match it sends one PTR query to that zone's resolver and stores the answer as the row's *learned* name. A row becomes due again 24 hours after the resolver answered or returned NXDOMAIN, and 1 hour after any other outcome — no covering zone, a transport failure, or a reply nxdns rejected — so declaring the missing zone or fixing the resolver shows names within the hour. This requires a conditional forward zone that covers the LAN's reverse space — for example `168.192.in-addr.arpa` pointed at the router. Without such a zone nxdns sends the reverse name to nobody, and the row stays unnamed.
The PTR query goes to the resolver of the declared zone, wherever the operator pointed it; nxdns does not second-guess that declaration, and it cannot stop a LAN resolver from forwarding the query onward.
A learned name is runtime state, like `last_seen`:
- A hand-typed `name` always wins, and a named row is never asked about again.
- Learned names never appear in `nxdns export`, and `nxdns import` never sets one.
- Each row refreshes once a day. A device rename or a DHCP lease change can therefore display a stale name for up to 24 hours, until the next refresh overwrites it or the router reports no name and nxdns clears it.
### client_prefixes
Group assignment by CIDR prefix, for clients without an exact entry.
| Field | Type | Default | Validation |
|---|---|---|---|
| `prefix` | string | required | a CIDR prefix (`192.168.2.0/24`, `fd00:abcd::/48`); unique after canonicalization |
| `group` | string | `"default"` | must name a declared group |
| `priority` | i32 | 100 | — (ties on match are broken by lower priority) |
Consumed by the filter engine's longest-prefix match (`src/filter/matcher.zig`).
### blocklist_sources
Downloadable blocklists.
| Field | Type | Default | Validation |
|---|---|---|---|
| `url` | string | required | an `http://` or `https://` URL with a host; unique |
| `name` | string | required | non-empty |
| `enabled` | bool | true | — |
| `is_suggested` | bool | false | — (web UI hint only, never read by the resolver) |
Consumed by the blocklist manager (`src/filter/manager.zig`): downloaded by the fetcher and compiled into domain sets. A disabled source is neither downloaded nor loaded.
A list in Adblock Plus syntax may also carry exception lines, `@@||name^` and `@@||name`, either of which may end in `$important`. Those become allow entries that cancel what any attached list blocks, for the name and its subdomains. They cancel nothing an operator decided: every rule of the table above is checked first, so a downloaded list can reopen only a hole another downloaded list dug. Each source reports how many it carried as `exceptions`; there is no way to write one by hand, and no reason to want one — write an allow rule instead.
Two counters report what a compile skipped, and they are different facts. `skipped_regex` counts regex lines: nxdns has a regex engine, but it takes patterns only from the operator, so a regex line in a downloaded list is counted, skipped and surfaced — adopt the ones you trust as `regex` rules. `skipped_unsupported` counts lines nxdns cannot safely translate into a DNS decision: cosmetic element hiding (`##`, `#@#`, `#?#`), rules carrying a `$` modifier (except `$important` on an exception line, tolerated above), scheme anchors, non-anchored `@@` forms — and, in a `domains`-format list, a line holding more than one field before its inline comment, which usually means the list is really a hosts file that was declared as `domains`. Neither is an error, and the two are never one number. A large `skipped_unsupported` beside a small `domain_count` usually means the list is written for browser extensions, and its DNS or hosts variant will block more here. Both appear per source in the blocklists UI and on `/api/blocklists`.
### group_sources
Which groups consult which blocklist sources.
| Field | Type | Default | Validation |
|---|---|---|---|
| `group` | string | required | must name a declared group |
| `source_url` | string | required | must name a declared blocklist source's `url`; the (group, source_url) pair is unique |
Consumed by the filter engine when assembling each group's compiled domain sets (`src/filter/matcher.zig`). A link to a disabled source is skipped.
### rules
Per-group allow and block overrides, checked before the blocklists.
| Field | Type | Default | Validation |
|---|---|---|---|
| `group` | string | required | must name a declared group |
| `pattern` | string | required | see below |
| `kind` | enum `.exact` \| `.wildcard` \| `.regex` | required | — |
| `action` | enum `.allow` \| `.block` | required | — |
Pattern rules: an `.exact` pattern is a plain domain name and may not contain `*`. A `.wildcard` pattern must contain at least one label that is exactly `*` (`*.tracker.example`, or `*` alone), and every other label must be a legal DNS label. `ads*.example` is not a valid wildcard; a partial label is what the `.regex` kind is for.
A `.regex` pattern is a regular expression matched against the whole normalized lowercase name, unanchored unless you write `^` or `$` — the POSIX-grep convention. It is stored exactly as you typed it, which the other two kinds are not: lowercasing would turn `\D` into `\d`, and trimming a trailing `.` would delete an any-byte atom. The engine (`src/filter/regex.zig`) accepts literal bytes, `.` for any byte, character classes `[a-z0-9]` with a leading `^` for negation, the escapes `\d` and `\w` plus `\` before any other ASCII punctuation to make it a literal, the repetitions `*` `+` `?` `{n}` `{n,m}` `{n,}`, alternation `|`, grouping `(...)`, and the anchors `^` and `$`.
Everything else is refused at the edge rather than approximated, so a pattern written for another engine fails where you can read the diagnostic instead of silently matching names you did not mean:
- backreferences, lookaround, captures, named groups, Unicode classes and the `(?…)` prefix they share;
- any alphanumeric escape the list above omits — `\s`, `\b`, `\1`, `\D`;
- a `]` inside a class, unless written `\]`;
- an empty pattern, and an empty branch: `ads|` is refused rather than read as a pattern that matches every name;
- a quantifier applied straight to another quantifier: `a+?` is refused rather than read as `(a+)?`, which matches every name. Write `(a+)?` to mean that.
A pattern is at most 256 bytes and compiles to at most 1024 instructions, each limit with its own diagnostic, and one group holds at most 256 regex rules. Groups do not capture, and the engine simulates every alternative in lockstep, so a pattern costs at most its compiled length times the length of the name — `(a+)+b` is as cheap here as it is expensive in a backtracking engine.
Consumed by the filter engine's rule sets (`src/filter/rules.zig`), which checks the three kinds in the order they are listed above, allow before block within each. Regex is checked last of the three because it is the only kind that costs more than a hash lookup or a label walk.
### local_records
Local DNS answers, served without touching any upstream.
| Field | Type | Default | Validation |
|---|---|---|---|
| `name` | string | required | a valid domain name |
| `rtype` | enum `.a` \| `.aaaa` \| `.cname` | required | stored as `A` / `AAAA` / `CNAME` |
| `value` | string | required | an IPv4 address for `.a`, an IPv6 address for `.aaaa`, a domain name for `.cname` |
| `ttl` | u32 | 300 | 1–604800 seconds |
The (name, rtype, value) triple is unique. Consumed by the local records table in the query path (`src/local/records.zig`).
### forward_zones
Zones resolved by a specific resolver instead of the configured upstreams, for LAN or corporate domains.
| Field | Type | Default | Validation |
|---|---|---|---|
| `zone` | string | required | a valid domain name; unique |
| `resolver` | string | required | `udp://IP:port` or `tcp://IP:port`; the host must be an IP literal and the port is mandatory |
The resolver host must be an IP literal because resolving the resolver's own name would be a bootstrap problem. Matching is longest suffix (`src/local/forward_zones.zig`); the exchange is UDP then TCP (`src/local/forward_client.zig`), with `upstream.read_timeout_ms` bounding the whole exchange rather than each leg.
Reverse zones are declared the same way, and one is the prerequisite for [learned client names](#learned-names):
```zig
.forward_zones = .{
.{ .zone = "lan", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
},
```
## Password and hash
Both fields are optional, and the difference between *absent* and *empty* is the whole design. Absent means "keep whatever is stored". Empty means "there is no password".
| The file says | What happens to the stored hash |
| --- | --- |
| Neither field | Untouched. Authentication stays exactly as it was. |
| `.password = "some-password"` | Verified against the stored hash; kept when it matches, replaced with a fresh hash when it does not. |
| `.password = ""` | Refused, with a diagnostic naming the remedy. |
| `.password_hash = "$argon2id$…"` | Written verbatim. |
| `.password_hash = ""` | Cleared, which disables authentication. |
| Both fields | Refused (`PasswordAndHashBothSet` — ambiguity in a security setting). |
Silence has to mean "keep", because the alternative is a trap. An operator who exports a configuration and trims the long PHC string out of it before committing the file to git means "leave the password alone", not "open the admin interface to the LAN". So disabling authentication takes the explicit empty string, and the empty *plaintext* — which would otherwise hash into a real hash that no login can ever satisfy — is refused outright:
```
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
```
- `web.password` is operator input only. It is hashed with argon2id (OWASP parameters: t=2, m=19 MiB, p=1, PHC encoding) into `web.password_hash` and discarded. There is no `web.password` settings row, and `nxdns export` always writes `.password = null`.
- `web.password_hash` is the stored argon2id PHC string. Supplying it directly, for example from a previous export, is how a backup restores authentication without knowing the password.
A plaintext password that has not changed is verified rather than re-hashed, so applying the same file twice leaves the same bytes in the database. That is what keeps the export/import round trip byte-stable with a password in the file. It costs a full argon2id computation either way — the verification is not a shortcut, and caching the plaintext to skip it would be a security bug.
Any change to whether a password is set is announced at startup, never left as a count:
```
web authentication is now enabled
web authentication is now disabled
```
See [set up admin authentication](../how-to/set-up-admin-authentication.md).
## Validation errors
`nxdns check`, `nxdns import` and a `nxdns run --config` that reads the file all print one `FAIL path: message` line per problem, and report every problem rather than the first. A finding that is legal but almost certainly unintended is prefixed `WARN` instead: it does not change the exit code, and it is printed by all three even when nothing failed, so an accepted configuration still says what is odd about it.
A url in a diagnostic is redacted to its scheme, host and port. The userinfo, the path, the query and the fragment are dropped, and control characters are escaped. These lines reach the journal, and every one of those parts can carry a credential: a NextDNS DoH upstream is `https://dns.nextdns.io/abcd12`, where the path segment is the whole account identifier. The field path beside the message names the entry, so `blocklist_sources[1].url` still says which one to go and fix.
What redaction cannot remove is the host, because a hostname is not a secret in the general case — it is resolved publicly and offered as SNI on every connection — and dropping it would leave a diagnostic that names nothing worth reading. A vendor that puts an account identifier in the hostname therefore has that identifier appear in any line naming the entry. NextDNS is the example: `abcd12.dns.nextdns.io` is a profile id.
Two things limit the exposure. A hostname `tls://` upstream is rejected by the validator (see `upstreams` above), so the DoT form of that URL can only reach the log once, in the line rejecting it — never on every failover. And NextDNS publishes a DoH endpoint, `https://dns.nextdns.io/abcd12`, whose identifier sits in the path and is redacted in full; configure that form and nothing identifying reaches the log at all.
The error set is `validate.ValidateError` in `src/config/validate.zig`:
| Error | Raised by |
| --- | --- |
| `NoUpstreams` | no upstream has `enabled = true` |
| `BadUpstreamUrl` | an upstream `url` is not an `https://` or `tls://` endpoint |
| `UpstreamHostNotIpLiteral` | a `tls://` upstream `url` names a host instead of an IP literal |
| `DuplicateUpstreamUrl` | two upstreams share a `url` |
| `BadTlsName` | a `tls_name` is not a valid domain name |
| `TlsNameOnNonTlsUpstream` | a `tls_name` on a non-`tls://` upstream |
| `MissingDefaultGroup` | no group is named `default` |
| `DuplicateGroupName` | two groups share a `name` |
| `EmptyGroupName` | a group `name` is empty |
| `GroupNameTooLong` | a group `name` is longer than 64 bytes; it is copied into every logged query |
| `UnknownGroup` | a client, prefix, group source or rule names a group that is not declared |
| `BadClientIp` / `DuplicateClientIp` | a client `ip` is unparseable, or collides after canonicalization |
| `BadClientPrefix` / `DuplicateClientPrefix` | the same for a `client_prefixes.prefix` |
| `BadSourceUrl` / `DuplicateSourceUrl` / `EmptySourceName` | blocklist source fields |
| `SourceNameTooLong` | a blocklist source `name` is longer than 64 bytes; it is copied into every logged query |
| `UnknownSource` / `DuplicateGroupSource` | `group_sources` links |
| `BadRulePattern` | a rule `pattern` does not match its `kind` |
| `BadLocalRecordName` / `BadLocalRecordValue` / `DuplicateLocalRecord` | local record fields |
| `BadForwardZone` / `DuplicateForwardZone` / `BadResolverUrl` | forward zone fields |
| `BadPort` | a port field is 0 |
| `BadTimeout` | a timeout is outside 100–120000 ms, or `attempt` is above `total` |
| `BadTtl` | `blocking.ttl`, `cache.negative_ttl_max`, a record `ttl`, `web.session_ttl_hours` or `blocklist_update.interval_hours` outside its range |
| `BadCacheSize` | `cache.size` outside 1–1000000 |
| `BadRetention` | `logging.retention_days` below 1, or `logging.query_log_buffer_max` outside 1–37449 |
| `BadFlushInterval` | `logging.query_log_flush_interval_s` above 3600 |
| `BadLogRotation` | `logging.max_size_mb` or `logging.max_files` below 1 |
| `BadDiskThresholds` | a threshold below 1, or `min_free_mb` above `warn_free_mb` |
| `BadRateLimit` | `dns.rate_limit`, `dns.rate_window_seconds`, `web.api_rate_limit_per_min` or `web.sse_max_connections_per_ip` out of range |
| `BadBindAddress` | a bind field is not an IP address, or not of the required family |
| `BadTrustedProxy` | an element of `web.trusted_proxies` is not an IP literal |
| `MissingCertPath` / `MissingKeyPath` | an enabled TLS listener with an empty path |
| `MissingLogPath` | `logging.output = .file` with an empty or relative `file_path` |
| `PasswordAndHashBothSet` | both `web.password` and `web.password_hash` are set |
Warnings are a separate set, outside `ValidateError` because they are not failures. There is one:
| Warning | Raised by |
| --- | --- |
| `SourceInNoGroup` | a `blocklist_sources` entry that no `group_sources` link names; it downloads and blocks nothing until a group uses it |
## Minimal working example
The smallest file that passes validation: a `default` group and one enabled upstream. Everything else keeps its default.
The smallest file that starts is a `default` group and one upstream:
```zon
.{
@@ -431,96 +19,384 @@ The smallest file that passes validation: a `default` group and one enabled upst
}
```
## Fuller annotated example
## Settings
Settings are grouped in sections. The **Key** column gives a setting's full name, as the web interface and `nxdns check` show it; in the file it sits inside its section, so `dns.port` is written `.dns = .{ .port = 53 }`.
Some settings take effect only after a restart; the web interface marks them **needs restart**.
### upstream
How long nxdns waits for upstream resolvers.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `upstream.attempt_timeout_ms` | integer | 2500 | 100–120000 ms, and not above `total_timeout_ms` | Time allowed for one try against one upstream, connecting included. When it runs out, nxdns tries the next upstream. |
| `upstream.total_timeout_ms` | integer | 5000 | 100–120000 ms | Time allowed for a whole query across every upstream it tries. When it runs out, the client gets SERVFAIL. Also the time `nxdns check` gives each upstream. |
| `upstream.read_timeout_ms` | integer | 3000 | 100–120000 ms | Time allowed for a query sent to a [forward zone](#forward_zones)'s resolver, including a retry over TCP. |
A query against several unreachable upstreams costs `total_timeout_ms` once, not one `attempt_timeout_ms` per upstream. An attempt that the total cut short is not held against that upstream's health.
### dns
The plain DNS listener, over UDP and TCP.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `dns.bind_ipv4` | string | `"0.0.0.0"` | an IPv4 address | The IPv4 address to listen on. |
| `dns.bind_ipv6` | string | `"::"` | an IPv6 address | The IPv6 address to listen on. |
| `dns.port` | integer | 53 | 1–65535 | The port to listen on. |
| `dns.rate_limit` | integer | 1000 | at least 1 | How many queries one client may send per window. |
| `dns.rate_window_seconds` | integer | 60 | 1–3600 s | The length of that window. |
Each bind address must belong to its own family: an IPv4 address in `dns.bind_ipv6` is refused.
### blocking
What a blocked query gets back.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `blocking.response` | `.zero` or `.nxdomain` | `.zero` | | `.zero` answers `0.0.0.0` or `::`; `.nxdomain` answers that the name does not exist. |
| `blocking.ttl` | integer | 5 | 0–86400 s | How long a client may cache a blocked answer. |
### cache
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `cache.size` | integer | 10000 | 1–1000000 entries | How many answers nxdns caches. The memory is reserved at startup. |
| `cache.negative_ttl_max` | integer | 3600 | 0–86400 s | The longest nxdns caches an answer that a name does not exist; 0 turns that off. |
The cache cannot be turned off; the smallest is `cache.size = 1`.
### web
The web interface.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `web.enabled` | bool | true | | Runs the web interface. |
| `web.bind` | string | `"0.0.0.0"` | an IP address | The address it listens on. |
| `web.port` | integer | 8080 | 1–65535 | The port it listens on. |
| `web.password` | string | none | not empty; not together with `password_hash` | Sets the admin password. Only its hash is stored; see [Passwords](#passwords). |
| `web.password_hash` | string | none | an argon2id hash, or `""` | The stored password hash. `""` removes the password. |
| `web.session_ttl_hours` | integer | 24 | at least 1 | How long a login lasts. |
| `web.api_rate_limit_per_min` | integer | 300 | at least 1 | How many requests one client may make to the web interface per minute. |
| `web.api_localhost_exempt` | bool | true | | Requests from the nxdns machine itself skip that limit. |
| `web.sse_max_connections_per_ip` | integer | 3 | at least 1 | How many live views (such as Activity's Live tab) one client may keep open. |
| `web.trusted_proxies` | string | `""` | IP addresses separated by commas | Reverse proxies whose `X-Forwarded-For` header nxdns believes; see [Behind a reverse proxy](../how-to/set-up-admin-authentication.md#behind-a-reverse-proxy). |
### doh_server
DNS over HTTPS for clients on your network. See [Enable DoH and DoT](../how-to/enable-doh-and-dot.md).
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `doh_server.enabled` | bool | false | | Runs the DoH listener. |
| `doh_server.bind` | string | `"0.0.0.0"` | an IP address | The address it listens on. |
| `doh_server.port` | integer | 443 | 1–65535 | The port it listens on. |
| `doh_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | not empty when enabled | The certificate, as PEM. A renewed file is picked up within 30 seconds. |
| `doh_server.key_path` | string | `"/etc/nxdns/key.pem"` | not empty when enabled | Its private key, as PEM. `nxdns check` warns when others can read it. |
### dot_server
DNS over TLS for clients on your network.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `dot_server.enabled` | bool | false | | Runs the DoT listener. |
| `dot_server.bind` | string | `"0.0.0.0"` | an IP address | The address it listens on. |
| `dot_server.port` | integer | 853 | 1–65535 | The port it listens on. |
| `dot_server.cert_path` | string | `"/etc/nxdns/cert.pem"` | not empty when enabled | The certificate, as PEM. |
| `dot_server.key_path` | string | `"/etc/nxdns/key.pem"` | not empty when enabled | Its private key, as PEM. |
### edns
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `edns.ecs_mode` | `.strip` or `.forward` | `.strip` | | `.strip` removes the client's subnet (EDNS Client Subnet) from queries before they go upstream; `.forward` passes it on. |
### logging
The process log and the query history.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `logging.level` | `.err`, `.warn`, `.info` or `.debug` | `.info` | | The least important log line nxdns writes. The file says `.err`; the web interface says `error`. |
| `logging.retention_days` | integer | 30 | at least 1 | How long the query history is kept, and how long a device that stopped asking stays in **Clients**. |
| `logging.query_log_buffer_max` | integer | 10000 | 1–37449 | How many queries nxdns holds in memory while it waits to write them. |
| `logging.query_log_flush_interval_s` | integer | 60 | 0–3600 s | How often the query history is written to disk; see below. |
| `logging.hide_domains` | bool | false | | Stores a placeholder instead of the name in the query history. |
| `logging.hide_client_ips` | bool | false | | Stores a placeholder instead of the client's address in the query history. |
| `logging.output` | `.stderr`, `.syslog` or `.file` | `.stderr` | | Where the process log goes. `.stderr` and `.syslog` both write to standard error, which journald and Docker collect. `.file` writes to `file_path` and rotates it. |
| `logging.file_path` | string | `"/var/log/nxdns/nxdns.log"` | an absolute path, when `output` is `.file` | The log file. nxdns does not create its directory. |
| `logging.max_size_mb` | integer | 50 | at least 1 | The size at which the log file is rotated. |
| `logging.max_files` | integer | 5 | at least 1 | How many log files are kept, the current one included. |
#### Why the query history is written once a minute
Writing each query to disk as it happens wears out an SD card for no benefit, so nxdns writes the queries of the last `query_log_flush_interval_s` seconds together. Pi-hole's default is the same minute, for the same reason.
The cost is that the **History** tab, **Overview** and the charts run up to a minute behind, and a crash loses up to that minute of history. **Activity → Live** is not delayed: it shows each query the moment it is answered. A burst of 100 queries is written at once without waiting, and stopping nxdns writes what it holds first.
`0` writes queries as they arrive, if you want **History** current to the second and do not mind the extra writes.
### disk
Free-space thresholds for the data directory. DNS keeps working whatever the free space.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `disk.min_free_mb` | integer | 200 | at least 1, and not above `warn_free_mb` | Below this, nxdns stops writing query history and device records and stops downloading blocklists, until space comes back. |
| `disk.warn_free_mb` | integer | 500 | at least 1 | Below this, nxdns logs a warning. |
### blocklist_update
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
| `blocklist_update.enabled` | bool | true | | Refreshes blocklists on a schedule. When false, they refresh only at startup and when you press **Update now**. |
| `blocklist_update.interval_hours` | integer | 24 | at least 1 | How often blocklists are refreshed. |
## Lists
The rest of the configuration is lists of entries. Fields with no default are required.
### groups
Groups of clients that share blocklists and rules. A group named `default` is required: every client not assigned to another group is in it.
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `name` | string | required | not empty, at most 64 bytes, unique |
| `safe_search` | bool | false | |
`safe_search` makes search engines that support it return their safe results to the group's clients.
### upstreams
The resolvers nxdns forwards queries to, over DoH (`https://`) or DoT (`tls://`). At least one must be enabled.
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `url` | string | required | an `https://` or `tls://` URL; unique |
| `priority` | integer | 100 | lower is tried first |
| `enabled` | bool | true | |
| `tls_name` | string | `""` | `tls://` only: the name the resolver's certificate must carry |
nxdns tries the upstreams in order of priority and moves to the next when one fails.
A `tls://` URL must use an IP address, because nxdns would need DNS to look up its own resolver's name. Put the name in `tls_name` so the certificate can still be checked:
```zon
.{ .url = "tls://9.9.9.9:853", .tls_name = "dns.quad9.net" },
```
An `https://` URL takes a hostname as usual.
nxdns speaks DoH over HTTP/1.1, so a DoH server that requires HTTP/2 does not work. Quad9's DoH endpoint is one: use Quad9 over DoT, as above.
### clients
Devices with a fixed group, by address.
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `ip` | string | required | an IP address; unique |
| `name` | string | `""` | the name shown in the web interface |
| `group` | string | `"default"` | a declared group |
#### Names nxdns learns
A device you have not named can still show a name. If a [forward zone](#forward_zones) covers your network's reverse names, nxdns asks that resolver, usually your router, for each unnamed device's name once a day, and shows the answer:
```zon
.forward_zones = .{
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
},
```
A name you type always wins. Learned names are never exported, and a renamed device can show its old name for up to a day.
### client_prefixes
Groups for whole address ranges, for devices without a `clients` entry.
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `prefix` | string | required | a CIDR range such as `192.168.2.0/24` or `fd00:abcd::/48`; unique |
| `group` | string | `"default"` | a declared group |
| `priority` | integer | 100 | breaks a tie between equally specific ranges; lower wins |
A `clients` entry beats any range, and a narrower range beats a wider one.
### blocklist_sources
Blocklists nxdns downloads.
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `url` | string | required | an `http://` or `https://` URL; unique |
| `name` | string | required | not empty, at most 64 bytes |
| `enabled` | bool | true | |
| `is_suggested` | bool | false | marks the list as a suggestion in the web interface |
A source blocks nothing until a group uses it through `group_sources`; `nxdns check` warns about one that no group uses.
nxdns reads hosts files, plain lists of domains, and Adblock Plus lists. From an Adblock Plus list it takes the rules that name a domain, `||example.com^`, which block the domain and everything under it, and exception rules, `@@||example.com^`, which unblock a name that another list blocks. Your own rules always win over both.
It skips what does not translate into a DNS answer, and **Protection → Sources** counts it for each list:
- **Skipped regex**: regex lines, which nxdns takes only from your own rules. Copy the ones you trust into a `regex` rule.
- **Skipped unsupported**: page-element hiding (`##`), rules with `$` options, and other browser-only syntax. A list with many of these and few domains is written for browser extensions; its DNS or hosts version blocks more.
### group_sources
Which group uses which blocklist.
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `group` | string | required | a declared group |
| `source_url` | string | required | the `url` of a declared blocklist source; each pair once |
### rules
Your own allow and block rules for a group. They are checked before any blocklist.
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `group` | string | required | a declared group |
| `pattern` | string | required | see below |
| `kind` | `.exact`, `.wildcard` or `.regex` | required | |
| `action` | `.allow` or `.block` | required | |
- `.exact` matches one name, such as `ads.example.com`.
- `.wildcard` has one or more labels that are exactly `*`: `*.example.com` matches every name under `example.com`, but not `example.com` itself. A partial label such as `ads*.example.com` is not a wildcard; write a regex for that.
- `.regex` is a regular expression matched against the lowercase name. It matches anywhere in the name unless you anchor it with `^` or `$`.
Exact rules are checked first, then wildcards, then regexes; within each kind, allow rules win over block rules.
A regex may use literal characters, `.`, classes such as `[a-z0-9]` and `[^0-9]`, `\d`, `\w`, `\` before punctuation to make it literal, `*`, `+`, `?`, `{n}`, `{n,m}`, `{n,}`, `|`, `( … )`, `^` and `$`. Anything else is refused with a message, rather than quietly read differently from what another engine would do: lookaround, backreferences, `\s`, `\b`, `(?…)`, an empty alternative such as `ads|`, and a quantifier directly after another, such as `a+?`. A pattern is at most 256 bytes, and a group holds at most 256 regex rules. Matching takes time in proportion to the pattern and the name, so no pattern can slow resolution down.
### local_records
Answers nxdns gives itself, without asking an upstream.
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `name` | string | required | a domain name |
| `rtype` | `.a`, `.aaaa` or `.cname` | required | |
| `value` | string | required | an IPv4 address for `.a`, an IPv6 address for `.aaaa`, a domain name for `.cname` |
| `ttl` | integer | 300 | 1–604800 s |
Each name, type and value together appear once.
### forward_zones
Domains that go to a resolver of your choice instead of the upstreams, such as your router's local names.
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `zone` | string | required | a domain name; unique |
| `resolver` | string | required | `udp://IP:port` or `tcp://IP:port`, with an IP address and a port |
The most specific zone wins.
```zon
.forward_zones = .{
.{ .zone = "lan", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
},
```
## Passwords
`web.password` and `web.password_hash` are both optional, and leaving one out is not the same as setting it empty.
| The file says | What happens to the stored password |
| --- | --- |
| Neither field | Nothing. It stays as it was. |
| `.password = "…"` | Sets that password. |
| `.password = ""` | Refused. |
| `.password_hash = "$argon2id$…"` | Sets the password that hash belongs to, for example from an export. |
| `.password_hash = ""` | Removes the password, which turns authentication off. |
| Both fields | Refused. |
The plaintext password is never stored: nxdns keeps an argon2id hash, and `nxdns export` writes `.password = null` with the hash. Applying the same file twice changes nothing.
## Errors and warnings
`nxdns check`, `nxdns import` and a start in file mode print one line per problem, and report all of them, not just the first:
```
FAIL groups: no group named 'default'; every unknown client is assigned to it
FAIL rules[0].pattern: '(' is not a valid regex pattern
```
A `FAIL` stops the command with exit code 2. A `WARN` marks something allowed but probably unintended, such as a blocklist no group uses, and does not stop it.
URLs in these lines are shortened to their scheme, host and port, because the rest can hold an account identifier: a NextDNS URL such as `https://dns.nextdns.io/abcd12` appears as `https://dns.nextdns.io`. A hostname is shown as it is, so prefer NextDNS's form with the identifier in the path over `abcd12.dns.nextdns.io`.
## A fuller example
```zon
.{
// Plain DNS on the standard port, rate-limited per client.
.dns = .{
.bind_ipv4 = "0.0.0.0",
.bind_ipv6 = "::",
.port = 53,
.rate_limit = 1000,
.rate_window_seconds = 60,
},
.dns = .{ .port = 53, .rate_limit = 1000 },
// Blocked queries answer 0.0.0.0 / :: with a 5 second TTL.
// Blocked names answer 0.0.0.0 or :: with a 5 second TTL.
.blocking = .{ .response = .zero, .ttl = 5 },
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
.web = .{ .password = "correct horse battery staple" },
// Web UI on 8080. The password is hashed and never stored as plaintext;
// leave .password_hash out when setting .password (they are exclusive).
.web = .{
.enabled = true,
.port = 8080,
.password = "correct horse battery staple",
.session_ttl_hours = 24,
},
// Serve DoT to the LAN. The key file should be mode 0600.
// Serve DoT to the network.
.dot_server = .{
.enabled = true,
.port = 853,
.cert_path = "/etc/nxdns/cert.pem",
.key_path = "/etc/nxdns/key.pem",
},
// Strip EDNS Client Subnet before forwarding (the default).
.edns = .{ .ecs_mode = .strip },
.logging = .{ .level = .warn, .retention_days = 14 },
// ".err" in the file; the settings API shows it as "error".
.logging = .{ .level = .err, .retention_days = 14 },
.blocklist_update = .{ .enabled = true, .interval_hours = 24 },
// "default" is mandatory. Additional groups get their own rules,
// blocklists and safe-search flag.
.groups = .{
.{ .name = "default" },
.{ .name = "kids", .safe_search = true },
},
// Lower priority is tried first; the second entry is a failover.
// tls_name is needed when a tls:// upstream is written as an IP
// literal, so certificate verification has a DNS name to match.
// Cloudflare first, Quad9 if it fails.
.upstreams = .{
.{ .url = "https://cloudflare-dns.com/dns-query", .priority = 10 },
.{ .url = "tls://9.9.9.9:853", .priority = 20, .tls_name = "dns.quad9.net" },
},
// Exact client assignments win over prefixes.
.clients = .{
.{ .ip = "192.168.1.20", .name = "tablet", .group = "kids" },
.{ .ip = "192.168.1.20", .name = "Kids' tablet", .group = "kids" },
},
.client_prefixes = .{
.{ .prefix = "192.168.2.0/24", .group = "kids" },
},
.blocklist_sources = .{
.{ .url = "https://lists.example/ads.txt", .name = "ads" },
.{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack hosts" },
},
.group_sources = .{
.{ .group = "kids", .source_url = "https://lists.example/ads.txt" },
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
.{ .group = "kids", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
},
// Overrides beat blocklists. Wildcards need a label that is exactly "*";
// a partial label takes a regex, which is unanchored unless you say "^".
.rules = .{
.{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
.{ .group = "default", .pattern = "allowed.example.com", .kind = .exact, .action = .allow },
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
.{ .group = "kids", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
},
// Local names, answered without any upstream.
.local_records = .{
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan" },
.{ .name = "files.lan", .rtype = .cname, .value = "nas.lan" },
},
// Everything under corp.lan goes to the LAN resolver directly.
// Names under lan, and reverse lookups, go to the router.
.forward_zones = .{
.{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "lan", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
},
}
```
+47 -75
View File
@@ -1,113 +1,85 @@
# Files and directories
Every path nxdns reads or writes, and the mode it is created with. Source of truth: `src/cli.zig` (`DataDir`), `src/storage/querylog_schema.zig` (the preserved query-log databases), `src/filter/manager.zig` (the blocklist snapshots), `src/platform/logging.zig` (the log file).
Every file nxdns reads or writes, and who may read it.
## The data directory
Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and `nxdns import` create it and its parents at mode 0700 when it is missing; `nxdns check` and `nxdns export` do not create it. `export` fails if it is not there. `check` opens it only when no `--config FILE` was given: with that flag it grades the file and never looks at the directory at all. Without it, an absent `config.db` is a failure naming the two ways to get one, exit 2.
`run`, `import` and `export` go through `DataDir.openConfigDb`, which opens `config.db` read/write, chmods it to 0600, enables WAL — creating `config.db-wal` and `config.db-shm` — and then runs any pending schema migrations. So `nxdns export` writes to the data directory, and on a database one schema version behind it migrates it. `export` also creates an empty `config.db` if the directory exists without one.
`nxdns check` is the exception: it does not use that path at all. It opens `config.db` immutable, which is `SQLITE_OPEN_READONLY` plus `immutable=1`, so SQLite refuses every statement that would write and builds no wal-index. No `config.db-wal` and no `config.db-shm` appear beside the file, the mode is left alone, and no migration runs — a database behind this binary's schema is reported as a failure naming `nxdns run` as the fix. A `check` against a data directory leaves it byte-identical, and `check` reaches the database branch only when `config.db` is already there.
`immutable=1` ignores any `-wal` file, so it is refused rather than used when one holds bytes: the newest settings would be invisible and `check` would grade older ones from the main file. That is the "uncheckpointed changes" failure in [the CLI reference](cli.md#what-check-does-not-do).
`/var/lib/nxdns` by default, or the directory `--data-dir` names. `nxdns run` and `nxdns import` create it at mode 0700 when it is missing.
| Path | What it is | Mode |
| --- | --- | --- |
| `config.db` | The configuration database, including `web.password_hash`. The source of truth in database mode; in file mode it is the runtime substrate the file is reconciled onto (see [the configuration file](#the-configuration-file)). | 0600 |
| `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created by `run`, `import` and `export` when WAL is enabled, inheriting the main file's permissions. `check` creates neither. | 0600 |
| `querylog.db` | The query log: every domain every client asked for. Missing or damaged, it is recreated empty; an older schema is migrated in place, and a schema this build cannot use refuses the startup. See [the query-log lifecycle](query-log-lifecycle.md). | 0600 |
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
| `querylog.db.<reason>-<unix-seconds>` | A damaged `querylog.db`, moved aside before an empty one was created in its place. Kept, never overwritten. `<reason>` is one of `corrupt`, `not-a-database` or `quick-check-failed`; see [why a query log is moved aside](#why-a-query-log-is-moved-aside). | Whatever the renamed file had — no chmod reaches it |
| `querylog.db.<reason>-<unix-seconds>-<n>` | The same, when the plain name is taken — `<n>` counts from 1 and rises until the name is free. Two recreates within one second is the case it exists for. | The same |
| `querylog.db.pre-migrate-<unix-seconds>` | A complete copy of `querylog.db` taken immediately before a schema migration. Exactly one survives: a successful migration deletes every other one, and a later successful start retries that cleanup. Written by `VACUUM INTO`, so it holds the committed database including anything still only in the write-ahead log, and it needs no sidecars of its own. | 0600 |
| `querylog.db.pre-migrate-<unix-seconds>-<n>` | The same, when the plain name is taken — `<n>` counts from 2. | The same |
| `blocklists/` | Compiled blocklist snapshots, one subdirectory of the data directory. | 0700 |
| `blocklists/<id>.list` | Exact domains for blocklist source `<id>`, one per line, behind a header. | 0600 |
| `blocklists/<id>.wild` | Wildcard entries for the same source. | 0600 |
| `blocklists/<id>.allow` | Exception entries for the same source: the names its `@@` lines lift. Absent on a source compiled before exceptions were honoured, which reads as empty. | 0600 |
| `blocklists/<id>.raw.tmp`, `<id>.list.tmp`, `<id>.wild.tmp`, `<id>.allow.tmp` | Transient refresh state: the downloaded body and the three compile outputs before they are published by rename. | 0600 |
| `config.db` | The configuration, including the password hash. In database mode this is the configuration; in file mode nxdns rebuilds it from the file at every start. | 0600 |
| `querylog.db` | The query history: every name every device asked for. See [The query history database](#the-query-history-database). | 0600 |
| `config.db-wal`, `config.db-shm`, `querylog.db-wal`, `querylog.db-shm` | SQLite's working files for each database. They hold the same data, and belong with their database whenever you copy or move it. | 0600 |
| `querylog.db.pre-migrate-<unix-seconds>` | A copy of the query history taken just before an upgrade converted it. The latest one is kept. | 0600 |
| `querylog.db.<reason>-<unix-seconds>` | A damaged query history, moved aside; see below. | as it was |
| `blocklists/` | The downloaded blocklists, compiled. | 0700 |
| `blocklists/<id>.list`, `<id>.wild`, `<id>.allow` | One blocklist's domains, wildcard entries and exceptions. `<id>` is the list's number in nxdns. | 0600 |
| `blocklists/<id>.*.tmp` | A download in progress. | 0600 |
`<id>` is the `blocklist_sources` row id.
The databases are private because they are sensitive: `config.db` holds the password hash, and `querylog.db` and its copies hold the browsing history of every device on your network. nxdns restricts them to 0600 whatever the umask.
### The orphan sweep
The sweep decides by id, not by suffix. It matches all seven names above and deletes those whose `<id>` is no longer a `blocklist_sources` row, so the compiled `.list`, `.wild` and `.allow` of a removed source go, and so do a `.raw.tmp`, `.list.tmp`, `.wild.tmp` or `.allow.tmp` left behind by a refresh that was killed before it could clean up. Files belonging to a source that still has a row are never touched, whatever state they are in: the sweep holds the same lock every refresh takes, so it never reads the directory while a refresh is part-way through.
It runs at three moments:
- at startup, before the first refresh pass — this is what collects what a killed process left behind, and the compiled files of a source deleted while the server was down;
- before each scheduled update pass, ahead of the disk-space gate: the sweep only unlinks, so it is the one step here that can give a critically full disk room back, and gating it would keep the residue that helped fill the disk;
- immediately after `DELETE /api/blocklists/{id}`, which is when an orphan is actually created in normal operation. Without it a deleted list would keep its megabytes until the next scheduled pass.
With `blocklist_update.enabled = false` there are no scheduled passes, so only the first and the last of those three happen.
Each deletion is logged:
nxdns deletes a blocklist's files when you remove the list, and at startup it deletes any left behind by a list removed while it was down or by a download that was interrupted:
```
info(blocklist_manager): pruned orphaned blocklist file 9999.list
```
A failed sweep is a warning, not an outage — leftover bytes do not justify losing the refresh pass behind them, let alone the server.
`nxdns check` only reads the data directory, and changes nothing in it.
The temporaries of a source that still exists are cleaned by the refresh that owns them rather than by the sweep: each refresh deletes its own `.raw.tmp`, `.list.tmp`, `.wild.tmp` and `.allow.tmp` as it finishes, successfully or not.
## The query history database
### Why a query log is moved aside
At each start nxdns checks `querylog.db` before it serves anything.
A `querylog.db` is moved aside only when it is genuinely damaged, and the name it is given says which of the three cases it hit:
- **Current**: it opens.
- **From an older release**: nxdns copies it to `querylog.db.pre-migrate-<unix-seconds>`, then converts it in one step. If converting fails, the file keeps its old contents and nxdns refuses to start.
- **Missing**: nxdns creates an empty one.
- **Damaged**: nxdns renames it to `querylog.db.<reason>-<unix-seconds>`, deletes its `-wal` and `-shm` files, and starts an empty one. `<reason>` is `corrupt`, `not-a-database` or `quick-check-failed`. Nothing deletes these copies; remove them when you no longer want the history in them.
- **Written by a newer release, or too old to convert**: nxdns refuses to start, exits 2 and leaves the file as it is. The message says what to do:
| `<reason>` | What happened |
| --- | --- |
| `corrupt` | SQLite reported the file as damaged. |
| `not-a-database` | The file is not a SQLite database at all. |
| `quick-check-failed` | `PRAGMA quick_check` did not answer `ok`. |
```
refusing to open querylog database '/var/lib/nxdns/querylog.db': it is stamped 2, and this build supports schema versions 1 to 1 (SchemaTooNew). The file is left exactly as it is; a newer nxdns wrote it. Run that release again, put back the querylog.db.pre-migrate-* copy an upgrade left beside it, or move the file aside, together with its -wal and -shm files, to start with an empty query history
```
There is no fourth case. A healthy file carrying a schema version this build cannot use is neither migrated nor renamed: the startup refuses and the file stays where it is, which is [the query-log lifecycle](query-log-lifecycle.md). You can still find a `querylog.db.schema-changed-<unix-seconds>` in a data directory, because 0.0.13 and older produced one on any schema change — and still do, if you downgrade to one of them. This build never writes that name.
To start with an empty history, stop nxdns and move the three files aside:
Only the main file is renamed — its `-wal` and `-shm` are deleted, because a stale WAL would be replayed into the fresh database. A missing `querylog.db` is created without any aside file. The rename happens inside `querylog_schema.open`, before the 0600 chmod, and that chmod names `querylog.db` and its two sidecars only — so an aside file keeps the mode the file had at rename time, which for a `querylog.db` nxdns itself created is 0600 and for one an operator put there is whatever they left it at. Nothing prunes the aside files; they accumulate until an operator removes them, and each one holds the same browsing history the live query log holds.
```sh
cd /var/lib/nxdns
mkdir -p old-querylog
mv querylog.db querylog.db-wal querylog.db-shm old-querylog/
```
### Why the databases are 0600
`mv` complains about a `-wal` or `-shm` file that is not there; that is fine.
The 0600 modes are not cosmetic. `config.db` holds the argon2id password hash and `querylog.db` holds the browsing history of every client on the LAN, so both are as sensitive as each other, and a WAL file holds the same rows as the database it belongs to. SQLite creates the main database at `0644 & ~umask`; nxdns chmods it to 0600 before enabling WAL, so the sidecars inherit 0600 rather than being created world-readable.
A healthy query history is never replaced or deleted. A release that cannot convert the old history says `resets your query history` in its release notes, and explains how to keep the old file.
A `pre-migrate` backup holds that same browsing history, so it is chmodded 0600 the way the live file is: SQLite's `VACUUM INTO` creates it at `0644 & ~umask` and nxdns restricts it immediately afterwards. A backup it cannot restrict is a failed backup — the partial file is deleted and the startup refuses with `MigrationBackupFailed`, rather than leaving a world-readable copy behind.
### Going back to 0.0.13 or older
The aside files are the exception: they get no chmod at all, and an aside keeps whatever mode it had at rename time. For a `querylog.db` nxdns itself created that is 0600; for one an operator put there it is whatever they left it at.
Releases up to 0.0.13 do not recognise the current query history. They rename it to `querylog.db.schema-changed-<unix-seconds>` and start an empty one. To get the history back after returning to a newer release, stop nxdns, then in the data directory:
1. Move the empty `querylog.db` out of the way.
2. Delete `querylog.db-wal` and `querylog.db-shm`. Left in place, they would corrupt the history you restore.
3. Rename `querylog.db.schema-changed-<unix-seconds>` to `querylog.db`.
4. Start nxdns.
## The configuration file
There is no default path. `--config FILE` names the file, and without that flag no file is read at all — a `config.zon` sitting in `/etc/nxdns` that no invocation names is inert. `/etc/nxdns/config.zon` is a convention the packaging follows, not a location nxdns probes.
nxdns reads a configuration file only when `--config` names it, and never writes it. `/etc/nxdns/config.zon` is the conventional place, and the packaged systemd unit creates `/etc/nxdns` and keeps the service from writing to it.
nxdns reads the file and never writes it, in either authority mode. It does not create the file or its directory either; the systemd unit's `ConfigurationDirectory=nxdns` creates `/etc/nxdns`, and the same unit's `ReadOnlyPaths=/etc/nxdns` denies the service write access to it, so the file cannot be modified by the process that reads it.
Under `run --config FILE` the file is the configuration and the database is the runtime substrate the server reads from: every start reconciles the one onto the other. So in that mode `config.db` is not the backup — the file is.
`nxdns export --out FILE` writes a ZON file at mode 0600 through a temporary file and a rename. That file carries `web.password_hash`, so treat exports as secrets. Without `--out` the export goes to stdout, where permissions are the redirect's business.
An export, `nxdns export --out FILE`, is created at mode 0600 because it carries the password hash.
## The log file
Only when `logging.output = .file`. The path is `logging.file_path`, default `/var/log/nxdns/nxdns.log`, and validation requires it to be absolute.
Only when `logging.output = .file`. The file is `logging.file_path`, `/var/log/nxdns/nxdns.log` by default.
**nxdns does not create the log directory.** It must exist and be writable by the user the service runs as before the process starts; under systemd the unit's `LogsDirectory=nxdns` does that.
nxdns does not create the log directory; under systemd the packaged unit does. It rotates the file itself at `logging.max_size_mb` and keeps `logging.max_files` files, so with the defaults that is `nxdns.log` and `nxdns.log.1` to `nxdns.log.4`.
Rotation triggers at `logging.max_size_mb` MiB and keeps `logging.max_files` files in total, the live one included, so the highest generation on disk is `logging.max_files - 1`. With the default 5 that is `nxdns.log` plus `nxdns.log.1` through `nxdns.log.4`. Rotation deletes the highest generation, renames each remaining one up by one, then renames the live file to `.1`. A `max_files` of 1 or 0 deletes the live file instead of renaming it. The directory holding the log file is also sampled by the disk monitor.
Do not rotate this file with logrotate. nxdns keeps writing where it left off, so a rotated file either fills with blank space or stops growing while the old one grows without limit. If another tool must own the log, set `logging.output = .stderr` and let journald or Docker collect it.
| Path | What it is | Mode |
| --- | --- | --- |
| `logging.file_path` (default `/var/log/nxdns/nxdns.log`) | The live process log. Opened write-only and written at a tracked offset; created when it does not exist. | `0666 & ~umask` — nxdns never chmods it |
| `<file_path>.1` … `<file_path>.<max_files - 1>` | Rotated generations, newest first. | Inherited from the live file they were renamed from |
nxdns does not set the log file's mode. Under the packaged unit, which sets `UMask=0077`, it is 0600; started from a shell with the usual umask it is readable by every local user.
**Do not point external logrotate at this file.** nxdns owns the rotation of its own log. There is no append mode in Zig 0.16, so the writer reads the file length once when it opens the file and then writes every line at an offset it tracks in the process. A rotator that moves the file behind it breaks that offset, and nxdns does not notice until the next restart:
With `logging.output = .stderr` or `.syslog`, nxdns writes to standard error and creates no file.
- With `copytruncate` the offset survives the truncation, so the next line lands where it would have without it. The file regrows with a sparse, NUL-filled prefix as long as the log that was just rotated away.
- With rename-and-create nxdns keeps writing to the renamed inode. The new file stays empty, the renamed one grows without bound, and `logging.max_size_mb` bounds nothing on disk.
## Certificates and keys
If an external rotator has to own the file, set `logging.output = .stderr` and let the collector capture the stream instead.
The log file is the one path here nxdns does not set a mode on. `config.db` and `querylog.db` are chmodded to 0600 whatever the umask; the log file is left at whatever the umask gives it. Under the shipped unit that is 0600, because `deploy/systemd/nxdns.service` sets `UMask=0077`. Run from a shell with the usual `umask 022` it is 0644, and `LogsDirectory=nxdns` leaves the directory at systemd's default 0755, so a log written there is readable by any local user.
With `logging.output = .stderr` or `.syslog` nxdns writes to stderr and creates no file; under systemd the journal captures it.
## Certificate and key files
`doh_server.cert_path` / `key_path` and `dot_server.cert_path` / `key_path`, conventionally under `/etc/nxdns`. nxdns reads them, never writes or creates them. Both must be readable by the user nxdns runs as, and the key must belong to the certificate: `nxdns check` loads the pair and fails when it does not. The key should also be readable by its owner only, which `check` warns about when it is not. A watcher polls both files and swaps a renewed pair in without a restart. See [enable DoH and DoT](../how-to/enable-doh-and-dot.md).
The files `doh_server.cert_path`, `doh_server.key_path`, `dot_server.cert_path` and `dot_server.key_path` name, `/etc/nxdns/cert.pem` and `/etc/nxdns/key.pem` by default. nxdns only reads them. The user nxdns runs as must be able to read both, and nobody else should be able to read the key. nxdns picks up a renewed pair within 30 seconds; see [Enable DoH and DoT](../how-to/enable-doh-and-dot.md).
-56
View File
@@ -1,56 +0,0 @@
# Performance reference
The PLAN §18 targets and the numbers measured against them. Why the targets are these targets, and why CI does not gate on them, is [performance and testing](../explanation/performance-and-testing.md). To reproduce the numbers, see [measure performance](../how-to/measure-performance.md).
## Targets (PLAN §18)
| Target | Where it is checked |
| --- | --- |
| Sustained ≥ 100 qps on Raspberry Pi 5 | End-to-end against the real binary on the Pi; not a harness number |
| Blocklist lookup p95 < 1 ms | `bench filter`: `matcher.normalize` + `Snapshot.evaluate` per op |
| Cached response p95 < 5 ms | `bench cache`: `buildKey` + `DnsCache.get` + `packet.setId` per op |
| Memory with ~1M blocked domains < 100 MiB | `bench filter`: VmRSS with the 1M-domain snapshot loaded |
| Stripped static binary ≤ 10,485,760 bytes per arch without the embedded frontend, ≤ 15,728,640 bytes with it | `zig build verify-dist`, run by the `package` gate and by the release |
The harness is `tools/bench.zig`. It measures the three targets that are measurable in process; the qps target is end to end and the binary-size target belongs to the packaging step.
The two size budgets are exact byte counts, not rounded mebibytes, because an assert on a rounded number is an assert on a number nobody wrote down. `verify-dist` checks the shipped binary against the larger budget and builds a second time against a generated empty assets directory for the smaller one, so the asset-free figure is a real measurement rather than an estimate.
## Measured: x86_64 development host
Date: 2026-08-13. Hardware and build: Intel Core i7-14700K, Linux 6.18, Zig 0.16.0, `-Doptimize=ReleaseFast`, harness defaults (1,000,000 domains, 200,000 iterations per suite, seed 0x5eed).
This is not the target platform. The Pi 5's Cortex-A76 is far slower and these numbers do not transfer; they establish that the harness works and set a baseline for regressions on the machine development happens on.
```
suite ops p50(us) p95(us) p99(us) max(us)
filter 200000 2.41 2.80 2.97 22.16
blocked 66699/200000, 32 regex rules, Snapshot.memoryBytes 28.0 MiB, VmRSS 32.0 MiB
target p95 < 1ms: PASS
target VmRSS < 100 MiB: PASS
cache 200000 0.11 0.17 0.22 5.40
hits 100000/200000, DnsCache.memoryBytes 4.3 MiB, VmRSS 7.7 MiB
target p95 < 5ms: PASS
compile 1000000 wall 98.597ms, 10142224 lines/s, 1000000 domains kept (informational)
```
Every in-process §18 target passes on this host: the filter target by about 360x, the cache target by about four orders of magnitude, the memory target by about 3x.
The filter suite loads 32 regex rules that no query in the mix matches, which is the expensive case rather than the cheap one: the regex levels sit below every hash and wildcard level, so a name no pattern matches is the name that runs all 32 programs to their end. Every op pays that, which is what moved the filter p95 from 0.18 µs before regex rules existed to the 2.80 µs above. The margin against the 1 ms target is what makes paying it on every miss an acceptable price.
### The two memory figures
`Snapshot.memoryBytes` and `DnsCache.memoryBytes` are the in-repo accounting of the structures themselves, which is the regression guard. VmRSS is what the kernel holds resident for the whole process, allocator slack and code included. The truth sits between them, and the §18 memory target is judged on VmRSS.
The filter suite frees the generated list source before reading VmRSS, so its number reflects the loaded snapshot rather than the generator. The cache suite's VmRSS is lower because the filter suite's snapshot has been freed by then.
## Raspberry Pi 5 (target platform)
| Target | Result |
| --- | --- |
| Blocklist lookup p95 < 1 ms | to be measured on hardware |
| Cached response p95 < 5 ms | to be measured on hardware |
| Memory with ~1M blocked domains < 100 MiB | to be measured on hardware |
| Sustained ≥ 100 qps | to be measured on hardware, end to end |
The qps target belongs to the real binary rather than the harness: it means `nxdns run` on the Pi, driven over the LAN by a DNS load generator against real blocklists.
-70
View File
@@ -1,70 +0,0 @@
# The query log's lifecycle
What happens to `querylog.db` when nxdns opens it: how the file is versioned, when it is migrated, when the server refuses to start over it, and the one case in which it is still replaced. Source of truth: `src/storage/querylog_versions.zig` (the version metadata), `src/storage/querylog_schema.zig` (the open path) and `src/storage/querylog_migrations.zig` (the migration runner).
The rule this page exists to state: **a healthy `querylog.db` is never replaced and never moved aside.** A schema this build cannot use refuses the startup instead. Your query history is not the server's to discard.
## The version stamp
Every `querylog.db` carries a logical schema version in SQLite's `PRAGMA user_version`. It is a small counter — 1 in this release — and not a hash of anything. A file created by this build is stamped as it is created.
Two other values matter, both in `querylog_versions.zig`:
| Constant | Today | What it means |
| --- | --- | --- |
| `current_version` | 1 | The version this build creates and reads. |
| `minimum_supported_version` | 1 | The oldest stamped version this build can migrate up to `current_version`. |
| `legacy_fingerprint` | 1975011655 | The `user_version` the 0.0.12 and 0.0.13 binaries wrote: a CRC32 of their schema text, under the older policy where a mismatch meant "replace the file". |
`legacy_fingerprint` is frozen forever. Those two releases stamped a hash rather than a version, so this build recognises that one literal number as "version 1" and restamps the file as 1 on the first open. The restamp runs in its own transaction; if it fails, the old stamp and every row stay exactly as they were and the startup refuses.
## What an open does
nxdns opens `querylog.db` once at startup, before it serves anything, and no second process shares a data directory. On a file that is readable and passes `PRAGMA quick_check`, the stamp decides:
| Stamp | What happens |
| --- | --- |
| `current_version` | Opens. Nothing is migrated. |
| `legacy_fingerprint` | Read as version 1: restamped to 1, then treated as version 1 by the rows above and below. |
| Between `minimum_supported_version` and `current_version` | Migrated in place, then opens. |
| Above `current_version`, up to 1000000 | REFUSE: `SchemaTooNew`. |
| Anything else — 0, a negative, another fingerprint, a version below the minimum | REFUSE: `SchemaUnsupported`. |
A refusal changes nothing. The schema, the rows, the coverage watermark and the stamp are all left as they are, no file is set aside, no new file is created, and `nxdns run` exits. The log line names the path, the stamp it found, the range this build supports and [the troubleshooting section](../how-to/troubleshoot.md#the-server-refuses-to-start-over-querylogdb).
## Migrating in place
A migration is one backup and one transaction.
1. **Back up.** `VACUUM INTO` writes a complete copy — including anything still only in the write-ahead log — to `querylog.db.pre-migrate-<unix-seconds>` beside the database. If that name is taken, `-2`, `-3` and so on are tried. A backup that cannot be written is `MigrationBackupFailed`, and the partial copy is deleted; an older backup beside it survives.
2. **Migrate.** `BEGIN IMMEDIATE`, re-read the stamp under the lock, run every step, run `PRAGMA foreign_key_check`, stamp the new version, `COMMIT`. One transaction covers the whole chain, so the file is either at the old version or at the new one and never in between.
3. **Clean up.** Every other `querylog.db.pre-migrate-*` beside the file is deleted. **One backup is kept**: the one this migration just took. A later successful start retries that cleanup if it failed.
If a step fails before the commit, the transaction rolls back, this run's backup is deleted, and the startup refuses with `MigrationFailed`. The database keeps the version and the rows it had.
If the commit succeeds and something after it fails, the log says so plainly — the migration DID complete and the file IS at the new version. The backup is kept, the startup still refuses with `MigrationFailed`, and the next start opens the migrated file normally.
## Corruption is the only automatic recreate
Four conditions still create a fresh, empty `querylog.db`: the file is missing, SQLite reports it as corrupt, it is not a SQLite database at all, or `PRAGMA quick_check` does not answer `ok`. Except for the missing case, the unusable file is renamed to `querylog.db.<reason>-<unix-seconds>` and kept. See [why a query log is moved aside](files-and-directories.md#why-a-query-log-is-moved-aside).
Every other failure — a lock held elsewhere, a permission problem, a full disk, a version this build cannot reach — propagates and leaves the file alone.
## Downgrading
**Downgrading to 0.0.13 or older resets your query log.** Those binaries predate this contract: they compare `user_version` against a hash of their own schema text, find this build's version stamp instead, and treat that as a mismatch — so they rename `querylog.db` to `querylog.db.schema-changed-<unix-seconds>` and start an empty log. Nothing is destroyed, but the live log is empty until you put the aside file back, and the restamp that provoked it takes no backup of its own.
To recover, go back to a migration-aware release, stop the server, then, in the data directory:
1. Move the empty `querylog.db` the old binary created out of the way.
2. Delete its `querylog.db-wal` and `querylog.db-shm`. This is not optional: replaying the empty file's write-ahead log into the restored history would corrupt it.
3. Rename `querylog.db.schema-changed-<unix-seconds>` back to `querylog.db`.
4. Start the server.
Downgrading between two migration-aware releases is safe in the sense that matters: a build that finds a stamp above its own `current_version` refuses to start with `SchemaTooNew` and touches nothing. Go forward again, or restore the `pre-migrate` backup the upgrade left.
## Breaking the schema on purpose
A release may still break the query-log schema outright rather than migrate it. That is allowed, and it is never silent. Such a release raises `current_version`, sets `minimum_supported_version` to the same value, and ships no migration step — so files from before the break classify as below the minimum and `open` refuses them with `SchemaUnsupported` rather than replacing them. The release notes carry the phrase `resets your query history` and a `Restoring your query history` section, and the cut gate refuses to build the release without both.
So the contract is: a break is always versioned, always refused at startup with the file intact, and always disclosed in the changelog.
-287
View File
@@ -1,287 +0,0 @@
# Your first nxdns
This page takes you from a checkout of the repository to a running nxdns that answers DNS queries, blocks domains from a real blocklist, and shows you the web interface. It runs on unprivileged ports in a scratch directory, so nothing on your machine changes and nothing needs root. At the end you stop the server and delete the directory.
Follow the steps in order. Each one says what it did.
Every command below was executed on x86_64 Linux with Zig 0.16.0, Node.js 24.14.1, dig 9.20.26 and curl 8.21.0. Steps 2, 4 to 11, 13 and 14 were re-run end to end for this revision, and the transcripts are that run's output with the tutorial directory substituted. Two things were not re-run: the browser page in step 12 — its endpoints were exercised, the page itself was not opened — and the `npm` build in step 1, whose `admin/dist` was already on disk and is the one the binary under test embeds. The ZON block at the end of step 14 was checked with `nxdns check --config` rather than started.
## What you need
- Zig 0.16.0 and Node.js 24, both on your `PATH`.
- `dig` (from bind-tools or dnsutils) and `curl`.
- Working internet access: nxdns queries an upstream resolver over HTTPS and downloads a 3 MB blocklist.
- Two terminals. The server runs in the first one; you type into the second.
Build commands run from the repository root. The server and the queries use `~/nxdns-tutorial` for everything they write.
## 1. Build the web interface
```sh
cd admin && npm ci && npm run build && cd ..
```
This produces `admin/dist`. Do not skip it. A plain `zig build` embeds `admin/dist-placeholder`, a one-page status stub, and you would reach step 12 and find no admin interface there.
## 2. Build nxdns
```sh
zig build -Dadmin-dist=admin/dist
```
The binary is `zig-out/bin/nxdns`, with `admin/dist` embedded in it. SQLite and mbedTLS are vendored and built by this command, so the first build takes a while; later builds are cached.
## 3. Write a configuration file
```sh
mkdir -p ~/nxdns-tutorial
cat > ~/nxdns-tutorial/config.zon <<'EOF'
.{
.dns = .{ .bind_ipv4 = "127.0.0.1", .bind_ipv6 = "::1", .port = 15353 },
.web = .{ .bind = "127.0.0.1", .port = 8080 },
.groups = .{
.{ .name = "default" },
},
.upstreams = .{
.{ .url = "https://cloudflare-dns.com/dns-query" },
},
}
EOF
```
Four things are set, and the rest of nxdns keeps its defaults.
DNS is on port 15353 instead of 53, and both listeners are bound to localhost. Port 53 is privileged: binding it needs root or `CAP_NET_BIND_SERVICE`, which is an install decision, not a first-run decision. A real install is covered in [how-to/install-with-systemd.md](../how-to/install-with-systemd.md).
The `default` group and one enabled upstream are the two things nxdns will not start without. Every client that nxdns has never seen is assigned to `default`, and with no usable upstream there is nowhere to send a query it cannot answer itself, so a configuration missing either one is rejected. Whichever command reads the file says the same thing and stops the same way: `run`, `nxdns check` and `nxdns import` all print the problem and exit 2.
This tutorial loads the file into the database once and then runs nxdns against the database, which is what the packaged systemd unit does. There is a second way to run nxdns, where the file itself stays the configuration; step 14 shows what changes.
## 4. Check the configuration before starting
```sh
zig-out/bin/nxdns check --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
```
```
checking configuration file /home/you/nxdns-tutorial/config.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
```
`check` parses the file, validates it, and contacts each upstream to confirm it answers. It exits 2 when it found something that has to be fixed and 0 otherwise. It writes nothing and starts no listener, so you can run it as often as you like.
## 5. Load it into the database
```sh
zig-out/bin/nxdns import ~/nxdns-tutorial/config.zon --data-dir ~/nxdns-tutorial/data
```
```
info(migrations): config.db migrated from schema version 0 to 1
imported /home/you/nxdns-tutorial/config.zon
```
The data directory did not exist; nxdns created it at mode 0700 along with `config.db`. From here the database holds the configuration, and you will change it through the API rather than by editing the file again.
## 6. Start the server
In your first terminal:
```sh
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data
```
```
info(querylog_schema): created querylog database '/home/you/nxdns-tutorial/data/querylog.db'
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 231 bytes
info(nxdns): authority: database
info(nxdns): nxdns <version> serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1
info(web_server): web interface listening on 127.0.0.1:8080
```
No `--config` here, and that is the point: `authority: database` says the database is the configuration and no file was opened at all. The file you wrote in step 3 has done its job.
Leave this terminal running and switch to the second one.
## 7. Resolve a name
```sh
dig @127.0.0.1 -p 15353 example.com A +noall +answer
```
```
example.com. 229 IN A 172.66.147.243
example.com. 229 IN A 104.20.23.154
```
nxdns had no answer cached, so it forwarded the query to `https://cloudflare-dns.com/dns-query` over HTTPS and returned what came back. You now have a working resolver. It blocks nothing yet: the log line in step 6 said `0 of 0 sources loaded`.
## 8. Add a blocklist source
```sh
curl -s -X POST http://127.0.0.1:8080/api/blocklists \
-H 'Content-Type: application/json' \
-d '{"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","name":"StevenBlack hosts"}'
```
```json
{"id":1,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","name":"StevenBlack hosts","enabled":true,"is_suggested":false}
```
The API is unauthenticated here because the configuration set no `web.password`. That is fine for a localhost tutorial and wrong for anything else; see [how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md).
Note the `"id":1` in the response. You need it in the next step.
## 9. Attach the source to the `default` group
A blocklist source belongs to the installation. Which groups use it is a separate decision, which is what lets one group get a strict list and another get none. A source that is attached to no group is downloaded and then filters nothing.
You are in that state right now, between the previous step and this one, and it is legal rather than wrong — creating a source and attaching it afterwards is the normal order, which is why the API accepted it without complaint. On a stopped server `nxdns check` names such a source in a `WARN` line and still exits 0, so a list that silently blocks nothing is something you can find out about later. Attaching it now is what makes it take effect.
Ask which groups exist:
```sh
curl -s http://127.0.0.1:8080/api/groups
```
```json
{"groups":[{"id":1,"name":"default","safe_search":false}]}
```
The `default` group is id 1. Give it the source you just created — the request body is the complete set of sources for that group, so sending `[1]` replaces whatever was there:
```sh
curl -s -X PUT http://127.0.0.1:8080/api/groups/1/sources \
-H 'Content-Type: application/json' \
-d '{"source_ids":[1]}'
```
```json
{"source_ids":[1]}
```
## 10. Download the list
Adding a source registers it; it does not fetch it. Ask for a refresh:
```sh
curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
```
```json
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786629237,"last_success":1786629238,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":97648,"wildcards":0,"exceptions":0,"skipped_regex":0,"skipped_unsupported":0}]}
```
The four zeros describe this particular download, not the hosts format. `wildcards` counts entries covering a name and its subdomains, `exceptions` counts the `@@` lines an Adblock Plus list uses to lift a name another list blocks, and `skipped_regex` counts the regex lines nxdns declines to take from a downloaded list. `skipped_unsupported` counts lines nxdns cannot translate into a DNS decision, and a large value next to a small `domains` means the list targets browsers rather than DNS. Only `exceptions` is Adblock-Plus-only: a hosts list can carry regex lines, `*.`-prefixed wildcards, and bare sink addresses that count as unsupported. This one carries none of them.
The download is about 3 MB and takes a few seconds. Watch the first terminal until this appears:
```
info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 2523973 bytes
```
`1 of 1 sources loaded` is the line to wait for. nxdns builds each blocklist snapshot in full and swaps it in atomically, so queries keep being answered from the previous snapshot the whole time the new one is being built. After this, the domain count in the JSON above — 97648 on the day this was run — is live.
From here on, the list is on disk under `~/nxdns-tutorial/data/blocklists`. Restarting nxdns does not re-download it.
## 11. Watch a domain get blocked
```sh
dig @127.0.0.1 -p 15353 doubleclick.net A +noall +answer
```
```
doubleclick.net. 5 IN A 0.0.0.0
```
`0.0.0.0` with a TTL of 5 is a block, not an answer. The default block response is the zero address and the default block TTL is 5 seconds, so a client that caches the answer forgets it quickly after you unblock something.
A name that is not on the list still resolves normally:
```sh
dig @127.0.0.1 -p 15353 wikipedia.org A +noall +answer
```
```
wikipedia.org. 130 IN A 185.15.58.224
```
One thing to know before you try other names: an entry in a hosts list blocks exactly the name it names. `doubleclick.net` is on this list, and so are `ad.doubleclick.net` and `www.google-analytics.com`. `ads.doubleclick.net` is not on it, and nxdns does not block it — the entry for the parent says nothing about the child. Two things do walk up the parent chain, and neither is in play here: a rule you write yourself, with a wildcard pattern such as `*.doubleclick.net`, and an Adblock Plus list's `||doubleclick.net^`, which covers the name and everything under it. This list is a hosts file, so when you pick a domain to test against it, pick one that is literally in the file.
## 12. Open the web interface
Visit <http://127.0.0.1:8080> in a browser. This is the single-page application you built in step 1, served out of the binary. The Overview shows query and block counts, and the Protection page's Sources tab shows the source you added with its domain count. (The endpoints behind those two pages were checked while writing this; the browser page itself was not opened on the verification host.)
## 13. Stop it
In the first terminal, press Ctrl-C.
```
info(nxdns): shutting down
```
nxdns catches SIGINT and SIGTERM, stops serving and exits 0.
Start it again with the same command as in step 6 and read the first log lines:
```
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 2523973 bytes
info(nxdns): authority: database
```
The blocklist came off disk rather than the network. The source you added, the group it is attached to, and the query log all survived the restart because they live in `~/nxdns-tutorial/data`, which is what `authority: database` means in practice.
Press Ctrl-C again to stop this second process. Nothing is listening on 15353 or 8080 now, and nothing of nxdns is running.
## 14. See what the other mode does
You have been running in database mode. The alternative is to hand the same file back as the configuration, which is what `--config` means:
```sh
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
```
```
reconciled '/home/you/nxdns-tutorial/config.zon': sources +0 ~0 -1; group_sources +0 ~0 -1;
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 231 bytes
info(nxdns): authority: file (/home/you/nxdns-tutorial/config.zon)
info(nxdns): nxdns <version> serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1
info(blocklist_manager): pruned orphaned blocklist file 1.allow
info(blocklist_manager): pruned orphaned blocklist file 1.wild
info(blocklist_manager): pruned orphaned blocklist file 1.list
```
**Read that first line.** The blocklist source is gone. That is not a bug — it is the whole contract. The file you wrote in step 3 never mentioned a blocklist source, and in file mode the file is the complete statement of what the configuration is, so anything the database holds that the file does not name is removed at every start. The reconcile said so in one line before doing it.
The three `pruned` lines are the rest of that removal: with the row gone, the compiled files it owned belong to nobody, so the sweep that runs at every start deletes them. The three names are the three bodies one source compiles into — exact domains, wildcards, and the exceptions an Adblock Plus list can lift. The query log is untouched; what changed is the configuration, and it now matches the file exactly.
Neither mode is the "advanced" one. Database mode suits a box someone administers through the web interface. File mode suits a file kept in git and deployed by a tool, where the deployed file being what is running matters more than clicking. To have kept the blocklist here, you would put it in the file:
```zon
.blocklist_sources = .{
.{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack hosts" },
},
.group_sources = .{
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
},
```
Ctrl-C to stop it.
## What you have now
A resolver that answers real queries, a real blocklist of about 98000 domains attached to the default group, a query log, and a web interface — all inside one directory you can delete:
```sh
rm -rf ~/nxdns-tutorial
```
You also saw the three things that surprise people most: a blocklist source does nothing until a group uses it, which authority a start runs under is printed rather than guessed, and in file mode anything the file does not name is removed at the next start.
## Where to go next
- [how-to/install-with-systemd.md](../how-to/install-with-systemd.md) — the same thing as a real service on port 53, including a Raspberry Pi 5.
- [how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md) — put a password on the web interface before it leaves localhost.
- [reference/configuration.md](../reference/configuration.md) — every field you did not set.
- [explanation/configuration-model.md](../explanation/configuration-model.md) — why there are two authority modes and what each one is for.
+153
View File
@@ -0,0 +1,153 @@
# Get started
Run nxdns on your own computer, point a query at it, and watch it block an ad domain from a real blocklist. Everything happens in one directory and on unprivileged ports, so nothing on your machine changes and nothing needs root. At the end you stop it and delete the directory.
You need a Linux machine (x86_64 or aarch64), `curl`, and `dig` (from bind-tools or dnsutils). nxdns is a single static binary with no dependencies.
## 1. Download
Make a directory to work in, download the release for your machine with its checksum file, and check the download:
```sh
mkdir ~/nxdns-try && cd ~/nxdns-try
curl -fLO https://git.mial.net/mokhtar/nxdns/releases/download/v<version>/nxdns-<version>-x86_64-linux-musl.tar.gz
curl -fLO https://git.mial.net/mokhtar/nxdns/releases/download/v<version>/SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txt
```
```
nxdns-<version>-x86_64-linux-musl.tar.gz: OK
```
On a Raspberry Pi or another 64-bit ARM machine, replace `x86_64` with `aarch64` in the URL and in the commands below. The checksum file is signed; [Verify a release](../how-to/verify-a-release.md) shows how to check the signature as well, which is worth doing before a real install.
Unpack it and go into the directory it creates:
```sh
tar -xzf nxdns-<version>-x86_64-linux-musl.tar.gz
cd nxdns-<version>-x86_64-linux-musl
./nxdns version
```
```
nxdns <version>
zig 0.16.0
```
## 2. Write a configuration
nxdns needs two things before it starts: a group called `default`, which every device belongs to until you say otherwise, and an upstream resolver to forward the queries it does not answer itself. Create `config.zon`:
```sh
cat > config.zon <<'EOF'
.{
.dns = .{ .bind_ipv4 = "127.0.0.1", .bind_ipv6 = "::1", .port = 5300 },
.web = .{ .bind = "127.0.0.1", .port = 8080 },
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}
EOF
```
DNS listens on port 5300 instead of 53, and both DNS and the web interface listen on this machine only. Port 53 needs root, which is a decision for a real install, not for a first try. Every field you did not set keeps its default; the [configuration reference](../reference/configuration.md) lists them all.
Check the file:
```sh
./nxdns check --config config.zon
```
```
checking configuration file config.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
```
`check` validates the file and sends one real query to each upstream to confirm it answers. It changes nothing, so you can run it as often as you like.
## 3. Load it and start
Load the file into a new data directory, then start the server:
```sh
./nxdns import config.zon --data-dir data
./nxdns run --data-dir data
```
```
info(migrations): config.db migrated from schema version 0 to 1
imported config.zon
info(querylog_schema): created querylog database 'data/querylog.db'
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 231 bytes
info(nxdns): authority: database
info(nxdns): nxdns <version> serving on udp [::1]:5300 udp 127.0.0.1:5300 tcp [::1]:5300 tcp 127.0.0.1:5300; 1 upstream(s); blocklist generation 1
info(web_server): web interface listening on 127.0.0.1:8080
```
`authority: database` means the configuration now lives in `data/config.db`, and you change it from the web interface. The file has done its job. (nxdns can also treat the file as the configuration at every start; [the configuration model](../explanation/configuration-model.md) explains both modes.)
Leave the server running in this terminal and open a second one.
## 4. Resolve a name
```sh
dig @127.0.0.1 -p 5300 example.com A +noall +answer
```
```
example.com. 214 IN A 104.20.23.154
example.com. 214 IN A 172.66.147.243
```
nxdns forwarded the query to Cloudflare over HTTPS and returned the answer. It blocks nothing yet: the log said `0 of 0 sources loaded`.
## 5. Add a blocklist
Open <http://127.0.0.1:8080> in a browser. There is no password yet, so the Overview opens straight away; [Set up admin authentication](../how-to/set-up-admin-authentication.md) adds one before the interface leaves your machine.
1. Go to **Protection** and open the **Sources** tab.
2. Under **Add source**, set **URL** to `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts` and **Name** to `StevenBlack hosts`, then press **Add source**.
3. Open the **Groups** tab. The `default` group is selected. Under **Assigned sources**, tick **StevenBlack hosts** and press **Save sources**.
4. Back on the **Sources** tab, press **Update now**.
After a few seconds the source shows its size, something like `71,494 domains · 0 wildcards · 0 exceptions`, and the server log says `1 of 1 sources loaded`.
Step 3 matters. A source belongs to the whole installation, and each group picks the sources it uses, which is how one group of devices can get a strict list while another gets none. A source no group uses is downloaded and blocks nothing.
## 6. Watch a domain get blocked
In the browser, go to **Activity** and open the **Live** tab. Then, in your second terminal:
```sh
dig @127.0.0.1 -p 5300 doubleclick.net A +noall +answer
```
```
doubleclick.net. 5 IN A 0.0.0.0
```
`0.0.0.0` is the block answer, with a short TTL so a device forgets it soon after you unblock a domain. The query appears in the Live tab at once, marked **Blocked**.
A domain that is not on the list still resolves normally:
```sh
dig @127.0.0.1 -p 5300 wikipedia.org A +short
```
The **History** tab keeps every query. It is written to disk about once a minute, so give it that long. Then select `doubleclick.net` to see why it was blocked: the decision, the reason (`Blocklist (domain)`), the entry that matched and the list it came from (`StevenBlack hosts`).
A hosts list blocks exactly the names it lists. `doubleclick.net` and `ad.doubleclick.net` are both on this one, but a name the file does not list resolves even when its parent is blocked. To block every name under a domain, add a wildcard rule such as `*.doubleclick.net` under **Rules** on the group; it covers the names below `doubleclick.net`, not `doubleclick.net` itself. An Adblock Plus list's `||doubleclick.net^` entries cover both.
## 7. Stop and clean up
Press Ctrl-C in the first terminal. nxdns logs `shutting down` and exits. Then remove the directory:
```sh
rm -rf ~/nxdns-try
```
## Next
- Install it for real, on port 53 for your whole network: with [Docker](../how-to/install-with-docker.md), with [systemd](../how-to/install-with-systemd.md), or on [NixOS](../how-to/install-with-nix.md).
- [Set up admin authentication](../how-to/set-up-admin-authentication.md) before the web interface is reachable from other machines.
- [The configuration model](../explanation/configuration-model.md) explains the choice between configuring nxdns in the web interface and keeping a file in git.
+37 -14
View File
@@ -89,17 +89,8 @@ const download_budget_s = 300;
pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
const code = serve(runner, args) catch |err| code: {
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
const mapped = failureExitCode(err);
if (mapped == cli.exit_check) {
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
// Ruling 6: after bootstrap seeding died, a fresh database fails
// validation naturally (`NoUsableUpstreams`) and the operator needs
// to be told how a database gets a configuration at all. In file
// mode they already have a file, and the diagnostics above name what
// is wrong with it.
if (args.config == null) cli.writeDbSourceHint(runner.err);
}
break :code mapped;
writeFailureHints(runner.err, err, args.config != null);
break :code failureExitCode(err);
};
// Output the operator never received is not output, so a failed flush
@@ -109,6 +100,18 @@ pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
return code;
}
/// What to do next, for a failure the operator has to fix. A schema refusal is
/// not about the configuration: its log line already names the remedy, and
/// `nxdns check` has nothing to add. `NoUsableUpstreams` in database mode is an
/// unconfigured database, which needs telling how a database gets a
/// configuration at all; in file mode the diagnostics above name what is wrong
/// with the file.
fn writeFailureHints(w: *Writer, err: anyerror, file_mode: bool) void {
if (!faults.isConfigFault(err) or faults.isSchemaRefusal(err)) return;
w.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
if (!file_mode and err == error.NoUsableUpstreams) cli.writeDbSourceHint(w);
}
/// The one classification, from `config/faults.zig`. This file keeps no list of
/// its own: `run` exiting 1 on a seed file `check` and `import` exit 2 on was
/// exactly the cost of the second list that used to be here.
@@ -1656,6 +1659,26 @@ test "a recreate under a long data directory keeps the watermark and a usable na
try deep.access(io, name, .{});
}
test "a failed run points at the next step only where it applies" {
const Case = struct { err: anyerror, file_mode: bool, check: bool, import: bool };
const cases = [_]Case{
.{ .err = error.NoUsableUpstreams, .file_mode = false, .check = true, .import = true },
.{ .err = error.NoUsableUpstreams, .file_mode = true, .check = true, .import = false },
.{ .err = error.MissingDefaultGroup, .file_mode = false, .check = true, .import = false },
.{ .err = error.SchemaTooNew, .file_mode = false, .check = false, .import = false },
.{ .err = error.MigrationFailed, .file_mode = true, .check = false, .import = false },
.{ .err = error.AddressInUse, .file_mode = false, .check = false, .import = false },
};
for (cases) |case| {
var buf: [512]u8 = undefined;
var w: Writer = .fixed(&buf);
writeFailureHints(&w, case.err, case.file_mode);
const text = w.buffered();
try testing.expectEqual(case.check, std.mem.indexOf(u8, text, "nxdns check") != null);
try testing.expectEqual(case.import, std.mem.indexOf(u8, text, cli.db_source_hint) != null);
}
}
test "parseBind refuses a bind address of the wrong family" {
var out_buf: [8]u8 = undefined;
var err_buf: [256]u8 = undefined;
@@ -1760,9 +1783,9 @@ test "a configuration whose blocklist source is in no group imports and checks c
const io = threaded.io();
const gpa = std.testing.allocator;
// A source is created before it is attached — `docs/tutorial/first-run.md`
// POSTs the blocklist and then PUTs the group's sources — so an unattached
// source is a legal intermediate state on every write path. It warns; it
// A source is created before it is attached — the Protection page adds the
// source and then saves the group's sources — so an unattached source is a
// legal intermediate state on every write path. It warns; it
// never fails.
const source: [:0]const u8 =
\\.{
+7
View File
@@ -59,6 +59,13 @@ const faults: []const anyerror = blk: {
break :blk &frozen;
};
/// True for the four schema refusals above: exit 2, but about a database this
/// binary will not touch rather than about the configuration it holds.
pub fn isSchemaRefusal(err: anyerror) bool {
return err == error.SchemaTooNew or err == error.SchemaUnsupported or
err == error.MigrationFailed or err == error.MigrationBackupFailed;
}
/// True when `err` means the configuration the operator supplied is wrong.
/// Linear over a set of about forty errors, on failure paths only.
pub fn isConfigFault(err: anyerror) bool {
+1 -20
View File
@@ -1,32 +1,13 @@
//! Textual-containment guards that keep the hand-written docs honest
//! (milestone-11 ruling 3, repointed at the Diátaxis reference pages by
//! milestone-13 ruling 7). They assert presence, not correctness — the same
//! contract as openapi.zig's route guard.
//! milestone-13 ruling 7). They assert presence, not correctness.
const std = @import("std");
const build_options = @import("build_options");
const docs = @import("docs_files");
const cli = @import("cli.zig");
const routes = @import("web/routes.zig");
const model = @import("config/model.zig");
test "every served operation has its own table row in docs/reference/api.md" {
const gpa = std.testing.allocator;
for (routes.table) |route| {
// Matches one full method + path cell pair ("| GET | `/api/groups` |"),
// so neither a same-path sibling method nor a longer-path prefix can
// satisfy the check for a missing operation.
const needle = try std.fmt.allocPrint(gpa, "| {s} | `{s}` |", .{
@tagName(route.method), route.pattern,
});
defer gpa.free(needle);
if (std.mem.indexOf(u8, docs.reference_api_md, needle) == null) {
std.debug.print("operation row missing from docs/reference/api.md: {s}\n", .{needle});
return error.OperationMissingFromApiDoc;
}
}
}
test "every settings key appears in docs/reference/configuration.md" {
const gpa = std.testing.allocator;
var pairs: std.ArrayList(model.SettingPair) = .empty;
+14 -7
View File
@@ -350,18 +350,18 @@ pub fn openVersioned(
const stamped = std.math.cast(i32, stamped64) orelse {
// `user_version` is a signed 32-bit field, so this cannot come from
// SQLite. Refusing is the same answer any other foreign stamp gets.
refusalLog(path, stamped64, plan, "SchemaUnsupported");
refusalLog(path, stamped64, plan, .SchemaUnsupported);
return error.SchemaUnsupported;
};
const verdict = classify(stamped, plan);
switch (verdict.action) {
.refuse_too_new => {
refusalLog(path, stamped64, plan, "SchemaTooNew");
refusalLog(path, stamped64, plan, .SchemaTooNew);
return error.SchemaTooNew;
},
.refuse_unsupported => {
refusalLog(path, stamped64, plan, "SchemaUnsupported");
refusalLog(path, stamped64, plan, .SchemaUnsupported);
return error.SchemaUnsupported;
},
.open_current, .migrate => {},
@@ -437,12 +437,19 @@ fn closeSlot(handle: *?db.Db) void {
handle.* = null;
}
fn refusalLog(path: []const u8, stamped: i64, plan: Plan, name: []const u8) void {
const Refusal = enum { SchemaTooNew, SchemaUnsupported };
fn refusalLog(path: []const u8, stamped: i64, plan: Plan, comptime refusal: Refusal) void {
const remedy = switch (refusal) {
.SchemaTooNew => "a newer nxdns wrote it. Run that release again, put back the " ++
"querylog.db.pre-migrate-* copy an upgrade left beside it, or move the file " ++
"aside, together with its -wal and -shm files, to start with an empty query history",
.SchemaUnsupported => "this nxdns cannot migrate it. Move the file aside, together " ++
"with its -wal and -shm files, to start with an empty query history",
};
fail("refusing to open querylog database '{s}': it is stamped {d}, and this build " ++
"supports schema versions {d} to {d} ({s}). The file is left exactly as it is; " ++
"see docs/how-to/troubleshoot.md, \"The server refuses to start over querylog.db\"", .{
path, stamped, plan.minimum, plan.current, name,
});
remedy, .{ path, stamped, plan.minimum, plan.current, @tagName(refusal) });
}
/// An additional connection to a `querylog.db` that `open` has already