18 Commits
Author SHA1 Message Date
mokhtar 27e1e497ec platform: the huge pages test skips where the kernel or emulator has no such switch
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 23m56s
Release / guard (push) Successful in 1m47s
Gates / frontend (push) Successful in 2m14s
Gates / test (push) Successful in 2m40s
Gates / test-aarch64 (push) Successful in 5m39s
Gates / package (push) Successful in 47s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 9m20s
Gates / frontend (push) Successful in 2m12s
Release / publish (push) Successful in 10m46s
Gates / test (push) Successful in 2m32s
Gates / test-aarch64 (push) Successful in 6m35s
Gates / package (push) Successful in 4m31s
2026-10-22 20:46:59 +02:00
mokhtar 98283e1569 ci: install the pinned node from its tarball instead of trusting the runner image
Gates / container (push) Successful in 14s
CI / gates (push) Failing after 27m7s
Gates / test (push) Successful in 2m37s
Gates / frontend (push) Successful in 2m6s
Gates / test-aarch64 (push) Failing after 5m59s
Gates / package (push) Successful in 4m35s
2026-10-22 20:43:35 +02:00
mokhtar f8344184e5 build: bump version to 0.0.25
Gates / frontend (push) Failing after 35s
Gates / package (push) Skipped
Gates / container (push) Skipped
Site / deploy (push) Successful in 2m14s
Gates / test (push) Successful in 3m28s
Gates / test-aarch64 (push) Failing after 7m17s
CI / gates (push) Failing after 10m47s
2026-10-22 20:31:25 +02:00
mokhtar 90b022f53e changelog: date the 0.0.25 section 2026-10-22 20:29:19 +02:00
mokhtar 91ed5ae774 server: a query that finds every permit taken waits for one instead of failing, the budget is 64, permit and upstream turns wait on a futex with one thread each, the thread pool has no task limit, and transparent huge pages are off for the process 2026-10-22 20:26:25 +02:00
mokhtar 63cc5f8dc3 server: every dns listener shares one budget of 32 queries, stream listeners take 16 connections, the task pool is capped from those bounds, and work it cannot start is counted by site 2026-10-22 15:24:10 +02:00
mokhtar e4e405f8e9 cache: a byte budget of 256 bytes per entry bounds the cached answers, evicting by clock, and a full or failed copy is counted instead of swallowed 2026-10-22 08:54:59 +02:00
mokhtar d951d03b7d querylog: the writer queue defaults to 1024 entries, and a live stream allocates its ring on subscribe instead of at boot 2026-10-21 22:38:53 +02:00
mokhtar 0cafd770c1 filter: a reload builds the snapshot one source at a time, and the startup pass reloads only when a source changed or was not enforced 2026-10-21 21:41:47 +02:00
mokhtar c2eac8d486 storage: each runtime database connection keeps a page cache sized for its role, read back at open 2026-10-21 21:40:05 +02:00
mokhtar 8f2794533b ci: time limits on the test, test-aarch64, package and container jobs
Gates / frontend (push) Successful in 2m36s
Gates / test (push) Successful in 2m56s
Gates / test-aarch64 (push) Successful in 7m5s
Gates / package (push) Successful in 4m30s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 14m47s
2026-10-21 21:32:40 +02:00
mokhtar 5a51e2d6b9 tests: a failed server check fails the test instead of hanging it, and the tls idle tests leave the handshake room 2026-10-21 21:32:40 +02:00
mokhtar 4916b01571 filter: store each blocklist name once, flagged exact and subdomains, instead of in two sets per source 2026-10-21 20:49:55 +02:00
mokhtar 2a06239676 ci: use the runner image's node instead of setup-node, which downloaded it again on every run
Site / deploy (push) Successful in 2m39s
Gates / test (push) Successful in 2m59s
Gates / frontend (push) Successful in 3m23s
Gates / test-aarch64 (push) Successful in 7m57s
CI / gates (push) Canceled after 22h20m36s
Gates / package (push) Failing after 53s
Gates / container (push) Canceled after 0s
2026-10-21 18:53:42 +02:00
mokhtar 7e0664250b ci: build nxdns.sh and deploy it as a cloudflare worker with static assets
Gates / test (push) Successful in 2m40s
Site / deploy (push) Failing after 2m17s
Gates / frontend (push) Successful in 2m12s
Gates / test-aarch64 (push) Successful in 8m11s
Gates / package (push) Successful in 5m4s
Gates / container (push) Successful in 19s
CI / gates (push) Successful in 16m18s
2026-10-21 17:31:06 +02:00
mokhtar 93e2683e71 website: nxdns.sh on astro and starlight, with the docs and 0.0.24 screenshots
Gates / frontend (push) Failing after 8m6s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test (push) Successful in 2m38s
Gates / test-aarch64 (push) Successful in 8m52s
CI / gates (push) Failing after 11m33s
2026-10-21 17:03:01 +02:00
mokhtar c21e5a9542 docs: rewrite for people running nxdns; query-history refusals name their remedy and a refused start gives no wrong hints 2026-10-21 17:03:00 +02:00
mokhtar fbc8fd7882 specs: memory budget drops the removed performance page and keeps stack_size, a thp artifact explained the 2 mib stacks 2026-10-21 17:02:03 +02:00
141 changed files with 13769 additions and 4932 deletions
+30 -10
View File
@@ -50,10 +50,12 @@ env:
# Exact patch, not a floating "24" (milestone-14 ruling 12): the bundled npm
# and the emitted bundle change under a floating major.
NODE_VERSION: "24.19.0"
# node-v24.19.0-linux-x64.tar.gz, as nodejs.org's SHASUMS256.txt lists it.
NODE_TARBALL_SHA256: "f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4"
# The npm the cut runs and the npm CI runs must be one version: npm writes the
# admin bundle whose bytes the release hashes are pinned to before CI ever
# rebuilds them. setup-node installs the npm that ships with the node above,
# so this pin is asserted, not installed.
# rebuilds them. The tarball above bundles this npm, so this pin is asserted,
# not installed.
NPM_VERSION: "11.17.0"
# There is deliberately no CI_VERSION literal here. Ruling 2 allows the
# version to exist in the tag and in build.zig.zon and nowhere else, and
@@ -66,6 +68,9 @@ env:
jobs:
test:
runs-on: ubuntu-24.04
# Twice the slowest green run (4.7 min). A test that hangs instead of failing
# held the runner for two hours without a limit.
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -88,6 +93,8 @@ jobs:
test-aarch64:
runs-on: ubuntu-24.04
# Twice the slowest green run (10.4 min).
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -126,20 +133,29 @@ jobs:
# 1536Mi dind limit, and it runs no docker command — so it overlaps the
# heavy runner's zig and image work instead of queueing behind it.
runs-on: ubuntu-24.04-light
# The slowest green run took 4 minutes. A stalled Node download leaves setup-node
# holding an open socket after its retry succeeds, so the step never exits:
# without a limit the job held the runner for over an hour.
# The slowest green run took 4 minutes. Without a limit, a request stalled
# on the network once held the runner for over an hour.
timeout-minutes: 8
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
# Installed from the official tarball, pinned by its digest: the runner
# image's Node moves with the image tag, and a rebuilt image on a newer
# Node failed the assertion below on 2026-10-22.
- name: Install the pinned Node
run: |
curl -fsSLo "${RUNNER_TEMP:?}/node.tar.gz" "https://nodejs.org/dist/v${NODE_VERSION:?}/node-v${NODE_VERSION:?}-linux-x64.tar.gz"
echo "${NODE_TARBALL_SHA256:?} ${RUNNER_TEMP:?}/node.tar.gz" | sha256sum -c -
mkdir "${RUNNER_TEMP:?}/node"
tar -xzf "${RUNNER_TEMP:?}/node.tar.gz" -C "${RUNNER_TEMP:?}/node" --strip-components=1
echo "${RUNNER_TEMP:?}/node/bin" >> "${GITHUB_PATH:?}"
- name: Cache npm downloads
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: admin/package-lock.json
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('admin/package-lock.json') }}
# The bundle is a release input whose hash is pinned before this run
# exists, so a runner on a different node or npm must fail here rather
@@ -215,6 +231,8 @@ jobs:
package:
needs: [frontend]
runs-on: ubuntu-24.04
# Twice the slowest green run (10.8 min).
timeout-minutes: 22
# The container job needs the version and installs no Zig, so it cannot read
# build.zig.zon the way this job does.
@@ -383,6 +401,8 @@ jobs:
container:
needs: [package]
runs-on: ubuntu-24.04
# Twice the slowest green run (5.5 min).
timeout-minutes: 11
env:
# Ruling 2 and ruling 5: one parse of build.zig.zon per run, done in the
+16 -5
View File
@@ -68,6 +68,7 @@ env:
ZIG_TARBALL_SHA256: "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00"
# Exact patch, not a floating "24" (ruling 12).
NODE_VERSION: "24.19.0"
NODE_TARBALL_SHA256: "f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4"
NPM_VERSION: "11.17.0"
# The author's commit- and tag-signing key. `git verify-tag` alone proves
@@ -271,12 +272,22 @@ jobs:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./zig-out/bin/release resolve
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
# Installed from the official tarball, pinned by its digest: the runner
# image's Node moves with the image tag, and a rebuilt image on a newer
# Node failed the assertion below on 2026-10-22.
- name: Install the pinned Node
run: |
curl -fsSLo "${RUNNER_TEMP:?}/node.tar.gz" "https://nodejs.org/dist/v${NODE_VERSION:?}/node-v${NODE_VERSION:?}-linux-x64.tar.gz"
echo "${NODE_TARBALL_SHA256:?} ${RUNNER_TEMP:?}/node.tar.gz" | sha256sum -c -
mkdir "${RUNNER_TEMP:?}/node"
tar -xzf "${RUNNER_TEMP:?}/node.tar.gz" -C "${RUNNER_TEMP:?}/node" --strip-components=1
echo "${RUNNER_TEMP:?}/node/bin" >> "${GITHUB_PATH:?}"
- name: Cache npm downloads
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: admin/package-lock.json
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('admin/package-lock.json') }}
# The same pinned toolchain and normalized environment as the frontend
# job in gates.yml: the cut pinned the bundle's bytes into flake.nix
+52
View File
@@ -0,0 +1,52 @@
name: Site
# Builds nxdns.sh and deploys it as a Cloudflare Worker serving static assets,
# configured in website/wrangler.jsonc, domain included. Cloudflare has no Gitea
# integration, so the build runs here and wrangler uploads it.
#
# The site publishes docs/ and CHANGELOG.md as they stand, and reads the release
# from build.zig.zon, so a change to any of them changes the site.
#
# Secret CLOUDFLARE_API_TOKEN: a token from the "Edit Cloudflare Workers"
# template, limited to this account and the nxdns.sh zone. Variable
# CLOUDFLARE_ACCOUNT_ID.
on:
push:
branches: [master]
paths:
- "website/**"
- "docs/**"
- "CHANGELOG.md"
- "build.zig.zon"
- ".gitea/workflows/site.yml"
workflow_dispatch:
concurrency:
group: site
cancel-in-progress: false
env:
# The newest wrangler older than npm's release-age floor on the day it was pinned.
WRANGLER_VERSION: "4.148.0"
jobs:
deploy:
runs-on: ubuntu-24.04-light
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
# Node is the one the runner image puts on PATH. setup-node is not used:
# it looks for Node in an empty tool cache and downloads it again.
- name: Build
working-directory: website
run: |
npm ci
npm run build
- name: Deploy
working-directory: website
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
run: npx --yes "wrangler@${WRANGLER_VERSION:?}" deploy --message "${GITHUB_SHA:?}"
+23
View File
@@ -4,6 +4,29 @@ 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.
## [0.0.25] - 2026-10-22
### Fixed
- **The DNS cache's memory is bounded in bytes, not only in entries.** Each cached answer is copied at whatever size the upstream sent, so 10000 entries could in principle hold 10000 large answers. The answers now share a budget of 256 bytes per `cache.size` entry, about 2.4 MiB at the default, and when a new answer does not fit, answers nobody has asked for lately make room for it. An answer larger than the whole budget is not cached. `/metrics` reports `nxdns_cache_response_bytes` against `nxdns_cache_max_bytes`, and counts `nxdns_cache_oversized_total` and `nxdns_cache_copy_failures_total`.
- **The DNS cache no longer evicts an answer while it has free room.** A new answer replaced an older one whenever the eviction scan met it first, even with most of the cache empty. A copy of an answer that failed for lack of memory was also dropped without a trace; it is now counted, and a failed replacement keeps the answer already cached.
- **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
- **Blocklists take less memory, and a refresh peaks lower.** A list that names a domain both on its own and with its subdomains, as Adblock-style lists do for every entry, now stores that name once instead of twice. A reload reads one list's compiled files at a time instead of all of them at once, and the startup pass no longer rebuilds the blocklists when no list was due for a refresh.
- **The query-history buffer defaults to 1024 queries** instead of 10000, under 2 MiB instead of about 17. It only holds queries that arrive while a write is in progress.
- **A live query stream takes memory only while someone watches it.** Each of the 32 possible streams reserved its buffer at startup, about 3.6 MiB in all. A stream that cannot get its buffer is refused with its own message and counted in `nxdns_live_stream_allocation_failures_total`.
- **All DNS listeners share one budget of 64 queries at a time**, about 4.6 MiB, where each UDP and TCP listener used to reserve room for 64 of its own, about 27 MiB across the four. The Pi's busiest moment over a day was 27 queries at once. A query beyond the budget waits for a free one instead of failing: over UDP it waits in the system's socket buffer, over TCP, DoT and DoH for at most the connection's idle time. `/metrics` counts the waits as `nxdns_dns_query_permit_waits_total`, their time as `nxdns_dns_query_permit_wait_seconds_total` and stream waits that ran out as `nxdns_dns_query_permit_wait_timeouts_total`, shows `nxdns_dns_query_permits_waiting`, and reports what the system holds and drops for the UDP/53 sockets as `nxdns_udp_server_receive_queue_bytes` and `nxdns_udp_server_kernel_drops_total`. TCP, DoT and DoH each accept 16 connections at a time instead of 64, and the DoT and DoH listeners now report `rejected_at_capacity_total` too.
- **A query waiting for its turn at an upstream holds one thread instead of three**, so a burst grows the server's thread pool less, and transparent huge pages are switched off for the process, so a thread's stack no longer holds 2 MiB on systems that enable them for everything. Work that could not get a thread used to fail as a generic error or not be counted at all; `/metrics` now counts it as `nxdns_concurrency_unavailable_total`, by where it happened.
- **Each database connection keeps a page cache sized for its job** instead of SQLite's 2 MiB default on all seven: 256 KiB for the configuration database and for retention, 1 MiB for the query-history writer and reader.
- **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.
### Removed
- **`nxdns_udp_server_dropped_no_slot_total`**, because a UDP query is no longer dropped for lack of room: it waits. A query still waiting when the listener shuts down is counted in the new `nxdns_udp_server_dropped_at_shutdown_total`.
## [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
+5 -4
View File
@@ -56,7 +56,8 @@ Serves a household LAN (≈2–20 devices). Portfolio-grade public repo with ext
Zig 0.16 stdlib is built on the `std.Io` interface: `std.Io.net` owns networking (`std.net` in its old form is gone) and `std.http.Client` requires an `io: Io`. Therefore:
- **`Io` is the injected platform abstraction.** Every component that does I/O takes `io: Io`. No project-owned wrapper interfaces around it — a second abstraction over an abstraction with one consumer is waste.
- **Backend: `std.Io.Threaded`** — the mature, debuggable path; at ≤20 devices throughput is a non-issue. The originally planned io_uring config flag was dropped in milestone 11: `std.Io.Evented` at Zig 0.16.0 stubs the networking a server needs — listen, accept, connect, lookup, and stream reads/writes return `error.NetworkDown` (`Uring.zig` netListenIp/netAccept/netConnectIp/netRead/netWrite) — so a selectable backend would boot a dead server. The code stays backend-agnostic by construction; revisit when std ships working evented networking.
- **Backend: `std.Io.Threaded`** — the mature, debuggable path; at ≤20 devices throughput is a non-issue. The originally planned io_uring config flag was dropped in milestone 11: `std.Io.Evented` at Zig 0.16.0 stubs the networking a server needs — listen, accept, connect, lookup, and stream reads/writes return `error.NetworkDown` (`Uring.zig` netListenIp/netAccept/netConnectIp/netRead/netWrite), and a receive inside a batch panics with `TODO` — so a selectable backend would boot a dead server. A replacement or hybrid backend of our own would bring runtime-level scheduling, cancellation, timer and transport adapters whose complexity is out of proportion to a household workload, so nxdns stays on `std.Io.Threaded`.
- **Work is bounded where it enters, not in the thread pool** (specs/memory-budget.md item 3). Every DNS listener draws from one budget of 64 query permits, and each stream listener accepts at most 16 connections. A query that finds no permit waits for one rather than being dropped or answered SERVFAIL; UDP's backlog stays in the kernel socket buffer, whose drops are exported. The `Threaded` pool has no task limit and grows to the peak the bounds allow; its workers are never retired. Waits for a permit sleep on a futex with a deadline, one thread each.
- No hand-written thread pool. `Io` async/concurrent/Group covers task management.
- Core domain modules (`dns`, `filter`, `cache`) stay pure: no `Io`, no sockets — bytes in, bytes out. Only servers, upstream clients, and storage touch `Io`.
@@ -92,7 +93,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)
@@ -489,7 +490,7 @@ The sketch above is the original shape; `src/storage/querylog_schema.zig` is the
### 11.4 Query Logger
- In-memory buffer, mutex guarded, hard cap `query_log_buffer_max` (default 10000).
- In-memory buffer, mutex guarded, hard cap `query_log_buffer_max` (default 1024, from the measured queue peak; see specs/memory-budget.md item 4).
- Flush: batch size (100, comptime) or max interval `query_log_flush_interval_s` (default 60s, 0–3600, 0 = do not wait). One transaction per interval: at household query rates a per-query commit costs orders of magnitude more disk writes than the rows are worth. The interval is also roughly what a crash costs, while the writer is healthy and the disk gate is open — a gated or lock-delayed batch is older, so it is a normal case, not a bound.
- Privacy transforms (hide_domains / hide_client_ips) applied before persist + SSE fanout.
- Backpressure: buffer full → drop oldest unflushed entry, increment monotonic `queries_dropped` (exposed in `/api/health` + `/metrics`). SSE fanout precedes buffer insert, so live viewers still see dropped-from-persistence entries.
@@ -554,7 +555,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
+1 -1
View File
@@ -1,6 +1,6 @@
.{
.name = .nxdns,
.version = "0.0.24",
.version = "0.0.25",
.minimum_zig_version = "0.16.0",
.paths = .{""},
.fingerprint = 0x3307b311dded1d91,
+1 -1
View File
@@ -5,7 +5,7 @@ services:
# from a tag runs that tag's image. NXDNS_VERSION picks another release. To
# run a locally built image instead, build it first (see
# deploy/docker/Dockerfile) and set NXDNS_IMAGE=nxdns.
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-0.0.24}}
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-0.0.25}}
restart: unless-stopped
# `run --config` makes the file the sole source of configuration: nxdns
# reconciles the database onto ./etc-nxdns/config.zon at every start, and
+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 for the cached answers grows with it, and when that memory is full, answers nobody has asked for lately make room for new ones. |
| `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 | 1024 | 1–37449 | How many queries nxdns holds in memory while it waits to write them, each about 1.8 KiB. The default covers over a minute and a half of queries at 10 a second, far more than a household asks while a write is in progress. When the buffer is full, the oldest waiting query is dropped from the history and counted. |
| `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.
+3 -3
View File
@@ -8,10 +8,10 @@
let
# `zig build cut` rewrites the lines between the delimiters by text replacement.
# BEGIN GENERATED BY zig build cut
version = "0.0.24";
version = "0.0.25";
hashes = {
"aarch64-linux" = "sha256-nsjFtsFlUJHp3AJj7ccrXmgBq1QK2tRAvmdofeTqc2E=";
"x86_64-linux" = "sha256-VxDq+UiOWPO4Wyhwdgky1X0QVAqeDgAqD7KeqbTLOJ8=";
"aarch64-linux" = "sha256-zK2i51cRX+5q+9rIRav4m6NhVZJJYwg9OJj0WstKd/M=";
"x86_64-linux" = "sha256-tJ0/mA0Zhic0SqYhwarNKLlK0s5QBQEzN/52QUGxYv0=";
};
# END GENERATED BY zig build cut
+23 -16
View File
@@ -1,6 +1,6 @@
# Memory budget: pay for what the household uses
Measured on the deployed Pi (0.0.21, aarch64, 2.85M blocklist entries, 18 days up): VmRSS 238 MiB, VmHWM 358 MiB, 207 threads, almost all private anonymous memory. `docs/explanation/performance-and-testing.md` promises "memory with ~1M blocked domains < 100 MiB". Nothing leaks. Every MiB below has a measured owner, and each owner pays for a worst case the household never reaches, or pays twice.
Measured on the deployed Pi (0.0.21, aarch64, 2.85M blocklist entries, 18 days up): VmRSS 238 MiB, VmHWM 358 MiB, 207 threads, almost all private anonymous memory. The docs promised "memory with ~1M blocked domains < 100 MiB" (`docs/explanation/performance-and-testing.md`, removed in the user-docs rewrite); the Pi never met it. Nothing leaks. Every MiB below has a measured owner, and each owner pays for a worst case the household never reaches, or pays twice.
| Owner | Resident | How it was measured |
|---|---|---|
@@ -22,24 +22,24 @@ Why freed small blocks stay resident: with libc linked, `malloc` is zig's `lib/c
- Fix each owner at its root. Do not bypass the safe-build 0xaa fill: it only makes the oversizing visible.
- Do not change the allocator in this work. Replacing `init.gpa` does not move SQLite, which calls C `malloc`. Revisit only if the measurement gate still names small-class slabs as a top owner.
- No compiled-format change, no config-key migration, no new deadline mechanism. Each of those was considered and rejected in review: the format change makes the upgrade depend on the network, packed query-log rings cost more complexity than sizing the queue, and a socket-shutdown deadline cannot bound `connect` (`std.Io.Threaded` has no connect timeout) or the pool admission wait.
- No compiled-format change, no config-key migration, no socket-shutdown deadline. Each of those was considered and rejected in review: the format change makes the upgrade depend on the network, packed query-log rings cost more complexity than sizing the queue, and a socket-shutdown deadline cannot bound `connect` (`std.Io.Threaded` has no connect timeout) or a wait for a permit. Permit waits take their deadline on a futex instead (item 3).
- Not `SQLITE_CONFIG_PAGECACHE`, not a database page-size migration.
## Contract
### 0. Make the budget measurable first
The admission budget, the listener caps, the stack size and the queue size are chosen from the Pi's real peaks, so those peaks become measurements before anything is resized:
The admission budget, the listener caps and the queue size are chosen from the Pi's real peaks, so those peaks become measurements before anything is resized:
- high-water of in-flight DNS queries: `nxdns_dns_in_flight_peak`, counted around `Handler.handle`, every listener together;
- high-water of active stream connections, per listener (today `connections` counts accepts over time, not concurrency): `nxdns_tcp_server_connections_peak` (the larger of the two address families), `nxdns_dot_server_connections_peak`, `nxdns_doh_server_connections_peak`;
- high-water of the `std.Io.Threaded` worker pool: the process thread count. `Threaded` spawns a worker only when every worker is busy and never retires one (`Threaded.zig:2110`), so the count is that high-water already and needs no code. It counts workers, not tasks: `async` work can run inline or wait in the run queue;
- high-water of the query-log writer queue's occupancy (not arrivals per interval: a full batch flushes early, and a disk gate or a slow write lets backlog build past one interval): `nxdns_querylog_queue_peak`. `std.Io.Queue` exposes no length, so a count beside it is raised before each insert and lowered after each removal; it can overstate the peak by the entries in transit, never understate it;
- worker call-stack use, read from `/proc/<pid>/smaps`: each worker stack is an anonymous mapping directly above its own guard page, all of one size, and its `Rss` is what the thread has touched and still holds resident. That finds the expensive paths; it is not a depth bound. Before item 3 lowers `stack_size`, each expensive path gets a measured stack high-water, and the size keeps headroom above the deepest one. The signal stack (`std_options.signal_stack_size`) is a separate allocation for signal handling and stack traces and keeps its stdlib default unless its own use is measured.
- worker call-stack use, read from `/proc/<pid>/smaps`: each worker stack is an anonymous mapping directly above its own guard page, all of one size, and its `Rss` is what the thread has touched and still holds resident. It is a record, not an input: a worker stack costs only the pages it touches, so `stack_size` is address space and lowering it saves no resident memory. `stack_size` keeps its default. The signal stack (`std_options.signal_stack_size`) is a separate allocation for signal handling and stack traces and keeps its stdlib default unless its own use is measured.
The five gauges are in `/metrics` beside the existing per-reason drop counters. One release carrying only these measurements runs on the Pi before the budget values are fixed.
First reading, static x86_64 musl build, one burst of 300 UDP queries: the in-flight peak reached 64 (the UDP slot pool), threads went from 14 to 205, and of 201 worker stacks 175 touched 32 kB or less while 26 touched 2 MiB each — the same 2 MiB with only forward-zone traffic and no DoT exchange, so the TLS client is not the owner. Item 3 identifies that owner before it lowers `stack_size`: a stack smaller than that path needs is a crash, not a saving.
First reading, static x86_64 musl build, one burst of 300 UDP queries: the in-flight peak reached 64 (the UDP slot pool), threads went from 14 to 205, and of 201 worker stacks 175 touched 32 kB or less while 26 touched 2 MiB each — the same 2 MiB with only forward-zone traffic and no DoT exchange, so the TLS client is not the owner. That 2 MiB was transparent huge pages, not a code path: the build host runs THP in `always` mode and backs a touched 2 MiB-aligned stack range with one huge page. The Pi runs `madvise`. Pi reading on 0.0.22 after 31 h (16 KiB pages): of 91 worker stacks, 6 hold 32 kB, 2 hold 80–96 kB, 73 hold 192 kB and 10 hold 208–256 kB, none with a huge page. The resident cost of the pool is therefore about 192 kB of stack per worker, which item 3 reduces by bounding the worker count.
### 1. Store each blocklist name once in memory
@@ -56,12 +56,19 @@ Within one source, as compiled entries, an exact `x` in `.list` together with `x
### 3. Bound concurrent DNS work
- `main` takes `std.process.Init.Minimal` and owns its `std.Io.Threaded`, with `stack_size` set from the item-0 stack measurement and a `concurrent_limit` derived from the chosen query and connection caps, the tasks each can nest, and the fixed services. The measured task high-water already counts every caller, so it is the cross-check, not a term in the sum.
- `ConcurrencyUnavailable` is counted at every boundary where it can surface. The spec work enumerates each `io.concurrent` and `Group.concurrent` call; today the logger turns the error into an early flush without counting it (`src/storage/logger.zig:748`) and the listener races fold it into a generic failure (`src/server/listener.zig:102`). Each gets its own counter or a reason on an existing one.
- Two kinds of bound: one cap on active DNS queries, shared by every listener (UDP and TCP, both families, DoT/DoH when enabled), and a slot cap per stream listener on accepted connections, each from the item-0 high-waters. A query takes a permit only while it does work; a keep-alive connection releases its permit between requests.
- UDP keeps today's counted drop when no permit is free (`dropped_no_slot`). The kernel buffer does not absorb overload invisibly.
- The upstream exchange keeps its whole-operation `Select` deadline and its distinction between peer result, budget expiry and cancellation. The thread high-water falls because the query budget is smaller, not because the race changes.
- UDP slots and TCP connection buffers are sized from these bounds, not 64 per listener.
- Two kinds of bound: one budget of query permits, shared by every listener (UDP and TCP, both families, DoT/DoH when enabled), and a slot cap per stream listener on accepted connections. A query takes a permit only while it does work; a keep-alive connection releases its permit between requests.
- The work is bounded where it enters, not in the thread pool. `main` keeps `std.process.Init` and its `std.Io.Threaded` sets no `concurrent_limit`: a limit below the tasks the admitted work can nest turns a burst into `ConcurrencyUnavailable` failures inside admitted queries, and a limit sized to cover them bounds nothing the budget does not already bound. `ConcurrencyUnavailable` therefore means the system refused a thread or a task could not be allocated, and it is counted at every boundary where it can surface as `nxdns_concurrency_unavailable_total{site}` (`platform/concurrency.zig`).
- A query that finds every permit taken waits for one; it is neither dropped nor answered SERVFAIL. glibc's stub retries a SERVFAIL once at once and then fails the lookup, and a dropped UDP query costs the client its whole retry timeout, while a wait behind a permit that frees within the upstream deadline is invisible to it. Every held permit is bounded (the upstream total deadline, the stream write budget), so a wait always ends.
- UDP: the receive loop receives into its own staging buffer, then waits for a permit holding that datagram, and receives nothing meanwhile. The backlog stays in the socket's kernel buffer, bounded by `SO_RCVBUF`; what overflows it is dropped by the kernel and exported from `/proc/net/udp{,6}` by socket inode as `nxdns_udp_server_kernel_drops_total`, with the queued bytes as `nxdns_udp_server_receive_queue_bytes` and a failed read as `nxdns_udp_server_kernel_read_failures_total`.
- Streams: a whole query read off the connection waits at most the connection's idle budget. A wait that outlasts it ends the connection unanswered, because DNS-over-TCP has no framing for "no answer"; DoH closes too.
- Every wait is counted: `nxdns_dns_query_permit_waits_total`, `nxdns_dns_query_permit_wait_seconds_total`, `nxdns_dns_query_permit_wait_timeouts_total` and the gauge `nxdns_dns_query_permits_waiting`.
- A listener that is closing wakes the budget's waiters, so its `deinit` does not wait for a permit to free.
- A query waits on its own thread. The budget and the upstream pool's per-entry admission sleep on a futex word with the caller's deadline (`platform/wake_word.zig`) instead of racing a condition wait against a timer task, which cost two extra threads per waiter. A waiter that leaves without a permit hands its wake on if a permit is free.
- The upstream exchange keeps its whole-operation deadline race and its distinction between peer result, budget expiry and cancellation.
- Stream listeners keep `listener.race` for their idle reads, writes and handshakes: three threads per open connection, at most 16 per listener. Removing those two threads needs a non-blocking, socket-aware TLS and HTTP transport with its own deadline, reset and partial-I/O handling; std's timed file reads do not provide one (a blocking syscall follows the poll, and `ECONNRESET` on a timed write is an `errnoBug`). That transport costs more than the threads it saves at household scale.
- The process opts out of transparent huge pages at startup (`PR_SET_THP_DISABLE`, `platform/huge_pages.zig`), with one warning if the kernel refuses. With THP set to `always`, a worker stack that touches a few KiB holds a 2 MiB page and keeps it, because workers are never retired: a 64-query burst on such a host left about 40 of 200 workers at 2 MiB each.
- What this costs, stated plainly: a burst beyond the budget is answered later, not faster; a UDP burst that also outruns the socket buffer loses datagrams to the kernel, counted; a stream query can wait out its idle budget and get no answer; an upstream in backoff is still probed, so a dead upstream costs each query its deadline and ends in SERVFAIL from the handler, as before.
- As built: 64 query permits for the whole process (`query_budget.permits`), each holding the datagram, the reply buffer and the handler scratch, about 4.6 MiB in all. The Pi's `nxdns_dns_in_flight_peak` was 27 over a day of household traffic; 64 simultaneous uncached queries on two DoT upstreams (x86_64 musl ReleaseSafe) were all answered, the slowest in 0.2 s, and grew the pool from 14 to 205 threads, which is the all-active envelope of 64 × 3 plus the fixed services, about 40 MiB of stack at the measured 200 KiB high-water per worker. 16 connections per DNS stream listener (`listener.dns_max_connections`), a policy rather than a measurement: the Pi's TCP peak was 0 and it runs neither DoT nor DoH.
### 4. Size the query-log buffers by use
@@ -75,13 +82,13 @@ Within one source, as compiled entries, an exact `x` in `.list` together with `x
### 6. DNS cache bounded in bytes
- `DnsCache` keeps its entry count and adds a budget on live response bytes, tracked separately from `memoryBytes` (which also counts slots and index). `put` evicts by CLOCK until the copy fits. A response larger than the whole budget is not cached and is counted. A replacement keeps the old entry if allocating the new copy fails. A failed copy allocation is counted; today `handler.zig:918` discards it with `catch {}` and `DnsCache.Stats` has no field for it. The answer already obtained is still sent. The budget, the live bytes and both counters appear in cache diagnostics.
- New config key `cache.max_bytes`, through the config field checklist. Default from the Pi's measured live response bytes.
- The budget is not a config key (owner, 2026-10-21: an operator has no basis for choosing it). It follows `cache.size`: 256 bytes per entry (about 2.4 MiB at the default 10000), about 2.5 times the Pi's measured average of 100 bytes per cached response, and never less than one 65535-byte message, so a small cache still caches any answer a large one would. The live bytes and the budget are reported as `nxdns_cache_response_bytes` and `nxdns_cache_max_bytes`.
## Propagation
- Config: `cache.max_bytes`; the changed default of `logging.query_log_buffer_max`. Config field checklist for the new key: model key + round-trip drift guards, validation + reference, settings API view, openapi.yaml, admin types + settings control, contract samples regenerated, docs config reference, CHANGELOG.
- Config: the changed default of `logging.query_log_buffer_max`. No new key (item 6).
- Diagnostics: the item-0 counters, `ConcurrencyUnavailable`, oversized cache responses, cache live bytes and budget.
- Docs: `performance-and-testing.md` states the measured RSS, refresh peak and thread count at the Pi's blocklist size, replacing the unmeasured target.
- Docs: the user docs make no memory claim. The measured RSS, refresh peak and thread count at the Pi's blocklist size go into the measurement record below and the release's CHANGELOG entry.
## Acceptance
@@ -89,9 +96,9 @@ Within one source, as compiled entries, an exact `x` in `.list` together with `x
- [ ] Builder: a reload holds at most one source's bodies (tested through an allocator that records peak bytes).
- [ ] Boot: one snapshot build when no source refreshes; listeners never start without the initial load having run.
- [ ] Measurements: each of the five item-0 gauges rises under a scripted load and is visible in `/metrics`; the thread count and the worker stack residency are recorded from `/proc` beside them.
- [ ] Bounds: with the query budget full, a further UDP query is dropped and counted; a stream listener at its slot cap refuses and counts; a keep-alive connection between requests holds no query permit; `ConcurrencyUnavailable` is counted at each enumerated boundary, including the logger wait and the listener races.
- [ ] Bounds: with the query budget full, a further UDP query waits and is answered once a permit frees, and the wait is counted; a stream query waits at most its idle budget, and a timeout ends the connection and is counted; closing a listener ends its waits without a permit freeing; a waiter canceled or timed out hands its wake on; a stream listener at its slot cap refuses and counts; a keep-alive connection between requests holds no query permit; the UDP sockets' kernel queue and drops are exported; `ConcurrencyUnavailable` is counted at each enumerated boundary, including the logger wait and the listener races.
- [ ] SSE: no ring is allocated without a subscriber; an allocation failure is counted, reported as its own error and leaves the slot reusable; overflow still ends the stream.
- [ ] SQLite: each runtime handle reports its configured `cache_size`; migrations open without it.
- [ ] DNS cache: puts beyond the byte budget evict; live bytes never exceed it; an oversized response is skipped and counted; a failed replacement keeps the old entry; a failed copy allocation is counted and the answer is still sent.
- [ ] Suites: `zig build test` and `-Dintegration` 0 failed; admin typecheck/vitest/prettier clean; contract samples regenerated.
- [ ] Measurement record, on the Pi with the same blocklists: steady VmRSS after 24 h, VmHWM across one scheduled refresh (including its concurrent download and compile), thread count, and a decoded heap dump per size class, each beside the baseline in the table above. The owner chose a record, not a pass threshold: each owner must be reduced as specified, and the numbers go into `performance-and-testing.md`. If small-class slabs remain a top owner, the allocator question reopens with that data.
- [ ] Measurement record, on the Pi with the same blocklists: steady VmRSS after 24 h, VmHWM across one scheduled refresh (including its concurrent download and compile), thread count, and a decoded heap dump per size class, each beside the baseline in the table above. The owner chose a record, not a pass threshold: each owner must be reduced as specified, and the numbers go into this spec beside the baseline and into the release's CHANGELOG entry. If small-class slabs remain a top owner, the allocator question reopens with that data.
+89 -57
View File
@@ -35,6 +35,7 @@ const cert_store = @import("server/cert_store.zig");
const cli = @import("cli.zig");
const client_names = @import("server/client_names.zig");
const clients = @import("server/clients.zig");
const concurrency = @import("platform/concurrency.zig");
const config_export = @import("config/export.zig");
const db = @import("storage/db.zig");
const disk_monitor = @import("storage/disk_monitor.zig");
@@ -47,6 +48,7 @@ const fetcher = @import("filter/fetcher.zig");
const forward_zones = @import("local/forward_zones.zig");
const handler = @import("server/handler.zig");
const http_util = @import("web/http_util.zig");
const huge_pages = @import("platform/huge_pages.zig");
const loader = @import("config/loader.zig");
const local_records = @import("local/records.zig");
const local_tables = @import("server/local_tables.zig");
@@ -57,6 +59,7 @@ const migrations = @import("storage/migrations.zig");
const model = @import("config/model.zig");
const pause = @import("server/pause.zig");
const queries_repo = @import("storage/repositories/queries_repo.zig");
const query_budget = @import("server/query_budget.zig");
const query_sink = @import("server/query_sink.zig");
const querylog_migrations = @import("storage/querylog_migrations.zig");
const querylog_schema = @import("storage/querylog_schema.zig");
@@ -89,17 +92,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 +103,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.
@@ -387,6 +393,11 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
const gpa = r.gpa;
const paths = args.paths;
// First, so no mapping the server makes is backed by a huge page.
huge_pages.disable() catch |err| {
log.warn("transparent huge pages stay enabled ({t}); worker stacks may each hold 2 MiB", .{err});
};
// -----------------------------------------------------------------------
// storage and configuration
// -----------------------------------------------------------------------
@@ -394,14 +405,13 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
var data = try cli.DataDir.open(io, gpa, paths.data_dir, true);
defer data.close(io, gpa);
var config_db = try data.openConfigDb(io);
var config_db = try data.openMigratedConfigDb(io);
defer config_db.close();
_ = try migrations.migrate(&config_db);
// Opened unconditionally, not gated on `cfg.web.enabled`: diagnostics record
// what went wrong whether or not anyone is running the UI, and the store
// owns this connection outright (`storage/events.zig`).
var events_db = try data.openConfigDb(io);
var events_db = try data.openRuntimeDb(io, .config);
defer events_db.close();
const boot_now_s = std.Io.Clock.real.now(io).toSeconds();
var event_store_storage: ?events.Store = events.Store.init(io, &events_db, boot_now_s) catch |err| blk: {
@@ -582,16 +592,10 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// Milestone 8 fans every logged query out to the SSE hub as well. The hub
// exists only when the web interface does (ruling 6) — without it the sink
// costs the query path one null check. Its rings are ~900 KiB, so it lives
// on the heap and initializes in place; a by-value init would copy the
// whole of it through this frame.
var hub: ?*sse.Hub = null;
defer if (hub) |hub_ptr| gpa.destroy(hub_ptr);
if (cfg.web.enabled) {
const created = try gpa.create(sse.Hub);
created.init();
hub = created;
}
// costs the query path one null check.
var hub_storage: sse.Hub = .init(gpa);
defer hub_storage.deinit();
const hub: ?*sse.Hub = if (cfg.web.enabled) &hub_storage else null;
// -----------------------------------------------------------------------
// disk, retention and the remaining connections (ruling 21)
@@ -653,21 +657,21 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer log_controller.deinit(io);
var sink: query_sink.QuerySink = .init(&log_controller, hub);
var querylog_retention_db = try data.reopenQuerylogDb(io);
var querylog_retention_db = try data.openRuntimeDb(io, .querylog_retention);
defer querylog_retention_db.close();
var tracker_db = try data.openConfigDb(io);
var tracker_db = try data.openRuntimeDb(io, .config);
defer tracker_db.close();
// The web task's own two connections (ruling 26; m7 ruling 21: one SQLite
// connection per task), opened only when the web interface is (ruling 6).
// `reopenQuerylogDb` requires the file `openQuerylogDb` established above.
// A query-log role requires the file `openQuerylogDb` established above.
var web_config_db: ?db.Db = null;
defer if (web_config_db) |*database| database.close();
var web_querylog_db: ?db.Db = null;
defer if (web_querylog_db) |*database| database.close();
if (cfg.web.enabled) {
web_config_db = try data.openConfigDb(io);
web_querylog_db = try data.reopenQuerylogDb(io);
web_config_db = try data.openRuntimeDb(io, .config);
web_querylog_db = try data.openRuntimeDb(io, .querylog_reader);
}
var sessions: ?auth.Sessions = if (cfg.web.enabled) .init(cfg.web.session_ttl_hours) else null;
@@ -722,7 +726,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
const both = blk: {
const c = try gpa.create(dns_cache.DnsCache);
errdefer gpa.destroy(c);
c.* = try .init(gpa, cfg.cache);
c.* = try .init(gpa, .sized(cfg.cache.size));
errdefer c.deinit();
const l = try gpa.create(rate_limiter.RateLimiter);
@@ -796,10 +800,15 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
var failed_listeners: [2][]const u8 = undefined;
var failed_listener_count: usize = 0;
// Declared before every listener, so its deinit runs after theirs: each
// listener returns its permits before it is gone.
var budget: query_budget.QueryBudget = try .init(gpa, query_budget.permits);
defer budget.deinit();
var doh: ?doh_server.DohServer = null;
defer if (doh) |*server| server.deinit(io);
if (doh_certs) |*store| {
doh = bindDoh(gpa, io, cfg.doh_server, &h, store, event_store, boot_now_s);
doh = bindDoh(gpa, io, cfg.doh_server, &h, &budget, store, event_store, boot_now_s);
if (doh == null) {
failed_listeners[failed_listener_count] = "doh";
failed_listener_count += 1;
@@ -809,7 +818,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
var dot: ?dot_server.DotServer = null;
defer if (dot) |*server| server.deinit(io);
if (dot_certs) |*store| {
dot = bindDot(gpa, io, cfg.dot_server, &h, store, event_store, boot_now_s);
dot = bindDot(gpa, io, cfg.dot_server, &h, &budget, store, event_store, boot_now_s);
if (dot == null) {
failed_listeners[failed_listener_count] = "dot";
failed_listener_count += 1;
@@ -887,30 +896,30 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4", .ip4) catch |err| return err;
// IPv6 first, and the order is load-bearing — see `Listeners`.
var udp6: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
var udp6: ?udp_server.UdpServer = udp_server.UdpServer.bind(io, v6_bind, &h, &budget) catch |err| bound: {
if (!ipv6Unavailable(err)) return reportBind(r, "udp", v6_bind, err);
log.warn("this system has no IPv6; serving IPv4 only", .{});
config_load.note("dns.bind_ipv6", "dns.bind_ipv6", "this system has no IPv6; serving IPv4 only");
break :bound null;
};
defer if (udp6) |*s| s.deinit(gpa, io);
defer if (udp6) |*s| s.deinit(io);
const v6_is_wildcard = udp6 != null and isWildcard(v6_bind);
var udp4: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
var udp4: ?udp_server.UdpServer = udp_server.UdpServer.bind(io, v4_bind, &h, &budget) catch |err| bound: {
if (err != error.AddressInUse or !v6_is_wildcard) return reportBind(r, "udp", v4_bind, err);
log.info("the IPv6 UDP listener is dual-stack and already serves IPv4", .{});
break :bound null;
};
defer if (udp4) |*s| s.deinit(gpa, io);
defer if (udp4) |*s| s.deinit(io);
var tcp6: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
var tcp6: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v6_bind, &h, &budget, .{}) catch |err| bound: {
if (!ipv6Unavailable(err)) return reportBind(r, "tcp", v6_bind, err);
break :bound null;
};
defer if (tcp6) |*s| s.deinit(io);
var tcp4: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
var tcp4: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v4_bind, &h, &budget, .{}) catch |err| bound: {
if (err != error.AddressInUse or !(tcp6 != null and isWildcard(v6_bind))) {
return reportBind(r, "tcp", v4_bind, err);
}
@@ -941,6 +950,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
udp_count += 1;
}
web_state.udp_listeners = udp_listeners[0..udp_count];
web_state.query_budget = &budget;
var tcp_listeners: [2]*tcp_server.TcpServer = undefined;
var tcp_count: usize = 0;
@@ -995,27 +1005,27 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
log_controller.shutdown(io);
}
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
if (udp4) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
if (tcp6) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
if (tcp4) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
if (doh) |*s| try group.concurrent(io, doh_server.DohServer.serve, .{ s, io });
if (dot) |*s| try group.concurrent(io, dot_server.DotServer.serve, .{ s, io });
if (doh_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io });
if (dot_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io });
if (udp6) |*s| try concurrency.counted(.service, group.concurrent(io, udp_server.UdpServer.serve, .{ s, io }));
if (udp4) |*s| try concurrency.counted(.service, group.concurrent(io, udp_server.UdpServer.serve, .{ s, io }));
if (tcp6) |*s| try concurrency.counted(.service, group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io }));
if (tcp4) |*s| try concurrency.counted(.service, group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io }));
if (doh) |*s| try concurrency.counted(.service, group.concurrent(io, doh_server.DohServer.serve, .{ s, io }));
if (dot) |*s| try concurrency.counted(.service, group.concurrent(io, dot_server.DotServer.serve, .{ s, io }));
if (doh_certs) |*store| try concurrency.counted(.service, group.concurrent(io, cert_store.CertStore.watch, .{ store, io }));
if (dot_certs) |*store| try concurrency.counted(.service, group.concurrent(io, cert_store.CertStore.watch, .{ store, io }));
try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db, gate, event_store });
try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io, event_store });
try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io });
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate, &client_names_resolver });
try group.concurrent(io, runMaintenance, .{ &h, if (web_limiter) |*l| l else null, io });
try concurrency.counted(.service, group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db, gate, event_store }));
try concurrency.counted(.service, group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io, event_store }));
try concurrency.counted(.service, group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io }));
try concurrency.counted(.service, group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate, &client_names_resolver }));
try concurrency.counted(.service, group.concurrent(io, runMaintenance, .{ &h, if (web_limiter) |*l| l else null, io }));
// Started last (ruling 26), canceled by the same `group.cancel`; its inner
// connection group is canceled, not awaited (ruling 4), so an idle
// keep-alive client cannot hold shutdown open. A web bind failure is not
// fatal: `web_server.serve` warns and returns, and the DNS side — the thing
// this box exists for — keeps serving.
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
if (cfg.web.enabled) try concurrency.counted(.service, group.concurrent(io, web_server.serve, .{ &web_state, io }));
logStartup(io, authority, &manager, upstream_count, .{
.udp6 = if (udp6) |*s| s.boundAddress() else null,
@@ -1115,6 +1125,7 @@ fn bindDoh(
io: std.Io,
endpoint: model.TlsEndpoint,
h: *handler.Handler,
budget: *query_budget.QueryBudget,
store: *cert_store.CertStore,
diagnostics: ?*events.Store,
now_s: i64,
@@ -1124,7 +1135,7 @@ fn bindDoh(
reportListener(diagnostics, io, now_s, "doh", "bind '{s}' is not an IP address", .{endpoint.bind});
return null;
};
const server = doh_server.DohServer.listen(gpa, io, bind_address, h, store, .{}) catch |err| {
const server = doh_server.DohServer.listen(gpa, io, bind_address, h, budget, store, .{}) catch |err| {
log.warn("doh listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
reportListener(diagnostics, io, now_s, "doh", "cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
return null;
@@ -1138,6 +1149,7 @@ fn bindDot(
io: std.Io,
endpoint: model.TlsEndpoint,
h: *handler.Handler,
budget: *query_budget.QueryBudget,
store: *cert_store.CertStore,
diagnostics: ?*events.Store,
now_s: i64,
@@ -1147,7 +1159,7 @@ fn bindDot(
reportListener(diagnostics, io, now_s, "dot", "bind '{s}' is not an IP address", .{endpoint.bind});
return null;
};
const server = dot_server.DotServer.listen(gpa, io, bind_address, h, store, .{}) catch |err| {
const server = dot_server.DotServer.listen(gpa, io, bind_address, h, budget, store, .{}) catch |err| {
log.warn("dot listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
reportListener(diagnostics, io, now_s, "dot", "cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
return null;
@@ -1656,6 +1668,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 +1792,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 =
\\.{
+263 -66
View File
@@ -21,7 +21,6 @@ const std = @import("std");
const packet = @import("../dns/packet.zig");
const record = @import("../dns/record.zig");
const types = @import("../dns/types.zig");
const model = @import("../config/model.zig");
const Allocator = std.mem.Allocator;
@@ -244,9 +243,27 @@ fn soaTtls(p: packet.Packet) ?SoaTtls {
return null;
}
/// Field-for-field the cache section of the config model. Aliasing it keeps one
/// type for one concept: `size` counts entries, not bytes (ruling 7).
pub const Config = model.Cache;
pub const Config = struct {
/// Entries, not bytes (ruling 7): `cache.size`.
size: u32,
/// The budget on stored response bytes.
max_bytes: usize,
/// The response bytes each entry is budgeted. The Pi's cached answers
/// averaged about 100 bytes; most answers fit a classic 512-byte datagram,
/// and the allowance is an average across entries, not a per-entry cap.
pub const bytes_per_entry = 256;
/// The budget never refuses an answer the largest DNS message could carry,
/// so a small `cache.size` still caches what a large one would.
pub const min_bytes = std.math.maxInt(u16);
/// The configuration for `size` entries: the byte budget follows the entry
/// count rather than being a setting of its own.
pub fn sized(size: u32) Config {
return .{ .size = size, .max_bytes = @max(@as(usize, size) * bytes_per_entry, min_bytes) };
}
};
pub const Stats = struct {
hits: u64 = 0,
@@ -257,6 +274,11 @@ pub const Stats = struct {
/// Entries dropped because aging their bytes made them unparsable. A
/// poisoned entry never serves twice.
invalid_hits: u64 = 0,
/// Responses larger than the whole byte budget. Answered, never cached.
oversized: u64 = 0,
/// Copies the allocator refused. The answer was still sent, and a key
/// already cached kept its previous entry.
copy_failures: u64 = 0,
};
/// The key lives inline so that a slot owns no memory besides its response
@@ -271,12 +293,16 @@ const Slot = struct {
negative: bool,
referenced: bool,
occupied: bool,
/// The next free slot while this one is free; `no_slot` ends the list.
next_free: u32,
fn keySlice(self: *const Slot) []const u8 {
return self.key[0..self.key_len];
}
};
const no_slot = std.math.maxInt(u32);
/// Key bytes to slot number. The stored key slices point into the slots array,
/// which is allocated once and never moves.
const Index = std.StringHashMapUnmanaged(u32);
@@ -288,6 +314,12 @@ pub const DnsCache = struct {
/// The CLOCK hand: the next slot the replacement scan will examine.
hand: u32,
occupied: u32,
/// Free slots, so an insert never evicts while one is unused.
free_head: u32,
/// The budget on `live_bytes`, which counts the stored responses alone:
/// the slots and the index are sized by `size` and reserved at `init`.
max_bytes: usize,
live_bytes: usize,
stats: Stats,
/// Allocates the slots and the index up front. `ensureTotalCapacity` at the
@@ -296,7 +328,7 @@ pub const DnsCache = struct {
pub fn init(gpa: Allocator, config: Config) Allocator.Error!DnsCache {
const slots = try gpa.alloc(Slot, config.size);
errdefer gpa.free(slots);
for (slots) |*slot| {
for (slots, 0..) |*slot, i| {
slot.* = .{
.key = undefined,
.key_len = 0,
@@ -306,6 +338,7 @@ pub const DnsCache = struct {
.negative = false,
.referenced = false,
.occupied = false,
.next_free = if (i + 1 < slots.len) @intCast(i + 1) else no_slot,
};
}
@@ -319,6 +352,9 @@ pub const DnsCache = struct {
.index = index,
.hand = 0,
.occupied = 0,
.free_head = if (slots.len == 0) no_slot else 0,
.max_bytes = config.max_bytes,
.live_bytes = 0,
.stats = .{},
};
}
@@ -334,42 +370,59 @@ pub const DnsCache = struct {
/// Stores a clone of `response` under `key`. A key already present is
/// replaced in place, which keeps the entry count and the CLOCK order
/// unchanged.
/// unchanged. Entries are evicted by CLOCK until the clone fits the byte
/// budget.
///
/// The clone never advertises a TTL that outlives the entry: a negative
/// response has its SOA TTL lowered to the entry's lifetime, and a positive
/// one has every record above `max_ttl_seconds` lowered to that ceiling. The
/// caller's buffer is not touched.
///
/// A cache configured with zero entries stores nothing. That is caching
/// switched off, not a failure, so it is not an error.
/// Nothing here fails the query that produced `response`, which has already
/// been answered. A response over the whole budget and a clone the
/// allocator refuses are each counted in `stats` and leave the cache as it
/// was. A cache configured with zero entries stores nothing: caching
/// switched off, not a failure.
pub fn put(
self: *DnsCache,
now_s: i64,
key: []const u8,
response: []const u8,
class: Class,
) Allocator.Error!void {
) void {
std.debug.assert(key.len <= max_key_len);
if (self.slots.len == 0) return;
if (response.len > self.max_bytes) {
self.stats.oversized += 1;
return;
}
const bytes = try self.gpa.dupe(u8, response);
errdefer self.gpa.free(bytes);
// Cloned before anything is released, so a refused allocation leaves a
// replaced key's previous entry serving.
const bytes = self.gpa.dupe(u8, response) catch {
self.stats.copy_failures += 1;
return;
};
if (class.negative) clampSoaTtl(bytes, class.ttl_seconds) else capRecordTtls(bytes);
const expires_at_s = now_s +| @as(i64, class.ttl_seconds);
if (self.index.get(key)) |existing| {
const slot = &self.slots[existing];
self.live_bytes -= slot.bytes.len;
self.gpa.free(slot.bytes);
slot.bytes = &.{};
self.reclaimBytes(now_s, bytes.len, existing);
slot.bytes = bytes;
slot.stored_at_s = now_s;
slot.expires_at_s = expires_at_s;
slot.negative = class.negative;
self.live_bytes += bytes.len;
self.stats.inserts += 1;
return;
}
self.reclaimBytes(now_s, bytes.len, null);
const index = self.acquireSlot(now_s);
const slot = &self.slots[index];
@memcpy(slot.key[0..key.len], key);
@@ -387,6 +440,7 @@ pub const DnsCache = struct {
gop.value_ptr.* = index;
self.occupied += 1;
self.live_bytes += bytes.len;
self.stats.inserts += 1;
}
@@ -461,24 +515,36 @@ pub const DnsCache = struct {
/// The index term follows its layout of a key array, a value array and one
/// metadata byte per bucket.
pub fn memoryBytes(self: *const DnsCache) usize {
var total = self.slots.len * @sizeOf(Slot);
for (self.slots) |*slot| {
if (slot.occupied) total += slot.bytes.len;
}
total += @as(usize, self.index.capacity()) *
(@sizeOf([]const u8) + @sizeOf(u32) + 1);
return total;
return self.slots.len * @sizeOf(Slot) + self.live_bytes +
@as(usize, self.index.capacity()) * (@sizeOf([]const u8) + @sizeOf(u32) + 1);
}
/// Returns a slot ready for a new entry, freeing whatever held it.
/// A free slot for a new entry, evicting one by CLOCK when none is free.
fn acquireSlot(self: *DnsCache, now_s: i64) u32 {
if (self.free_head == no_slot) self.evictOne(now_s, null);
const index = self.free_head;
self.free_head = self.slots[index].next_free;
return index;
}
/// Evicts by CLOCK until `needed` more bytes fit the budget. `keep` is the
/// slot being replaced, whose bytes are already out of `live_bytes`.
fn reclaimBytes(self: *DnsCache, now_s: i64, needed: usize, keep: ?u32) void {
std.debug.assert(needed <= self.max_bytes);
while (self.live_bytes + needed > self.max_bytes) self.evictOne(now_s, keep);
}
/// Releases one occupied slot other than `keep`, chosen by CLOCK.
///
/// An expired slot met on the way is reclaimed as an expiration rather than
/// an eviction: it was already dead, and counting it as an eviction would
/// report cache pressure that is not there.
fn acquireSlot(self: *DnsCache, now_s: i64) u32 {
fn evictOne(self: *DnsCache, now_s: i64, keep: ?u32) void {
const capacity: u32 = @intCast(self.slots.len);
// Every occupied slot is examined at most twice: the first pass clears
// the reference bits, the second finds a slot with none set.
// Every candidate is examined at most twice: the first pass clears the
// reference bits, the second finds a slot with none set. A candidate
// exists, because both callers only get here while an entry other than
// `keep` holds bytes or a slot.
const limit = @as(usize, capacity) * 2;
var steps: usize = 0;
@@ -487,11 +553,11 @@ pub const DnsCache = struct {
self.hand = (self.hand + 1) % capacity;
const slot = &self.slots[index];
if (!slot.occupied) return index;
if (!slot.occupied or index == keep) continue;
if (now_s >= slot.expires_at_s) {
self.release(index);
self.stats.expirations += 1;
return index;
return;
}
if (slot.referenced) {
slot.referenced = false;
@@ -499,7 +565,7 @@ pub const DnsCache = struct {
}
self.release(index);
self.stats.evictions += 1;
return index;
return;
}
unreachable;
}
@@ -509,11 +575,14 @@ pub const DnsCache = struct {
std.debug.assert(slot.occupied);
const removed = self.index.remove(slot.keySlice());
std.debug.assert(removed);
self.live_bytes -= slot.bytes.len;
self.gpa.free(slot.bytes);
slot.bytes = &.{};
slot.key_len = 0;
slot.occupied = false;
slot.referenced = false;
slot.next_free = self.free_head;
self.free_head = index;
self.occupied -= 1;
}
};
@@ -798,7 +867,7 @@ test "classify refuses every other rcode" {
}
test "put and get round-trip a response with aged ttls" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(8));
defer cache.deinit();
var response_buf: [512]u8 = undefined;
@@ -808,7 +877,7 @@ test "put and get round-trip a response with aged ttls" {
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, response, class);
cache.put(1000, key, response, class);
try testing.expectEqual(@as(u32, 1), cache.len());
var out: [512]u8 = undefined;
@@ -829,7 +898,7 @@ test "put and get round-trip a response with aged ttls" {
}
test "a cached positive response caps its stored ttls at the sanity ceiling" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit();
var response_buf: [512]u8 = undefined;
@@ -839,7 +908,7 @@ test "a cached positive response caps its stored ttls at the sanity ceiling" {
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, response, class);
cache.put(1000, key, response, class);
// The over-ceiling record comes back at the ceiling; its shorter sibling is
// untouched, because a downstream cache holds each record for its own TTL.
@@ -856,7 +925,7 @@ test "a cached positive response caps its stored ttls at the sanity ceiling" {
}
test "a positive response under the ceiling is stored byte for byte" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit();
var response_buf: [512]u8 = undefined;
@@ -864,7 +933,7 @@ test "a positive response under the ceiling is stored byte for byte" {
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, response, classify(response, 3600).?);
cache.put(1000, key, response, classify(response, 3600).?);
// A TTL exactly at the ceiling is not rewritten, so a hit at elapsed zero
// returns the caller's bytes unchanged.
@@ -873,7 +942,7 @@ test "a positive response under the ceiling is stored byte for byte" {
}
test "a cached negative response serves the SOA ttl the entry expires at" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit();
// The fixture's SOA record TTL is 3600 and its MINIMUM is 600, so the entry
@@ -884,7 +953,7 @@ test "a cached negative response serves the SOA ttl the entry expires at" {
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, nxdomain_bytes, class);
cache.put(1000, key, nxdomain_bytes, class);
var out: [512]u8 = undefined;
const fresh = cache.get(1000, key, &out).?;
@@ -900,38 +969,38 @@ test "a cached negative response serves the SOA ttl the entry expires at" {
}
test "the stored SOA ttl follows negative_ttl_max and is never raised" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 120 });
var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit();
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
var out: [512]u8 = undefined;
try cache.put(0, key, nxdomain_bytes, classify(nxdomain_bytes, 120).?);
cache.put(0, key, nxdomain_bytes, classify(nxdomain_bytes, 120).?);
try testing.expectEqual(@as(u32, 120), try firstAuthoritySoaTtl(cache.get(0, key, &out).?));
// An SOA record TTL already below the entry's lifetime keeps its own value.
var short_buf: [nxdomain_bytes.len]u8 = undefined;
const short = nxdomainWithSoaTtls(&short_buf, 60, 600);
try cache.put(0, key, short, classify(short, 3600).?);
cache.put(0, key, short, classify(short, 3600).?);
try testing.expectEqual(@as(u32, 60), try firstAuthoritySoaTtl(cache.get(0, key, &out).?));
// A positive response has no SOA to clamp and keeps every answer TTL.
var answer_buf: [512]u8 = undefined;
const answer = try buildAnswer(&answer_buf, &.{ 300, 900 });
try cache.put(0, key, answer, classify(answer, 3600).?);
cache.put(0, key, answer, classify(answer, 3600).?);
try testing.expectEqual(@as(u32, 300), try firstAnswerTtl(cache.get(0, key, &out).?));
}
test "an out buffer too small for the response is a miss" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit();
var response_buf: [512]u8 = undefined;
const response = try buildAnswer(&response_buf, &.{300});
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(0, key, response, classify(response, 3600).?);
cache.put(0, key, response, classify(response, 3600).?);
var small: [8]u8 = undefined;
try testing.expectEqual(@as(?[]u8, null), cache.get(0, key, &small));
@@ -941,14 +1010,14 @@ test "an out buffer too small for the response is a miss" {
}
test "an expired entry is a miss and is freed on access" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit();
var response_buf: [512]u8 = undefined;
const response = try buildAnswer(&response_buf, &.{60});
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, response, classify(response, 3600).?);
cache.put(1000, key, response, classify(response, 3600).?);
var out: [512]u8 = undefined;
try testing.expect(cache.get(1059, key, &out) != null);
@@ -970,12 +1039,12 @@ test "aging that breaks the packet drops the entry once" {
"\x00\x00\x01\x00\x01\x00\x01\x41\x00\x00\x04\x01\x02\x03\x04" ++
"\xc0\x12\x00\x01\x00\x01\x00\x00\x00\x64\x00\x04\x05\x06\x07\x08";
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit();
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(0, key, poisoned, classify(poisoned, 3600).?);
cache.put(0, key, poisoned, classify(poisoned, 3600).?);
var out: [512]u8 = undefined;
try testing.expectEqual(@as(?[]u8, null), cache.get(1, key, &out));
@@ -985,7 +1054,7 @@ test "aging that breaks the packet drops the entry once" {
}
test "CLOCK evicts the unreferenced entry and keeps the referenced one" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 2, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(2));
defer cache.deinit();
var response_buf: [512]u8 = undefined;
@@ -999,13 +1068,13 @@ test "CLOCK evicts the unreferenced entry and keeps the referenced one" {
const b = buildKey(&buf_b, "b.example.com", 1, 1, false, null);
const c = buildKey(&buf_c, "c.example.com", 1, 1, false, null);
try cache.put(0, a, response, class);
try cache.put(0, b, response, class);
cache.put(0, a, response, class);
cache.put(0, b, response, class);
var out: [512]u8 = undefined;
try testing.expect(cache.get(0, a, &out) != null);
try cache.put(0, c, response, class);
cache.put(0, c, response, class);
try testing.expectEqual(@as(u32, 2), cache.len());
try testing.expectEqual(@as(u64, 1), cache.stats.evictions);
try testing.expect(cache.get(0, a, &out) != null);
@@ -1014,7 +1083,7 @@ test "CLOCK evicts the unreferenced entry and keeps the referenced one" {
}
test "replacing a key in place does not grow the cache" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit();
var first_buf: [512]u8 = undefined;
@@ -1025,8 +1094,8 @@ test "replacing a key in place does not grow the cache" {
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, first, classify(first, 3600).?);
try cache.put(2000, key, second, classify(second, 3600).?);
cache.put(1000, key, first, classify(first, 3600).?);
cache.put(2000, key, second, classify(second, 3600).?);
try testing.expectEqual(@as(u32, 1), cache.len());
try testing.expectEqual(@as(u64, 2), cache.stats.inserts);
@@ -1040,7 +1109,7 @@ test "replacing a key in place does not grow the cache" {
}
test "sweep removes only the expired entries" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(8));
defer cache.deinit();
var short_buf: [512]u8 = undefined;
@@ -1053,8 +1122,8 @@ test "sweep removes only the expired entries" {
const a = buildKey(&buf_a, "short.example.com", 1, 1, false, null);
const b = buildKey(&buf_b, "long.example.com", 1, 1, false, null);
try cache.put(0, a, short, classify(short, 3600).?);
try cache.put(0, b, long, classify(long, 3600).?);
cache.put(0, a, short, classify(short, 3600).?);
cache.put(0, b, long, classify(long, 3600).?);
try testing.expectEqual(@as(u32, 0), cache.sweep(59));
try testing.expectEqual(@as(u32, 2), cache.len());
@@ -1074,7 +1143,7 @@ test "sweep removes only the expired entries" {
test "a slot freed by eviction is reusable many times over" {
// The index deletes and reinserts on every eviction; this fails loudly if
// deletion ever stopped returning capacity to it.
var cache = try DnsCache.init(testing.allocator, .{ .size = 2, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(2));
defer cache.deinit();
var response_buf: [512]u8 = undefined;
@@ -1085,7 +1154,7 @@ test "a slot freed by eviction is reusable many times over" {
var key_buf: [max_key_len]u8 = undefined;
for (0..500) |i| {
const qname = try std.fmt.bufPrint(&name_buf, "host{d}.example.com", .{i});
try cache.put(0, buildKey(&key_buf, qname, 1, 1, false, null), response, class);
cache.put(0, buildKey(&key_buf, qname, 1, 1, false, null), response, class);
try testing.expect(cache.len() <= 2);
}
try testing.expectEqual(@as(u32, 2), cache.len());
@@ -1093,7 +1162,7 @@ test "a slot freed by eviction is reusable many times over" {
}
test "a cache of zero entries stores nothing" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 0, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(0));
defer cache.deinit();
var response_buf: [512]u8 = undefined;
@@ -1101,7 +1170,7 @@ test "a cache of zero entries stores nothing" {
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(0, key, response, classify(response, 3600).?);
cache.put(0, key, response, classify(response, 3600).?);
try testing.expectEqual(@as(u32, 0), cache.len());
var out: [512]u8 = undefined;
@@ -1109,7 +1178,7 @@ test "a cache of zero entries stores nothing" {
}
test "memoryBytes covers the slots, the responses and the index" {
var cache = try DnsCache.init(testing.allocator, .{ .size = 4, .negative_ttl_max = 3600 });
var cache = try DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit();
const empty_bytes = cache.memoryBytes();
@@ -1119,23 +1188,151 @@ test "memoryBytes covers the slots, the responses and the index" {
const response = try buildAnswer(&response_buf, &.{300});
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(0, key, response, classify(response, 3600).?);
cache.put(0, key, response, classify(response, 3600).?);
try testing.expectEqual(empty_bytes + response.len, cache.memoryBytes());
}
fn initAndPut(gpa: Allocator, response: []const u8) !void {
var cache = try DnsCache.init(gpa, .{ .size = 16, .negative_ttl_max = 3600 });
fn initUnderFailure(gpa: Allocator) !void {
var cache = try DnsCache.init(gpa, .sized(16));
cache.deinit();
}
test "init survives every allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, initUnderFailure, .{});
}
fn keyFor(buf: *[max_key_len]u8, domain: []const u8) []const u8 {
return buildKey(buf, domain, 1, 1, false, null);
}
const positive_300: Class = .{ .ttl_seconds = 300, .negative = false };
test "puts beyond the byte budget evict, and the live bytes never exceed it" {
var response_buf: [512]u8 = undefined;
const response = try buildAnswer(&response_buf, &.{300});
var cache = try DnsCache.init(testing.allocator, .{
.size = 16,
.max_bytes = @intCast(3 * response.len),
});
defer cache.deinit();
var name_buf: [32]u8 = undefined;
var key_buf: [max_key_len]u8 = undefined;
for (0..10) |i| {
const domain = try std.fmt.bufPrint(&name_buf, "n{d}.example.com", .{i});
cache.put(0, keyFor(&key_buf, domain), response, positive_300);
try testing.expect(cache.live_bytes <= cache.max_bytes);
}
// The entry count never bound: the bytes did.
try testing.expectEqual(@as(u32, 3), cache.len());
try testing.expectEqual(@as(u64, 7), cache.stats.evictions);
try testing.expectEqual(3 * response.len, cache.live_bytes);
}
test "a response larger than the whole budget is skipped, counted, and displaces nothing" {
var small_buf: [512]u8 = undefined;
const small = try buildAnswer(&small_buf, &.{300});
var large_buf: [512]u8 = undefined;
const large = try buildAnswer(&large_buf, &.{ 300, 300, 300, 300 });
var cache = try DnsCache.init(testing.allocator, .{
.size = 4,
.max_bytes = @intCast(small.len),
});
defer cache.deinit();
var key_buf: [max_key_len]u8 = undefined;
const key = buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(0, key, response, classify(response, 3600).?);
try cache.put(0, key, response, classify(response, 3600).?);
const key = keyFor(&key_buf, "example.com");
cache.put(0, key, small, positive_300);
cache.put(0, key, large, positive_300);
try testing.expectEqual(@as(u64, 1), cache.stats.oversized);
var out: [512]u8 = undefined;
try testing.expectEqualSlices(u8, small, cache.get(0, key, &out).?);
}
test "init and put survive every allocation failure" {
test "a replacement the allocator refuses keeps the old entry and is counted" {
var first_buf: [512]u8 = undefined;
const first = try buildAnswer(&first_buf, &.{300});
var second_buf: [512]u8 = undefined;
const second = try buildAnswer(&second_buf, &.{ 300, 300 });
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{});
var cache = try DnsCache.init(failing.allocator(), .sized(4));
defer cache.deinit();
var key_buf: [max_key_len]u8 = undefined;
const key = keyFor(&key_buf, "example.com");
cache.put(0, key, first, positive_300);
failing.fail_index = failing.alloc_index;
cache.put(0, key, second, positive_300);
try testing.expectEqual(@as(u64, 1), cache.stats.copy_failures);
try testing.expectEqual(first.len, cache.live_bytes);
var out: [512]u8 = undefined;
try testing.expectEqualSlices(u8, first, cache.get(0, key, &out).?);
}
test "a replacement that needs room evicts others, never itself" {
var small_buf: [512]u8 = undefined;
const small = try buildAnswer(&small_buf, &.{300});
var large_buf: [512]u8 = undefined;
const large = try buildAnswer(&large_buf, &.{ 300, 300 });
var cache = try DnsCache.init(testing.allocator, .{
.size = 4,
.max_bytes = @intCast(small.len + large.len - 1),
});
defer cache.deinit();
var key_a: [max_key_len]u8 = undefined;
var key_b: [max_key_len]u8 = undefined;
const a = keyFor(&key_a, "a.example.com");
const b = keyFor(&key_b, "b.example.com");
cache.put(0, a, small, positive_300);
cache.put(0, b, small, positive_300);
// Growing `b` to `large` no longer fits beside `a`.
cache.put(0, b, large, positive_300);
var out: [512]u8 = undefined;
try testing.expectEqual(@as(?[]u8, null), cache.get(0, a, &out));
try testing.expectEqualSlices(u8, large, cache.get(0, b, &out).?);
try testing.expectEqual(large.len, cache.live_bytes);
}
test "an insert takes a free slot instead of evicting a live entry" {
var response_buf: [512]u8 = undefined;
const response = try buildAnswer(&response_buf, &.{300});
try testing.checkAllAllocationFailures(testing.allocator, initAndPut, .{response});
var short_buf: [512]u8 = undefined;
const short = try buildAnswer(&short_buf, &.{10});
var cache = try DnsCache.init(testing.allocator, .sized(3));
defer cache.deinit();
var keys: [5][max_key_len]u8 = undefined;
const a = keyFor(&keys[0], "a.example.com");
const b = keyFor(&keys[1], "b.example.com");
const c = keyFor(&keys[2], "c.example.com");
const d = keyFor(&keys[3], "d.example.com");
const e = keyFor(&keys[4], "e.example.com");
cache.put(0, a, response, positive_300);
cache.put(0, b, response, positive_300);
cache.put(0, c, short, .{ .ttl_seconds = 10, .negative = false });
// Full: `d` evicts `a`, and the hand moves on to `b`.
cache.put(0, d, response, positive_300);
try testing.expectEqual(@as(u64, 1), cache.stats.evictions);
// `c` expires, freeing a slot behind the hand.
var out: [512]u8 = undefined;
try testing.expectEqual(@as(?[]u8, null), cache.get(20, c, &out));
cache.put(20, e, response, positive_300);
try testing.expectEqual(@as(u64, 1), cache.stats.evictions);
try testing.expect(cache.get(20, b, &out) != null);
try testing.expect(cache.get(20, d, &out) != null);
try testing.expect(cache.get(20, e, &out) != null);
}
+28 -1
View File
@@ -335,10 +335,14 @@ pub const DataDir = struct {
/// Returns the whole `OpenResult`, recreate reason and aside name included:
/// the composition root records a recreate as a one-shot diagnostics event,
/// and the aside name is what tells an operator where the old file went.
///
/// The connection is the query-log writer's, made a runtime connection
/// once the schema step is done.
pub fn openQuerylogDb(self: *const DataDir, io: std.Io) !querylog_schema.OpenResult {
var opened = try querylog_schema.open(io, std.Io.Dir.cwd(), self.querylog_db_path);
errdefer opened.database.close();
try self.restrictQuerylogPermissions(io);
try db.initRuntimeConnection(&opened.database, .querylog_writer);
return opened;
}
@@ -352,6 +356,29 @@ pub const DataDir = struct {
return querylog_schema.reopen(io, std.Io.Dir.cwd(), self.querylog_db_path);
}
/// The server's own `config.db` connection: migrated to the current schema,
/// then made a runtime connection. CLI commands migrate through
/// `openConfigDb` and keep SQLite's default cache.
pub fn openMigratedConfigDb(self: *const DataDir, io: std.Io) !db.Db {
var database = try self.openConfigDb(io);
errdefer database.close();
_ = try migrations.migrate(&database);
try db.initRuntimeConnection(&database, .config);
return database;
}
/// A further connection for the running server, its page cache sized for
/// `role`. A query-log role requires the file `openQuerylogDb` established.
pub fn openRuntimeDb(self: *const DataDir, io: std.Io, role: db.Role) !db.Db {
var database = switch (role) {
.config => try self.openConfigDb(io),
.querylog_writer, .querylog_retention, .querylog_reader => try self.reopenQuerylogDb(io),
};
errdefer database.close();
try db.initRuntimeConnection(&database, role);
return database;
}
/// A sidecar that does not exist yet is not a failure: `-wal` and `-shm`
/// appear when SQLite first writes, and the next call catches them.
fn restrictQuerylogPermissions(self: *const DataDir, io: std.Io) !void {
@@ -1011,7 +1038,7 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
.priority = server.priority,
.enabled = true,
.health = .init,
.admission = .{ .permits = slots.len },
.admission = .{ .permits = .init(slots.len) },
.reuse_recoveries = &recoveries,
}};
var single: pool.Pool = .init(&entries, .{}, timeouts, seed);
+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 {
+5 -1
View File
@@ -232,7 +232,11 @@ pub const LogOutput = enum {
pub const Logging = struct {
level: LogLevel = .info,
retention_days: u16 = 30,
query_log_buffer_max: u32 = 10000,
/// The queue is allocated in full, about 1.8 KiB an entry. It only holds
/// queries that arrive while a batch commits or the disk gate waits: the
/// Pi's peak was 7 over 37 hours at 0.35 queries a second, so 1024 is
/// over a minute and a half of queries at 10 a second.
query_log_buffer_max: u32 = 1024,
/// How long the query-log writer gathers entries before it commits them.
/// `0` does not wait at all: it flushes the entry that woke the writer plus
/// whatever is already queued.
+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;
+183 -39
View File
@@ -1,10 +1,15 @@
//! An immutable exact-match set of domain names, built from a compiled
//! blocklist body.
//! An immutable exact-match set of domain names, built from compiled
//! blocklist bodies, with a flag byte per name.
//!
//! The layout is a flat arena of length-prefixed names plus an open-addressed
//! table of `u32` offsets into it. No Bloom filter and no hash-only key set: a
//! false positive in a DNS sinkhole blocks a real domain for a real household
//! and is undebuggable from the outside, so every probe compares full bytes.
//! The layout is a flat arena of length-prefixed, flag-tagged names plus an
//! open-addressed table of `u32` offsets into it. No Bloom filter and no
//! hash-only key set: a false positive in a DNS sinkhole blocks a real domain
//! for a real household and is undebuggable from the outside, so every probe
//! compares full bytes.
//!
//! The flags let one set stand for two bodies that share names. A source's
//! `.list` and `.wild` bodies are byte-identical for every `||x^` line of an ABP
//! list, so storing them as two sets paid for each such name twice.
//!
//! Pure. Takes an allocator and bytes; no `std.Io`, no clock, no entropy
//! source — the hash seed arrives as a parameter.
@@ -12,7 +17,7 @@
const std = @import("std");
pub const DomainSet = struct {
/// Length-prefixed lowercase names, back to back: [len: u8][bytes]…
/// Back to back: [len: u8][flags: u8][len bytes of lowercase name]…
arena: []const u8,
/// Open-addressed table of offsets into `arena`; `empty_slot` marks a hole.
/// Length is always a power of two.
@@ -23,10 +28,15 @@ pub const DomainSet = struct {
pub const empty_slot: u32 = std.math.maxInt(u32);
pub const max_count: u32 = 4_000_000;
pub const max_arena_bytes: usize = 1 << 31;
/// The flag of a name in the first body passed to `buildMerged`, and of
/// every name `build` stores.
pub const in_first: u8 = 1 << 0;
/// The flag of a name in the second body passed to `buildMerged`.
pub const in_second: u8 = 1 << 1;
pub const Error = error{ OutOfMemory, TooManyDomains, SetTooLarge, NotSorted, NotLowercase };
/// An empty set that owns nothing. `contains` on it is always false.
/// An empty set that owns nothing. `flags` on it is always 0.
pub const empty: DomainSet = .{
.arena = &.{},
.index = &.{},
@@ -34,15 +44,22 @@ pub const DomainSet = struct {
.seed = 0,
};
/// Builds from a compiled body: LF-separated, lowercase, sorted ascending,
/// deduplicated, every line 1–255 bytes.
/// A set over one body, every name flagged `in_first`.
pub fn build(gpa: std.mem.Allocator, body: []const u8, seed: u64) Error!DomainSet {
return buildMerged(gpa, body, "", seed);
}
/// A set over the union of two bodies. A name in both is stored once and
/// carries both flags.
///
/// The sortedness is verified, not assumed: a hand-edited or truncated file
/// must fail loudly rather than produce a set that silently misses entries.
/// Verification is one comparison per line. Because a valid line is at least
/// one byte, comparing against an initial empty `prev` also rejects an empty
/// line and a duplicate line — both are `error.NotSorted`, since neither is
/// strictly ascending.
/// Each body must be a compiled body: LF-separated, lowercase, sorted
/// ascending, deduplicated, every line 1–255 bytes. The sortedness is
/// verified, not assumed: a hand-edited or truncated file must fail loudly
/// rather than produce a set that silently misses entries. Verification is
/// one comparison per line. Because a valid line is at least one byte,
/// comparing against an initial empty `prev` also rejects an empty line and
/// a duplicate line — both are `error.NotSorted`, since neither is strictly
/// ascending.
///
/// A line longer than 255 bytes is `error.SetTooLarge`: the arena stores a
/// `u8` length prefix and must not silently truncate, for the same reason
@@ -50,25 +67,25 @@ pub const DomainSet = struct {
///
/// `seed` randomizes the hash. Query names are attacker-supplied, so a fixed
/// seed would make probe-chain flooding computable offline.
pub fn build(gpa: std.mem.Allocator, body: []const u8, seed: u64) Error!DomainSet {
if (body.len == 0) return empty;
if (body.len > max_arena_bytes) return error.SetTooLarge;
pub fn buildMerged(gpa: std.mem.Allocator, first: []const u8, second: []const u8, seed: u64) Error!DomainSet {
// Ahead of the line scan: a body this large cannot fit the `u32`
// offsets whatever its lines hold.
if (first.len > max_arena_bytes or second.len > max_arena_bytes) return error.SetTooLarge;
try validate(first);
try validate(second);
var count: u32 = 0;
var arena_len: usize = 0;
{
var prev: []const u8 = "";
var it: LineIterator = .{ .rest = body };
while (it.next()) |line| {
for (line) |c| if (c >= 'A' and c <= 'Z') return error.NotLowercase;
if (line.len > std.math.maxInt(u8)) return error.SetTooLarge;
if (std.mem.order(u8, prev, line) != .lt) return error.NotSorted;
var merge: Merge = .init(first, second);
while (merge.next()) |entry| {
if (count == max_count) return error.TooManyDomains;
prev = line;
count += 1;
arena_len += 1 + line.len;
arena_len += 2 + entry.name.len;
}
}
if (count == 0) return empty;
if (arena_len > max_arena_bytes) return error.SetTooLarge;
const capacity = capacityFor(count);
@@ -79,14 +96,15 @@ pub const DomainSet = struct {
const mask = capacity - 1;
var write_at: usize = 0;
var it: LineIterator = .{ .rest = body };
while (it.next()) |line| {
var merge: Merge = .init(first, second);
while (merge.next()) |entry| {
const offset: u32 = @intCast(write_at);
arena[write_at] = @intCast(line.len);
@memcpy(arena[write_at + 1 ..][0..line.len], line);
write_at += 1 + line.len;
arena[write_at] = @intCast(entry.name.len);
arena[write_at + 1] = entry.flags;
@memcpy(arena[write_at + 2 ..][0..entry.name.len], entry.name);
write_at += 2 + entry.name.len;
var slot: usize = @as(usize, @truncate(std.hash.Wyhash.hash(seed, line))) & mask;
var slot: usize = @as(usize, @truncate(std.hash.Wyhash.hash(seed, entry.name))) & mask;
while (index[slot] != empty_slot) slot = (slot + 1) & mask;
index[slot] = offset;
}
@@ -100,28 +118,97 @@ pub const DomainSet = struct {
self.* = empty;
}
/// `domain` must be normalized (lowercase, no trailing dot). Allocation-free.
pub fn contains(self: *const DomainSet, domain: []const u8) bool {
if (self.index.len == 0) return false;
/// The flags `domain` carries, 0 when it is not in the set. `domain` must
/// be normalized (lowercase, no trailing dot). Allocation-free.
pub fn flags(self: *const DomainSet, domain: []const u8) u8 {
if (self.index.len == 0) return 0;
const mask = self.index.len - 1;
var slot: usize = @as(usize, @truncate(std.hash.Wyhash.hash(self.seed, domain))) & mask;
while (true) {
const offset = self.index[slot];
if (offset == empty_slot) return false;
if (offset == empty_slot) return 0;
const len = self.arena[offset];
if (len == domain.len and std.mem.eql(u8, self.arena[offset + 1 ..][0..len], domain)) {
return true;
if (len == domain.len and std.mem.eql(u8, self.arena[offset + 2 ..][0..len], domain)) {
return self.arena[offset + 1];
}
slot = (slot + 1) & mask;
}
}
pub fn contains(self: *const DomainSet, domain: []const u8) bool {
return self.flags(domain) != 0;
}
/// Bytes held, for the memory report in `Snapshot.memoryBytes`.
pub fn memoryBytes(self: *const DomainSet) usize {
return self.arena.len + self.index.len * @sizeOf(u32);
}
};
fn validate(body: []const u8) DomainSet.Error!void {
var prev: []const u8 = "";
var lines: u32 = 0;
var it: LineIterator = .{ .rest = body };
while (it.next()) |line| {
for (line) |c| if (c >= 'A' and c <= 'Z') return error.NotLowercase;
if (line.len > std.math.maxInt(u8)) return error.SetTooLarge;
if (std.mem.order(u8, prev, line) != .lt) return error.NotSorted;
if (lines == DomainSet.max_count) return error.TooManyDomains;
prev = line;
lines += 1;
}
}
/// Walks the union of two sorted, validated bodies in ascending order,
/// yielding each distinct name once with the flags of the bodies holding it.
const Merge = struct {
first: LineIterator,
second: LineIterator,
first_head: ?[]const u8,
second_head: ?[]const u8,
const Entry = struct { name: []const u8, flags: u8 };
fn init(first: []const u8, second: []const u8) Merge {
var merge: Merge = .{
.first = .{ .rest = first },
.second = .{ .rest = second },
.first_head = null,
.second_head = null,
};
merge.first_head = merge.first.next();
merge.second_head = merge.second.next();
return merge;
}
fn next(self: *Merge) ?Entry {
const a = self.first_head orelse {
const b = self.second_head orelse return null;
self.second_head = self.second.next();
return .{ .name = b, .flags = DomainSet.in_second };
};
const b = self.second_head orelse {
self.first_head = self.first.next();
return .{ .name = a, .flags = DomainSet.in_first };
};
switch (std.mem.order(u8, a, b)) {
.lt => {
self.first_head = self.first.next();
return .{ .name = a, .flags = DomainSet.in_first };
},
.gt => {
self.second_head = self.second.next();
return .{ .name = b, .flags = DomainSet.in_second };
},
.eq => {
self.first_head = self.first.next();
self.second_head = self.second.next();
return .{ .name = a, .flags = DomainSet.in_first | DomainSet.in_second };
},
}
}
};
/// Smallest power of two at least `count * 4 / 3`, minimum 16. The load factor
/// stays at or under 0.75, which keeps at least one hole and therefore
/// terminates the probe loop in `contains`.
@@ -283,3 +370,60 @@ test "capacityFor keeps the load factor at or under three quarters" {
try testing.expectEqual(@as(usize, 32), capacityFor(13));
try testing.expectEqual(@as(usize, 2 << 20), capacityFor(1_000_000));
}
const list_part =
"a.example\n" ++
"both.example\n" ++
"m.example\n";
const wild_part =
"both.example\n" ++
"w.example\n" ++
"z.example\n";
test "two bodies merge into one entry per name, carrying each body's flag" {
var set = try DomainSet.buildMerged(testing.allocator, list_part, wild_part, 0x77);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(u32, 5), set.count);
try testing.expectEqual(@as(u8, 1), set.flags("a.example"));
try testing.expectEqual(@as(u8, 1), set.flags("m.example"));
try testing.expectEqual(@as(u8, 3), set.flags("both.example"));
try testing.expectEqual(@as(u8, 2), set.flags("w.example"));
try testing.expectEqual(@as(u8, 2), set.flags("z.example"));
try testing.expectEqual(@as(u8, 0), set.flags("example"));
try testing.expectEqual(@as(u8, 0), set.flags("x.both.example"));
}
test "two identical bodies cost what one body costs" {
var one = try DomainSet.build(testing.allocator, small_body, 0x77);
defer one.deinit(testing.allocator);
var two = try DomainSet.buildMerged(testing.allocator, small_body, small_body, 0x77);
defer two.deinit(testing.allocator);
try testing.expectEqual(one.count, two.count);
try testing.expectEqual(one.memoryBytes(), two.memoryBytes());
try testing.expectEqual(@as(u8, 3), two.flags("example.com"));
}
test "an empty first body leaves the second body's flags alone" {
var set = try DomainSet.buildMerged(testing.allocator, "", wild_part, 0);
defer set.deinit(testing.allocator);
try testing.expectEqual(@as(u32, 3), set.count);
try testing.expectEqual(@as(u8, 2), set.flags("both.example"));
}
test "both bodies are validated, not just the first" {
try testing.expectError(error.NotSorted, DomainSet.buildMerged(testing.allocator, list_part, "b.example\na.example\n", 0));
try testing.expectError(error.NotLowercase, DomainSet.buildMerged(testing.allocator, list_part, "A.example\n", 0));
}
fn buildMergedUnderFailure(gpa: std.mem.Allocator) !void {
var set = try DomainSet.buildMerged(gpa, list_part, wild_part, 0x1234);
defer set.deinit(gpa);
try testing.expectEqual(@as(u8, 3), set.flags("both.example"));
}
test "buildMerged under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, buildMergedUnderFailure, .{});
}
+78 -21
View File
@@ -915,7 +915,7 @@ test "3: a damaged compiled file never replaces a serving snapshot with a worse
/// The whole refresh path for one source served by `fixture`, with the status
/// table synced first so `refreshSource` has somewhere to record its outcome.
fn refreshOnce(env: *Env, url: []const u8) !bool {
fn refreshOnce(env: *Env, url: []const u8) !manager.RefreshOutcome {
try env.mgr.reload(env.io());
var rows = try listRows(&env.database);
defer rows.deinit();
@@ -940,7 +940,7 @@ test "4: a 200 response is fetched, compiled, recorded and served" {
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
try env.mgr.reload(io);
var dir = try env.blocklistDir();
@@ -997,7 +997,7 @@ test "5: a redirect is followed to the same result" {
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
try env.mgr.reload(io);
try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
@@ -1040,7 +1040,7 @@ test "6: a 404 leaves the compiled files and the snapshot untouched" {
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
try env.mgr.reload(io);
var dir = try env.blocklistDir();
@@ -1049,7 +1049,7 @@ test "6: a 404 leaves the compiled files and the snapshot untouched" {
defer before.deinit(gpa);
fixture.setRoute(.not_found);
try testing.expect(!try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.failed, try refreshOnce(env, url));
const failed = try env.status(id);
try testing.expectEqual(manager.State.fetch_failed, failed.state);
@@ -1093,7 +1093,7 @@ test "6b: a local failure reports itself, not the previous source's network faul
const id = try seedSource(&env.database, url);
// First a real network failure, so the fetcher is holding a record.
try testing.expect(!try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.failed, try refreshOnce(env, url));
try expectFailureText(
"HttpStatus (receive, cause HttpStatus, http 404, 0 bytes, ",
(try env.status(id)).errorText(),
@@ -1107,7 +1107,7 @@ test "6b: a local failure reports itself, not the previous source's network faul
try dir.setPermissions(io, .fromMode(0o500));
defer dir.setPermissions(io, .fromMode(0o700)) catch {};
try testing.expect(!try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.failed, try refreshOnce(env, url));
const failed = try env.status(id);
try testing.expectEqual(manager.State.fetch_failed, failed.state);
@@ -1135,7 +1135,7 @@ test "7: a body over the cap fails the refresh and leaves no temporary file" {
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(!try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.failed, try refreshOnce(env, url));
const failed = try env.status(id);
try testing.expectEqual(manager.State.fetch_failed, failed.state);
@@ -1170,7 +1170,7 @@ test "8: refetching identical content skips the rewrite and still moves last_upd
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
var dir = try env.blocklistDir();
defer dir.close(io);
@@ -1194,7 +1194,7 @@ test "8: refetching identical content skips the rewrite and still moves last_upd
},
});
try testing.expect(!try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.unchanged, try refreshOnce(env, url));
// `replace` renames a new file over the old one, so an untouched file keeps
// both its inode and its modification time.
@@ -1464,7 +1464,7 @@ const RefreshAllTask = struct {
const RefreshSourceTask = struct {
mgr: *manager.Manager,
row: sources_repo.SourceRow,
result: manager.Manager.Error!bool = false,
result: manager.Manager.Error!manager.RefreshOutcome = .failed,
fn run(self: *RefreshSourceTask, io: std.Io) void {
self.result = self.mgr.refreshSource(io, self.row);
@@ -1589,7 +1589,7 @@ test "10d: a source deleted mid-refresh does not take the refresh's temporary fi
try tasks.await(io);
// True only if the compile found the raw file the download had written.
try testing.expect(try task.result);
try testing.expectEqual(manager.RefreshOutcome.replaced, try task.result);
// The sweep did run, once the refresh was out of the way: the compiled
// files of a source with no row are gone, and so is every temporary.
@@ -1626,7 +1626,7 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
// One real download, so the compiled artifacts exist and are named after
// the row id the rest of this test is about.
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
var dir = try env.blocklistDir();
@@ -1755,6 +1755,63 @@ fn localTable(gpa: std.mem.Allocator, database: *db.Db) !records.Records {
return records.Records.build(gpa, rows.items);
}
test "10f: a source the boot load could not read is enforced once its refresh finds the same files" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
const env = try Env.create(gpa);
defer env.destroy();
const io = env.io();
var fixture = try HttpFixture.init(io, http_body);
defer fixture.deinit(io);
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
var url_buf: [64]u8 = undefined;
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
// The restart, with the `.list` file missing for the boot load alone.
env.mgr.deinit(io);
env.mgr = try manager.Manager.init(
gpa,
&env.database,
.{ .dir = env.tmp.dir },
&env.f,
.{ .enabled = false },
budget,
);
var dir = try env.blocklistDir();
defer dir.close(io);
var list_buf: [64]u8 = undefined;
const list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id});
try dir.rename(list_name, dir, "aside", io);
try env.mgr.reload(io);
try dir.rename("aside", dir, list_name, io);
try testing.expect(!(try env.status(id)).loaded);
{
const decision, _ = try env.evaluate("ads.example.com");
try testing.expect(!decision.blocked);
}
// The startup pass refreshes the source, which is not `ok`, and the
// download compiles to the files already on disk. That alone rewrites
// nothing, so it is the reload after it that puts the source back.
env.mgr.schedule_clock = .shutdown_at_first_park;
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
try testing.expectEqual(@as(u32, 2), fixture.accepted.load(.monotonic));
const status = try env.status(id);
try testing.expect(status.loaded);
try testing.expectEqual(manager.State.ok, status.state);
const decision, _ = try env.evaluate("ads.example.com");
try testing.expect(decision.blocked);
}
test "11: a local A record read from the database answers an A query" {
if (!build_options.integration) return error.SkipZigTest;
@@ -2109,7 +2166,7 @@ test "18: a body streamed in flushed parts survives the fetcher's multi-read pum
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
try env.mgr.reload(io);
try testing.expect(fixture.flushed_parts.load(.acquire) >= 3);
@@ -2181,7 +2238,7 @@ test "19: a downloaded list's exceptions lift its own blocks and nothing else" {
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
try env.mgr.reload(io);
// The `.allow` body is a third file beside the two, and the row and the
@@ -2371,7 +2428,7 @@ test "21: an abp list's unsupported lines are counted, persisted and read back"
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
try env.mgr.reload(io);
var rows = try listRows(&env.database);
@@ -2432,7 +2489,7 @@ test "23: a name moving from the list body to the wild body forces a republish"
const url = try fixture.url(&url_buf);
_ = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
var first_checksum: [64]u8 = undefined;
{
@@ -2464,7 +2521,7 @@ test "22: a list that changed only its skipped lines still updates both skip cou
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
var first_checksum: [64]u8 = undefined;
{
@@ -2479,7 +2536,7 @@ test "22: a list that changed only its skipped lines still updates both skip cou
// The edited list. Two more skipped lines and not one written entry moved,
// so the refresh finds its stored checksum and rewrites nothing on disk.
fixture.setRoute(.changed);
try testing.expect(!try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.unchanged, try refreshOnce(env, url));
{
var rows = try listRows(&env.database);
@@ -2548,7 +2605,7 @@ test "27: a failing refresh opens a blocklist.refresh episode a good one closes"
const url = try fixture.url(&url_buf);
_ = try seedSource(&env.database, url);
try testing.expect(!try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.failed, try refreshOnce(env, url));
try testing.expectEqualStrings("blocklist.refresh", try fx.text(
"SELECT code FROM operational_events WHERE resolved_at IS NULL",
@@ -2562,7 +2619,7 @@ test "27: a failing refresh opens a blocklist.refresh episode a good one closes"
));
fixture.setRoute(.body);
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(manager.RefreshOutcome.replaced, try refreshOnce(env, url));
try testing.expectEqual(
@as(i64, 0),
+307
View File
@@ -0,0 +1,307 @@
//! Equivalence of the list levels (7–9 of `Snapshot.evaluate`) against a
//! reference model that keeps every compiled body as its own list of lines.
//!
//! The snapshot stores a source's `.list` and `.wild` names in one flagged set;
//! the reference keeps them apart and answers by scanning lines, the way the
//! bodies read on disk. Real list text goes through the real compiler, so the
//! corpus exercises what the compiler emits, not what a test assumes it emits.
const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("../config/model.zig");
const compiler = @import("compiler.zig");
const parsers = @import("parsers.zig");
const matcher = @import("matcher.zig");
const testing = std.testing;
const Raw = struct { url: []const u8, format: parsers.Format, text: []const u8 };
/// Three overlapping sources covering each shape a body can take: `||x^`
/// (both bodies), `*.x` alone, `x` alone, `x` together with `*.x`, `@@`
/// exceptions, an exact entry for a subdomain of another source's wildcard,
/// and nested wildcards `*.x` with `*.y.x`.
const corpus = [_]Raw{
.{ .url = "https://lists.test/abp", .format = .abp, .text =
\\! abp
\\||ads.example^
\\||t.example^
\\||deep.y.x.example^
\\@@||good.ads.example^
\\@@||t.example^
\\
},
.{ .url = "https://lists.test/domains", .format = .domains, .text =
\\# domains
\\x.example
\\v.example
\\*.v.example
\\*.w.example
\\*.y.w.example
\\sub.ads.example
\\ads.example
\\
},
.{ .url = "https://lists.test/hosts", .format = .hosts, .text =
\\0.0.0.0 ads.example
\\0.0.0.0 h.example
\\0.0.0.0 a.w.example
\\0.0.0.0 good.ads.example
\\
},
};
const Compiled = struct {
list: std.Io.Writer.Allocating,
wild: std.Io.Writer.Allocating,
allow: std.Io.Writer.Allocating,
fn init(gpa: Allocator, raw: Raw) !Compiled {
var self: Compiled = .{ .list = .init(gpa), .wild = .init(gpa), .allow = .init(gpa) };
errdefer self.deinit();
var r: std.Io.Reader = .fixed(raw.text);
_ = try compiler.compile(gpa, &r, raw.format, &self.list.writer, &self.wild.writer, &self.allow.writer);
return self;
}
fn deinit(self: *Compiled) void {
self.list.deinit();
self.wild.deinit();
self.allow.deinit();
}
fn bodies(self: *Compiled) matcher.Snapshot.Compiled {
return .{ .list_body = self.list.written(), .wild_body = self.wild.written(), .allow_body = self.allow.written() };
}
};
fn bodyHas(body: []const u8, wanted: []const u8) bool {
var lines = std.mem.tokenizeScalar(u8, body, '\n');
while (lines.next()) |line| {
if (std.mem.eql(u8, line, wanted)) return true;
}
return false;
}
/// Levels 7–9 as documented on `Snapshot.evaluate`, over separate bodies.
fn reference(bodies: []const matcher.Snapshot.Compiled, sources: []const u32, domain: []const u8) matcher.Decision {
var exceptions: matcher.Candidates = .init(domain);
while (exceptions.next()) |candidate| {
for (sources) |index| {
if (bodyHas(bodies[index].allow_body, candidate)) {
return .{ .blocked = false, .reason = .blocklist_exception, .matched = candidate, .source = index };
}
}
}
for (sources) |index| {
if (bodyHas(bodies[index].list_body, domain)) {
return .{ .blocked = true, .reason = .blocklist_domain, .matched = domain, .source = index };
}
}
var parents: matcher.Candidates = .init(domain);
_ = parents.next();
while (parents.next()) |parent| {
for (sources) |index| {
if (bodyHas(bodies[index].wild_body, parent)) {
return .{ .blocked = true, .reason = .blocklist_wildcard, .matched = parent, .source = index };
}
}
}
return .{ .blocked = false, .reason = .none, .matched = "" };
}
fn expectSameDecision(expected: matcher.Decision, actual: matcher.Decision, probe: []const u8) !void {
errdefer std.debug.print("probe {s}: expected {t} {s} {?d}, got {t} {s} {?d}\n", .{
probe, expected.reason, expected.matched, expected.source,
actual.reason, actual.matched, actual.source,
});
try testing.expectEqual(expected.blocked, actual.blocked);
try testing.expectEqual(expected.reason, actual.reason);
try testing.expectEqualStrings(expected.matched, actual.matched);
try testing.expectEqual(expected.source, actual.source);
}
/// Every name in every body, each with two subdomains and its own parents,
/// plus names no list mentions.
fn probes(gpa: Allocator, bodies: []const matcher.Snapshot.Compiled) !std.ArrayList([]u8) {
var out: std.ArrayList([]u8) = .empty;
errdefer {
for (out.items) |probe| gpa.free(probe);
out.deinit(gpa);
}
for (bodies) |b| {
for ([_][]const u8{ b.list_body, b.wild_body, b.allow_body }) |body| {
var lines = std.mem.tokenizeScalar(u8, body, '\n');
while (lines.next()) |line| {
var parents: matcher.Candidates = .init(line);
while (parents.next()) |parent| try out.append(gpa, try gpa.dupe(u8, parent));
try out.append(gpa, try std.fmt.allocPrint(gpa, "p.{s}", .{line}));
try out.append(gpa, try std.fmt.allocPrint(gpa, "q.r.{s}", .{line}));
}
}
}
for ([_][]const u8{ "example", "unlisted.example", "x.unlisted.example", "" }) |name| {
try out.append(gpa, try gpa.dupe(u8, name));
}
return out;
}
test "list levels: the flagged name set decides exactly what separate bodies decide" {
const gpa = testing.allocator;
var compiled: [corpus.len]Compiled = undefined;
var built: usize = 0;
defer for (compiled[0..built]) |*c| c.deinit();
for (corpus, &compiled) |raw, *slot| {
slot.* = try .init(gpa, raw);
built += 1;
}
var bodies: [corpus.len]matcher.Snapshot.Compiled = undefined;
var compiled_opt: [corpus.len]?matcher.Snapshot.Compiled = undefined;
var sources: [corpus.len]model.BlocklistSource = undefined;
var source_ids: [corpus.len]i64 = undefined;
for (&compiled, &bodies, &compiled_opt, &sources, &source_ids, corpus, 0..) |*c, *b, *o, *s, *id, raw, i| {
b.* = c.bodies();
o.* = b.*;
s.* = .{ .url = raw.url, .name = raw.url };
id.* = @intCast(i + 1);
}
// `default` sees all three sources; `narrow` only the last two, so the
// ascending-index walk starts at a different source.
const links = [_]model.GroupSource{
.{ .group = "default", .source_url = corpus[0].url },
.{ .group = "default", .source_url = corpus[1].url },
.{ .group = "default", .source_url = corpus[2].url },
.{ .group = "narrow", .source_url = corpus[2].url },
.{ .group = "narrow", .source_url = corpus[1].url },
};
var snapshot = try matcher.Snapshot.build(gpa, .{
.groups = &.{ .{ .name = "default" }, .{ .name = "narrow" } },
.group_ids = &.{ 1, 2 },
.group_sources = &links,
.sources = &sources,
.source_ids = &source_ids,
.rules = &.{},
.clients = &.{},
.prefixes = &.{},
.compiled = &compiled_opt,
.seed = 0x5eed,
.generation = 1,
});
defer snapshot.deinit();
var probe_list = try probes(gpa, &bodies);
defer {
for (probe_list.items) |probe| gpa.free(probe);
probe_list.deinit(gpa);
}
var decided = [_]u32{0} ** @typeInfo(matcher.Reason).@"enum".fields.len;
for ([_][]const u32{ &.{ 0, 1, 2 }, &.{ 1, 2 } }, 0..) |group_sources, group| {
try testing.expectEqualSlices(u32, group_sources, snapshot.groups[group].sources);
for (probe_list.items) |probe| {
const actual = snapshot.evaluate(@intCast(group), probe);
try expectSameDecision(reference(&bodies, group_sources, probe), actual, probe);
decided[@intFromEnum(actual.reason)] += 1;
}
}
// The corpus reaches every list level, so the comparison is not vacuous.
try testing.expect(decided[@intFromEnum(matcher.Reason.blocklist_exception)] > 0);
try testing.expect(decided[@intFromEnum(matcher.Reason.blocklist_domain)] > 0);
try testing.expect(decided[@intFromEnum(matcher.Reason.blocklist_wildcard)] > 0);
try testing.expect(decided[@intFromEnum(matcher.Reason.none)] > 0);
// The reference and the snapshot read the same compiled bodies, so a
// compiler that dropped a shape would drop it from both. Each shape the
// corpus exists for is therefore also pinned to the decision it must give.
for (named_cases) |case| {
const blocked = case.reason == .blocklist_domain or case.reason == .blocklist_wildcard;
try expectSameDecision(
.{ .blocked = blocked, .reason = case.reason, .matched = case.matched, .source = case.source },
snapshot.evaluate(case.group, case.probe),
case.probe,
);
}
}
const NamedCase = struct {
group: u32,
probe: []const u8,
reason: matcher.Reason,
matched: []const u8 = "",
source: ?u32 = null,
};
const default_group = 0;
const narrow_group = 1;
const named_cases = [_]NamedCase{
// `||x^`: the apex is an exact match, a subdomain a wildcard match.
.{ .group = default_group, .probe = "ads.example", .reason = .blocklist_domain, .matched = "ads.example", .source = 0 },
.{ .group = default_group, .probe = "p.ads.example", .reason = .blocklist_wildcard, .matched = "ads.example", .source = 0 },
// An exact entry for the queried name beats another source's wildcard.
.{ .group = default_group, .probe = "sub.ads.example", .reason = .blocklist_domain, .matched = "sub.ads.example", .source = 1 },
// `@@` beats every block, its own source's and another's, for the name
// and its subdomains.
.{ .group = default_group, .probe = "good.ads.example", .reason = .blocklist_exception, .matched = "good.ads.example", .source = 0 },
.{ .group = default_group, .probe = "x.good.ads.example", .reason = .blocklist_exception, .matched = "good.ads.example", .source = 0 },
.{ .group = default_group, .probe = "t.example", .reason = .blocklist_exception, .matched = "t.example", .source = 0 },
.{ .group = default_group, .probe = "p.t.example", .reason = .blocklist_exception, .matched = "t.example", .source = 0 },
// `x` alone blocks the apex only.
.{ .group = default_group, .probe = "x.example", .reason = .blocklist_domain, .matched = "x.example", .source = 1 },
.{ .group = default_group, .probe = "p.x.example", .reason = .none },
// `*.x` alone blocks subdomains only.
.{ .group = default_group, .probe = "w.example", .reason = .none },
.{ .group = default_group, .probe = "p.w.example", .reason = .blocklist_wildcard, .matched = "w.example", .source = 1 },
// An exact entry for a subdomain of a wildcard reports the exact entry.
.{ .group = default_group, .probe = "a.w.example", .reason = .blocklist_domain, .matched = "a.w.example", .source = 2 },
// Nested wildcards: the nearer parent wins.
.{ .group = default_group, .probe = "p.y.w.example", .reason = .blocklist_wildcard, .matched = "y.w.example", .source = 1 },
.{ .group = default_group, .probe = "y.w.example", .reason = .blocklist_wildcard, .matched = "w.example", .source = 1 },
.{ .group = default_group, .probe = "q.deep.y.x.example", .reason = .blocklist_wildcard, .matched = "deep.y.x.example", .source = 0 },
// `x` with `*.x` in a domains list.
.{ .group = default_group, .probe = "v.example", .reason = .blocklist_domain, .matched = "v.example", .source = 1 },
.{ .group = default_group, .probe = "p.v.example", .reason = .blocklist_wildcard, .matched = "v.example", .source = 1 },
// Overlapping sources: the lowest linked index reports, and a group that
// does not link the ABP source has neither its wildcard nor its `@@`.
.{ .group = narrow_group, .probe = "ads.example", .reason = .blocklist_domain, .matched = "ads.example", .source = 1 },
.{ .group = narrow_group, .probe = "p.ads.example", .reason = .none },
.{ .group = narrow_group, .probe = "good.ads.example", .reason = .blocklist_domain, .matched = "good.ads.example", .source = 2 },
};
test "list levels: a name in both bodies of one source is stored once" {
const gpa = testing.allocator;
var c = try Compiled.init(gpa, corpus[0]);
defer c.deinit();
const b = c.bodies();
// The ABP source compiles each `||x^` into both bodies.
try testing.expectEqualStrings(b.list_body, b.wild_body);
const sources = [_]model.BlocklistSource{.{ .url = corpus[0].url, .name = "abp" }};
const compiled = [_]?matcher.Snapshot.Compiled{b};
var snapshot = try matcher.Snapshot.build(gpa, .{
.groups = &.{.{ .name = "default" }},
.group_ids = &.{1},
.group_sources = &.{.{ .group = "default", .source_url = corpus[0].url }},
.sources = &sources,
.source_ids = &.{1},
.rules = &.{},
.clients = &.{},
.prefixes = &.{},
.compiled = &compiled,
.seed = 0x5eed,
.generation = 1,
});
defer snapshot.deinit();
const names = snapshot.sources[0].names;
try testing.expectEqual(@as(u32, @intCast(std.mem.count(u8, b.list_body, "\n"))), names.count);
try testing.expectEqual(
matcher.SourceSets.exact | matcher.SourceSets.subdomains,
names.flags("ads.example"),
);
}
+297 -63
View File
@@ -52,6 +52,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("../config/model.zig");
const concurrency = @import("../platform/concurrency.zig");
const safe_url = @import("../safe_url.zig");
const db = @import("../storage/db.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
@@ -892,17 +893,32 @@ pub const Manager = struct {
defer self.gpa.free(sources);
const source_ids = try self.gpa.alloc(i64, rows.items.len);
defer self.gpa.free(source_ids);
const compiled = try self.gpa.alloc(?matcher.Snapshot.Compiled, rows.items.len);
defer self.gpa.free(compiled);
// The file contents outlive the header stripping and are freed once the
// snapshot has copied what it needs into its own arena.
var bodies: std.ArrayList([]u8) = .empty;
defer {
for (bodies.items) |body| self.gpa.free(body);
bodies.deinit(self.gpa);
for (rows.items, sources, source_ids) |row, *source, *source_id| {
source_id.* = row.id;
// `is_suggested` is a UI hint the snapshot never reads.
source.* = .{ .url = row.url, .name = row.name, .enabled = row.enabled };
}
// Query names are attacker-supplied, so a fixed seed would make
// probe-chain flooding computable offline.
var seed_bytes: [8]u8 = undefined;
io.random(&seed_bytes);
var builder: matcher.Snapshot.Builder = try .init(
self.gpa,
sources,
source_ids,
std.mem.readInt(u64, &seed_bytes, .little),
);
defer builder.deinit();
// One source's file contents at a time: each is freed once the builder
// has copied what it keeps, before the next source is read. A reload
// then holds the old snapshot, the new one and one source's bodies.
var bodies: std.ArrayList([]u8) = .empty;
defer bodies.deinit(self.gpa);
defer for (bodies.items) |body| self.gpa.free(body);
// What this reload found, per source. It is applied to the status table
// only if the snapshot it describes is published: everything below here
// can still fail, and a status table describing a snapshot nobody
@@ -911,24 +927,25 @@ pub const Manager = struct {
defer self.gpa.free(outcomes);
var loaded: usize = 0;
for (rows.items, sources, source_ids, compiled, outcomes) |row, *source, *source_id, *slot, *outcome| {
source_id.* = row.id;
// `is_suggested` is a UI hint the snapshot never reads.
source.* = .{ .url = row.url, .name = row.name, .enabled = row.enabled };
outcome.* = if (row.enabled) try self.loadSource(io, dir, row, &bodies) else .disabled;
switch (outcome.*) {
.loaded => |body| {
slot.* = body;
for (rows.items, outcomes, 0..) |row, *outcome, index| {
if (!row.enabled) {
outcome.* = .disabled;
continue;
}
switch (try self.loadSource(io, dir, row, &bodies)) {
.loaded => |compiled| {
try builder.addSource(index, compiled);
outcome.* = .loaded;
loaded += 1;
},
// Not loadable and therefore not enforced. Saying so here is
// what keeps `Snapshot.build`'s `MissingCompiledSource` for the
// case it is meant for: a caller that forgot to read a body.
.disabled, .failed => {
slot.* = null;
source.enabled = false;
// Not loadable and therefore not enforced.
.failed => |failure| {
builder.skipSource(index);
outcome.* = .{ .failed = failure };
},
}
for (bodies.items) |body| self.gpa.free(body);
bodies.clearRetainingCapacity();
}
var groups = try groups_repo.listGroups(self.database, self.gpa);
@@ -954,25 +971,16 @@ pub const Manager = struct {
defer prefixes.deinit(self.gpa);
defer clients_repo.freeClientPrefixes(self.gpa, prefixes.items);
// Query names are attacker-supplied, so a fixed seed would make
// probe-chain flooding computable offline.
var seed_bytes: [8]u8 = undefined;
io.random(&seed_bytes);
const generation = self.generation + 1;
const snapshot = try self.gpa.create(matcher.Snapshot);
errdefer self.gpa.destroy(snapshot);
snapshot.* = try matcher.Snapshot.build(self.gpa, .{
snapshot.* = try builder.finish(.{
.groups = groups.items,
.group_ids = group_ids,
.group_sources = group_sources.items,
.sources = sources,
.source_ids = source_ids,
.rules = rule_rows.items,
.clients = clients.items,
.prefixes = prefixes.items,
.compiled = compiled,
.seed = std.mem.readInt(u64, &seed_bytes, .little),
.generation = generation,
});
@@ -1014,7 +1022,7 @@ pub const Manager = struct {
dir: std.Io.Dir,
row: sources_repo.SourceRow,
bodies: *std.ArrayList([]u8),
) Error!LoadOutcome {
) Error!SourceLoad {
const stored = row.checksum orelse
// No stored checksum means no successful compile has ever
// happened. A fresh install is here on every source.
@@ -1091,15 +1099,14 @@ pub const Manager = struct {
/// Downloads, compiles and atomically replaces the compiled files of one
/// source, then writes its runtime columns.
///
/// Returns `true` only when the compiled files were replaced. Unchanged
/// content (equal checksum) and every recorded failure return `false`; the
/// reason for a failure is in the status table, not in the return value.
/// The reason for a failure is in the status table, not in the return
/// value.
///
/// The status entry is found by row id, so a source added since the last
/// `reload` has nowhere to record its outcome. `refreshAll` syncs the table
/// before it refreshes anything, which is why `POST /api/blocklists/update`
/// and the scheduler both enter through `refreshAll`.
pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool {
pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!RefreshOutcome {
self.refresh_lock.lockUncancelable(io);
defer self.refresh_lock.unlock(io);
// Registered after the unlock, so it runs before it: a pass drains its
@@ -1112,7 +1119,7 @@ pub const Manager = struct {
/// Assumes `refresh_lock`. Takes `writer_lock` itself, for the publish half
/// alone.
fn refreshSourceLocked(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool {
fn refreshSourceLocked(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!RefreshOutcome {
// The previous entry describes the compiled files that are still on
// disk, and a failed refresh leaves them serving. Starting from a blank
// status would erase `last_success` and the counters of a list that is
@@ -1156,9 +1163,9 @@ pub const Manager = struct {
self.writer_lock.lockUncancelable(io);
defer self.writer_lock.unlock(io);
const replaced = try self.publishRefresh(io, dir, row, &status, prepared, tmp);
const outcome = try self.publishRefresh(io, dir, row, &status, prepared, tmp);
self.commitStatus(io, status);
return replaced;
return outcome;
}
/// Every enabled source, one at a time, then one `reload`. A failing source
@@ -1260,7 +1267,6 @@ pub const Manager = struct {
}
/// Installs what `prepareRefresh` produced and writes the runtime columns.
/// Returns `true` only when the compiled files were replaced.
///
/// Assumes `writer_lock`. Reading the files on disk belongs here and not in
/// the half above: they are published under this lock.
@@ -1272,9 +1278,9 @@ pub const Manager = struct {
status: *SourceStatus,
prepared: Prepared,
tmp: TempNames,
) Error!bool {
) Error!RefreshOutcome {
const compiled = switch (prepared) {
.failed => return false,
.failed => return .failed,
.compiled => |value| value,
};
@@ -1308,7 +1314,7 @@ pub const Manager = struct {
.checksum = stored,
});
status.succeed(now, compiled.result.counts);
return false;
return .unchanged;
}
}
@@ -1324,7 +1330,7 @@ pub const Manager = struct {
error.Canceled => return error.Canceled,
else => {
self.reportCompileFailure(row, status, err);
return false;
return .failed;
},
};
@@ -1338,7 +1344,7 @@ pub const Manager = struct {
.checksum = &compiled.result.checksum,
});
status.succeed(now, compiled.result.counts);
return true;
return .replaced;
}
/// The body goes to a temporary file, never to memory: `max_body_bytes` is
@@ -1407,10 +1413,16 @@ pub const Manager = struct {
defer race.cancelDiscard();
race.concurrent(.fetch, fetcher.Fetcher.fetch, .{ self.fetcher, io, url, w }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
error.ConcurrencyUnavailable => {
concurrency.unavailable(.blocklist_fetch);
return error.SystemResources;
},
};
race.concurrent(.expiry, expire, .{ io, self.total_budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
error.ConcurrencyUnavailable => {
concurrency.unavailable(.blocklist_fetch);
return error.SystemResources;
},
};
// Read before the deferred `cancelDiscard`, which tears the loser down:
@@ -1817,10 +1829,13 @@ pub const Manager = struct {
// `refreshSource`.
defer self.flushDiagnostics(io);
// Ahead of the gate on purpose: loading the compiled files that already
// exist is a read. A full disk must not cost the household its
// filtering as well as its downloads.
try self.reloadCollecting(io);
// Boot already loaded the compiled files before the listeners started,
// and its status table is what the refresh decisions below read. Only
// a boot load that published nothing is retried here, ahead of the
// gate on purpose: loading the files that already exist is a read, and
// a full disk must not cost the household its filtering as well as its
// downloads.
if (!self.published(io)) try self.reloadCollecting(io);
if (self.refreshGated()) return;
@@ -1829,13 +1844,36 @@ pub const Manager = struct {
defer sources_repo.freeSourceRows(self.gpa, rows.items);
const now = std.Io.Clock.real.now(io).toSeconds();
var refreshed = false;
var stale = false;
for (rows.items) |row| {
if (!row.enabled) continue;
if (!self.needsRefresh(io, row, now)) continue;
if (try self.refreshSourceLocked(io, row)) refreshed = true;
switch (try self.refreshSourceLocked(io, row)) {
.replaced => stale = true,
// The files on disk match their checksum, yet the published
// snapshot may still lack them: a boot load that could not read
// them left the source out, and nothing else would add it back
// before the next scheduled pass.
.unchanged => if (!self.enforced(io, row.id)) {
stale = true;
},
.failed => {},
}
}
if (refreshed) try self.reloadCollecting(io);
if (stale) try self.reloadCollecting(io);
}
fn enforced(self: *Manager, io: std.Io, id: i64) bool {
self.lock.lockSharedUncancelable(io);
defer self.lock.unlockShared(io);
const entry = entryFor(self.statuses, id) orelse return false;
return entry.loaded;
}
fn published(self: *Manager, io: std.Io) bool {
self.lock.lockSharedUncancelable(io);
defer self.lock.unlockShared(io);
return self.generation != 0;
}
fn needsRefresh(self: *Manager, io: std.Io, row: sources_repo.SourceRow, now: i64) bool {
@@ -2146,12 +2184,32 @@ const LoadOutcome = union(enum) {
/// The source is switched off. Not in the snapshot, whatever it was before.
disabled,
/// Its compiled files are in the snapshot.
loaded: matcher.Snapshot.Compiled,
loaded,
/// It is not in the snapshot, for this reason.
failed: struct { state: State, text: []const u8 },
failed: LoadFailure,
};
fn loadFailure(row: sources_repo.SourceRow, file_name: []const u8, err: anyerror) LoadOutcome {
const LoadFailure = struct { state: State, text: []const u8 };
/// What one source's refresh did to its compiled files.
pub const RefreshOutcome = enum {
/// Recorded in the status table; the previous files, if any, still serve.
failed,
/// The download compiled to the files already on disk, which were left
/// alone.
unchanged,
/// New compiled files were installed.
replaced,
};
/// What reading one enabled source's compiled files produced. The bodies
/// borrow the buffers `loadSource` appended to its caller's list.
const SourceLoad = union(enum) {
loaded: matcher.Snapshot.Compiled,
failed: LoadFailure,
};
fn loadFailure(row: sources_repo.SourceRow, file_name: []const u8, err: anyerror) SourceLoad {
log.warn("blocklist {f}: reading {s} failed: {s}", .{ SourceLabel.of(row), file_name, @errorName(err) });
return .{ .failed = .{ .state = .load_failed, .text = @errorName(err) } };
}
@@ -2608,6 +2666,184 @@ test "a canceled compiled-file read cancels the reload instead of recording it"
}
}
/// Writes one source's compiled files and the checksum and stamp a refresh
/// would have recorded, so a reload loads it.
fn installCompiledSource(
io: std.Io,
database: *db.Db,
dir: std.Io.Dir,
url: []const u8,
list_body: []const u8,
wild_body: []const u8,
last_updated: i64,
) !void {
try sources_repo.insertBlocklistSource(database, .{ .url = url, .name = url }, .{});
var rows = try sources_repo.listSourceRows(database, testing.allocator);
defer rows.deinit(testing.allocator);
defer sources_repo.freeSourceRows(testing.allocator, rows.items);
const id = for (rows.items) |row| {
if (std.mem.eql(u8, row.url, url)) break row.id;
} else unreachable;
var list_buf: [name_buf_len]u8 = undefined;
var wild_buf: [name_buf_len]u8 = undefined;
try dir.writeFile(io, .{ .sub_path = compiledName(&list_buf, id, ".list"), .data = list_body });
try dir.writeFile(io, .{ .sub_path = compiledName(&wild_buf, id, ".wild"), .data = wild_body });
try sources_repo.updateSourceStats(database, id, .{
.last_updated = last_updated,
.domain_count = @intCast(std.mem.count(u8, list_body, "\n")),
.wildcard_count = @intCast(std.mem.count(u8, wild_body, "\n")),
.skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.exception_count = 0,
.checksum = &bodyChecksum(list_body, wild_body, ""),
});
}
/// Forwards to `child` and records the most bytes it held at once.
const PeakAllocator = struct {
child: Allocator,
live: usize = 0,
peak: usize = 0,
fn allocator(self: *PeakAllocator) Allocator {
return .{ .ptr = self, .vtable = &.{
.alloc = alloc,
.resize = resize,
.remap = remap,
.free = free,
} };
}
fn held(self: *PeakAllocator, old_len: usize, new_len: usize) void {
self.live = self.live - old_len + new_len;
self.peak = @max(self.peak, self.live);
}
fn alloc(ctx: *anyopaque, len: usize, alignment: std.mem.Alignment, ret_addr: usize) ?[*]u8 {
const self: *PeakAllocator = @ptrCast(@alignCast(ctx));
const ptr = self.child.rawAlloc(len, alignment, ret_addr) orelse return null;
self.held(0, len);
return ptr;
}
fn resize(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) bool {
const self: *PeakAllocator = @ptrCast(@alignCast(ctx));
if (!self.child.rawResize(memory, alignment, new_len, ret_addr)) return false;
self.held(memory.len, new_len);
return true;
}
fn remap(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, new_len: usize, ret_addr: usize) ?[*]u8 {
const self: *PeakAllocator = @ptrCast(@alignCast(ctx));
const ptr = self.child.rawRemap(memory, alignment, new_len, ret_addr) orelse return null;
self.held(memory.len, new_len);
return ptr;
}
fn free(ctx: *anyopaque, memory: []u8, alignment: std.mem.Alignment, ret_addr: usize) void {
const self: *PeakAllocator = @ptrCast(@alignCast(ctx));
self.child.rawFree(memory, alignment, ret_addr);
self.held(memory.len, 0);
}
};
test "a reload holds one source's bodies at a time, not every source's" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
defer dir.close(io);
var database = try openMigrated();
defer database.close();
// As an ABP list compiles: the same names in `.list` and `.wild`.
var body: std.ArrayList(u8) = .empty;
defer body.deinit(testing.allocator);
for (0..4_000) |i| try body.print(testing.allocator, "n{d:0>5}.example.com\n", .{i});
const source_count = 6;
for (0..source_count) |i| {
var url_buf: [64]u8 = undefined;
const url = try std.fmt.bufPrint(&url_buf, "https://lists.example/{d}.txt", .{i});
try installCompiledSource(io, &database, dir, url, body.items, body.items, 1_700_000_000);
}
const one_source_bytes = 2 * body.items.len;
var peak: PeakAllocator = .{ .child = testing.allocator };
var f: fetcher.Fetcher = undefined;
var mgr: Manager = try .init(peak.allocator(), &database, .{ .dir = tmp.dir }, &f, .{}, .{ .raw = .fromSeconds(30), .clock = .awake });
defer mgr.deinit(io);
try mgr.reload(io);
try testing.expectEqual(@as(u64, 1), mgr.generation);
// What stays live is the snapshot and the status table. Everything above
// that was transient, and every source's bodies at once would be six
// times `one_source_bytes`. A file read may grow its buffer by copying,
// which holds one body twice for a moment, hence two sources' worth.
const transient = peak.peak - peak.live;
try testing.expect(transient < 2 * one_source_bytes);
}
test "the startup pass reuses the boot load when no source is due" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
defer dir.close(io);
var database = try openMigrated();
defer database.close();
const now = std.Io.Clock.real.now(io).toSeconds();
try installCompiledSource(io, &database, dir, "https://lists.example/a.txt", "a.example\n", "", now);
// Never called: a source refreshed `now` is not due.
var f: fetcher.Fetcher = undefined;
var mgr = try testManager(&database, &f);
defer mgr.deinit(io);
mgr.paths = .{ .dir = tmp.dir };
try mgr.reload(io);
try mgr.startupPass(io);
try testing.expectEqual(@as(u64, 1), mgr.generation);
const handle = mgr.acquire(io).?;
defer handle.release(io);
try testing.expectEqual(@as(usize, 1), handle.snapshot.sources.len);
}
test "the startup pass loads the snapshot a failed boot load left unpublished" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var dir = try tmp.dir.createDirPathOpen(io, "blocklists", .{});
defer dir.close(io);
var database = try openMigrated();
defer database.close();
const now = std.Io.Clock.real.now(io).toSeconds();
try installCompiledSource(io, &database, dir, "https://lists.example/a.txt", "a.example\n", "", now);
var f: fetcher.Fetcher = undefined;
var mgr = try testManager(&database, &f);
defer mgr.deinit(io);
mgr.paths = .{ .dir = tmp.dir };
try mgr.startupPass(io);
try testing.expectEqual(@as(u64, 1), mgr.generation);
}
test "committing a status for an id the table has no entry for is not silent" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
@@ -3123,8 +3359,7 @@ test "a source that failed to refresh keeps its failure while its old files serv
statuses[0].fail(.fetch_failed, "HttpStatus");
const rows = [_]sources_repo.SourceRow{testRow(1, true)};
const body: matcher.Snapshot.Compiled = .{ .list_body = "", .wild_body = "" };
applyLoadOutcomes(&statuses, &rows, &.{.{ .loaded = body }});
applyLoadOutcomes(&statuses, &rows, &.{.loaded});
try testing.expect(statuses[0].loaded);
try testing.expectEqual(State.fetch_failed, statuses[0].state);
@@ -3157,8 +3392,7 @@ test "a load failure is recorded when no refresh failure explains it" {
test "a load of a source this process never refreshed takes the row counters" {
var statuses = [_]SourceStatus{.{ .id = 1 }};
const rows = [_]sources_repo.SourceRow{testRow(1, true)};
const body: matcher.Snapshot.Compiled = .{ .list_body = "", .wild_body = "" };
applyLoadOutcomes(&statuses, &rows, &.{.{ .loaded = body }});
applyLoadOutcomes(&statuses, &rows, &.{.loaded});
try testing.expect(statuses[0].loaded);
try testing.expectEqual(State.ok, statuses[0].state);
+240 -126
View File
@@ -100,12 +100,18 @@ pub const SourceSets = struct {
id: i64,
/// Snapshot-arena-owned; the source's display name for the UI.
name: []const u8,
domains: domain_set.DomainSet,
wildcards: domain_set.DomainSet,
/// Every name of the source's `.list` and `.wild` bodies, stored once and
/// flagged with the body it came from: `exact` blocks the name itself,
/// `subdomains` blocks its proper subdomains. An ABP `||x^` line compiles
/// into both bodies, so one entry carries both flags.
names: domain_set.DomainSet,
/// The names this source's `@@` exceptions lift. One entry covers the name
/// and every subdomain of it, because `evaluate` walks the full name and
/// each parent against this set.
exceptions: domain_set.DomainSet,
pub const exact = domain_set.DomainSet.in_first;
pub const subdomains = domain_set.DomainSet.in_second;
};
pub const Group = struct {
@@ -177,114 +183,233 @@ pub const Snapshot = struct {
BadClientPrefix,
} || rules.Error;
/// Builds an immutable snapshot. Every string is copied into the arena, so
/// the caller may free the repository lists immediately afterwards.
/// Disabled sources are skipped entirely — they cost no memory.
/// Builds an immutable snapshot from bodies the caller already holds, every
/// one at once. A caller reading bodies from disk uses `Builder` instead,
/// so it can free each source's bodies before it reads the next.
pub fn build(gpa: Allocator, input: Input) Error!Snapshot {
std.debug.assert(input.group_ids.len == input.groups.len);
std.debug.assert(input.source_ids.len == input.sources.len);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
errdefer arena_state.deinit();
const arena = arena_state.allocator();
// `position[i]` is where `input.sources[i]` landed in `sources`, or
// null when the source is disabled and therefore not loaded.
const position = try arena.alloc(?u32, input.sources.len);
var enabled: u32 = 0;
for (input.sources, position) |row, *slot| {
if (!row.enabled) {
slot.* = null;
continue;
}
slot.* = enabled;
enabled += 1;
}
const sources = try arena.alloc(SourceSets, enabled);
for (input.sources, input.source_ids, position, 0..) |row, id, slot, i| {
const at = slot orelse continue;
var builder: Builder = try .init(gpa, input.sources, input.source_ids, input.seed);
defer builder.deinit();
for (input.sources, 0..) |row, i| {
if (!row.enabled) continue;
if (i >= input.compiled.len) return error.MissingCompiledSource;
const bodies = input.compiled[i] orelse return error.MissingCompiledSource;
sources[at] = .{
.id = id,
.name = try arena.dupe(u8, row.name),
.domains = try domain_set.DomainSet.build(arena, bodies.list_body, input.seed),
.wildcards = try domain_set.DomainSet.build(arena, bodies.wild_body, input.seed),
.exceptions = try domain_set.DomainSet.build(arena, bodies.allow_body, input.seed),
};
try builder.addSource(i, input.compiled[i] orelse return error.MissingCompiledSource);
}
const groups = try arena.alloc(Group, input.groups.len);
var group_rules: std.ArrayList(model.Rule) = .empty;
defer group_rules.deinit(gpa);
var group_sources: std.ArrayList(u32) = .empty;
defer group_sources.deinit(gpa);
for (groups, input.groups, input.group_ids) |*group, row, id| {
group_rules.clearRetainingCapacity();
for (input.rules) |rule_row| {
if (std.mem.eql(u8, rule_row.group, row.name)) try group_rules.append(gpa, rule_row);
}
group_sources.clearRetainingCapacity();
for (input.group_sources) |link| {
if (!std.mem.eql(u8, link.group, row.name)) continue;
const at = try findSource(input.sources, position, link.source_url);
// A link to a disabled source is not an error: the source
// exists, it is simply not loaded.
if (at) |index| try group_sources.append(gpa, index);
}
std.mem.sort(u32, group_sources.items, {}, std.sort.asc(u32));
group.* = .{
.id = id,
.name = try arena.dupe(u8, row.name),
.safe_search = row.safe_search,
// `arena` retains, `gpa` scratches: an arena reclaims only its
// most recent allocation, so a rule build's temporaries taken
// from it would outlive the build and go unreported by
// `memoryBytes`.
.rules = try rules.RuleSet.build(arena, gpa, group_rules.items, input.seed),
.sources = try arena.dupe(u32, dedupSorted(group_sources.items)),
};
}
// Every `group_sources` row must name a group that exists, whether or
// not that group also owns rules.
for (input.group_sources) |link| {
if (indexOfGroup(groups, link.group) == null) return error.UnknownGroup;
}
const clients = try arena.alloc(ClientEntry, input.clients.len);
for (clients, input.clients) |*entry, row| {
const addr = address.NetAddress.parse(row.ip) catch return error.BadClientIp;
entry.* = .{
.key = addr.key(),
.group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
};
}
const prefixes = try arena.alloc(PrefixEntry, input.prefixes.len);
for (prefixes, input.prefixes) |*entry, row| {
entry.* = .{
.prefix = address.Prefix.parse(row.prefix) catch return error.BadClientPrefix,
.group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
.priority = row.priority,
};
}
return .{
.arena = arena_state,
.groups = groups,
.sources = sources,
.clients = clients,
.prefixes = prefixes,
.default_group = indexOfGroup(groups, "default") orelse return error.MissingDefaultGroup,
return builder.finish(.{
.groups = input.groups,
.group_ids = input.group_ids,
.group_sources = input.group_sources,
.rules = input.rules,
.clients = input.clients,
.prefixes = input.prefixes,
.generation = input.generation,
};
});
}
/// Everything but the sources: what `Builder.finish` assigns them to.
pub const Assignments = struct {
groups: []const model.Group,
/// One `groups` row id per `groups[i]`, in the same order.
group_ids: []const i64,
group_sources: []const model.GroupSource,
rules: []const model.Rule,
clients: []const model.Client,
prefixes: []const model.ClientPrefix,
generation: u64,
};
/// A snapshot built one source at a time. Each `addSource` copies what the
/// snapshot keeps into its arena, so the caller may free that source's
/// bodies as soon as the call returns: a reload then holds at most one
/// source's bodies beside the snapshot it is building.
///
/// Every enabled source row is either added or skipped before `finish`. A
/// skipped source is not enforced and costs no memory; an enabled one left
/// pending is `error.MissingCompiledSource`, because a silently unenforced
/// blocklist is exactly the failure PLAN §1.3 exists to prevent.
///
/// `deinit` is always safe to call, before or after `finish`.
pub const Builder = struct {
gpa: Allocator,
/// Null once `finish` has handed the arena to the snapshot.
arena_state: ?std.heap.ArenaAllocator,
rows: []const model.BlocklistSource,
row_ids: []const i64,
/// One per `rows[i]`.
states: []RowState,
/// Capacity: the enabled rows. Length: the rows added so far.
sources: []SourceSets,
added: u32,
/// The lowest row `addSource` still accepts.
next_row: usize,
seed: u64,
const RowState = union(enum) {
pending,
skipped,
/// Index into `sources`.
added: u32,
};
/// `rows` and `row_ids` are borrowed until `finish`. Disabled rows are
/// skipped here.
pub fn init(gpa: Allocator, rows: []const model.BlocklistSource, row_ids: []const i64, seed: u64) Error!Builder {
std.debug.assert(row_ids.len == rows.len);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
errdefer arena_state.deinit();
const arena = arena_state.allocator();
const states = try arena.alloc(RowState, rows.len);
var enabled: usize = 0;
for (rows, states) |row, *state| {
state.* = if (row.enabled) .pending else .skipped;
if (row.enabled) enabled += 1;
}
const sources = try arena.alloc(SourceSets, enabled);
return .{
.gpa = gpa,
.arena_state = arena_state,
.rows = rows,
.row_ids = row_ids,
.states = states,
.sources = sources,
.added = 0,
.next_row = 0,
.seed = seed,
};
}
pub fn deinit(self: *Builder) void {
if (self.arena_state) |*arena| arena.deinit();
self.arena_state = null;
}
/// Builds `rows[row]`'s sets from `bodies`. Nothing of `bodies` is
/// borrowed once this returns.
///
/// Rows are added in ascending order. A source's index in the snapshot
/// is its position among the added rows, and that index decides which
/// of several overlapping sources reports a match.
pub fn addSource(self: *Builder, row: usize, bodies: Compiled) Error!void {
std.debug.assert(self.states[row] == .pending);
std.debug.assert(row >= self.next_row);
const arena = self.arena_state.?.allocator();
self.sources[self.added] = .{
.id = self.row_ids[row],
.name = try arena.dupe(u8, self.rows[row].name),
.names = try domain_set.DomainSet.buildMerged(arena, bodies.list_body, bodies.wild_body, self.seed),
.exceptions = try domain_set.DomainSet.build(arena, bodies.allow_body, self.seed),
};
self.states[row] = .{ .added = self.added };
self.added += 1;
self.next_row = row + 1;
}
/// An enabled source whose bodies could not be loaded. It is not
/// enforced; the caller reports why.
pub fn skipSource(self: *Builder, row: usize) void {
std.debug.assert(self.states[row] == .pending);
self.states[row] = .skipped;
}
pub fn finish(self: *Builder, assignments: Assignments) Error!Snapshot {
std.debug.assert(assignments.group_ids.len == assignments.groups.len);
for (self.states) |state| {
if (state == .pending) return error.MissingCompiledSource;
}
const gpa = self.gpa;
const arena = self.arena_state.?.allocator();
const sources = self.sources[0..self.added];
const groups = try arena.alloc(Group, assignments.groups.len);
var group_rules: std.ArrayList(model.Rule) = .empty;
defer group_rules.deinit(gpa);
var group_sources: std.ArrayList(u32) = .empty;
defer group_sources.deinit(gpa);
for (groups, assignments.groups, assignments.group_ids) |*group, row, id| {
group_rules.clearRetainingCapacity();
for (assignments.rules) |rule_row| {
if (std.mem.eql(u8, rule_row.group, row.name)) try group_rules.append(gpa, rule_row);
}
group_sources.clearRetainingCapacity();
for (assignments.group_sources) |link| {
if (!std.mem.eql(u8, link.group, row.name)) continue;
const at = try self.findSource(link.source_url);
// A link to a disabled or unloaded source is not an error:
// the source exists, it is simply not enforced.
if (at) |index| try group_sources.append(gpa, index);
}
std.mem.sort(u32, group_sources.items, {}, std.sort.asc(u32));
group.* = .{
.id = id,
.name = try arena.dupe(u8, row.name),
.safe_search = row.safe_search,
// `arena` retains, `gpa` scratches: an arena reclaims only
// its most recent allocation, so a rule build's temporaries
// taken from it would outlive the build and go unreported
// by `memoryBytes`.
.rules = try rules.RuleSet.build(arena, gpa, group_rules.items, self.seed),
.sources = try arena.dupe(u32, dedupSorted(group_sources.items)),
};
}
// Every `group_sources` row must name a group that exists, whether
// or not that group also owns rules.
for (assignments.group_sources) |link| {
if (indexOfGroup(groups, link.group) == null) return error.UnknownGroup;
}
const clients = try arena.alloc(ClientEntry, assignments.clients.len);
for (clients, assignments.clients) |*entry, row| {
const addr = address.NetAddress.parse(row.ip) catch return error.BadClientIp;
entry.* = .{
.key = addr.key(),
.group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
};
}
const prefixes = try arena.alloc(PrefixEntry, assignments.prefixes.len);
for (prefixes, assignments.prefixes) |*entry, row| {
entry.* = .{
.prefix = address.Prefix.parse(row.prefix) catch return error.BadClientPrefix,
.group = indexOfGroup(groups, row.group) orelse return error.UnknownGroup,
.priority = row.priority,
};
}
const default_group = indexOfGroup(groups, "default") orelse return error.MissingDefaultGroup;
const snapshot: Snapshot = .{
.arena = self.arena_state.?,
.groups = groups,
.sources = sources,
.clients = clients,
.prefixes = prefixes,
.default_group = default_group,
.generation = assignments.generation,
};
self.arena_state = null;
return snapshot;
}
/// The position in `sources`, or null when the named source exists but
/// is not enforced. A URL naming no configured source is an error: the
/// group would silently enforce one list fewer than the operator
/// configured.
fn findSource(self: *const Builder, url: []const u8) error{UnknownSource}!?u32 {
for (self.rows, self.states) |row, state| {
if (!std.mem.eql(u8, row.url, url)) continue;
return switch (state) {
.added => |index| index,
.pending, .skipped => null,
};
}
return error.UnknownSource;
}
};
pub fn deinit(self: *Snapshot) void {
self.arena.deinit();
self.* = undefined;
@@ -369,7 +494,7 @@ pub const Snapshot = struct {
}
for (g.sources) |index| {
if (self.sources[index].domains.contains(domain)) {
if (self.sources[index].names.flags(domain) & SourceSets.exact != 0) {
return .{
.blocked = true,
.reason = .blocklist_domain,
@@ -384,7 +509,7 @@ pub const Snapshot = struct {
_ = parents.next();
while (parents.next()) |parent| {
for (g.sources) |index| {
if (self.sources[index].wildcards.contains(parent)) {
if (self.sources[index].names.flags(parent) & SourceSets.subdomains != 0) {
return .{
.blocked = true,
.reason = .blocklist_wildcard,
@@ -434,8 +559,7 @@ pub const Snapshot = struct {
var total: usize = 0;
for (self.sources) |*source| {
total += @sizeOf(SourceSets) + source.name.len +
source.domains.memoryBytes() + source.wildcards.memoryBytes() +
source.exceptions.memoryBytes();
source.names.memoryBytes() + source.exceptions.memoryBytes();
}
for (self.groups) |*group| {
total += @sizeOf(Group) + group.name.len +
@@ -468,20 +592,6 @@ fn indexOfGroup(groups: []const Group, group_name: []const u8) ?u32 {
return null;
}
/// The position in the loaded `sources` array, or null when the named source
/// exists but is disabled. A URL naming no configured source is an error: the
/// group would silently enforce one list fewer than the operator configured.
fn findSource(
rows: []const model.BlocklistSource,
position: []const ?u32,
url: []const u8,
) error{UnknownSource}!?u32 {
for (rows, position) |row, slot| {
if (std.mem.eql(u8, row.url, url)) return slot;
}
return error.UnknownSource;
}
fn dedupSorted(items: []u32) []const u32 {
var kept: usize = 0;
for (items) |item| {
@@ -1347,14 +1457,18 @@ test "memoryBytes stays within the compiled-data bound" {
try body.appendSlice(gpa, try std.fmt.bufPrint(&line, "d{d:0>5}.example.com\n", .{i}));
}
const lists: Lists = .init(body.items, "");
// Identical bodies, as an ABP list compiles them: the names are stored
// once, so one arena and one index bound the lot.
const lists: Lists = .init(body.items, body.items);
var snapshot = try build(gpa, lists.fixture());
defer snapshot.deinit();
// arena + index, over both bodies, plus a kilobyte of struct overhead.
const index_bytes = 2 * 16 * 1024 * @sizeOf(u32);
try testing.expect(snapshot.memoryBytes() < 2 * body.items.len + index_bytes + 1024);
// Per name: the length prefix in place of the newline, plus the flag byte.
const arena_bytes = body.items.len + count;
const index_bytes = 16 * 1024 * @sizeOf(u32);
try testing.expect(snapshot.memoryBytes() < arena_bytes + index_bytes + 1024);
try testing.expect(snapshot.evaluate(0, "d00042.example.com").blocked);
try testing.expect(snapshot.evaluate(0, "x.d00042.example.com").blocked);
}
fn buildUnderFailure(gpa: Allocator) !void {
+5 -1
View File
@@ -20,6 +20,7 @@
const std = @import("std");
const net = std.Io.net;
const concurrency = @import("../platform/concurrency.zig");
const transport = @import("../upstream/transport.zig");
const validate = @import("../config/validate.zig");
const dns_header = @import("../dns/header.zig");
@@ -193,7 +194,10 @@ pub const ForwardClient = struct {
while (true) {
const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) {
error.Timeout => return error.Timeout,
error.ConcurrencyUnavailable => return error.SystemResources,
error.ConcurrencyUnavailable => {
concurrency.unavailable(.forward_receive);
return error.SystemResources;
},
else => return transport.mapPhase(err, error.ReceiveFailed),
};
+92
View File
@@ -0,0 +1,92 @@
//! Where `error.ConcurrencyUnavailable` surfaced (specs/memory-budget.md item 3).
//!
//! The process's `std.Io.Threaded` sets no task limit: the work is bounded
//! where it enters, by the shared query budget and each listener's connection
//! cap, and the pool grows to that peak. So this error means the system
//! refused a thread or the pool could not allocate a task. Either way the work
//! it cost is lost, and each boundary that can lose work to it counts here
//! under its own site rather than folding it into a generic failure.
//!
//! Process-wide, like the log sink's counters: the failure belongs to the one
//! `Threaded` every subsystem shares, not to any one of them, and a counter
//! beside each call site keeps transport helpers free of a stats pointer they
//! would otherwise thread through for this alone. Never reset,
//! so a test reads a delta, not a value.
const std = @import("std");
pub const Site = enum {
/// A UDP datagram that held a query permit but got no task to answer it.
/// The datagram is dropped.
udp_query,
/// A UDP receive whose timed wait could not start.
udp_receive,
/// An accepted stream connection that got no task. The stream is closed.
connection,
/// A stream listener's read, write or TLS handshake that could not start
/// its race against the idle budget. The connection ends.
listener_race,
/// An upstream or forward-zone exchange that could not start its race
/// against the query's deadline. The exchange fails as a local resource
/// fault.
exchange_race,
/// A forward-zone receive whose timed wait could not start.
forward_receive,
/// A blocklist download that could not start its race against its budget.
blocklist_fetch,
/// The query-log writer's wait for the next entry. The writer flushes what
/// it holds instead of waiting out the interval.
querylog_wait,
/// A long-running task the server starts at boot or on a query-log resize.
/// The start or the resize fails.
service,
};
var counts: std.enums.EnumArray(Site, std.atomic.Value(u64)) = .initFill(.init(0));
pub fn unavailable(site: Site) void {
_ = counts.getPtr(site).fetchAdd(1, .monotonic);
}
/// `result` unchanged, with a `ConcurrencyUnavailable` in it counted under
/// `site`: for a spawn whose failure its caller propagates.
pub fn counted(site: Site, result: anytype) @TypeOf(result) {
if (result) |_| {} else |err| switch (err) {
error.ConcurrencyUnavailable => unavailable(site),
}
return result;
}
pub const Snapshot = std.enums.EnumArray(Site, u64);
pub fn snapshot() Snapshot {
var out: Snapshot = .initFill(0);
for (std.enums.values(Site)) |site| out.set(site, counts.getPtrConst(site).load(.monotonic));
return out;
}
test "a propagated spawn failure is counted and passed through" {
const before = snapshot().get(.service);
const failed: error{ConcurrencyUnavailable}!u8 = error.ConcurrencyUnavailable;
try std.testing.expectError(error.ConcurrencyUnavailable, counted(.service, failed));
const started: error{ConcurrencyUnavailable}!u8 = 7;
try std.testing.expectEqual(@as(u8, 7), try counted(.service, started));
try std.testing.expectEqual(@as(u64, 1), snapshot().get(.service) - before);
}
test "each site counts on its own" {
const before = snapshot();
unavailable(.querylog_wait);
unavailable(.querylog_wait);
unavailable(.udp_query);
const after = snapshot();
for (std.enums.values(Site)) |site| {
const expected: u64 = switch (site) {
.querylog_wait => 2,
.udp_query => 1,
else => 0,
};
try std.testing.expectEqual(expected, after.get(site) - before.get(site));
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Opting the process out of transparent huge pages.
//!
//! With THP set to `always`, the kernel backs any 2 MiB-aligned anonymous
//! region with a huge page on first touch. Each worker thread's stack mapping
//! qualifies, so a thread that touches a few KiB of stack can hold 2 MiB
//! resident, and `std.Io.Threaded` never retires a worker: one burst on such a
//! host left about 40 of 200 workers at 2 MiB each. nxdns's memory is sized
//! for small pages, so the whole process opts out instead of chasing each
//! mapping.
const std = @import("std");
const linux = std.os.linux;
pub const DisableError = error{
/// A kernel older than 3.15, which has no such switch.
Unsupported,
Unexpected,
};
/// Sets `PR_SET_THP_DISABLE`, which covers every mapping of the process,
/// existing and future, and is inherited across `fork`.
pub fn disable() DisableError!void {
const rc = linux.prctl(@intFromEnum(linux.PR.SET_THP_DISABLE), 1, 0, 0, 0);
switch (linux.errno(rc)) {
.SUCCESS => {},
.INVAL => return error.Unsupported,
else => return error.Unexpected,
}
}
test "disable takes effect for the process" {
disable() catch |err| switch (err) {
// qemu-user, which runs the aarch64 suite, rejects the option the way
// a pre-3.15 kernel does.
error.Unsupported => return error.SkipZigTest,
error.Unexpected => return err,
};
const rc = linux.prctl(@intFromEnum(linux.PR.GET_THP_DISABLE), 0, 0, 0, 0);
try std.testing.expectEqual(linux.E.SUCCESS, linux.errno(rc));
try std.testing.expectEqual(@as(usize, 1), rc);
}
+201
View File
@@ -0,0 +1,201 @@
//! The kernel's own counters for this process's UDP sockets, read from
//! `/proc/net/udp` and `/proc/net/udp6`.
//!
//! A UDP listener that is waiting for a query permit leaves datagrams in its
//! socket's receive buffer, and what overflows that buffer is dropped by the
//! kernel before nxdns ever sees it. Only the kernel can count those, so each
//! listener's socket is found in the tables by its inode and the row's
//! `rx_queue` (bytes waiting) and `drops` are reported as they are.
const std = @import("std");
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.udp_socket_stats);
pub const Reading = struct {
receive_queue_bytes: u64 = 0,
drops: u64 = 0,
};
/// The tables nxdns reads, IPv4 then IPv6. A dual-stack socket bound to `::`
/// appears only in the second.
pub const tables = [_][]const u8{ "/proc/net/udp", "/proc/net/udp6" };
/// A table larger than this is not a household host's, and a scrape stops
/// rather than allocate without bound.
const max_table_bytes = 4 * 1024 * 1024;
var read_failures: std.atomic.Value(u64) = .init(0);
/// Scrapes whose reading failed, process-wide like the log sink's counters: a
/// failed read leaves the families out of that scrape, and this is what says
/// why they are missing. Never reset.
pub fn readFailures() u64 {
return read_failures.load(.monotonic);
}
pub const ReadError = error{
/// A socket in `inodes` was in neither table.
SocketNotFound,
/// A row did not have the shape the kernel has printed since 2.6.
Malformed,
TableTooLarge,
OutOfMemory,
Unreadable,
};
/// The sum over `sockets`, or null with the failure counted. Every socket must
/// be found, so a partial sum is never reported as the whole.
pub fn readSockets(io: std.Io, arena: Allocator, sockets: []const std.Io.net.Socket) ?Reading {
return readOrError(io, arena, sockets) catch |err| {
_ = read_failures.fetchAdd(1, .monotonic);
log.debug("udp socket counters unavailable: {t}", .{err});
return null;
};
}
fn readOrError(io: std.Io, arena: Allocator, sockets: []const std.Io.net.Socket) (ReadError || std.Io.File.StatError)!Reading {
const inodes = try arena.alloc(u64, sockets.len);
for (sockets, inodes) |socket, *inode| inode.* = try socketInode(io, socket);
return read(io, arena, inodes);
}
/// The sum over the sockets in `inodes`. Every one must be found, so a partial
/// sum is never reported as the whole.
pub fn read(io: std.Io, arena: Allocator, inodes: []const u64) ReadError!Reading {
var reading: Reading = .{};
var found: usize = 0;
for (tables) |path| {
const file = std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| switch (err) {
// A kernel without IPv6 has no `udp6` table, and then no socket
// can be in it.
error.FileNotFound => continue,
else => return error.Unreadable,
};
defer file.close(io);
// Streaming: procfs reports a size of zero, which a positional read
// takes for an empty file.
var reader = file.readerStreaming(io, &.{});
const table = reader.interface.allocRemaining(arena, .limited(max_table_bytes)) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.StreamTooLong => return error.TableTooLarge,
error.ReadFailed => return error.Unreadable,
};
found += try accumulate(table, inodes, &reading);
}
if (found != inodes.len) return error.SocketNotFound;
return reading;
}
/// Adds the rows of one table whose inode is in `inodes` to `into`, and
/// returns how many matched.
pub fn accumulate(table: []const u8, inodes: []const u64, into: *Reading) error{Malformed}!usize {
var lines = std.mem.tokenizeScalar(u8, table, '\n');
// The header names the columns; the rows follow it.
_ = lines.next() orelse return 0;
var found: usize = 0;
while (lines.next()) |line| {
const row = try parseRow(line);
if (std.mem.indexOfScalar(u64, inodes, row.inode) == null) continue;
into.receive_queue_bytes += row.rx_queue;
into.drops += row.drops;
found += 1;
}
return found;
}
const Row = struct { rx_queue: u64, inode: u64, drops: u64 };
/// `sl local rem st tx_queue:rx_queue tr:tm->when retrnsmt uid timeout inode
/// ref pointer drops`, as `udp4_format_sock` and `__ip6_dgram_sock_seq_show`
/// print them.
fn parseRow(line: []const u8) error{Malformed}!Row {
var fields: [13][]const u8 = undefined;
var it = std.mem.tokenizeScalar(u8, line, ' ');
for (&fields) |*field| field.* = it.next() orelse return error.Malformed;
const queues = fields[4];
const colon = std.mem.indexOfScalar(u8, queues, ':') orelse return error.Malformed;
return .{
.rx_queue = std.fmt.parseInt(u64, queues[colon + 1 ..], 16) catch return error.Malformed,
.inode = std.fmt.parseInt(u64, fields[9], 10) catch return error.Malformed,
.drops = std.fmt.parseInt(u64, fields[12], 10) catch return error.Malformed,
};
}
/// The inode `/proc/net/udp` names this socket by.
pub fn socketInode(io: std.Io, socket: std.Io.net.Socket) std.Io.File.StatError!u64 {
const file: std.Io.File = .{ .handle = socket.handle, .flags = .{ .nonblocking = false } };
return @intCast((try file.stat(io)).inode);
}
const testing = std.testing;
const udp4_table =
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ref pointer drops \n" ++
" 123: 00000000:0035 00000000:0000 07 00000000:00000a00 00:00000000 00000000 997 0 41001 2 0000000000000000 3 \n" ++
" 456: 0100007F:0277 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 52 2 0000000000000000 9 \n";
const udp6_table =
" sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ref pointer drops\n" ++
" 789: 00000000000000000000000000000000:0035 00000000000000000000000000000000:0000 07 00000000:00000100 00:00000000 00000000 997 0 41002 2 0000000000000000 4\n";
test "only the named sockets are summed" {
var reading: Reading = .{};
try testing.expectEqual(@as(usize, 1), try accumulate(udp4_table, &.{ 41001, 41002 }, &reading));
try testing.expectEqual(@as(usize, 1), try accumulate(udp6_table, &.{ 41001, 41002 }, &reading));
try testing.expectEqual(@as(u64, 0xa00 + 0x100), reading.receive_queue_bytes);
try testing.expectEqual(@as(u64, 3 + 4), reading.drops);
}
test "a header with no rows matches nothing" {
var reading: Reading = .{};
const header_only = udp4_table[0 .. std.mem.indexOfScalar(u8, udp4_table, '\n').? + 1];
try testing.expectEqual(@as(usize, 0), try accumulate(header_only, &.{41001}, &reading));
try testing.expectEqual(@as(usize, 0), try accumulate("", &.{41001}, &reading));
}
test "a short or garbled row is malformed, not skipped" {
var reading: Reading = .{};
try testing.expectError(error.Malformed, accumulate("header\n 1: 0:0 0:0 07\n", &.{1}, &reading));
try testing.expectError(
error.Malformed,
accumulate("header\n 1: a b 07 00000000 0 0 0 0 7 2 0 0\n", &.{7}, &reading),
);
}
test "a bound socket is found in the live tables with its own counters" {
const io = testing.io;
const any: std.Io.net.IpAddress = .{ .ip4 = .loopback(0) };
const socket = try any.bind(io, .{ .mode = .dgram });
defer socket.close(io);
const inode = try socketInode(io, socket);
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const reading = read(io, arena.allocator(), &.{inode}) catch |err| switch (err) {
// A sandbox without procfs cannot run this; the parser tests above
// still cover the format.
error.Unreadable => return error.SkipZigTest,
else => return err,
};
try testing.expectEqual(@as(u64, 0), reading.receive_queue_bytes);
try testing.expectEqual(@as(u64, 0), reading.drops);
const before = readFailures();
try testing.expect(readSockets(io, arena.allocator(), &.{socket}) != null);
try testing.expectEqual(before, readFailures());
}
test "a socket the tables do not list fails the whole reading" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Inode 0 is never a socket's.
if (read(testing.io, arena.allocator(), &.{0})) |_| {
return error.TestUnexpectedResult;
} else |err| switch (err) {
error.SocketNotFound => {},
error.Unreadable => return error.SkipZigTest,
else => return err,
}
}
+89
View File
@@ -0,0 +1,89 @@
//! A futex word that counts releases, so a task can wait for "something was
//! released since I looked" with a deadline on its own thread.
//!
//! `std.Io.Condition` and `std.Io.Semaphore` have no timed wait in 0.16, and
//! racing one against a timer costs two extra tasks per waiter. A waiter reads
//! `observe` *before* it checks for a free resource; a release bumps the word
//! *after* freeing one. A release that lands between the check and the wait
//! therefore changes the word, and the wait returns at once instead of
//! sleeping through it.
//!
//! Like any futex, a wait can return spuriously, so callers loop on their own
//! condition. A waiter that leaves without taking what it was woken for — a
//! timeout or a cancel that consumed a `wakeOne` — must call `wakeOne` again
//! if something is still free, or a peer sleeps through it.
const std = @import("std");
pub const WakeWord = struct {
releases: std.atomic.Value(u32) = .init(0),
pub fn observe(self: *const WakeWord) u32 {
return self.releases.load(.acquire);
}
/// Returns on a wake, a spurious wake, or `deadline`; `null` waits for a
/// wake only. Returns at once when the word has moved past `seen`.
pub fn wait(
self: *const WakeWord,
io: std.Io,
seen: u32,
deadline: ?std.Io.Clock.Timestamp,
) std.Io.Cancelable!void {
const timeout: std.Io.Timeout = if (deadline) |at| .{ .deadline = at } else .none;
return io.futexWaitTimeout(u32, &self.releases.raw, seen, timeout);
}
pub fn wakeOne(self: *WakeWord, io: std.Io) void {
_ = self.releases.fetchAdd(1, .release);
io.futexWake(u32, &self.releases.raw, 1);
}
pub fn wakeAll(self: *WakeWord, io: std.Io) void {
_ = self.releases.fetchAdd(1, .release);
io.futexWake(u32, &self.releases.raw, std.math.maxInt(u32));
}
};
const testing = std.testing;
test "a release between observe and wait is not slept through" {
var word: WakeWord = .{};
const seen = word.observe();
word.wakeOne(testing.io);
// No deadline: a lost wake would hang this test.
try word.wait(testing.io, seen, null);
}
test "a wait with nothing released returns at its deadline" {
const io = testing.io;
var word: WakeWord = .{};
const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromMilliseconds(20), .clock = .awake });
try word.wait(io, word.observe(), deadline);
try testing.expect(std.Io.Clock.Timestamp.now(io, .awake).compare(.gte, deadline));
}
fn waitForever(word: *const WakeWord, io: std.Io, seen: u32) std.Io.Cancelable!void {
return word.wait(io, seen, null);
}
test "wakeAll releases every waiter" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var word: WakeWord = .{};
const seen = word.observe();
var first = io.concurrent(waitForever, .{ &word, io, seen }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
var second = io.concurrent(waitForever, .{ &word, io, seen }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
_ = first.cancel(io) catch {};
return error.SkipZigTest;
},
};
word.wakeAll(io);
try first.await(io);
try second.await(io);
}
+44 -24
View File
@@ -11,7 +11,7 @@
//!
//! The handshake runs under the same race budget tcp_server applies to its
//! reads (ruling 3's rationale): a client that connects and never handshakes
//! must not pin one of the 64 slots forever.
//! must not pin a slot forever.
//!
//! Over capacity the raw TCP stream is closed and counted, with no response.
//! The web listener's 503 is not possible here: HTTP exists only on the far
@@ -19,6 +19,11 @@
//! certificate acquisition — exactly what an over-capacity listener does not
//! have to spend. Closing is the cheapest honest refusal.
//!
//! A query holds a permit from the shared query budget from its decoded body
//! to its sent response. With none free the connection is closed unanswered
//! and counted, the TCP/53 rule: a 503 would be the one DNS refusal that
//! differs by transport.
//!
//! Only `/dns-query` exists. This is not the admin API, so errors are plain
//! text, not JSON, and there are no cache-control headers (LAN, no
//! intermediaries).
@@ -34,6 +39,7 @@ const doh_client = @import("../upstream/doh_client.zig");
const handler = @import("handler.zig");
const listener = @import("listener.zig");
const model = @import("../config/model.zig");
const query_budget = @import("query_budget.zig");
const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
@@ -52,12 +58,10 @@ pub const alpn_protocols: [*:null]const ?[*:0]const u8 = &.{"http/1.1"};
const recv_buffer_len = 8 * 1024;
const send_buffer_len = 4 * 1024;
pub const default_max_connections: u16 = 64;
const allow_header: http.Header = .{ .name = "allow", .value = "GET, POST" };
pub const Options = struct {
max_connections: u16 = default_max_connections,
max_connections: u16 = listener.dns_max_connections,
/// The budget for the TLS handshake and for the wait on the next request
/// head of a keep-alive connection (milestone-16 ruling 10). A request
/// already being served has no timeout, like the web listener: the port is
@@ -92,22 +96,17 @@ pub const Snapshot = struct {
pub const DohServer = struct {
core: listener.Core(Config),
handler: *handler.Handler,
budget: *query_budget.QueryBudget,
certs: *cert_store.CertStore,
options: Options,
stats: Stats,
/// One slot is ~150 KiB, so the default 64 connections cost ~9.4 MiB. The
/// two message buffers cannot shrink: a POST body and the reply both go up
/// to the 65535 bytes a DNS message can be. The `ServerStream` plaintext
/// buffers belong to the core; its `read_buf` doubles as the HTTP head cap
/// (see `recv_buffer_len`).
/// The `ServerStream` plaintext buffers belong to the core; its `read_buf`
/// doubles as the HTTP head cap (see `recv_buffer_len`).
pub const Payload = struct {
/// The decoded query: a POST body or a GET `dns` parameter.
/// The decoded query: a POST body or a GET `dns` parameter, up to the
/// 65535 bytes a DNS message can be. Read before a permit is taken.
query: [transport.max_message_len]u8,
reply: [transport.max_message_len]u8,
/// The handler's per-query working memory. A connection is answered
/// serially, so one query uses it at a time.
scratch: handler.Scratch,
/// Valid between a successful `ServerStream.accept` and the
/// `close(gpa)` in `serveOne`'s defer.
tls: tls_server.ServerStream,
@@ -117,6 +116,7 @@ pub const DohServer = struct {
pub const Owner = DohServer;
pub const ConnPayload = Payload;
pub const serveConn = serveOne;
pub const closing = wakeBudgetWaiters;
pub const read_buffer_len = recv_buffer_len;
pub const write_buffer_len = send_buffer_len;
pub const log = std.log.scoped(.doh_server);
@@ -131,12 +131,14 @@ pub const DohServer = struct {
io: std.Io,
listen_address: net.IpAddress,
h: *handler.Handler,
budget: *query_budget.QueryBudget,
certs: *cert_store.CertStore,
options: Options,
) ListenError!DohServer {
return .{
.core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.handler = h,
.budget = budget,
.certs = certs,
.options = options,
.stats = .{},
@@ -171,6 +173,10 @@ pub const DohServer = struct {
self.core.serve(io);
}
fn wakeBudgetWaiters(self: *DohServer, io: std.Io) void {
self.budget.wakeWaiters(io);
}
pub fn deinit(self: *DohServer, io: std.Io) void {
self.core.deinit(io);
self.* = undefined;
@@ -192,7 +198,7 @@ pub const DohServer = struct {
const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io };
switch (listener.handshakeStage(io, self.options.idle_timeout, stage)) {
.ok => {},
.canceled => return,
.canceled, .unavailable => return,
// Milestone-16 ruling 9: a stalled handshake is refused like a broken
// one, the DoT arrangement. `idle_timeouts` belongs to the keep-alive
// wait below, so the two listeners export the same names for the
@@ -220,9 +226,10 @@ pub const DohServer = struct {
listener.bump(&stats.idle_timeouts);
return;
},
// Cancellation is shutdown; `.failed` here is only the wrapper
// failing to start, which costs nothing and counts as nothing.
.canceled, .failed => return,
// Cancellation is shutdown, and `.unavailable` is counted by
// the race. `.failed` cannot happen: `receiveHeadInto` stores
// the head's error instead of returning it.
.canceled, .unavailable, .failed => return,
}
var request = head catch |err| switch (err) {
@@ -385,13 +392,19 @@ pub const DohServer = struct {
request: *http.Server.Request,
query: []const u8,
) error{ WriteFailed, HttpExpectationFailed }!Next {
// Bounded like a read: a query still waiting when the idle budget runs
// out closes the connection, with the timeout counted by the budget.
const permit_deadline: std.Io.Clock.Timestamp = .fromNow(io, self.options.idle_timeout);
const work = self.budget.acquire(io, permit_deadline, &self.core.run_state) catch return .close;
defer self.budget.release(io, work);
const outcome = self.handler.handle(
io,
.tcp,
address.NetAddress.fromIp(conn.peer),
query,
&conn.payload.reply,
&conn.payload.scratch,
&work.reply,
&work.scratch,
);
switch (outcome) {
.drop => return self.refuse(request, .bad_request, "bad request\n", &.{}, false),
@@ -626,6 +639,7 @@ const Harness = struct {
upstream: FailingUpstream,
upstream_owner: upstream_owner.Borrowed,
h: handler.Handler,
permits: query_budget.QueryBudget,
server: DohServer,
group: std.Io.Group,
@@ -659,8 +673,11 @@ const Harness = struct {
.local_tables = &hx.tables,
};
hx.permits = try .init(testing.allocator, 4);
errdefer hx.permits.deinit();
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, options);
hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.permits, &hx.store, options);
errdefer hx.server.deinit(hio);
hx.group = .init;
@@ -673,6 +690,7 @@ const Harness = struct {
hx.group.await(hio) catch |err| switch (err) {
error.Canceled => unreachable,
};
hx.permits.deinit();
hx.store.deinit(hio);
hx.tables.deinit(testing.allocator);
hx.tmp.cleanup();
@@ -1216,9 +1234,11 @@ fn twoPostsOneConnection(io: std.Io, remote: net.IpAddress) anyerror!void {
try conn.end();
}
/// Long enough for a loopback round trip, short enough that the `bounded`
/// budget still fails a server that never reclaims the connection.
const short_idle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(300), .clock = .awake };
/// The TLS handshake runs inside this budget too: a Debug-build handshake takes
/// about 100 ms on an idle machine, and a loaded CI runner failed it at
/// 300 ms. The `bounded` budget still fails a server that never reclaims the
/// connection.
const short_idle: std.Io.Clock.Duration = .{ .raw = .fromSeconds(2), .clock = .awake };
/// One request, then silence on a connection the client keeps open. The server
/// owes no answer, so the only thing that can end the read is the idle budget.
+90 -55
View File
@@ -18,6 +18,8 @@
//! TCP/53 each connection costs one allocation. The pool itself is still
//! fixed and pre-allocated.
//!
//! A query holds a permit from the shared query budget exactly as on TCP/53.
//!
//! ALPN belongs to the `CertStore`'s `ServerContext` (set at store init);
//! this listener only acquires entries.
@@ -26,17 +28,18 @@ const address = @import("../platform/address.zig");
const cert_store = @import("cert_store.zig");
const handler = @import("handler.zig");
const listener = @import("listener.zig");
const query_budget = @import("query_budget.zig");
const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
/// Plaintext staging for `ServerStream`: the framing bytes and the decrypted
/// record tail pass through here, while whole messages go straight to
/// `Payload.query`/`Payload.reply`.
/// `Payload.query` and from the permit's reply.
const stream_buffer_len = 1024;
pub const Options = struct {
max_connections: u16 = 64,
max_connections: u16 = listener.dns_max_connections,
/// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance, and the
/// handshake runs under the same budget.
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
@@ -53,6 +56,7 @@ pub const Stats = struct {
/// walks for the `nxdns_dot_server_*` families.
pub const StatsSnapshot = struct {
connections: u64,
rejected_at_capacity: u64,
tls_handshake_failures: u64,
idle_timeouts: u64,
connection_errors: u64,
@@ -61,20 +65,14 @@ pub const StatsSnapshot = struct {
pub const DotServer = struct {
core: listener.Core(Config),
handler: *handler.Handler,
budget: *query_budget.QueryBudget,
certs: *cert_store.CertStore,
options: Options,
stats: Stats,
/// One slot is ~137 KiB — the same two message ceilings as TCP/53 plus the
/// `ServerStream` bookkeeping — so the default 64 connections stay inside
/// the PLAN §18 budget.
pub const Payload = struct {
/// Read before a permit is taken, as on TCP/53.
query: [transport.max_message_len]u8,
reply: [transport.max_message_len]u8,
/// The handler's per-query working memory. It belongs to the slot so
/// that answering a message allocates nothing, and a connection is
/// answered serially, so one query uses it at a time.
scratch: handler.Scratch,
/// Pinned once its `accept` succeeds: mbedTLS holds a pointer to it,
/// and the slot never moves.
tls: tls_server.ServerStream,
@@ -84,6 +82,7 @@ pub const DotServer = struct {
pub const Owner = DotServer;
pub const ConnPayload = Payload;
pub const serveConn = serveOne;
pub const closing = wakeBudgetWaiters;
pub const read_buffer_len = stream_buffer_len;
pub const write_buffer_len = stream_buffer_len;
pub const log = std.log.scoped(.dot_server);
@@ -98,12 +97,14 @@ pub const DotServer = struct {
io: std.Io,
listen_address: std.Io.net.IpAddress,
h: *handler.Handler,
budget: *query_budget.QueryBudget,
certs: *cert_store.CertStore,
options: Options,
) ListenError!DotServer {
return .{
.core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.handler = h,
.budget = budget,
.certs = certs,
.options = options,
.stats = .{},
@@ -120,6 +121,10 @@ pub const DotServer = struct {
self.core.serve(io);
}
fn wakeBudgetWaiters(self: *DotServer, io: std.Io) void {
self.budget.wakeWaiters(io);
}
pub fn deinit(self: *DotServer, io: std.Io) void {
self.core.deinit(io);
self.* = undefined;
@@ -133,6 +138,7 @@ pub const DotServer = struct {
const core = &self.core.stats;
return .{
.connections = core.connections.load(.monotonic),
.rejected_at_capacity = core.rejected_at_capacity.load(.monotonic),
.tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic),
.idle_timeouts = core.idle_timeouts.load(.monotonic),
.connection_errors = core.connection_errors.load(.monotonic),
@@ -157,7 +163,7 @@ pub const DotServer = struct {
const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io };
switch (listener.handshakeStage(io, budget, stage)) {
.ok => {},
.canceled => return,
.canceled, .unavailable => return,
// A stalled handshake is refused like a broken one: it must not
// pin a connection slot for longer than the idle budget.
.timed_out, .failed => {
@@ -181,7 +187,7 @@ pub const DotServer = struct {
listener.bump(&stats.idle_timeouts);
return;
},
.canceled => return,
.canceled, .unavailable => return,
// A transport EOF without close_notify lands here too: the
// stream reads it as a truncation, never as a clean end.
.failed => {
@@ -208,7 +214,7 @@ pub const DotServer = struct {
switch (listener.race(io, budget, listener.readBody, .{ reader, conn.payload.query[0..len] })) {
.ok => {},
.canceled => return,
.canceled, .unavailable => return,
// A half-sent message is a broken peer, not an idle one.
.timed_out, .failed => {
listener.bump(&stats.connection_errors);
@@ -216,13 +222,20 @@ pub const DotServer = struct {
},
}
// Bounded like a read: a query still waiting when the idle budget
// runs out ends the connection, with the timeout counted by the
// budget. There is no framing for "no answer".
const permit_deadline: std.Io.Clock.Timestamp = .fromNow(io, budget);
const work = self.budget.acquire(io, permit_deadline, &self.core.run_state) catch return;
defer self.budget.release(io, work);
const outcome = self.handler.handle(
io,
.tcp,
address.NetAddress.fromIp(conn.peer),
conn.payload.query[0..len],
&conn.payload.reply,
&conn.payload.scratch,
&work.reply,
&work.scratch,
);
const bytes = switch (outcome) {
// There is no framing for "no answer", so the connection ends.
@@ -233,7 +246,7 @@ pub const DotServer = struct {
const out = transport.framePrefix(@intCast(bytes.len));
switch (listener.race(io, budget, listener.writeReply, .{ writer, &out, bytes })) {
.ok => {},
.canceled => return,
.canceled, .unavailable => return,
.timed_out, .failed => {
listener.bump(&stats.connection_errors);
return;
@@ -326,9 +339,11 @@ fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
const test_budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };
/// Short enough to keep the idle-timeout test quick, long enough that a
/// loopback handshake cannot lose to scheduling and time out on its own.
const short_idle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(300), .clock = .awake };
/// The handshake runs inside this budget too: a Debug-build handshake takes
/// about 100 ms on an idle machine, and a loaded CI runner failed it at
/// 300 ms. `test_budget` still fails a server that never reclaims the
/// connection.
const short_idle: std.Io.Clock.Duration = .{ .raw = .fromSeconds(2), .clock = .awake };
/// What the store advertises in production; std.crypto.tls.Client cannot send
/// ALPN in 0.16, so this also proves no-ALPN clients still connect.
@@ -575,11 +590,21 @@ test "dot: two framed queries share one TLS connection" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, DotServer.serve, .{ &server, io });
// Deferred so a failed check still stops the server: a `serve` left in
// accept makes `threaded.deinit` wait for it forever.
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, dotKeepAlive, .{ io, server_address });
@@ -591,11 +616,6 @@ test "dot: two framed queries share one TLS connection" {
try testing.expectEqual(@as(u64, 0), stats.idle_timeouts);
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic));
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
test "dot: a transport EOF without close_notify is a connection error, not a crash" {
@@ -616,11 +636,19 @@ test "dot: a transport EOF without close_notify is a connection error, not a cra
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, DotServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, dotDropWithoutCloseNotify, .{ io, server_address });
try waitForCounter(io, &server.core.stats.connection_errors, 1);
@@ -630,11 +658,6 @@ test "dot: a transport EOF without close_notify is a connection error, not a cra
try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures);
try testing.expectEqual(@as(u64, 1), stats.connection_errors);
try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic));
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
test "dot: plain TCP bytes fail the handshake and are counted" {
@@ -655,11 +678,19 @@ test "dot: plain TCP bytes fail the handshake and are counted" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, DotServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, dotGarbageHandshake, .{ io, server_address });
try waitForCounter(io, &server.stats.tls_handshake_failures, 1);
@@ -669,11 +700,6 @@ test "dot: plain TCP bytes fail the handshake and are counted" {
try testing.expectEqual(@as(u64, 1), stats.tls_handshake_failures);
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
/// Ruling 6 across a live listener: a reload retires the old generation
@@ -742,11 +768,19 @@ test "dot: a reload serves new handshakes without breaking the old connection" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, DotServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, dotReloadKeepsOldConnAndServesNew, .{ io, server_address, &env });
@@ -760,11 +794,6 @@ test "dot: a reload serves new handshakes without breaking the old connection" {
const store_stats = env.store.snapshotStats();
try testing.expectEqual(@as(u64, 1), store_stats.reloads);
try testing.expectEqual(@as(u64, 0), store_stats.reload_failures);
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
/// S3.4 across a live listener: a cert PATH change — new files, not rewritten
@@ -833,11 +862,19 @@ test "dot: a cert path change serves the new certificate on the next handshake"
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, DotServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, dotPathChangeServesNewCert, .{ io, server_address, &env });
@@ -849,11 +886,6 @@ test "dot: a cert path change serves the new certificate on the next handshake"
const store_stats = env.store.snapshotStats();
try testing.expectEqual(@as(u64, 1), store_stats.reloads);
try testing.expectEqual(@as(u64, 0), store_stats.reload_failures);
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
test "dot: an idle connection is closed with close_notify and counted" {
@@ -874,7 +906,9 @@ test "dot: an idle connection is closed with close_notify and counted" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try DotServer.listen(gpa, io, listen_address, &h, &permits, &env.store, .{
.max_connections = 2,
.idle_timeout = short_idle,
});
@@ -882,6 +916,12 @@ test "dot: an idle connection is closed with close_notify and counted" {
var group: std.Io.Group = .init;
try group.concurrent(io, DotServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, dotIdleUntilServerCloses, .{ io, server_address });
@@ -890,9 +930,4 @@ test "dot: an idle connection is closed with close_notify and counted" {
try testing.expectEqual(@as(u64, 1), stats.idle_timeouts);
try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures);
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
+44 -22
View File
@@ -468,7 +468,7 @@ pub const Handler = struct {
}
};
/// Per-query working memory, owned by the listener's slot so that no query path
/// Per-query working memory, owned by the query's permit (`query_budget.Work`) so that no query path
/// allocates. Every buffer is used by one query at a time.
pub const Scratch = struct {
normalize: [types.max_name_len]u8,
@@ -937,14 +937,14 @@ const Context = struct {
/// A response the cache declines to hold, or an allocation it cannot make,
/// is not a query failure: the answer already went out. `DnsCache.stats`
/// carries what happened.
/// counts both.
fn cachePut(ctx: *Context, key: []const u8, message: []const u8) void {
const class = dns_cache.classify(message, ctx.policy.negative_ttl_max) orelse return;
ctx.handler.cache_mutex.lockUncancelable(ctx.io);
defer ctx.handler.cache_mutex.unlock(ctx.io);
const cache = ctx.handler.cache orelse return;
cache.put(ctx.now_s, key, message, class) catch {};
cache.put(ctx.now_s, key, message, class);
}
/// The bytes to send upstream. Null means the rewrite did not fit and the
@@ -2653,7 +2653,7 @@ test "a forward zone answers from the cache and never reaches the pool" {
});
defer zones.deinit(testing.allocator);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
// The name is blocklisted, so only the zone path can answer it from the
@@ -2692,7 +2692,7 @@ test "a forward zone answers from the cache and never reaches the pool" {
false,
null,
);
try cache.put(std.Io.Clock.real.now(t.io()).toSeconds(), key, zone_reply, .{ .ttl_seconds = 300, .negative = false });
cache.put(std.Io.Clock.real.now(t.io()).toSeconds(), key, zone_reply, .{ .ttl_seconds = 300, .negative = false });
var buf: [udp_limit_min]u8 = undefined;
const p = try packet.parse(try expectReply(udp(&h, t.io(), query, &buf)));
@@ -2817,11 +2817,33 @@ test "a handler with no snapshot answers unfiltered and counts it" {
// --- cache -----------------------------------------------------------------
test "a copy the cache cannot allocate is counted and the answer still goes out" {
var t: TestIo = .init();
defer t.deinit();
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{});
var cache: dns_cache.DnsCache = try .init(failing.allocator(), .sized(8));
defer cache.deinit();
failing.fail_index = failing.alloc_index;
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bare(h_owner.client(fake.client()));
h.cache = &cache;
h.policy.negative_ttl_max = 3600;
var buf: [udp_limit_min]u8 = undefined;
const p = try packet.parse(try expectReply(udp(&h, t.io(), query_bytes, &buf)));
try testing.expectEqual(@as(u16, 1), p.header.ancount);
try testing.expectEqual(@as(u32, 0), cache.len());
try testing.expectEqual(@as(u64, 1), cache.stats.copy_failures);
}
test "a miss stores the answer and the next query is a hit with a fresh id" {
var t: TestIo = .init();
defer t.deinit();
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
@@ -2850,8 +2872,8 @@ test "a cache and a rate-limiter swap land in the middle of a query without a da
defer t.deinit();
const io = t.io();
var retired_cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var live_cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var retired_cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
var live_cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer live_cache.deinit();
var retired_limiter: rate_limiter.RateLimiter = try .init(
@@ -2914,7 +2936,7 @@ test "a blocked answer is never cached" {
var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
@@ -3126,7 +3148,7 @@ test "ecs forward keeps the subnet in the query and splits the cache key" {
var t: TestIo = .init();
defer t.deinit();
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
@@ -3202,7 +3224,7 @@ test "a non-IN class query skips filtering and the cache" {
var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
@@ -3335,7 +3357,7 @@ test "the upstream pipeline explains every allowed and blocked answer" {
var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var queue_buf: [8]logger_mod.Entry = undefined;
@@ -3545,7 +3567,7 @@ test "a local record and a forward zone are explained with no upstream named" {
});
defer zones.deinit(testing.allocator);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var snapshot = try provenanceSnapshot(&.{});
@@ -3586,7 +3608,7 @@ test "a local record and a forward zone are explained with no upstream named" {
false,
null,
);
try cache.put(
cache.put(
std.Io.Clock.real.now(io).toSeconds(),
key,
b.finish(),
@@ -3678,7 +3700,7 @@ test "a forward zone that answers names its resolver, and its cache hit does not
});
defer zones.deinit(testing.allocator);
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var snapshot = try provenanceSnapshot(&.{});
@@ -4281,9 +4303,9 @@ test "the sink both streams and logs the query the handler answered" {
defer t.deinit();
const io = t.io();
const hub = try testing.allocator.create(sse.Hub);
defer testing.allocator.destroy(hub);
hub.init();
var hub_storage: sse.Hub = .init(testing.allocator);
defer hub_storage.deinit();
const hub = &hub_storage;
var queue_buf: [4]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
@@ -4295,7 +4317,7 @@ test "the sink both streams and logs the query the handler answered" {
var h = bare(h_owner.client(fake.client()));
h.sink = &sink;
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
var buf: [udp_limit_min]u8 = undefined;
@@ -4434,7 +4456,7 @@ test "a failed ecs strip keeps the query out of the cache in both directions" {
var t: TestIo = .init();
defer t.deinit();
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
// An entry stored under the subnet-free key every other client shares.
@@ -4457,7 +4479,7 @@ test "a failed ecs strip keeps the query out of the cache in both directions" {
false,
null,
);
try cache.put(
cache.put(
std.Io.Clock.real.now(t.io()).toSeconds(),
global_key,
planted,
@@ -4494,7 +4516,7 @@ test "forward mode refuses to cache a query carrying two subnets" {
var t: TestIo = .init();
defer t.deinit();
var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(testing.allocator, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
+40 -4
View File
@@ -28,8 +28,16 @@ const std = @import("std");
const net = std.Io.net;
const Allocator = std.mem.Allocator;
const concurrency = @import("../platform/concurrency.zig");
const transport = @import("../upstream/transport.zig");
/// The connection cap of each DNS stream listener: TCP per address family, DoT
/// and DoH. A connection holds memory and a task, never a query permit, while
/// it waits for a query. The Pi saw no concurrent TCP connection in 37 hours
/// and runs neither DoT nor DoH, so this is household policy, not a
/// measurement: a few stub resolvers retrying truncated answers, with room.
pub const dns_max_connections: u16 = 16;
/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes
/// `.closing`, and the two meet at `stopped` so no task touches a connection
/// slot after it is freed.
@@ -98,7 +106,10 @@ pub const Outcome = union(enum) {
expiry: std.Io.Cancelable!void,
};
pub const Result = enum { ok, timed_out, failed, canceled };
/// `unavailable` is the race that could not start for want of a task. It is
/// counted here, under `concurrency.Site.listener_race`, so a caller ends the
/// connection without counting it as the peer's failure.
pub const Result = enum { ok, timed_out, failed, canceled, unavailable };
/// Runs one connection operation against a budget and cancels the loser. No
/// stream read or write in 0.16.0 accepts a timeout, so every wait on a peer
@@ -114,10 +125,16 @@ pub fn race(
defer select.cancelDiscard();
select.concurrent(.op, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
error.ConcurrencyUnavailable => {
concurrency.unavailable(.listener_race);
return .unavailable;
},
};
select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return .failed,
error.ConcurrencyUnavailable => {
concurrency.unavailable(.listener_race);
return .unavailable;
},
};
return switch (select.await() catch return .canceled) {
@@ -247,6 +264,10 @@ pub fn decideClaim(conns: anytype, shutdown_begun: bool) Claim {
/// - `initPayload` / `deinitPayload` — for a payload that owns memory (the web
/// listener's per-connection arena). `initPayload` runs inside `listen`,
/// `deinitPayload` inside `deinit` after every connection task has joined.
/// - `closing: fn (*Owner, std.Io) void` — runs in `deinit` once `.closing` is
/// published, for a connection blocked on something other than its socket,
/// which the shutdown of each stream cannot reach. The DNS listeners wake
/// their query-budget waiters here.
pub fn Core(comptime Cfg: type) type {
return struct {
const Self = @This();
@@ -315,6 +336,7 @@ pub fn Core(comptime Cfg: type) type {
pub fn deinit(self: *Self, io: std.Io) void {
const was_serving = self.run_state.swap(.closing, .acq_rel) == .serving;
if (@hasDecl(Cfg, "closing")) Cfg.closing(self.owner(), io);
// Shutting the listening socket down is the documented way to
// unblock a pending `accept`: it fails with
@@ -421,7 +443,7 @@ pub fn Core(comptime Cfg: type) type {
group.concurrent(io, runConn, .{ self, io, index }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
bump(&self.stats.rejected_at_capacity);
concurrency.unavailable(.connection);
self.finish(io, index);
continue;
},
@@ -524,6 +546,20 @@ fn testConns(count: usize) ![]TestSlot {
return conns;
}
fn neverCalled() anyerror!void {
unreachable;
}
test "a race that cannot start is unavailable and counted, not a failure" {
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{ .concurrent_limit = .nothing });
defer threaded.deinit();
const before = concurrency.snapshot().get(.listener_race);
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(60), .clock = .awake };
try std.testing.expectEqual(Result.unavailable, race(threaded.io(), budget, neverCalled, .{}));
try std.testing.expectEqual(@as(u64, 1), concurrency.snapshot().get(.listener_race) - before);
}
test "the connection pool hands out every slot once" {
const conns = try testConns(3);
defer testing.allocator.free(conns);
+18 -6
View File
@@ -53,6 +53,7 @@ const shutdown = @import("shutdown.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
const types = @import("../dns/types.zig");
const query_budget = @import("query_budget.zig");
const udp_server = @import("udp_server.zig");
const testing = std.testing;
@@ -96,20 +97,29 @@ fn baseHandler(up: *upstream_owner.Owner) handler.Handler {
/// holds a pointer into it.
const Loop = struct {
server: udp_server.UdpServer,
/// On the heap because the server keeps a pointer to it and `bind` returns
/// the loop by value.
permits: *query_budget.QueryBudget,
group: std.Io.Group,
client: net.Socket,
server_address: net.IpAddress,
fn bind(gpa: Allocator, io: std.Io, h: *handler.Handler) !Loop {
const permits = try gpa.create(query_budget.QueryBudget);
errdefer gpa.destroy(permits);
permits.* = try .init(gpa, 4);
errdefer permits.deinit();
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, h, .{ .max_in_flight = 4 });
errdefer server.deinit(gpa, io);
var server = try udp_server.UdpServer.bind(io, listen_address, h, permits);
errdefer server.deinit(io);
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
const client = try client_address.bind(io, .{ .mode = .dgram });
return .{
.server = server,
.permits = permits,
.group = .init,
.client = client,
.server_address = server.boundAddress(),
@@ -128,9 +138,11 @@ const Loop = struct {
}
fn stop(self: *Loop, gpa: Allocator, io: std.Io) void {
self.server.deinit(gpa, io);
self.server.deinit(io);
self.group.cancel(io);
self.client.close(io);
self.permits.deinit();
gpa.destroy(self.permits);
}
};
@@ -486,7 +498,7 @@ test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and
var mgr: manager.Manager = undefined;
fixtureManager(&mgr, &snapshot);
var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(gpa, .sized(8));
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = .a };
@@ -540,7 +552,7 @@ test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a l
defer threaded.deinit();
const io = threaded.io();
var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 });
var cache: dns_cache.DnsCache = try .init(gpa, .sized(8));
defer cache.deinit();
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
@@ -595,7 +607,7 @@ test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a l
null,
);
const aged_by = 10;
try cache.put(
cache.put(
std.Io.Clock.real.now(io).toSeconds() - aged_by,
key,
b.finish(),
+444
View File
@@ -0,0 +1,444 @@
//! The DNS work budget every listener shares (specs/memory-budget.md item 3).
//!
//! A permit is the memory one query needs while it is answered: the reply
//! buffer, the handler's scratch and, for UDP, the datagram. Both address
//! families of UDP and TCP, DoT and DoH all draw from one pool, allocated once
//! at boot. A query takes a permit only once it has arrived whole and returns
//! it when its reply is written, so a keep-alive connection between requests
//! holds none.
//!
//! A query that finds every permit taken waits for one, on its own thread,
//! rather than being dropped or answered SERVFAIL: a stub resolver treats
//! either as a failed lookup, while a short wait is invisible to it. Each held
//! permit is bounded by the upstream deadline and the stream write budget, so a
//! wait always ends. A UDP listener waits with its datagram staged and stops
//! receiving meanwhile, so the backlog sits in the socket's kernel buffer,
//! where `/proc/net/udp` counts what overflows. A stream query waits at most
//! its connection's idle budget. Every wait is counted.
const std = @import("std");
const Allocator = std.mem.Allocator;
const handler = @import("handler.zig");
const listener = @import("listener.zig");
const transport = @import("../upstream/transport.zig");
const WakeWord = @import("../platform/wake_word.zig").WakeWord;
/// The most DNS queries answered at once, across every listener. The Pi's
/// highest reading of `nxdns_dns_in_flight_peak` was 27 over a day of
/// household traffic; a burst of 64 simultaneous uncached queries was answered
/// whole on two upstreams with the slowest at 0.2 s. Each answered query can
/// hold up to three threads while its exchange runs.
pub const permits = 64;
/// The EDNS ceiling the UDP listener advertises. A larger datagram arrives
/// truncated and is dropped rather than guessed at.
pub const max_datagram = 4096;
comptime {
// `handler.handle` asserts this range on the buffers it is given.
std.debug.assert(max_datagram >= handler.udp_limit_min);
std.debug.assert(max_datagram >= handler.udp_limit_max);
std.debug.assert(transport.max_message_len >= handler.udp_limit_min);
}
pub const Work = struct {
/// UDP's copy of the datagram, so its listener can receive the next one
/// while this query is answered. A stream query stays in its connection,
/// which reads it before taking a permit: a slow sender holds none.
datagram: [max_datagram]u8,
/// A whole DNS message, not a whole datagram. A UDP reply between
/// `max_datagram` and `transport.max_message_len` must reach the handler
/// intact, because only the handler can turn it into the TC=1 reply that
/// sends the client to TCP; the datagram it sends stays bounded by
/// `udpLimit`.
reply: [transport.max_message_len]u8,
scratch: handler.Scratch,
};
/// Why `acquire` returned without a permit.
pub const AcquireError = error{
/// The waiting task is being torn down.
Canceled,
/// The caller's deadline passed. Counted in `wait_timeouts`.
Timeout,
/// The caller's listener left `.serving`.
Stopped,
};
pub const Snapshot = struct {
/// Queries that found every permit taken and had to wait.
waits: u64,
wait_seconds: f64,
/// Waits that ran out of their deadline. Only stream queries have one.
wait_timeouts: u64,
/// Queries waiting right now.
waiting: u64,
};
pub const QueryBudget = struct {
gpa: Allocator,
work: []Work,
mutex: std.Io.Mutex,
/// Guarded by `mutex`: the indices of the free permits are
/// `free[0..free_len]`.
free: []u16,
free_len: usize,
released: WakeWord,
waits: std.atomic.Value(u64),
wait_ns: std.atomic.Value(u64),
wait_timeouts: std.atomic.Value(u64),
waiting: std.atomic.Value(u64),
pub fn init(gpa: Allocator, count: u16) Allocator.Error!QueryBudget {
std.debug.assert(count > 0);
const work = try gpa.alloc(Work, count);
errdefer gpa.free(work);
const free = try gpa.alloc(u16, count);
for (free, 0..) |*slot, index| slot.* = @intCast(index);
return .{
.gpa = gpa,
.work = work,
.mutex = .init,
.free = free,
.free_len = count,
.released = .{},
.waits = .init(0),
.wait_ns = .init(0),
.wait_timeouts = .init(0),
.waiting = .init(0),
};
}
/// Every listener has stopped, so every permit is back.
pub fn deinit(self: *QueryBudget) void {
std.debug.assert(self.free_len == self.work.len);
std.debug.assert(self.waiting.load(.monotonic) == 0);
self.gpa.free(self.free);
self.gpa.free(self.work);
self.* = undefined;
}
/// A permit, waiting for one while every permit is taken, until `deadline`
/// (`null`: no deadline) or until `run_state` leaves `.serving`. A listener
/// that publishes `.closing` calls `wakeWaiters` so a waiter sees it.
pub fn acquire(
self: *QueryBudget,
io: std.Io,
deadline: ?std.Io.Clock.Timestamp,
run_state: *const std.atomic.Value(listener.State),
) AcquireError!*Work {
if (self.tryAcquire(io)) |work| return work;
_ = self.waits.fetchAdd(1, .monotonic);
_ = self.waiting.fetchAdd(1, .monotonic);
const started = std.Io.Clock.awake.now(io);
const outcome = self.waitForPermit(io, deadline, run_state);
const elapsed = started.durationTo(std.Io.Clock.awake.now(io)).toNanoseconds();
_ = self.wait_ns.fetchAdd(@intCast(@max(elapsed, 0)), .monotonic);
_ = self.waiting.fetchSub(1, .monotonic);
return outcome;
}
/// A permit, or null when every one is taken. Waits for the bookkeeping
/// mutex, which no holder keeps across any Io, but never for a permit.
pub fn tryAcquire(self: *QueryBudget, io: std.Io) ?*Work {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
if (self.free_len == 0) return null;
self.free_len -= 1;
return &self.work[self.free[self.free_len]];
}
pub fn release(self: *QueryBudget, io: std.Io, work: *Work) void {
const index = (@intFromPtr(work) - @intFromPtr(self.work.ptr)) / @sizeOf(Work);
std.debug.assert(&self.work[index] == work);
{
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
std.debug.assert(self.free_len < self.work.len);
self.free[self.free_len] = @intCast(index);
self.free_len += 1;
}
self.released.wakeOne(io);
}
/// Wakes every waiter so each re-reads its listener's `run_state`. A
/// listener calls this after publishing `.closing`.
pub fn wakeWaiters(self: *QueryBudget, io: std.Io) void {
self.released.wakeAll(io);
}
/// Permits held right now.
pub fn taken(self: *QueryBudget, io: std.Io) usize {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.work.len - self.free_len;
}
/// The counters, read one at a time; a scrape mid-wait can see a wait
/// counted before its seconds are.
pub fn snapshot(self: *const QueryBudget) Snapshot {
const ns: f64 = @floatFromInt(self.wait_ns.load(.monotonic));
return .{
.waits = self.waits.load(.monotonic),
.wait_seconds = ns / std.time.ns_per_s,
.wait_timeouts = self.wait_timeouts.load(.monotonic),
.waiting = self.waiting.load(.monotonic),
};
}
fn waitForPermit(
self: *QueryBudget,
io: std.Io,
deadline: ?std.Io.Clock.Timestamp,
run_state: *const std.atomic.Value(listener.State),
) AcquireError!*Work {
while (true) {
// Read before the check, so a release or a `wakeWaiters` that
// lands after the check moves the word and the wait returns at
// once.
const seen = self.released.observe();
if (run_state.load(.acquire) != .serving) return self.leave(io, error.Stopped);
// Before the take, so a permit freed just as the deadline passes
// does not start a query its caller has already given up on.
if (deadline) |at| {
if (std.Io.Clock.Timestamp.now(io, at.clock).compare(.gte, at)) {
_ = self.wait_timeouts.fetchAdd(1, .monotonic);
return self.leave(io, error.Timeout);
}
}
if (self.tryAcquire(io)) |work| return work;
self.released.wait(io, seen, deadline) catch return self.leave(io, error.Canceled);
}
}
/// This waiter may have consumed the one wake a release sent, so a permit
/// still free is handed on rather than left for nobody.
fn leave(self: *QueryBudget, io: std.Io, err: AcquireError) AcquireError {
const free_len = free: {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
break :free self.free_len;
};
if (free_len != 0) self.released.wakeOne(io);
return err;
}
};
const testing = std.testing;
var test_serving: std.atomic.Value(listener.State) = .init(.serving);
test "a budget hands out every permit once, then refuses" {
var budget: QueryBudget = try .init(testing.allocator, 3);
defer budget.deinit();
const io = testing.io;
var held: [3]*Work = undefined;
for (&held) |*slot| slot.* = budget.tryAcquire(io) orelse return error.TestUnexpectedResult;
try testing.expect(held[0] != held[1] and held[1] != held[2] and held[0] != held[2]);
try testing.expectEqual(@as(?*Work, null), budget.tryAcquire(io));
try testing.expectEqual(@as(usize, 3), budget.taken(io));
for (held) |work| budget.release(io, work);
try testing.expectEqual(@as(usize, 0), budget.taken(io));
}
test "a released permit is handed out again" {
var budget: QueryBudget = try .init(testing.allocator, 2);
defer budget.deinit();
const io = testing.io;
const first = budget.tryAcquire(io).?;
const second = budget.tryAcquire(io).?;
budget.release(io, first);
try testing.expectEqual(first, budget.tryAcquire(io).?);
budget.release(io, first);
budget.release(io, second);
}
test "a free permit is taken without counting a wait" {
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const io = testing.io;
const work = try budget.acquire(io, null, &test_serving);
budget.release(io, work);
try testing.expectEqual(@as(u64, 0), budget.snapshot().waits);
}
test "a wait past its deadline times out, holds nothing and is counted" {
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const io = testing.io;
const held = budget.tryAcquire(io).?;
defer budget.release(io, held);
const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromMilliseconds(20), .clock = .awake });
try testing.expectError(error.Timeout, budget.acquire(io, deadline, &test_serving));
const snap = budget.snapshot();
try testing.expectEqual(@as(u64, 1), snap.waits);
try testing.expectEqual(@as(u64, 1), snap.wait_timeouts);
try testing.expectEqual(@as(u64, 0), snap.waiting);
try testing.expect(snap.wait_seconds >= 0.02);
try testing.expectEqual(@as(usize, 1), budget.taken(io));
}
test "a listener that is not serving gets no permit, even a free one" {
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const io = testing.io;
const held = budget.tryAcquire(io).?;
var closing: std.atomic.Value(listener.State) = .init(.closing);
try testing.expectError(error.Stopped, budget.acquire(io, null, &closing));
budget.release(io, held);
try testing.expectEqual(@as(usize, 0), budget.taken(io));
}
fn acquireAndHold(
budget: *QueryBudget,
io: std.Io,
deadline: ?std.Io.Clock.Timestamp,
run_state: *const std.atomic.Value(listener.State),
) AcquireError!*Work {
return budget.acquire(io, deadline, run_state);
}
fn waitUntilWaiting(budget: *QueryBudget, io: std.Io, count: u64) !void {
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(1), .clock = .awake };
while (budget.waiting.load(.monotonic) != count) try tick.sleep(io);
}
test "a release wakes a waiter and hands it the permit" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const held = budget.tryAcquire(io).?;
var waiter = io.concurrent(acquireAndHold, .{ &budget, io, null, &test_serving }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
budget.release(io, held);
return error.SkipZigTest;
},
};
try waitUntilWaiting(&budget, io, 1);
budget.release(io, held);
const got = try waiter.await(io);
try testing.expectEqual(held, got);
budget.release(io, got);
try testing.expectEqual(@as(u64, 1), budget.snapshot().waits);
}
test "a canceled waiter hands its wake on to a peer" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const held = budget.tryAcquire(io).?;
var canceled = io.concurrent(acquireAndHold, .{ &budget, io, null, &test_serving }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
budget.release(io, held);
return error.SkipZigTest;
},
};
var peer = io.concurrent(acquireAndHold, .{ &budget, io, null, &test_serving }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
_ = canceled.cancel(io) catch {};
budget.release(io, held);
return error.SkipZigTest;
},
};
try waitUntilWaiting(&budget, io, 2);
// One release, one wake. Whichever task the wake reaches, the peer must
// end up with the permit: the canceled task either never saw the wake or
// hands it on as it leaves. A lost wake hangs this test.
budget.release(io, held);
if (canceled.cancel(io)) |work| {
// The cancel lost the race to the wake; give the permit to the peer.
budget.release(io, work);
} else |err| try testing.expectEqual(error.Canceled, err);
const got = try peer.await(io);
budget.release(io, got);
try testing.expectEqual(@as(usize, 0), budget.taken(io));
}
test "wakeWaiters ends a wait whose listener is closing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var budget: QueryBudget = try .init(testing.allocator, 1);
defer budget.deinit();
const held = budget.tryAcquire(io).?;
defer budget.release(io, held);
var state: std.atomic.Value(listener.State) = .init(.serving);
var waiter = io.concurrent(acquireAndHold, .{ &budget, io, null, &state }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
try waitUntilWaiting(&budget, io, 1);
state.store(.closing, .release);
budget.wakeWaiters(io);
try testing.expectError(error.Stopped, waiter.await(io));
}
test "many waiters on few permits all get one, and every permit comes back" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var budget: QueryBudget = try .init(testing.allocator, 2);
defer budget.deinit();
var group: std.Io.Group = .init;
var taken_total: std.atomic.Value(u32) = .init(0);
const rounds = 64;
for (0..rounds) |_| {
group.concurrent(io, takeBriefly, .{ &budget, io, &taken_total }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
try group.await(io);
return error.SkipZigTest;
},
};
}
try group.await(io);
try testing.expectEqual(@as(u32, rounds), taken_total.load(.monotonic));
try testing.expectEqual(@as(usize, 0), budget.taken(io));
try testing.expectEqual(@as(u64, 0), budget.snapshot().waiting);
}
fn takeBriefly(budget: *QueryBudget, io: std.Io, taken_total: *std.atomic.Value(u32)) void {
const work = budget.acquire(io, null, &test_serving) catch return;
defer budget.release(io, work);
_ = taken_total.fetchAdd(1, .monotonic);
const hold: std.Io.Clock.Duration = .{ .raw = .fromMicroseconds(200), .clock = .awake };
hold.sleep(io) catch {};
}
test "init survives every allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, struct {
fn run(gpa: Allocator) !void {
var budget: QueryBudget = try .init(gpa, 2);
budget.deinit();
}
}.run, .{});
}
test "the default budget stays small" {
// 4096 + 65535 + scratch per permit: under 5 MiB for the whole process,
// where each UDP and TCP listener used to hold 4.8 and 8.7 MiB of its own.
try testing.expect(@sizeOf(Work) * permits < 5 * 1024 * 1024);
}
+12 -12
View File
@@ -66,16 +66,16 @@ test "the sink publishes and logs the same entry" {
defer threaded.deinit();
const io = threaded.io();
const hub = try testing.allocator.create(sse.Hub);
defer testing.allocator.destroy(hub);
hub.init();
var hub_storage: sse.Hub = .init(testing.allocator);
defer hub_storage.deinit();
const hub = &hub_storage;
var queue_buf: [4]logger.Entry = undefined;
var query_logger: logger.Logger = .init(.{}, &queue_buf);
var owner: logger_controller.Borrowed = .{};
var sink: QuerySink = .init(owner.over(&query_logger), hub);
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
sink.log(io, sampleEntry(11, "example.com"));
@@ -94,16 +94,16 @@ test "fanout does not depend on the entry reaching the queue" {
defer threaded.deinit();
const io = threaded.io();
const hub = try testing.allocator.create(sse.Hub);
defer testing.allocator.destroy(hub);
hub.init();
var hub_storage: sse.Hub = .init(testing.allocator);
defer hub_storage.deinit();
const hub = &hub_storage;
var queue_buf: [4]logger.Entry = undefined;
var query_logger: logger.Logger = .init(.{}, &queue_buf);
var owner: logger_controller.Borrowed = .{};
var sink: QuerySink = .init(owner.over(&query_logger), hub);
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
// A closed queue drops what it is handed. The stream still carries the
@@ -120,9 +120,9 @@ test "the privacy transforms run once, before both consumers" {
defer threaded.deinit();
const io = threaded.io();
const hub = try testing.allocator.create(sse.Hub);
defer testing.allocator.destroy(hub);
hub.init();
var hub_storage: sse.Hub = .init(testing.allocator);
defer hub_storage.deinit();
const hub = &hub_storage;
var queue_buf: [4]logger.Entry = undefined;
var query_logger: logger.Logger = .init(
@@ -132,7 +132,7 @@ test "the privacy transforms run once, before both consumers" {
var owner: logger_controller.Borrowed = .{};
var sink: QuerySink = .init(owner.over(&query_logger), hub);
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
sink.log(io, sampleEntry(4, "tracker.example"));
+13 -7
View File
@@ -16,6 +16,7 @@ const build_options = @import("build_options");
const net = std.Io.net;
const handler = @import("handler.zig");
const query_budget = @import("query_budget.zig");
const tcp_server = @import("tcp_server.zig");
const udp_server = @import("udp_server.zig");
const model = @import("../config/model.zig");
@@ -178,7 +179,7 @@ const EntryStorage = struct {
.priority = priority,
.enabled = true,
.health = .init,
.admission = .{ .permits = self.slots.len },
.admission = .{ .permits = .init(self.slots.len) },
.reuse_recoveries = &self.recoveries,
};
}
@@ -282,14 +283,23 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
var h = bareHandler(h_owner.client(upstreams.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var udp = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
var tcp = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var udp = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
var tcp = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{ .max_connections = 2 });
const udp_address = udp.boundAddress();
const tcp_address = tcp.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &udp, io });
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &tcp, io });
// Deferred so a failed check still stops both servers: a `serve` left in
// accept makes `threaded.deinit` wait for it forever.
defer {
udp.deinit(io);
tcp.deinit(io);
group.cancel(io);
}
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
var client = try client_address.bind(io, .{ .mode = .dgram });
@@ -335,8 +345,4 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
}
try testing.expectEqual(bad_calls, bad.calls.load(.monotonic));
try testing.expectEqual(@as(u64, 3), good.calls.load(.monotonic));
udp.deinit(gpa, io);
tcp.deinit(io);
group.cancel(io);
}
+31 -19
View File
@@ -9,7 +9,9 @@
//! `listener.Core`'s (milestone-18 ruling 1); this file is the per-connection
//! loop and nothing else. Connection slots are fixed and pre-allocated. Over
//! capacity the listener closes the new stream immediately and counts it; it
//! never queues, and it never allocates per connection.
//! never queues, and it never allocates per connection. Each query holds a
//! permit from the shared query budget from its last byte read to its reply's
//! last byte written; with none free the connection ends and is counted.
//!
//! No stream read or write in 0.16.0 accepts a timeout, so every per-connection
//! operation is raced against `Options.idle_timeout` through `listener.race`
@@ -19,20 +21,21 @@ const std = @import("std");
const address = @import("../platform/address.zig");
const handler = @import("handler.zig");
const listener = @import("listener.zig");
const query_budget = @import("query_budget.zig");
const transport = @import("../upstream/transport.zig");
/// The stream buffers only stage the framing bytes. A message longer than this
/// is read straight into `Conn.query` and written straight from `Conn.reply`,
/// so making them larger would buy nothing.
/// is read straight into `Payload.query` and written straight from the
/// permit's reply, so making them larger would buy nothing.
const stream_buffer_len = 1024;
pub const Options = struct {
max_connections: u16 = 64,
max_connections: u16 = listener.dns_max_connections,
/// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance.
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
};
/// TCP/53 keeps no counter of its own: the shared six are exactly what it
/// TCP/53 keeps no counter of its own: the shared ones are exactly what it
/// counts.
pub const Stats = listener.CoreStats;
@@ -52,25 +55,21 @@ pub const Snapshot = struct {
pub const TcpServer = struct {
core: listener.Core(Config),
handler: *handler.Handler,
budget: *query_budget.QueryBudget,
options: Options,
/// One slot is ~137 KiB, so the default 64 connections cost ~8.8 MiB, which
/// is inside the PLAN §18 budget. The two message buffers cannot be shared
/// or shrunk: the handler holds the query while the reply is built, and
/// both ceilings are the 65535 bytes the length prefix can express.
/// The query is read here, before a permit is taken, so a client that
/// sends slowly holds connection memory and no query permit. 65535 bytes,
/// the most the length prefix can express.
pub const Payload = struct {
query: [transport.max_message_len]u8,
reply: [transport.max_message_len]u8,
/// The handler's per-query working memory. It belongs to the slot so
/// that answering a message allocates nothing, and a connection is
/// answered serially, so one query uses it at a time.
scratch: handler.Scratch,
};
const Config = struct {
pub const Owner = TcpServer;
pub const ConnPayload = Payload;
pub const serveConn = serveOne;
pub const closing = wakeBudgetWaiters;
pub const read_buffer_len = stream_buffer_len;
pub const write_buffer_len = stream_buffer_len;
pub const log = std.log.scoped(.tcp_server);
@@ -85,11 +84,13 @@ pub const TcpServer = struct {
io: std.Io,
listen_address: std.Io.net.IpAddress,
h: *handler.Handler,
budget: *query_budget.QueryBudget,
options: Options,
) ListenError!TcpServer {
return .{
.core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections),
.handler = h,
.budget = budget,
.options = options,
};
}
@@ -123,6 +124,10 @@ pub const TcpServer = struct {
self.core.serve(io);
}
fn wakeBudgetWaiters(self: *TcpServer, io: std.Io) void {
self.budget.wakeWaiters(io);
}
pub fn deinit(self: *TcpServer, io: std.Io) void {
self.core.deinit(io);
self.* = undefined;
@@ -147,7 +152,7 @@ pub const TcpServer = struct {
listener.bump(&stats.idle_timeouts);
return;
},
.canceled => return,
.canceled, .unavailable => return,
.failed => {
listener.bump(&stats.connection_errors);
return;
@@ -172,7 +177,7 @@ pub const TcpServer = struct {
switch (listener.race(io, budget, listener.readBody, .{ &reader.interface, conn.payload.query[0..len] })) {
.ok => {},
.canceled => return,
.canceled, .unavailable => return,
// A half-sent message is a broken peer, not an idle one.
.timed_out, .failed => {
listener.bump(&stats.connection_errors);
@@ -180,13 +185,20 @@ pub const TcpServer = struct {
},
}
// Bounded like a read: a query still waiting when the idle budget
// runs out ends the connection, with the timeout counted by the
// budget. There is no framing for "no answer".
const permit_deadline: std.Io.Clock.Timestamp = .fromNow(io, budget);
const work = self.budget.acquire(io, permit_deadline, &self.core.run_state) catch return;
defer self.budget.release(io, work);
const outcome = self.handler.handle(
io,
.tcp,
address.NetAddress.fromIp(conn.peer),
conn.payload.query[0..len],
&conn.payload.reply,
&conn.payload.scratch,
&work.reply,
&work.scratch,
);
const bytes = switch (outcome) {
// There is no framing for "no answer", so the connection ends.
@@ -197,7 +209,7 @@ pub const TcpServer = struct {
const out = transport.framePrefix(@intCast(bytes.len));
switch (listener.race(io, budget, listener.writeReply, .{ &writer.interface, &out, bytes })) {
.ok => {},
.canceled => return,
.canceled, .unavailable => return,
.timed_out, .failed => {
listener.bump(&stats.connection_errors);
return;
+338 -27
View File
@@ -13,7 +13,9 @@ const std = @import("std");
const build_options = @import("build_options");
const net = std.Io.net;
const concurrency = @import("../platform/concurrency.zig");
const handler = @import("handler.zig");
const query_budget = @import("query_budget.zig");
const tcp_server = @import("tcp_server.zig");
const model = @import("../config/model.zig");
const response = @import("../filter/response.zig");
@@ -179,22 +181,27 @@ test "two length-prefixed queries share one connection" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
// Deferred so a failed check still stops the server: a `serve` left in
// accept makes `threaded.deinit` wait for it forever.
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, twoQueriesOnOneConnection, .{ io, server_address });
try testing.expectEqual(@as(u64, 1), server.core.stats.connections.load(.monotonic));
try testing.expectEqual(@as(u64, 0), server.core.stats.rejected_at_capacity.load(.monotonic));
try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic));
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
test "the claimed slot records the connecting client" {
@@ -210,11 +217,19 @@ test "the claimed slot records the connecting client" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, twoQueriesOnOneConnection, .{ io, server_address });
@@ -226,11 +241,6 @@ test "the claimed slot records the connecting client" {
try testing.expectEqual(net.IpAddress.ip4, std.meta.activeTag(peer));
try testing.expectEqualSlices(u8, &[_]u8{ 127, 0, 0, 1 }, &peer.ip4.bytes);
try testing.expect(peer.ip4.port != 0);
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
/// `std.Io.Group` takes only `Cancelable!void`, so the result is reported out
@@ -291,7 +301,9 @@ test "a canceled serve does not wait for a live connection" {
// The idle budget is the whole time a drain would have to wait out, so it
// is set far beyond any patience this test run has: if `serve` waits for
// the connection instead of canceling it, the wait never ends.
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{
.max_connections = 2,
.idle_timeout = .{ .raw = .fromSeconds(600), .clock = .awake },
});
@@ -342,7 +354,9 @@ test "the connections peak is the most slots held at once" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{
.max_connections = 3,
.idle_timeout = .{ .raw = .fromSeconds(600), .clock = .awake },
});
@@ -397,7 +411,9 @@ test "an idle connection is closed and counted" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{
.max_connections = 2,
.idle_timeout = short_idle,
});
@@ -405,17 +421,18 @@ test "an idle connection is closed and counted" {
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, waitForServerClose, .{ io, server_address });
try testing.expectEqual(@as(u64, 1), server.core.stats.connections.load(.monotonic));
try testing.expectEqual(@as(u64, 1), server.core.stats.idle_timeouts.load(.monotonic));
try testing.expectEqual(@as(u64, 0), server.core.stats.connection_errors.load(.monotonic));
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
test "a zero-length message is a connection error" {
@@ -431,7 +448,9 @@ test "a zero-length message is a connection error" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{
.max_connections = 2,
.idle_timeout = short_idle,
});
@@ -439,16 +458,17 @@ test "a zero-length message is a connection error" {
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, sendZeroLength, .{ io, server_address });
try testing.expectEqual(@as(u64, 1), server.core.stats.connection_errors.load(.monotonic));
try testing.expectEqual(@as(u64, 0), server.core.stats.idle_timeouts.load(.monotonic));
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
/// A prefix of 0 announces a message RFC 1035 §4.2.2 gives no meaning to, so
@@ -484,7 +504,9 @@ test "deinit ends a serve loop that is blocked on accept" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{ .max_connections = 2 });
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
@@ -495,3 +517,292 @@ test "deinit ends a serve loop that is blocked on accept" {
error.Canceled => unreachable,
};
}
/// Writes one framed query and returns the frame the server answered with.
fn askOn(reader: *std.Io.Reader, writer: *std.Io.Writer) ![]const u8 {
try writer.writeAll(&transport.framePrefix(@intCast(query_bytes.len)));
try writer.writeAll(query_bytes);
try writer.flush();
const len = transport.parsePrefix((try reader.takeArray(transport.prefix_len)).*);
return reader.take(len);
}
fn waitForWaiters(permits: *query_budget.QueryBudget, io: std.Io, count: u64) !void {
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(1), .clock = .awake };
while (permits.snapshot().waiting != count) try tick.sleep(io);
}
fn sendQuery(writer: *std.Io.Writer) !void {
try writer.writeAll(&transport.framePrefix(@intCast(query_bytes.len)));
try writer.writeAll(query_bytes);
try writer.flush();
}
/// One answered query, then the connection idles with every permit back in the
/// budget; a second query that finds every permit taken waits, and is answered
/// once one is released.
fn keepAliveThenWait(io: std.Io, address: net.IpAddress, permits: *query_budget.QueryBudget) anyerror!void {
var stream = try address.connect(io, .{ .mode = .stream });
defer stream.close(io);
var read_buf: [1024]u8 = undefined;
var write_buf: [1024]u8 = undefined;
var reader = stream.reader(io, &read_buf);
var writer = stream.writer(io, &write_buf);
try expectAnswersQuery(try askOn(&reader.interface, &writer.interface));
// The server returns the permit just after its reply's last byte, which can
// reach this side first. `bounded` caps the wait.
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(1), .clock = .awake };
while (permits.taken(io) != 0) try tick.sleep(io);
const held = permits.tryAcquire(io).?;
var released = false;
defer if (!released) permits.release(io, held);
try sendQuery(&writer.interface);
try waitForWaiters(permits, io, 1);
permits.release(io, held);
released = true;
const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*);
try expectAnswersQuery(try reader.interface.take(len));
}
test "a keep-alive connection holds no permit between queries, and a query without one waits for it" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 1);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, keepAliveThenWait, .{ io, server_address, &permits });
try testing.expectEqual(@as(u64, 1), permits.snapshot().waits);
try testing.expectEqual(@as(u64, 0), permits.snapshot().wait_timeouts);
try testing.expectEqual(@as(u64, 0), server.core.stats.connection_errors.load(.monotonic));
try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic));
}
/// A query that waits out the whole idle budget for a permit: the server ends
/// the connection without an answer.
fn waitOutlastsIdle(io: std.Io, address: net.IpAddress) anyerror!void {
var stream = try address.connect(io, .{ .mode = .stream });
defer stream.close(io);
var read_buf: [1024]u8 = undefined;
var write_buf: [1024]u8 = undefined;
var reader = stream.reader(io, &read_buf);
var writer = stream.writer(io, &write_buf);
try sendQuery(&writer.interface);
var sink: [64]u8 = undefined;
if (try reader.interface.readSliceShort(&sink) != 0) return error.TestUnexpectedBytes;
}
test "a stream query whose permit wait outlasts the idle budget ends its connection, counted" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 1);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{
.max_connections = 2,
.idle_timeout = short_idle,
});
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
const held = permits.tryAcquire(io).?;
defer permits.release(io, held);
try bounded(io, waitOutlastsIdle, .{ io, server_address });
try testing.expectEqual(@as(u64, 1), permits.snapshot().wait_timeouts);
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
}
/// Leaves a query waiting for a permit and returns once the server sees it
/// waiting.
fn queryThenWait(io: std.Io, address: net.IpAddress, permits: *query_budget.QueryBudget, stream: *net.Stream) anyerror!void {
stream.* = try address.connect(io, .{ .mode = .stream });
var write_buf: [1024]u8 = undefined;
var writer = stream.writer(io, &write_buf);
try sendQuery(&writer.interface);
try waitForWaiters(permits, io, 1);
}
test "closing a tcp listener ends a connection that waits for a permit" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 1);
defer permits.deinit();
// An idle budget far past `bounded`'s, so only the close can end the wait.
const long_idle: std.Io.Clock.Duration = .{ .raw = .fromSeconds(60), .clock = .awake };
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{
.max_connections = 2,
.idle_timeout = long_idle,
});
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
const held = permits.tryAcquire(io).?;
defer permits.release(io, held);
var stream: net.Stream = undefined;
try bounded(io, queryThenWait, .{ io, server_address, &permits, &stream });
defer stream.close(io);
try bounded(io, closeAndJoin, .{ &server, io, &group });
try testing.expectEqual(@as(u64, 0), permits.snapshot().waiting);
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
}
fn closeAndJoin(server: *tcp_server.TcpServer, io: std.Io, group: *std.Io.Group) anyerror!void {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
/// Holds the only slot with an answered connection, then opens a second one,
/// which the server closes without reading.
fn oneHeldOneRefused(io: std.Io, address: net.IpAddress) anyerror!void {
var held = try address.connect(io, .{ .mode = .stream });
defer held.close(io);
var read_buf: [1024]u8 = undefined;
var write_buf: [1024]u8 = undefined;
var reader = held.reader(io, &read_buf);
var writer = held.writer(io, &write_buf);
try expectAnswersQuery(try askOn(&reader.interface, &writer.interface));
var refused = try address.connect(io, .{ .mode = .stream });
defer refused.close(io);
var refused_buf: [64]u8 = undefined;
var refused_reader = refused.reader(io, &refused_buf);
var sink: [64]u8 = undefined;
if (try refused_reader.interface.readSliceShort(&sink) != 0) return error.TestUnexpectedBytes;
}
test "a listener at its connection cap refuses and counts" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, &permits, .{ .max_connections = 1 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
try bounded(io, oneHeldOneRefused, .{ io, server_address });
try testing.expectEqual(@as(u64, 1), server.core.stats.rejected_at_capacity.load(.monotonic));
try testing.expectEqual(@as(u64, 1), server.core.stats.connections.load(.monotonic));
}
test "a connection that gets no task is closed and counted, not refused at capacity" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
// The accept loop is the one task the server's pool allows. The client
// runs on a pool of its own, so `bounded` has the tasks it needs.
var server_threaded: std.Io.Threaded = .init(gpa, .{ .concurrent_limit = .limited(1) });
defer server_threaded.deinit();
const server_io = server_threaded.io();
var client_threaded: std.Io.Threaded = .init(gpa, .{});
defer client_threaded.deinit();
const io = client_threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try tcp_server.TcpServer.listen(gpa, server_io, listen_address, &h, &permits, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(server_io, tcp_server.TcpServer.serve, .{ &server, server_io });
defer {
server.deinit(server_io);
group.await(server_io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
const before = concurrency.snapshot().get(.connection);
try bounded(io, waitForServerClose, .{ io, server_address });
try testing.expectEqual(@as(u64, 1), concurrency.snapshot().get(.connection) - before);
try testing.expectEqual(@as(u64, 0), server.core.stats.rejected_at_capacity.load(.monotonic));
try testing.expectEqual(@as(u64, 0), server.core.stats.connections.load(.monotonic));
}
+67 -193
View File
@@ -1,50 +1,42 @@
//! The UDP/53 listener.
//!
//! One receive loop feeds a bounded pool of in-flight tasks. A datagram that
//! finds no free slot is dropped and counted, never queued: an unbounded queue
//! only moves the failure from "visible drop" to "invisible latency", which is
//! the failure mode PLAN §1 names.
//! One receive loop hands each datagram to a task that holds a permit from the
//! shared query budget. While every permit is taken the loop waits for one with
//! the datagram it holds and receives nothing more, so the backlog stays in the
//! socket's kernel buffer: bounded by `SO_RCVBUF`, and an overflow is counted
//! by the kernel and exported as `nxdns_udp_server_kernel_drops_total`.
//!
//! Every slot carries its own query and reply buffer, so answering a datagram
//! allocates nothing. The pool is sized once in `bind` and never grows.
//! The permit carries the query, the reply and the handler's scratch, so
//! answering a datagram allocates nothing.
const std = @import("std");
const address = @import("../platform/address.zig");
const concurrency = @import("../platform/concurrency.zig");
const handler = @import("handler.zig");
const transport = @import("../upstream/transport.zig");
const listener = @import("listener.zig");
const query_budget = @import("query_budget.zig");
const log = std.log.scoped(.udp_server);
/// The EDNS ceiling this server advertises. A larger datagram arrives
/// truncated and is dropped rather than guessed at.
pub const max_datagram = 4096;
comptime {
// `handler.handle` asserts this range on the buffers it is given.
std.debug.assert(max_datagram >= handler.udp_limit_min);
std.debug.assert(max_datagram >= handler.udp_limit_max);
std.debug.assert(transport.max_message_len >= handler.udp_limit_min);
}
pub const max_datagram = query_budget.max_datagram;
/// How long a receive waits before the loop re-reads the stop flag. `deinit`
/// therefore returns within this much time, without depending on a blocked
/// `receive` being unblocked by a close — a guarantee POSIX does not make.
const poll_interval: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
pub const Options = struct {
max_in_flight: u16 = 64,
};
pub const Stats = struct {
received: std.atomic.Value(u64) = .init(0),
/// The datagram arrived with `flags.trunc`: the kernel discarded its tail.
dropped_oversize: std.atomic.Value(u64) = .init(0),
dropped_no_slot: std.atomic.Value(u64) = .init(0),
/// The handler answered `.drop` — an upstream exchange canceled by
/// shutdown. The handler counts its own malformed drops; this counter is
/// what keeps the listener's "every dropped datagram is counted" rule true
/// for the drops it cannot see.
/// for the drops it cannot see. A datagram that got a permit but no task
/// is counted under `nxdns_concurrency_unavailable_total{site="udp_query"}`.
dropped_handler: std.atomic.Value(u64) = .init(0),
/// The listener closed while the datagram waited for a permit.
dropped_at_shutdown: std.atomic.Value(u64) = .init(0),
receive_errors: std.atomic.Value(u64) = .init(0),
send_errors: std.atomic.Value(u64) = .init(0),
};
@@ -56,76 +48,35 @@ pub const Stats = struct {
pub const Snapshot = struct {
received: u64,
dropped_oversize: u64,
dropped_no_slot: u64,
dropped_handler: u64,
dropped_at_shutdown: u64,
receive_errors: u64,
send_errors: u64,
};
/// Lifecycle of the receive loop. `serve` claims `.serving`, `deinit` publishes
/// `.closing`, and the two meet at `stopped` so no task touches the slots after
/// they are freed.
const State = enum(u32) { idle, serving, closing };
pub const UdpServer = struct {
socket: std.Io.net.Socket,
handler: *handler.Handler,
slots: []Slot,
mutex: std.Io.Mutex,
budget: *query_budget.QueryBudget,
stats: Stats,
state: std.atomic.Value(State),
/// `serve` claims `.serving`, `deinit` publishes `.closing`, and the two
/// meet at `stopped` so no task touches the socket after it is closed.
state: std.atomic.Value(listener.State),
stopped: std.Io.Event,
pub const Slot = struct {
query: [max_datagram]u8,
/// The buffer the handler receives the upstream answer into, so it
/// holds a whole DNS message, not a whole datagram. A reply between
/// `max_datagram` and `transport.max_message_len` must reach the
/// handler intact: only the handler can turn it into the TC=1 reply
/// that makes the client retry over TCP (S5.3). A 4096-byte buffer
/// would instead fail the exchange with `error.ResponseTooLarge` and
/// answer SERVFAIL, which is wrong.
///
/// The datagram actually sent stays bounded by `udpLimit` inside the
/// handler, so nothing larger than 4096 bytes leaves this socket.
///
/// Cost: 4096 + 65535 + the scratch below ≈ 74 KiB per slot, so the
/// default 64 slots hold ≈ 4.6 MiB. The PLAN §18 budget is 100 MiB with
/// ~1M blocked domains, so this pool takes about 5% of it.
reply: [transport.max_message_len]u8,
/// The handler's per-query working memory. It belongs to the slot so
/// that answering a datagram still allocates nothing, and one slot
/// serves one query at a time.
scratch: handler.Scratch,
from: std.Io.net.IpAddress,
len: usize,
/// Guarded by `UdpServer.mutex`.
in_use: bool,
};
pub const BindError = std.Io.net.IpAddress.BindError || error{OutOfMemory};
pub const BindError = std.Io.net.IpAddress.BindError;
pub fn bind(
gpa: std.mem.Allocator,
io: std.Io,
bind_address: std.Io.net.IpAddress,
h: *handler.Handler,
options: Options,
budget: *query_budget.QueryBudget,
) BindError!UdpServer {
std.debug.assert(options.max_in_flight > 0);
const slots = try gpa.alloc(Slot, options.max_in_flight);
errdefer gpa.free(slots);
for (slots) |*slot| slot.in_use = false;
const local = bind_address;
const socket = try local.bind(io, .{ .mode = .dgram });
const socket = try bind_address.bind(io, .{ .mode = .dgram });
return .{
.socket = socket,
.handler = h,
.slots = slots,
.mutex = .init,
.budget = budget,
.stats = .{},
.state = .init(.idle),
.stopped = .unset,
@@ -139,13 +90,13 @@ pub const UdpServer = struct {
/// The counters, read one at a time. A scrape that lands mid-datagram can
/// see a receive counted before its drop is; a lock would buy a consistency
/// no consumer needs, and the receive loop takes that lock per datagram.
/// no consumer needs.
pub fn snapshotStats(self: *const UdpServer) Snapshot {
return .{
.received = self.stats.received.load(.monotonic),
.dropped_oversize = self.stats.dropped_oversize.load(.monotonic),
.dropped_no_slot = self.stats.dropped_no_slot.load(.monotonic),
.dropped_handler = self.stats.dropped_handler.load(.monotonic),
.dropped_at_shutdown = self.stats.dropped_at_shutdown.load(.monotonic),
.receive_errors = self.stats.receive_errors.load(.monotonic),
.send_errors = self.stats.send_errors.load(.monotonic),
};
@@ -169,172 +120,95 @@ pub const UdpServer = struct {
self.stopped.set(io);
}
pub fn deinit(self: *UdpServer, gpa: std.mem.Allocator, io: std.Io) void {
// `serve` reads this between receives, so it stops on its own and the
// socket is closed only once nothing can be reading from it.
if (self.state.swap(.closing, .acq_rel) == .serving) self.stopped.waitUncancelable(io);
pub fn deinit(self: *UdpServer, io: std.Io) void {
// `serve` reads this between receives and while it waits for a permit,
// so it stops on its own and the socket is closed only once nothing
// can be reading from it.
if (self.state.swap(.closing, .acq_rel) == .serving) {
self.budget.wakeWaiters(io);
self.stopped.waitUncancelable(io);
}
self.socket.close(io);
gpa.free(self.slots);
self.* = undefined;
}
fn receiveLoop(self: *UdpServer, io: std.Io, group: *std.Io.Group) void {
// A datagram that finds no free slot still has to leave the socket
// buffer, or the loop would spin on the same undeliverable packet.
var overflow: [max_datagram]u8 = undefined;
// The datagram is received here, without a permit, so an idle listener
// holds none; a query that gets one is copied into it.
var datagram: [max_datagram]u8 = undefined;
while (self.state.load(.acquire) == .serving) {
const claimed = self.claim(io);
const buffer = if (claimed) |index| &self.slots[index].query else &overflow;
const msg = self.socket.receiveTimeout(io, buffer, .{ .duration = poll_interval }) catch |err| {
if (claimed) |index| self.release(io, index);
const msg = self.socket.receiveTimeout(io, &datagram, .{ .duration = poll_interval }) catch |err| {
switch (err) {
error.Canceled => return,
// The stop flag is re-read at the top of the loop.
error.Timeout => continue,
error.ConcurrencyUnavailable => concurrency.unavailable(.udp_receive),
else => {
bump(&self.stats.receive_errors);
log.debug("udp receive failed: {t}", .{err});
// One bad datagram must not kill the listener, and a
// persistent error must not turn it into a spin.
poll_interval.sleep(io) catch return;
continue;
},
}
// One bad datagram must not kill the listener, and a persistent
// error must not turn it into a spin.
poll_interval.sleep(io) catch return;
continue;
};
bump(&self.stats.received);
const index = claimed orelse {
bump(&self.stats.dropped_no_slot);
continue;
};
// The kernel already threw the tail away, so the message cannot be
// parsed and any answer would be a guess.
if (msg.flags.trunc) {
bump(&self.stats.dropped_oversize);
self.release(io, index);
continue;
}
const slot = &self.slots[index];
slot.from = msg.from;
slot.len = msg.data.len;
const work = self.budget.acquire(io, null, &self.state) catch |err| switch (err) {
error.Canceled, error.Stopped => {
bump(&self.stats.dropped_at_shutdown);
return;
},
error.Timeout => unreachable,
};
@memcpy(work.datagram[0..msg.data.len], msg.data);
group.concurrent(io, respondOne, .{ self, io, index }) catch |err| switch (err) {
group.concurrent(io, respondOne, .{ self, io, work, msg.from, msg.data.len }) catch |err| switch (err) {
error.ConcurrencyUnavailable => {
bump(&self.stats.dropped_no_slot);
self.release(io, index);
concurrency.unavailable(.udp_query);
self.budget.release(io, work);
},
};
}
}
fn respondOne(self: *UdpServer, io: std.Io, index: usize) void {
const slot = &self.slots[index];
defer self.release(io, index);
fn respondOne(
self: *UdpServer,
io: std.Io,
work: *query_budget.Work,
from: std.Io.net.IpAddress,
len: usize,
) void {
defer self.budget.release(io, work);
const outcome = self.handler.handle(
io,
.udp,
address.NetAddress.fromIp(slot.from),
slot.query[0..slot.len],
&slot.reply,
&slot.scratch,
address.NetAddress.fromIp(from),
work.datagram[0..len],
&work.reply,
&work.scratch,
);
switch (outcome) {
.drop => bump(&self.stats.dropped_handler),
.reply => |bytes| self.socket.send(io, &slot.from, bytes) catch |err| {
.reply => |bytes| self.socket.send(io, &from, bytes) catch |err| {
bump(&self.stats.send_errors);
log.debug("udp send to a client failed: {t}", .{err});
},
}
}
fn claim(self: *UdpServer, io: std.Io) ?usize {
// Uncancelable: this section takes no Io and never blocks on a peer, so
// it cannot deadlock, and losing the lock mid-update would leak a slot.
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const index = firstFree(self.slots) orelse return null;
self.slots[index].in_use = true;
return index;
}
fn release(self: *UdpServer, io: std.Io, index: usize) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.slots[index].in_use = false;
}
};
/// The capacity rule, without the mutex, so it is testable without a backend.
fn firstFree(slots: []const UdpServer.Slot) ?usize {
for (slots, 0..) |*slot, index| {
if (!slot.in_use) return index;
}
return null;
}
fn bump(counter: *std.atomic.Value(u64)) void {
_ = counter.fetchAdd(1, .monotonic);
}
const testing = std.testing;
fn testSlots(count: usize) ![]UdpServer.Slot {
const slots = try testing.allocator.alloc(UdpServer.Slot, count);
for (slots) |*slot| slot.in_use = false;
return slots;
}
test "the slot pool hands out every index once" {
const slots = try testSlots(3);
defer testing.allocator.free(slots);
for (0..slots.len) |expected| {
const index = firstFree(slots) orelse return error.TestUnexpectedResult;
try testing.expectEqual(expected, index);
slots[index].in_use = true;
}
}
test "a full slot pool refuses instead of growing" {
const slots = try testSlots(2);
defer testing.allocator.free(slots);
for (slots) |*slot| slot.in_use = true;
try testing.expectEqual(@as(?usize, null), firstFree(slots));
}
test "a slot's reply buffer holds a whole DNS message" {
const slots = try testSlots(1);
defer testing.allocator.free(slots);
// The handler asserts both ends of this range on the buffer it is given,
// and an upstream reply of up to 65535 bytes has to fit so the handler can
// truncate it instead of the exchange failing.
try testing.expectEqual(@as(usize, transport.max_message_len), slots[0].reply.len);
try testing.expect(slots[0].reply.len >= handler.udp_limit_min);
try testing.expect(slots[0].reply.len > max_datagram);
}
test "the default slot pool stays inside the memory budget" {
// 4096 + 65535 + scratch ≈ 74 KiB per slot; 64 slots ≈ 4.6 MiB, against the
// 100 MiB of PLAN §18.
const options: Options = .{};
const pool_bytes = @sizeOf(UdpServer.Slot) * @as(usize, options.max_in_flight);
try testing.expect(pool_bytes < 8 * 1024 * 1024);
}
test "a released slot is handed out again" {
const slots = try testSlots(2);
defer testing.allocator.free(slots);
for (slots) |*slot| slot.in_use = true;
slots[1].in_use = false;
try testing.expectEqual(@as(?usize, 1), firstFree(slots));
}
+212 -20
View File
@@ -12,7 +12,9 @@ const std = @import("std");
const build_options = @import("build_options");
const net = std.Io.net;
const concurrency = @import("../platform/concurrency.zig");
const handler = @import("handler.zig");
const query_budget = @import("query_budget.zig");
const udp_server = @import("udp_server.zig");
const model = @import("../config/model.zig");
const response = @import("../filter/response.zig");
@@ -116,11 +118,21 @@ test "a udp query is answered on the loopback" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
// Deferred so a failed check still stops the server: a `serve` left in
// accept makes `threaded.deinit` wait for it forever.
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
var client = try client_address.bind(io, .{ .mode = .dgram });
@@ -135,11 +147,6 @@ test "a udp query is answered on the loopback" {
try testing.expectEqual(@as(u64, 1), server.stats.received.load(.monotonic));
try testing.expectEqual(@as(u64, 0), server.stats.send_errors.load(.monotonic));
try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic));
server.deinit(gpa, io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
test "a runt datagram is dropped and no reply is sent" {
@@ -155,11 +162,19 @@ test "a runt datagram is dropped and no reply is sent" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
var client = try client_address.bind(io, .{ .mode = .dgram });
@@ -171,11 +186,6 @@ test "a runt datagram is dropped and no reply is sent" {
try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence));
try testing.expectEqual(@as(u64, 1), server.stats.received.load(.monotonic));
try testing.expectEqual(@as(u64, 1), h.stats.dropped_malformed.load(.monotonic));
server.deinit(gpa, io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
test "an oversize datagram arrives truncated and is dropped" {
@@ -191,11 +201,19 @@ test "an oversize datagram arrives truncated and is dropped" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
var client = try client_address.bind(io, .{ .mode = .dgram });
@@ -213,11 +231,6 @@ test "an oversize datagram arrives truncated and is dropped" {
try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence));
try testing.expectEqual(@as(u64, 1), server.stats.dropped_oversize.load(.monotonic));
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
server.deinit(gpa, io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
test "deinit ends a serve loop that is blocked on receive" {
@@ -233,14 +246,193 @@ test "deinit ends a serve loop that is blocked on receive" {
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
// No datagram ever arrives, so `serve` is inside a receive when this runs.
server.deinit(gpa, io);
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
fn waitForWaiters(permits: *query_budget.QueryBudget, io: std.Io, count: u64) !void {
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(1), .clock = .awake };
while (permits.snapshot().waiting != count) try tick.sleep(io);
}
test "with every permit taken a udp query waits, and is answered once one frees" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 1);
defer permits.deinit();
var server = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
// Another listener's query, as far as this one can tell.
var held: ?*query_budget.Work = permits.tryAcquire(io).?;
defer if (held) |work| permits.release(io, work);
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
var client = try client_address.bind(io, .{ .mode = .dgram });
defer client.close(io);
try client.send(io, &server_address, query_bytes);
try waitForWaiters(&permits, io, 1);
var buf: [udp_server.max_datagram]u8 = undefined;
try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence));
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
permits.release(io, held.?);
held = null;
const msg = try client.receiveTimeout(io, &buf, budget);
try expectAnswersQuery(msg.data);
const waits = permits.snapshot();
try testing.expectEqual(@as(u64, 1), waits.waits);
try testing.expectEqual(@as(u64, 0), waits.wait_timeouts);
try testing.expect(waits.wait_seconds > 0);
}
test "a udp listener waiting for a permit stops when it is closed" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 1);
defer permits.deinit();
var server = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
const held = permits.tryAcquire(io).?;
defer permits.release(io, held);
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
var client = try client_address.bind(io, .{ .mode = .dgram });
defer client.close(io);
try client.send(io, &server_address, query_bytes);
try waitForWaiters(&permits, io, 1);
// The permit is still held, so only the close can end this wait; a deinit
// that missed the waiter would hang here.
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
try testing.expectEqual(@as(u64, 0), permits.snapshot().waiting);
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
}
test "a udp listener canceled while it waits for a permit counts the datagram it held" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 1);
defer permits.deinit();
var server = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
defer server.deinit(io);
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
const held = permits.tryAcquire(io).?;
defer permits.release(io, held);
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
var client = try client_address.bind(io, .{ .mode = .dgram });
defer client.close(io);
try client.send(io, &server_address, query_bytes);
try waitForWaiters(&permits, io, 1);
group.cancel(io);
try testing.expectEqual(@as(u64, 1), server.stats.dropped_at_shutdown.load(.monotonic));
try testing.expectEqual(@as(u64, 0), permits.snapshot().waiting);
}
test "a udp query with a permit but no task is dropped, counted, and returns its permit" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
// The serve task is the one task this pool allows.
var threaded: std.Io.Threaded = .init(gpa, .{ .concurrent_limit = .limited(1) });
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var permits: query_budget.QueryBudget = try .init(gpa, 4);
defer permits.deinit();
var server = try udp_server.UdpServer.bind(io, listen_address, &h, &permits);
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
defer {
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
var client = try client_address.bind(io, .{ .mode = .dgram });
defer client.close(io);
const before = concurrency.snapshot().get(.udp_query);
try client.send(io, &server_address, query_bytes);
var buf: [udp_server.max_datagram]u8 = undefined;
try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence));
try testing.expectEqual(@as(u64, 1), concurrency.snapshot().get(.udp_query) - before);
try testing.expectEqual(@as(usize, 0), permits.taken(io));
}
+79
View File
@@ -878,6 +878,69 @@ pub fn applyPragmas(self: *Db, p: Pragmas) Error!void {
}
}
/// What a runtime connection is for, which decides how much page cache it
/// may keep.
///
/// SQLite's default is 2000 KiB per connection, and every page it caches is a
/// heap block the process keeps once the cache has filled. A page that falls
/// out of this cache is read again from the kernel's page cache, not from the
/// disk, so each role keeps only the pages its own access pattern reuses. The
/// sizes below are in 4 KiB pages, SQLite's default page size, against the
/// Pi's files: `config.db` 136 KiB, `querylog.db` 107 MiB.
pub const Role = enum {
/// 64 pages. The Pi's whole `config.db` is 34. Its one growing table, the
/// resolved diagnostics events, is capped at `events.Store.max_resolved_rows`
/// and is read only when the diagnostics page asks; the configuration and
/// the active events every request reads stay within the cache.
config,
/// 256 pages. One insert walks ten b-trees: the query log, its three
/// indexes, the domains table and its unique index, and the four bucket
/// projections. On a 107 MiB log their interior pages number about a
/// hundred; the rest holds the rightmost leaves the appends land on and
/// the leaves of the domains asked for most.
querylog_writer,
/// 64 pages. A pass runs once a day: one statement deletes the day that
/// aged out, the four projections drop their old buckets and recompute
/// the one the cutoff straddles, and every seventh pass a `VACUUM` streams
/// the whole file through the pager. The pages a pass would reuse, such as
/// the domain-index leaves its deleted rows share, come back from the
/// kernel's cache as copies, and a transaction with more dirty pages than
/// fit spills them to the WAL early. A larger cache would stay allocated
/// through the 24 hours between passes to save the pass a few copies.
querylog_retention,
/// 256 pages. The web API's history page walks the timestamp index and
/// its rows; the statistics read the bucket projections, which every
/// dashboard refresh asks for again.
querylog_reader,
pub fn cacheKib(role: Role) u32 {
return switch (role) {
.config => 256,
.querylog_writer => 1024,
.querylog_retention => 256,
.querylog_reader => 1024,
};
}
};
/// The step that makes a connection a runtime connection: sets its page cache
/// for `role` and reads it back. Called after the schema is in place, by the
/// code that opens a connection for the running server; migrations and CLI
/// commands keep SQLite's default.
pub fn initRuntimeConnection(self: *Db, role: Role) Error!void {
const kib = role.cacheKib();
var buf: [48]u8 = undefined;
// A negative `cache_size` is a size in KiB; a positive one counts pages,
// whose size depends on the database file.
const sql = std.fmt.bufPrintZ(&buf, "PRAGMA cache_size = -{d};", .{kib}) catch unreachable;
try self.exec(sql);
const set = try self.queryInt("PRAGMA cache_size");
if (set != -@as(i64, kib)) {
log.warn("PRAGMA cache_size for a {t} connection reads {d}, expected -{d}", .{ role, set, kib });
return error.SqliteError;
}
}
/// A write transaction.
///
/// Usage contract, followed everywhere in this milestone:
@@ -1168,6 +1231,22 @@ test "applyPragmas succeeds and foreign_keys reads back as 1" {
try testing.expectEqual(@as(i64, 1), try db.queryInt("PRAGMA foreign_keys"));
}
test "a runtime connection reports its role's cache size, a migration connection the default" {
var migrating = try openMemory();
defer migrating.close();
try applyPragmas(&migrating, .{});
// SQLite's compiled-in default: 2000 KiB.
try testing.expectEqual(@as(i64, -2000), try migrating.queryInt("PRAGMA cache_size"));
for (std.enums.values(Role)) |role| {
var runtime = try openMemory();
defer runtime.close();
try applyPragmas(&runtime, .{});
try initRuntimeConnection(&runtime, role);
try testing.expectEqual(-@as(i64, role.cacheKib()), try runtime.queryInt("PRAGMA cache_size"));
}
}
test "open on a directory path returns error.CantOpen and leaks no handle" {
var i: usize = 0;
while (i < 1000) : (i += 1) {
+25 -4
View File
@@ -33,6 +33,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const concurrency = @import("../platform/concurrency.zig");
const db = @import("db.zig");
const disk_monitor = @import("disk_monitor.zig");
const events = @import("events.zig");
@@ -268,7 +269,7 @@ pub const Entry = struct {
/// The memory budget the queue is allowed to occupy. `Entry` travels by value,
/// so the composition root allocates `query_log_buffer_max` of them in full at
/// boot (`app.zig`) and the SSE hub embeds a ring of them per subscriber.
/// boot (`app.zig`), and each live SSE subscriber holds a ring of them.
const queue_budget_bytes = 64 * 1024 * 1024;
/// The ceiling `config/validate.zig` enforces on `logging.query_log_buffer_max`,
@@ -817,10 +818,16 @@ pub const Logger = struct {
race.concurrent(.entry, takeOne, .{ &self.queue, io }) catch |err| switch (err) {
// No second unit of concurrency: the caller flushes what it holds
// rather than block past the interval.
error.ConcurrencyUnavailable => return null,
error.ConcurrencyUnavailable => {
concurrency.unavailable(.querylog_wait);
return null;
},
};
race.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return drainRace(&race),
error.ConcurrencyUnavailable => {
concurrency.unavailable(.querylog_wait);
return drainRace(&race);
},
};
const first = race.await() catch |err| {
@@ -1259,6 +1266,20 @@ test "log applies both privacy transforms before the entry reaches the queue" {
try testing.expectEqual(@as(u64, 0), logger.queries_dropped.load(.monotonic));
}
test "a wait that cannot start its race is counted and ends the batch early" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{ .concurrent_limit = .nothing });
defer threaded.deinit();
const io = threaded.io();
var buf: [4]Entry = undefined;
var logger: Logger = .init(.{}, &buf);
const before = concurrency.snapshot().get(.querylog_wait);
const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(60), .clock = .awake };
try testing.expectEqual(@as(?Entry, null), try logger.getWithin(io, long, Gate.initial));
try testing.expectEqual(@as(u64, 1), concurrency.snapshot().get(.querylog_wait) - before);
}
test "hide_domains hides every query-derived name and leaves the labels alone" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
@@ -1335,7 +1356,7 @@ test "the entry queue's worst case stays inside its byte budget" {
// The shipped default has to be comfortably inside the budget, or the
// out-of-the-box configuration is the one that spends it. At the widths
// above it costs about 17 MiB, roughly a quarter of the ceiling.
// above it costs under 2 MiB.
const default_max: usize = (model.Logging{}).query_log_buffer_max;
try testing.expect(default_max <= query_log_buffer_max);
try testing.expect(default_max * @sizeOf(Entry) <= queue_budget_bytes / 2);
+23 -4
View File
@@ -38,6 +38,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const concurrency = @import("../platform/concurrency.zig");
const db = @import("db.zig");
const disk_monitor = @import("disk_monitor.zig");
const events = @import("events.zig");
@@ -249,19 +250,19 @@ pub const Controller = struct {
.live = generation,
};
owned.writer = try io.concurrent(logger.Logger.runWriter, .{
owned.writer = try concurrency.counted(.service, io.concurrent(logger.Logger.runWriter, .{
generation.logger,
io,
opts.gpa,
&owned.database,
opts.monitor,
});
}));
errdefer {
generation.logger.shutdown(io);
owned.writer.await(io) catch {};
}
self.reaper = try io.concurrent(runReaper, .{ self, io });
self.reaper = try concurrency.counted(.service, io.concurrent(runReaper, .{ self, io }));
}
/// Stops every generation in the order the writer deadlock demands, and
@@ -433,6 +434,10 @@ pub const Controller = struct {
if (self.retired != null) return error.PreviousResizeDraining;
}
var database = try querylog_schema.reopen(io, source.dir, source.path);
db.initRuntimeConnection(&database, .querylog_writer) catch |err| {
database.close();
return err;
};
const generation = build(self.gpa, database, cfg, self.diagnostics) catch |err| {
database.close();
return err;
@@ -440,7 +445,7 @@ pub const Controller = struct {
errdefer generation.deinit(self.gpa);
const owned = &generation.owned.?;
owned.writer = try io.concurrent(runParkedWriter, .{ generation, io, self.gpa, self.monitor });
owned.writer = try concurrency.counted(.service, io.concurrent(runParkedWriter, .{ generation, io, self.gpa, self.monitor }));
// The statements are prepared before anything is published, so a
// failure here is a refused settings change rather than a writer that
@@ -851,6 +856,20 @@ test "a candidate whose writer cannot prepare its statements leaves the running
try testing.expectEqual(@as(u64, 8), h.controller.sample(test_io).rows_written);
}
test "a resized generation's connection is a runtime writer connection" {
var h: Harness = .{};
try h.start(.{ .query_log_buffer_max = 16, .query_log_flush_interval_s = 0 });
defer h.stop();
const prepared = try h.prepareResize(64);
defer h.controller.abandon(test_io, prepared);
const database = &prepared.generation.owned.?.database;
try testing.expectEqual(
-@as(i64, db.Role.querylog_writer.cacheKib()),
try database.queryInt("PRAGMA cache_size"),
);
}
test "a prepared candidate the caller abandons closes everything it opened" {
var h: Harness = .{};
try h.start(.{ .query_log_buffer_max = 16, .query_log_flush_interval_s = 0 });
+2 -2
View File
@@ -604,13 +604,13 @@ test "S8 case 8: the cache ages a real response and expires it at the boundary"
try testing.expectEqual(@as(u32, 300), class.ttl_seconds);
try testing.expectEqual(false, class.negative);
var cache = try dns_cache.DnsCache.init(testing.allocator, .{ .size = 16, .negative_ttl_max = 3600 });
var cache = try dns_cache.DnsCache.init(testing.allocator, .sized(16));
defer cache.deinit();
var key_buf: [dns_cache.max_key_len]u8 = undefined;
const key = dns_cache.buildKey(&key_buf, "example.com", 1, 1, false, null);
try cache.put(1000, key, response, class);
cache.put(1000, key, response, class);
try testing.expectEqual(@as(u32, 1), cache.len());
var out: [512]u8 = undefined;
+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
+29
View File
@@ -678,6 +678,35 @@ test "S7 case 10: a config.db stamped one version ahead is refused and left alon
try testing.expectEqualSlices(u8, before, after);
}
test "every server connection reports its role's cache size, and a migrating connection the default" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
// The CLI's path: migrations run on SQLite's default.
var data = try openMigrated(&f, "data");
defer data.deinit();
try testing.expectEqual(@as(i64, -2000), try data.database.queryInt("PRAGMA cache_size"));
// The server's two schema connections, then every further one.
var config = try data.dir.openMigratedConfigDb(io);
defer config.close();
try testing.expectEqual(-@as(i64, db.Role.config.cacheKib()), try config.queryInt("PRAGMA cache_size"));
var writer = try data.dir.openQuerylogDb(io);
defer writer.database.close();
try testing.expectEqual(
-@as(i64, db.Role.querylog_writer.cacheKib()),
try writer.database.queryInt("PRAGMA cache_size"),
);
for (std.enums.values(db.Role)) |role| {
var runtime = try data.dir.openRuntimeDb(io, role);
defer runtime.close();
try testing.expectEqual(-@as(i64, role.cacheKib()), try runtime.queryInt("PRAGMA cache_size"));
}
}
// ---------------------------------------------------------------------------
// case 11-19: export and import on real files
// ---------------------------------------------------------------------------
+6
View File
@@ -14,6 +14,10 @@ comptime {
_ = @import("dns/packet.zig");
_ = @import("dns/dns.zig");
_ = @import("platform/address.zig");
_ = @import("platform/concurrency.zig");
_ = @import("platform/huge_pages.zig");
_ = @import("platform/udp_socket_stats.zig");
_ = @import("platform/wake_word.zig");
_ = @import("platform/tls_client.zig");
_ = @import("platform/tls_client_integration_test.zig");
_ = @import("platform/tls_server.zig");
@@ -70,6 +74,7 @@ comptime {
_ = @import("filter/domain_set.zig");
_ = @import("filter/rules.zig");
_ = @import("filter/matcher.zig");
_ = @import("filter/list_levels_test.zig");
_ = @import("filter/compiler.zig");
_ = @import("filter/safesearch.zig");
_ = @import("filter/response.zig");
@@ -82,6 +87,7 @@ comptime {
_ = @import("local/reverse_name.zig");
_ = @import("cache/dns_cache.zig");
_ = @import("server/rate_limiter.zig");
_ = @import("server/query_budget.zig");
_ = @import("storage/repositories/queries_repo.zig");
_ = @import("storage/repositories/events_repo.zig");
_ = @import("storage/events.zig");
+1 -1
View File
@@ -458,7 +458,7 @@ test "a session the upstream closed is recovered by one redial and counted, not
.priority = 10,
.enabled = true,
.health = .init,
.admission = .{ .permits = slots.len },
.admission = .{ .permits = .init(slots.len) },
.reuse_recoveries = &fixture.recoveries,
}};
var pool: pool_mod.Pool = .init(&entries, .{
+1 -1
View File
@@ -577,7 +577,7 @@ const Upstreams = struct {
.priority = server.priority,
.enabled = true,
.health = .init,
.admission = .{ .permits = entry_slots.len },
.admission = .{ .permits = .init(entry_slots.len) },
.reuse_recoveries = &self.recovery_counters[self.used],
};
self.used += 1;
+53 -115
View File
@@ -72,6 +72,7 @@ const events = @import("../storage/events.zig");
const health = @import("health.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("transport.zig");
const WakeWord = @import("../platform/wake_word.zig").WakeWord;
const log = std.log.scoped(.upstream);
@@ -116,17 +117,9 @@ pub const slots_per_entry = 8;
/// Admission control for one entry: one permit per slot, a try-acquire, and an
/// acquire bounded by the caller's deadline. `std.Io.Semaphore` offers neither
/// in 0.16, and `std.Io.Condition` has no timed wait, so this mirrors the
/// standard semaphore's mutex/count/condition protocol with the two operations
/// the failover loop needs added. The stdlib is not patched and nothing spins.
///
/// The mutex owns the permit count and the decision to take one, and the caller
/// takes its permit only *after* the timed race has resolved. That ordering is
/// the whole design: racing a bare `Semaphore.wait` against an expiry can
/// decrement the count in the same instant the expiry wins, and the discarded
/// success never reaches the caller's release, so the permit is gone for good. A
/// wait discarded here loses a notification instead, which the exiting waiter
/// repairs by re-signalling.
/// in 0.16, so the count is an atomic and a waiter sleeps on a `WakeWord`
/// until a release or its deadline, on its own thread: a timed race would cost
/// two more tasks per waiter.
///
/// Bound worth knowing: `acquireUntil` blocks on one entry. A permit freeing on
/// some *other* entry does not wake this waiter, so the pool's no-head-of-line
@@ -134,85 +127,56 @@ pub const slots_per_entry = 8;
/// blocked. Waking on any of several entries needs multi-wait machinery the pool
/// does not have.
pub const Admission = struct {
mutex: std.Io.Mutex = .init,
cond: std.Io.Condition = .init,
/// One per slot at build time; `Pool.init` asserts it.
permits: usize = 0,
permits: std.atomic.Value(usize) = .init(0),
released: WakeWord = .{},
/// Which side of `acquireUntil` ended the wait. `.expired` holds no permit.
pub const Outcome = enum { acquired, expired };
/// Takes a permit if one is free this instant; never blocks.
///
/// A contended mutex reads as "no permit now" rather than as a reason to
/// wait. The failover loop calls this on every candidate in turn, so
/// blocking here for a descheduled permit holder would stall the whole
/// sweep and spend the caller's budget outside `acquireUntil`, which is the
/// only step allowed to consume it.
pub fn tryAcquire(self: *Admission, io: std.Io) bool {
if (!self.mutex.tryLock()) return false;
defer self.mutex.unlock(io);
if (self.permits == 0) return false;
self.permits -= 1;
return true;
pub fn tryAcquire(self: *Admission) bool {
var current = self.permits.load(.acquire);
while (current != 0) {
current = self.permits.cmpxchgWeak(current, current - 1, .acquire, .acquire) orelse return true;
}
return false;
}
/// Returns a permit. Uncancelable, so a torn-down attempt still gives its
/// slot back.
pub fn release(self: *Admission, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.permits += 1;
self.cond.signal(io);
_ = self.permits.fetchAdd(1, .release);
self.released.wakeOne(io);
}
/// Waits for a permit until `deadline`. Returns `.acquired` holding one,
/// `.expired` holding none, or an error holding none — `error.Canceled`
/// when the whole task is torn down, `error.SystemResources` when the race
/// cannot start.
/// `.expired` holding none, or `error.Canceled` holding none when the whole
/// task is torn down.
pub fn acquireUntil(
self: *Admission,
io: std.Io,
deadline: std.Io.Clock.Timestamp,
) transport.ExchangeError!Outcome {
) std.Io.Cancelable!Outcome {
while (true) {
// Every take is non-blocking, including the retries. A take that
// waits on the mutex spends time no deadline bounds: before the
// first race it carries the call past the deadline outright, and
// after a wake it can return `.acquired` late. A miss instead
// re-enters the race below, which is aimed at the same absolute
// instant every time, so the cycle ends at the deadline with
// `.expired` — the correct answer, not a spin.
if (self.tryAcquire(io)) return .acquired;
var race: transport.RaceOutcome = .completed;
_ = transport.raceUntilTagged(io, deadline, &race, waitForPermit, .{ self, io }) catch |err| {
// This task may be leaving with a notification meant for a peer:
// `Condition`'s cancel path consumes a pending signal before it
// reports the cancellation, and a wait whose success the race
// discarded consumed one outright. Hand it back before exiting.
self.resignal(io);
if (err == error.Timeout and race == .expired) return .expired;
return err;
// Read before the take, so a release that lands after a missed
// take moves the word and the wait returns at once.
const seen = self.released.observe();
if (self.tryAcquire()) return .acquired;
if (std.Io.Clock.Timestamp.now(io, deadline.clock).compare(.gte, deadline)) {
self.handOn(io);
return .expired;
}
self.released.wait(io, seen, deadline) catch {
self.handOn(io);
return error.Canceled;
};
// The wait saw a permit but did not take one, so another task may
// have taken it first. The loop re-checks rather than assuming, and
// does not spin: `waitForPermit` blocks whenever the count is zero.
}
}
/// Returns once the count is non-zero, without touching it.
fn waitForPermit(self: *Admission, io: std.Io) transport.ExchangeError!void {
self.mutex.lock(io) catch return error.Canceled;
defer self.mutex.unlock(io);
while (self.permits == 0) self.cond.wait(io, &self.mutex) catch return error.Canceled;
}
/// Only when a permit is actually there to hand over: an unconditional
/// signal would wake a peer that would find nothing.
fn resignal(self: *Admission, io: std.Io) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
if (self.permits > 0) self.cond.signal(io);
/// A waiter leaving without a permit may have consumed the one wake a
/// release sent, so a permit still free is handed on rather than left for
/// nobody.
fn handOn(self: *Admission, io: std.Io) void {
if (self.permits.load(.acquire) != 0) self.released.wakeOne(io);
}
};
@@ -236,9 +200,8 @@ pub const Entry = struct {
enabled: bool,
health: health.State,
/// One permit per slot, so a task waits here rather than spinning over
/// `Slot.busy`. `.{ .permits = slots.len }` at build time; `Pool.init`
/// asserts it. Its mutex and condition are plain values, so the sort in
/// `init` may copy it.
/// `Slot.busy`. `.{ .permits = .init(slots.len) }` at build time;
/// `Pool.init` asserts it. Plain values, so the sort in `init` may copy it.
admission: Admission,
in_flight: std.atomic.Value(u32) = .init(0),
/// Test-only: the most exchanges this entry ever had in flight at once.
@@ -411,7 +374,7 @@ pub const Pool = struct {
std.debug.assert(cfg.failure_threshold >= 1);
for (entries) |*entry| {
std.debug.assert(entry.slots.len >= 1);
std.debug.assert(entry.admission.permits == entry.slots.len);
std.debug.assert(entry.admission.permits.load(.monotonic) == entry.slots.len);
}
// Stable, so entries sharing a priority keep their configured order.
std.mem.sort(Entry, entries, {}, byPriority);
@@ -709,7 +672,7 @@ pub const Pool = struct {
if (spent.isSet(index)) continue;
if (!entry.enabled) continue;
if (!self.entryAvailable(io, entry, now)) continue;
if (entry.admission.tryAcquire(io)) return index;
if (entry.admission.tryAcquire()) return index;
if (first_eligible == null) first_eligible = index;
}
@@ -1033,7 +996,7 @@ fn testEntrySlots(url: []const u8, fake: *Fake, priority: i32, slot_count: usize
.priority = priority,
.enabled = true,
.health = .init,
.admission = .{ .permits = slot_count },
.admission = .{ .permits = .init(slot_count) },
.reuse_recoveries = &fake.recoveries,
};
}
@@ -1092,7 +1055,7 @@ test "a canceled admission waiter holds nothing and its peer still wakes" {
defer threaded.deinit();
const io = threaded.io();
var admission: Admission = .{ .permits = 0 };
var admission: Admission = .{};
const far: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromSeconds(30), .clock = .awake });
var canceled = io.concurrent(Admission.acquireUntil, .{ &admission, io, far }) catch |err| switch (err) {
@@ -1114,41 +1077,16 @@ test "a canceled admission waiter holds nothing and its peer still wakes" {
// forever and this test would hang.
admission.release(io);
try testing.expectEqual(Admission.Outcome.acquired, try peer.await(io));
try testing.expectEqual(@as(usize, 0), admission.permits);
try testing.expectEqual(@as(usize, 0), admission.permits.load(.monotonic));
}
fn holdAdmissionMutex(admission: *Admission, io: std.Io, hold: std.Io.Clock.Duration) void {
admission.mutex.lockUncancelable(io);
defer admission.mutex.unlock(io);
hold.sleep(io) catch {};
}
test "a contended admission mutex does not carry acquireUntil past its deadline" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// One permit, but the mutex guarding it is held far past the caller's
// deadline. Every take must therefore run inside the deadline race: a
// blocking lock before the race starts would return `.acquired` only once
// the holder let go, long after the budget was gone.
var admission: Admission = .{ .permits = 1 };
const hold: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(200), .clock = .awake };
var holder = io.concurrent(holdAdmissionMutex, .{ &admission, io, hold }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
};
defer holder.await(io);
const settle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
try settle.sleep(io);
const deadline: std.Io.Clock.Timestamp = .fromNow(io, settle);
// `.expired` is the whole assertion: the permit is free, so a take that
// waited on the mutex outside the race would report `.acquired` instead,
// hundreds of milliseconds after the deadline. The exit path itself still
// waits for the mutex, uncancelably, to re-signal the condition.
test "an admission wait with no release expires at its deadline holding nothing" {
const io = testing.io;
var admission: Admission = .{};
const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromMilliseconds(20), .clock = .awake });
try testing.expectEqual(Admission.Outcome.expired, try admission.acquireUntil(io, deadline));
try testing.expectEqual(@as(usize, 1), admission.permits);
try testing.expect(std.Io.Clock.Timestamp.now(io, .awake).compare(.gte, deadline));
try testing.expectEqual(@as(usize, 0), admission.permits.load(.monotonic));
}
test "an admission expiry racing a release leaves the permit count exact" {
@@ -1156,13 +1094,13 @@ test "an admission expiry racing a release leaves the permit count exact" {
defer threaded.deinit();
const io = threaded.io();
// The tie the design exists for: the permit is taken by the caller under
// the mutex only after the race resolves, so a discarded wait can lose a
// notification but never a permit. Repeated because the interleaving is a
// race — a leak would show as a count that drifts below one.
// An expiry and a release in the same instant: the waiter either takes
// the permit or leaves it counted, never both and never neither. Repeated
// because the interleaving is a race — a leak would show as a count that
// drifts below one.
const tick: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(2), .clock = .awake };
for (0..50) |_| {
var admission: Admission = .{ .permits = 0 };
var admission: Admission = .{};
const deadline: std.Io.Clock.Timestamp = .fromNow(io, tick);
var waiter = io.concurrent(Admission.acquireUntil, .{ &admission, io, deadline }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SkipZigTest,
@@ -1174,7 +1112,7 @@ test "an admission expiry racing a release leaves the permit count exact" {
.acquired => admission.release(io),
.expired => {},
}
try testing.expectEqual(@as(usize, 1), admission.permits);
try testing.expectEqual(@as(usize, 1), admission.permits.load(.monotonic));
}
}
@@ -1635,7 +1573,7 @@ test "an exchange whose deadline is already gone starts no attempt" {
try testing.expectEqual(@as(u64, 0), entries[0].health.total_successes);
try testing.expectEqual(@as(u64, 1), pool.budgetExhaustedTotal());
// The permit was released on the way out, so the entry is usable again.
try testing.expectEqual(@as(usize, 1), entries[0].admission.permits);
try testing.expectEqual(@as(usize, 1), entries[0].admission.permits.load(.monotonic));
}
/// The pass-two probing test's numbers: the holder's stall outlives the
+25 -2
View File
@@ -16,6 +16,7 @@
//! wrong bucket.
const std = @import("std");
const concurrency = @import("../platform/concurrency.zig");
const packet = @import("../dns/packet.zig");
const name = @import("../dns/name.zig");
@@ -390,10 +391,16 @@ pub fn raceUntilTagged(
defer race.cancelDiscard();
race.concurrent(.raced, f, args) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
error.ConcurrencyUnavailable => {
concurrency.unavailable(.exchange_race);
return error.SystemResources;
},
};
race.concurrent(.expiry, expire, .{ io, expiry_at }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
error.ConcurrencyUnavailable => {
concurrency.unavailable(.exchange_race);
return error.SystemResources;
},
};
switch (try race.await()) {
@@ -919,6 +926,22 @@ test "raceUntilTagged tells a raced Timeout apart from an expiry" {
try testing.expectEqual(RaceOutcome.expired, outcome);
}
test "raceUntilTagged that cannot start is a local resource fault, counted" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{ .concurrent_limit = .nothing });
defer threaded.deinit();
const io = threaded.io();
const before = concurrency.snapshot().get(.exchange_race);
var outcome: RaceOutcome = .completed;
const far: std.Io.Clock.Timestamp = .fromNow(io, .{ .raw = .fromSeconds(30), .clock = .awake });
try testing.expectError(
error.SystemResources,
raceUntilTagged(io, far, &outcome, racedReply, .{ io, 0, @as(ExchangeError!usize, 7) }),
);
try testing.expectEqual(RaceOutcome.completed, outcome);
try testing.expectEqual(@as(u64, 1), concurrency.snapshot().get(.exchange_race) - before);
}
test "raceUntilTagged tags a successful completion" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+1 -1
View File
@@ -326,7 +326,7 @@ pub const Plan = struct {
if (self.ops.contains(.cache)) {
const candidate = try gpa.create(dns_cache.DnsCache);
candidate.* = dns_cache.DnsCache.init(gpa, self.cfg.cache) catch |err| {
candidate.* = dns_cache.DnsCache.init(gpa, .sized(self.cfg.cache.size)) catch |err| {
gpa.destroy(candidate);
return err;
};
+4 -2
View File
@@ -72,8 +72,10 @@ pub fn stream(
}
defer if (state.limiter) |limiter| limiter.releaseSse(io, client);
const id = hub.subscribe(io) orelse
return http_util.respondError(request, .service_unavailable, "live stream is full");
const id = hub.subscribe(io) catch |err| return switch (err) {
error.Full => http_util.respondError(request, .service_unavailable, "live stream is full"),
error.OutOfMemory => http_util.respondError(request, .service_unavailable, "no memory for a live stream"),
};
defer hub.unsubscribe(io, id);
var response = try request.http.respondStreaming(&.{}, .{
+173 -18
View File
@@ -26,6 +26,7 @@ const Allocator = std.mem.Allocator;
const cert_store = @import("../server/cert_store.zig");
const client_names = @import("../server/client_names.zig");
const clients = @import("../server/clients.zig");
const concurrency = @import("../platform/concurrency.zig");
const dns_cache = @import("../cache/dns_cache.zig");
const dns_handler = @import("../server/handler.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
@@ -41,6 +42,8 @@ const safe_url = @import("../safe_url.zig");
const server = @import("server.zig");
const tcp_server = @import("../server/tcp_server.zig");
const udp_server = @import("../server/udp_server.zig");
const udp_socket_stats = @import("../platform/udp_socket_stats.zig");
const query_budget = @import("../server/query_budget.zig");
/// The exposition format version, as the 0.0.4 specification writes it.
pub const content_type = "text/plain; version=0.0.4; charset=utf-8";
@@ -67,6 +70,8 @@ pub const CacheSample = struct {
stats: dns_cache.Stats,
entries: u64,
memory_bytes: u64,
response_bytes: u64,
max_bytes: u64,
};
pub const LimiterSample = struct {
@@ -91,11 +96,13 @@ pub const DiskSample = struct {
};
/// The DoH listener counters this exposition exports (milestone-10 ruling 10):
/// the four every TLS listener keeps, plus DoH's `bad_requests`. A subset of
/// `doh_server.Snapshot` on purpose — the accept-side refusal counters stay
/// internal, exactly as they do for the DoT listener and TCP/53.
/// the ones every TLS listener keeps, plus DoH's `bad_requests`. A subset of
/// `doh_server.Snapshot` on purpose: of the accept-side refusals only
/// `rejected_at_capacity` is exported, the one a full connection cap explains,
/// exactly as for the DoT listener.
pub const DohListenerSample = struct {
connections: u64,
rejected_at_capacity: u64,
tls_handshake_failures: u64,
idle_timeouts: u64,
connection_errors: u64,
@@ -150,6 +157,9 @@ pub const Sample = struct {
dns: DnsCounters = @splat(0),
logger: LoggerCounters = .{},
log_sink: logging.Stats = .{},
/// Process-wide, like the log sink: every `concurrency.Site`, always
/// rendered, so a zero reads as "never happened" rather than "not wired".
concurrency: concurrency.Snapshot = .initFill(0),
cache: ?CacheSample = null,
limiter: ?LimiterSample = null,
tracker: ?TrackerSample = null,
@@ -178,7 +188,14 @@ pub const Sample = struct {
/// operator asks how much UDP/53 dropped, not how much of it arrived over
/// IPv6. Absent when no listener is wired, like every other collaborator.
udp_listener: ?udp_server.Snapshot = null,
/// The kernel's counters for the UDP listeners' sockets, summed like the
/// listener family. Absent with no UDP listener, or when the scrape could
/// not read them, which `udp_kernel_read_failures_total` then counts.
udp_kernel: ?udp_socket_stats.Reading = null,
udp_kernel_read_failures_total: ?u64 = null,
tcp_listener: ?tcp_server.Snapshot = null,
/// The shared query budget's waits. Absent while no budget is wired.
query_budget: ?query_budget.Snapshot = null,
upstreams: []const UpstreamSample = &.{},
/// Exchanges the pool gave up on because the request's own budget ran out.
/// Pool-wide rather than per-upstream on purpose: budget exhaustion is
@@ -189,6 +206,9 @@ pub const Sample = struct {
/// Exchanges the pool ended on this process's own resources. Pool-wide for
/// the same reason as `upstream_budget_exhausted_total`.
upstream_local_resource_total: ?u64 = null,
/// Live streams refused because their ring could not be allocated. Absent
/// while no hub is wired.
live_stream_allocation_failures_total: ?u64 = null,
peaks: PeakSample = .{},
};
@@ -207,7 +227,7 @@ pub fn handle(
}
pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.Error!Sample {
var sample: Sample = .{ .log_sink = logging.stats() };
var sample: Sample = .{ .log_sink = logging.stats(), .concurrency = concurrency.snapshot() };
if (state.handler) |handler| {
sample.dns = dnsCounters(&handler.stats);
@@ -223,6 +243,8 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
.stats = cache.stats,
.entries = cache.len(),
.memory_bytes = cache.memoryBytes(),
.response_bytes = cache.live_bytes,
.max_bytes = cache.max_bytes,
};
}
@@ -283,6 +305,7 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
const snapshot = listener.snapshotStats();
sample.doh_listener = .{
.connections = snapshot.connections,
.rejected_at_capacity = snapshot.rejected_at_capacity,
.tls_handshake_failures = snapshot.tls_handshake_failures,
.idle_timeouts = snapshot.idle_timeouts,
.connection_errors = snapshot.connection_errors,
@@ -294,6 +317,13 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
if (state.dot_listener) |listener| sample.peaks.dot_connections = listener.connectionsPeak();
sample.udp_listener = sumListeners(udp_server.Snapshot, udp_server.UdpServer, state.udp_listeners);
if (state.udp_listeners.len != 0) {
const sockets = try arena.alloc(std.Io.net.Socket, state.udp_listeners.len);
for (state.udp_listeners, sockets) |listener, *socket| socket.* = listener.socket;
sample.udp_kernel = udp_socket_stats.readSockets(io, arena, sockets);
sample.udp_kernel_read_failures_total = udp_socket_stats.readFailures();
}
if (state.query_budget) |budget| sample.query_budget = budget.snapshot();
sample.tcp_listener = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, state.tcp_listeners);
sample.peaks.tcp_connections = largestConnectionsPeak(state.tcp_listeners);
@@ -307,6 +337,8 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
}
}
if (state.hub) |hub| sample.live_stream_allocation_failures_total = hub.ringAllocationFailures(io);
return sample;
}
@@ -405,10 +437,27 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
try counterGroup(w, "nxdns_querylog_", "Query log writer counter", sample.logger);
try counterGroup(w, "nxdns_log_", "Diagnostic log sink counter", sample.log_sink);
try labeledHead(
w,
"nxdns_concurrency_unavailable_total",
"Work lost because the system refused a thread or a task could not be allocated, by where it happened.",
"counter",
);
for (std.enums.values(concurrency.Site)) |site| {
try w.print("nxdns_concurrency_unavailable_total{{site=\"{t}\"}} {d}\n", .{ site, sample.concurrency.get(site) });
}
if (sample.cache) |cache| {
try counterGroup(w, "nxdns_cache_", "DNS cache counter", cache.stats);
try gauge(w, "nxdns_cache_entries", "Responses currently held in the DNS cache.", cache.entries);
try gauge(w, "nxdns_cache_memory_bytes", "Bytes held by the DNS cache.", cache.memory_bytes);
try gauge(
w,
"nxdns_cache_response_bytes",
"Bytes of cached responses, the part nxdns_cache_max_bytes bounds.",
cache.response_bytes,
);
try gauge(w, "nxdns_cache_max_bytes", "The DNS cache's response byte budget.", cache.max_bytes);
}
if (sample.limiter) |limiter| {
@@ -492,6 +541,27 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
if (sample.udp_listener) |listener| {
try counterGroup(w, "nxdns_udp_server_", "UDP/53 listener counter", listener);
}
if (sample.udp_kernel) |kernel| {
try gauge(
w,
"nxdns_udp_server_receive_queue_bytes",
"Bytes the kernel holds for the UDP/53 sockets, waiting to be received.",
kernel.receive_queue_bytes,
);
try counter(
w,
"nxdns_udp_server_kernel_drops_total",
"Datagrams the kernel dropped for the UDP/53 sockets, mostly a full receive buffer.",
kernel.drops,
);
}
if (sample.udp_kernel_read_failures_total) |failures| try counter(
w,
"nxdns_udp_server_kernel_read_failures_total",
"Scrapes that could not read the UDP/53 sockets' kernel counters.",
failures,
);
if (sample.query_budget) |budget| try renderQueryBudget(w, budget);
if (sample.tcp_listener) |listener| {
try counterGroup(w, "nxdns_tcp_server_", "TCP/53 listener counter", listener);
}
@@ -525,6 +595,42 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
);
try w.print("nxdns_upstream_local_resource_total {d}\n", .{total});
}
if (sample.live_stream_allocation_failures_total) |total| {
try counter(
w,
"nxdns_live_stream_allocation_failures_total",
"Live query streams refused because their buffer could not be allocated.",
total,
);
}
}
fn renderQueryBudget(w: *std.Io.Writer, budget: query_budget.Snapshot) std.Io.Writer.Error!void {
try counter(
w,
"nxdns_dns_query_permit_waits_total",
"DNS queries that found every query permit taken and waited for one.",
budget.waits,
);
try labeledHead(
w,
"nxdns_dns_query_permit_wait_seconds_total",
"Time DNS queries spent waiting for a query permit.",
"counter",
);
try w.print("nxdns_dns_query_permit_wait_seconds_total {d:.4}\n", .{budget.wait_seconds});
try counter(
w,
"nxdns_dns_query_permit_wait_timeouts_total",
"Stream DNS queries whose connection ended because the wait for a permit outlasted the idle budget.",
budget.wait_timeouts,
);
try gauge(
w,
"nxdns_dns_query_permits_waiting",
"DNS queries waiting for a query permit right now.",
budget.waiting,
);
}
fn renderPeaks(w: *std.Io.Writer, peaks: PeakSample) std.Io.Writer.Error!void {
@@ -860,9 +966,20 @@ test "a full sample renders the whole exposition, byte for byte" {
.logger = .{ .queries_dropped = 1, .rows_written = 40, .batches_gated = 2 },
.log_sink = .{ .lines_written = 5, .lines_deduped = 1, .rotations = 0, .sink_errors = 0 },
.cache = .{
.stats = .{ .hits = 7, .misses = 8, .inserts = 6, .evictions = 1, .expirations = 2, .invalid_hits = 0 },
.stats = .{
.hits = 7,
.misses = 8,
.inserts = 6,
.evictions = 1,
.expirations = 2,
.invalid_hits = 0,
.oversized = 3,
.copy_failures = 4,
},
.entries = 5,
.memory_bytes = 4096,
.response_bytes = 512,
.max_bytes = 1024,
},
.limiter = .{ .stats = .{ .allowed = 20, .refused = 2, .untracked = 1 }, .tracked_clients = 3 },
.tracker = .{
@@ -902,6 +1019,10 @@ test "a full sample renders the whole exposition, byte for byte" {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_hits_total 7\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_entries 5\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_memory_bytes 4096\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_response_bytes 512\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_max_bytes 1024\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_oversized_total 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_copy_failures_total 4\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_refused_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_tracked_clients 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_dropped_full_total 0\n"));
@@ -1009,16 +1130,20 @@ test "every HELP line has a TYPE line and a sample, and every sample a name" {
try testing.expect(std.mem.startsWith(u8, line, "nxdns_"));
}
}
// Every family a bare state has is one sample, except the concurrency
// family, which has one per site.
const sites = std.enums.values(concurrency.Site).len;
try testing.expectEqual(helps, types);
try testing.expectEqual(helps, samples);
try testing.expectEqual(helps - 1 + sites, samples);
// The families a bare state still has: `nxdns_up`, every DNS counter, the
// query log writer's, and the diagnostic log sink's. Derived rather than
// counted, so a new counter in any of those structs extends the exposition
// and this assertion together.
// query log writer's, the diagnostic log sink's and the concurrency sites.
// Derived rather than counted, so a new counter in any of those structs
// extends the exposition and this assertion together.
try testing.expectEqual(
1 + dns_stat_fields.len +
@typeInfo(LoggerCounters).@"struct".fields.len +
@typeInfo(logging.Stats).@"struct".fields.len,
@typeInfo(logging.Stats).@"struct".fields.len +
sites,
samples,
);
// The reflective walk names the counters, so a renamed `Handler.Stats`
@@ -1090,8 +1215,8 @@ test "the plain-DNS listener families carry every counter of both listeners" {
.udp_listener = .{
.received = 90,
.dropped_oversize = 1,
.dropped_no_slot = 2,
.dropped_handler = 3,
.dropped_at_shutdown = 6,
.receive_errors = 4,
.send_errors = 5,
},
@@ -1108,8 +1233,8 @@ test "the plain-DNS listener families carry every counter of both listeners" {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_received_total 90\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_oversize_total 1\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_no_slot_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_handler_total 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_at_shutdown_total 6\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n"));
@@ -1134,12 +1259,12 @@ test "one family covers all four listeners, summed" {
var udp6: udp_server.UdpServer = undefined;
udp6.stats = .{};
udp6.stats.received.store(10, .monotonic);
udp6.stats.dropped_no_slot.store(1, .monotonic);
udp6.stats.dropped_oversize.store(1, .monotonic);
var udp4: udp_server.UdpServer = undefined;
udp4.stats = .{};
udp4.stats.received.store(7, .monotonic);
udp4.stats.dropped_no_slot.store(2, .monotonic);
udp4.stats.dropped_oversize.store(2, .monotonic);
var tcp6: tcp_server.TcpServer = undefined;
tcp6.core.stats = .{};
@@ -1152,7 +1277,7 @@ test "one family covers all four listeners, summed" {
const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?;
try testing.expectEqual(@as(u64, 17), udp.received);
try testing.expectEqual(@as(u64, 3), udp.dropped_no_slot);
try testing.expectEqual(@as(u64, 3), udp.dropped_oversize);
try testing.expectEqual(@as(u64, 0), udp.send_errors);
const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?;
@@ -1184,6 +1309,7 @@ test "listener counters render only for the wired servers" {
const doh_only = try renderToString(testing.allocator, .{
.doh_listener = .{
.connections = 9,
.rejected_at_capacity = 6,
.tls_handshake_failures = 2,
.idle_timeouts = 1,
.connection_errors = 0,
@@ -1197,11 +1323,13 @@ test "listener counters render only for the wired servers" {
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_idle_timeouts_total 1\n"));
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connection_errors_total 0\n"));
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_bad_requests_total 4\n"));
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_rejected_at_capacity_total 6\n"));
try testing.expect(!std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_dot_server_"));
const dot_only = try renderToString(testing.allocator, .{
.dot_listener = .{
.connections = 5,
.rejected_at_capacity = 2,
.tls_handshake_failures = 0,
.idle_timeouts = 3,
.connection_errors = 1,
@@ -1212,11 +1340,27 @@ test "listener counters render only for the wired servers" {
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connections_total 5\n"));
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_idle_timeouts_total 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connection_errors_total 1\n"));
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_rejected_at_capacity_total 2\n"));
// The DoT listener has no HTTP layer, so no bad_requests family.
try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_bad_requests_total"));
try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_doh_server_"));
}
test "every concurrency site renders under its own label, zero included" {
var sample: Sample = .{};
sample.concurrency.set(.querylog_wait, 3);
const text = try renderToString(testing.allocator, sample);
defer testing.allocator.free(text);
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_concurrency_unavailable_total counter\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_concurrency_unavailable_total{site=\"querylog_wait\"} 3\n"));
for (std.enums.values(concurrency.Site)) |site| {
var line: [96]u8 = undefined;
const prefix = try std.fmt.bufPrint(&line, "nxdns_concurrency_unavailable_total{{site=\"{t}\"}} ", .{site});
try testing.expect(std.mem.containsAtLeast(u8, text, 1, prefix));
}
}
test "cert reload counters render per endpoint, only for the wired stores" {
const one = try renderToString(testing.allocator, .{
.doh_certs = .{ .reloads = 2, .reload_failures = 1, .last_reload_unix = 1_700_000_000 },
@@ -1435,7 +1579,7 @@ test "two upstreams on one host stay two series" {
keys[count] = key;
count += 1;
}
try testing.expectEqual(@as(usize, 22), count);
try testing.expectEqual(@as(usize, 22 + std.enums.values(concurrency.Site).len), count);
}
test "an upstream url is redacted before it reaches an open endpoint's label" {
@@ -1660,7 +1804,7 @@ test "the queue families carry what a real pool recorded, through the real snaps
.priority = 10,
.enabled = true,
.health = .init,
.admission = .{ .permits = slots.len },
.admission = .{ .permits = .init(slots.len) },
.reuse_recoveries = &recoveries,
}};
var pool: pool_mod.Pool = .init(&entries, .{}, .{
@@ -1720,7 +1864,7 @@ test "collect reads the live counters of the components it is given" {
defer threaded.deinit();
const io = threaded.io();
var cache = try dns_cache.DnsCache.init(testing.allocator, .{ .size = 4 });
var cache = try dns_cache.DnsCache.init(testing.allocator, .sized(4));
defer cache.deinit();
cache.stats.hits = 11;
cache.stats.misses = 5;
@@ -1818,3 +1962,14 @@ test "the local-resource counter renders pool-wide, without a url label" {
defer testing.allocator.free(absent);
try testing.expect(!std.mem.containsAtLeast(u8, absent, 1, "nxdns_upstream_local_resource_total"));
}
test "live stream allocation failures render as a counter, absent without a hub" {
const text = try renderToString(testing.allocator, .{ .live_stream_allocation_failures_total = 2 });
defer testing.allocator.free(text);
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_live_stream_allocation_failures_total counter\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_live_stream_allocation_failures_total 2\n"));
const absent = try renderToString(testing.allocator, .{});
defer testing.allocator.free(absent);
try testing.expect(!std.mem.containsAtLeast(u8, absent, 1, "nxdns_live_stream_allocation_failures"));
}
+4
View File
@@ -47,6 +47,7 @@ const router = @import("router.zig");
const sse = @import("sse.zig");
const tcp_server = @import("../server/tcp_server.zig");
const udp_server = @import("../server/udp_server.zig");
const query_budget = @import("../server/query_budget.zig");
const log = std.log.scoped(.web_server);
@@ -340,6 +341,9 @@ pub const WebState = struct {
/// (milestone-10 ruling 10).
doh_listener: ?*doh_server.DohServer = null,
dot_listener: ?*dot_server.DotServer = null,
/// The query permits every DNS listener shares; `/metrics` reads its
/// waits.
query_budget: ?*query_budget.QueryBudget = null,
/// The plain DNS listeners. The app binds one per family per protocol, so
/// `/metrics` sums each family across its slice (milestone-16 ruling 13).
udp_listeners: []const *udp_server.UdpServer = &.{},
+3 -1
View File
@@ -644,7 +644,6 @@ test "cancellation returns promptly with an idle keep-alive connection open" {
opened.wait(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
try testing.expect(!failed);
// The listener task is canceled with a client parked in `receiveHead`. If
// the cancel path awaited the connection group instead of canceling it,
@@ -657,5 +656,8 @@ test "cancellation returns promptly with an idle keep-alive connection open" {
client.cancel(io);
web.deinit(io);
// Checked after teardown: a failed check before it would leave `serve`
// running, and `threaded.deinit` would wait for it forever.
try testing.expect(!failed);
try testing.expect(elapsed.toMilliseconds() < settle.raw.toMilliseconds());
}
+173 -94
View File
@@ -4,6 +4,8 @@
//! before it hands the same entry to the logger: the event stream must never
//! wait on a database. `publish` therefore copies and returns — it allocates
//! nothing, touches no I/O, and holds one mutex across a scan of 32 slots.
//! A subscriber's ring is allocated by `subscribe` and freed by `unsubscribe`,
//! both off the query path.
//!
//! A subscriber that cannot keep up loses its stream rather than the queries:
//! a full ring sets `overflowed`, the subscriber task sees the flag and ends
@@ -19,97 +21,129 @@ const logger = @import("../storage/logger.zig");
pub const Entry = logger.Entry;
/// Concurrent live streams. The per-IP cap (`web.sse_max_connections_per_ip`)
/// keeps one client from taking all of them; `subscribe` returning null is the
/// backstop and answers 503.
/// keeps one client from taking all of them; `error.Full` from `subscribe` is
/// the backstop and answers 503.
pub const max_subscribers = 32;
/// Entries one subscriber may fall behind by. At household query rates this is
/// several seconds of slack on a stalled TCP connection.
///
/// The ring is embedded in the slot, so this multiplies `@sizeOf(logger.Entry)`
/// — about 1.8 KiB once milestone 28 widened it for provenance. One subscriber
/// therefore costs roughly 115 KiB of ring and the whole hub roughly 3.6 MiB,
/// allocated once for the life of the process. That is the reason to keep both
/// this and `max_subscribers` small: they are paid whether or not anyone is
/// watching. `logger.query_log_buffer_max` bounds the other consumer of the
/// same width, the writer queue.
/// One ring is `ring_capacity * @sizeOf(logger.Entry)`, about 115 KiB, held
/// only while its subscriber is connected.
pub const ring_capacity = 64;
pub const SubscriberId = enum(u8) { _ };
pub const SubscribeError = error{
/// Every slot holds a live stream.
Full,
/// No memory for the ring. Counted in `Hub.ring_allocation_failures`.
OutOfMemory,
};
/// What `wait` returns: an entry (or the overflow flag) is ready, the caller's
/// timeout passed and it owes the client a heartbeat, or the hub is closing and
/// the subscriber must end its response now.
pub const Wake = enum { ready, timeout, closed };
pub const Hub = struct {
gpa: std.mem.Allocator,
/// Guards every field of every slot. `publish` runs on the DNS hot path,
/// so the critical section is copies and flag writes only.
mutex: std.Io.Mutex,
/// Milestone-16 ruling 11. Set once, never cleared: a hub that is closing
/// belongs to a server that is going away.
closing: bool,
/// Subscriptions refused because the ring could not be allocated.
ring_allocation_failures: u64,
slots: [max_subscribers]Slot,
const Slot = struct {
active: bool,
/// The subscriber's ring; empty while the slot is free. Its storage
/// stays undefined: `len` says which entries are live.
ring: []Entry,
/// Set by `publish` when the ring is full. Never cleared while the
/// subscriber lives: the stream it belongs to is over.
overflowed: bool,
head: u32,
len: u32,
event: std.Io.Event,
ring: [ring_capacity]Entry,
fn active(slot: *const Slot) bool {
return slot.ring.len != 0;
}
};
/// Initializes in place. The rings are close to a megabyte, which a
/// by-value `init` would copy through the caller's frame.
///
/// The ring storage stays undefined: `len` says which slots hold entries.
pub fn init(self: *Hub) void {
self.mutex = .init;
self.closing = false;
for (&self.slots) |*slot| {
slot.active = false;
slot.overflowed = false;
slot.head = 0;
slot.len = 0;
slot.event = .unset;
}
pub fn init(gpa: std.mem.Allocator) Hub {
return .{
.gpa = gpa,
.mutex = .init,
.closing = false,
.ring_allocation_failures = 0,
.slots = @splat(.{ .ring = &.{}, .overflowed = false, .head = 0, .len = 0, .event = .unset }),
};
}
/// Claims a slot, or null when all 32 are taken.
/// Every subscriber must have unsubscribed.
pub fn deinit(self: *Hub) void {
for (&self.slots) |*slot| std.debug.assert(!slot.active());
self.* = undefined;
}
/// Claims a slot with a fresh ring.
///
/// The ring is allocated and, when every slot is taken, freed outside the
/// mutex, so `publish` never waits on the allocator.
///
/// `lockUncancelable` throughout this file: `publish`'s caller is
/// `Handler.handle`, which has no error union to carry `error.Canceled`
/// out of (the same reasoning as `clients.Tracker.track`), and the rest of
/// the surface shares the mutex with it.
pub fn subscribe(self: *Hub, io: std.Io) ?SubscriberId {
pub fn subscribe(self: *Hub, io: std.Io) SubscribeError!SubscriberId {
const ring = self.gpa.alloc(Entry, ring_capacity) catch {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.ring_allocation_failures += 1;
return error.OutOfMemory;
};
return self.claim(io, ring) orelse {
self.gpa.free(ring);
return error.Full;
};
}
fn claim(self: *Hub, io: std.Io, ring: []Entry) ?SubscriberId {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
for (&self.slots, 0..) |*slot, index| {
if (slot.active) continue;
slot.active = true;
slot.overflowed = false;
slot.head = 0;
slot.len = 0;
slot.event = .unset;
if (slot.active()) continue;
slot.* = .{ .ring = ring, .overflowed = false, .head = 0, .len = 0, .event = .unset };
return @enumFromInt(index);
}
return null;
}
/// Releases the slot. The caller must not be waiting on it.
/// Releases the slot and frees its ring. The caller must not be waiting
/// on it.
pub fn unsubscribe(self: *Hub, io: std.Io, id: SubscriberId) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const slot = self.slotOf(id);
slot.active = false;
const ring = slot.ring;
slot.ring = &.{};
slot.overflowed = false;
slot.len = 0;
slot.head = 0;
self.mutex.unlock(io);
self.gpa.free(ring);
}
pub fn ringAllocationFailures(self: *Hub, io: std.Io) u64 {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.ring_allocation_failures;
}
/// Milestone-16 ruling 11. Ends every live stream.
@@ -124,7 +158,7 @@ pub const Hub = struct {
self.closing = true;
for (&self.slots) |*slot| {
if (slot.active) slot.event.set(io);
if (slot.active()) slot.event.set(io);
}
}
@@ -135,7 +169,7 @@ pub const Hub = struct {
defer self.mutex.unlock(io);
for (&self.slots) |*slot| {
if (!slot.active or slot.overflowed) continue;
if (!slot.active() or slot.overflowed) continue;
if (slot.len == ring_capacity) {
slot.overflowed = true;
} else {
@@ -214,7 +248,7 @@ pub const Hub = struct {
fn slotOf(self: *Hub, id: SubscriberId) *Slot {
const slot = &self.slots[@intFromEnum(id)];
std.debug.assert(slot.active);
std.debug.assert(slot.active());
return slot;
}
};
@@ -234,21 +268,15 @@ fn sampleEntry(timestamp: i64, domain: []const u8) Entry {
});
}
fn newHub(gpa: std.mem.Allocator) !*Hub {
const hub = try gpa.create(Hub);
hub.init();
return hub;
}
test "a subscriber reads what was published, oldest first" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
hub.publish(io, sampleEntry(1, "first.example"));
@@ -265,12 +293,12 @@ test "an entry published before a subscription is not delivered" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
hub.publish(io, sampleEntry(1, "early.example"));
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
try testing.expect(hub.next(io, id) == null);
}
@@ -280,11 +308,11 @@ test "every live subscriber receives its own copy" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const first = hub.subscribe(io).?;
const second = hub.subscribe(io).?;
const first = try hub.subscribe(io);
const second = try hub.subscribe(io);
defer hub.unsubscribe(io, first);
defer hub.unsubscribe(io, second);
@@ -299,16 +327,17 @@ test "the hub hands out every slot and then refuses" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
var ids: [max_subscribers]SubscriberId = undefined;
for (&ids) |*id| id.* = hub.subscribe(io).?;
try testing.expect(hub.subscribe(io) == null);
for (&ids) |*id| id.* = try hub.subscribe(io);
try testing.expectError(error.Full, hub.subscribe(io));
hub.unsubscribe(io, ids[3]);
const reused = hub.subscribe(io).?;
const reused = try hub.subscribe(io);
try testing.expectEqual(ids[3], reused);
for (ids) |id| hub.unsubscribe(io, id);
}
test "a full ring marks the subscriber overflowed and stops copying" {
@@ -316,10 +345,10 @@ test "a full ring marks the subscriber overflowed and stops copying" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
for (0..ring_capacity) |i| hub.publish(io, sampleEntry(@intCast(i), "fill.example"));
@@ -343,10 +372,10 @@ test "the ring wraps around its head" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
// Two and a half laps, consuming as we go: the head passes the end of the
@@ -368,10 +397,10 @@ test "wait returns as soon as an entry is waiting" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(60), .clock = .awake };
@@ -384,10 +413,10 @@ test "wait times out on an idle subscriber so the heartbeat can go out" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
const brief: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
@@ -400,14 +429,14 @@ test "a publish wakes a waiting subscriber" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(60), .clock = .awake };
var future = try io.concurrent(Hub.wait, .{ hub, io, id, long });
var future = try io.concurrent(Hub.wait, .{ &hub, io, id, long });
hub.publish(io, sampleEntry(5, "late.example"));
@@ -420,10 +449,10 @@ test "an overflow wakes a waiting subscriber" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
for (0..ring_capacity) |i| hub.publish(io, sampleEntry(@intCast(i), "fill.example"));
@@ -434,7 +463,7 @@ test "an overflow wakes a waiting subscriber" {
for (0..ring_capacity) |i| hub.publish(io, sampleEntry(@intCast(i), "fill.example"));
const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(60), .clock = .awake };
var future = try io.concurrent(Hub.wait, .{ hub, io, id, long });
var future = try io.concurrent(Hub.wait, .{ &hub, io, id, long });
hub.publish(io, sampleEntry(999, "lost.example"));
try testing.expectEqual(Wake.ready, try future.await(io));
@@ -446,16 +475,16 @@ test "close wakes a parked subscriber instead of leaving it on the heartbeat" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
// Far longer than the 15 s heartbeat the handler passes, so a pass here
// cannot come from the timeout arm.
const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(600), .clock = .awake };
var future = try io.concurrent(Hub.wait, .{ hub, io, id, long });
var future = try io.concurrent(Hub.wait, .{ &hub, io, id, long });
hub.close(io);
@@ -467,10 +496,10 @@ test "wait on a closed hub returns at once, with no entry pending" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const id = hub.subscribe(io).?;
const id = try hub.subscribe(io);
defer hub.unsubscribe(io, id);
hub.close(io);
@@ -484,13 +513,13 @@ test "publishing while subscribers come and go reaches only the live ones" {
defer threaded.deinit();
const io = threaded.io();
const hub = try newHub(testing.allocator);
defer testing.allocator.destroy(hub);
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
const steady = hub.subscribe(io).?;
const steady = try hub.subscribe(io);
defer hub.unsubscribe(io, steady);
var churner = try io.concurrent(churn, .{ hub, io });
var churner = try io.concurrent(churn, .{ &hub, io });
var published: usize = 0;
while (published < 500) : (published += 1) {
@@ -504,14 +533,64 @@ test "publishing while subscribers come and go reaches only the live ones" {
// Every slot the churner used is free again.
var ids: [max_subscribers - 1]SubscriberId = undefined;
for (&ids) |*id| id.* = hub.subscribe(io).?;
for (&ids) |*id| id.* = try hub.subscribe(io);
for (ids) |id| hub.unsubscribe(io, id);
}
fn churn(hub: *Hub, io: std.Io) void {
for (0..200) |i| {
const id = hub.subscribe(io) orelse continue;
const id = hub.subscribe(io) catch continue;
if (i % 3 == 0) _ = hub.next(io, id);
hub.unsubscribe(io, id);
}
}
test "a subscription holds a ring only while it lives" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var counting: std.testing.FailingAllocator = .init(testing.allocator, .{});
var hub: Hub = .init(counting.allocator());
defer hub.deinit();
try testing.expectEqual(@as(usize, 0), counting.allocated_bytes);
const id = try hub.subscribe(io);
try testing.expectEqual(ring_capacity * @sizeOf(Entry), counting.allocated_bytes - counting.freed_bytes);
hub.unsubscribe(io, id);
try testing.expectEqual(counting.allocated_bytes, counting.freed_bytes);
}
test "a ring that cannot be allocated is counted and leaves the slot reusable" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = 0 });
var hub: Hub = .init(failing.allocator());
defer hub.deinit();
try testing.expectError(error.OutOfMemory, hub.subscribe(io));
try testing.expectEqual(@as(u64, 1), hub.ringAllocationFailures(io));
failing.fail_index = std.math.maxInt(usize);
var ids: [max_subscribers]SubscriberId = undefined;
for (&ids) |*id| id.* = try hub.subscribe(io);
for (ids) |id| hub.unsubscribe(io, id);
}
test "a subscription refused for a full hub frees the ring it allocated" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var hub: Hub = .init(testing.allocator);
defer hub.deinit();
var ids: [max_subscribers]SubscriberId = undefined;
for (&ids) |*id| id.* = try hub.subscribe(io);
defer for (ids) |id| hub.unsubscribe(io, id);
// `testing.allocator` reports the leak if the refused ring is kept.
try testing.expectError(error.Full, hub.subscribe(io));
try testing.expectEqual(@as(u64, 0), hub.ringAllocationFailures(io));
}
+4 -4
View File
@@ -477,10 +477,9 @@ const Env = struct {
});
errdefer self.limiter.deinit();
// ~900 KiB: never a stack local (W5 rule).
self.hub = try gpa.create(sse.Hub);
errdefer gpa.destroy(self.hub);
self.hub.init();
self.hub.* = .init(gpa);
self.pauser = .{};
self.tables = .empty;
@@ -495,7 +494,7 @@ const Env = struct {
.priority = 1,
.enabled = true,
.health = .init,
.admission = .{ .permits = self.pool_slots.len },
.admission = .{ .permits = .init(self.pool_slots.len) },
.reuse_recoveries = &self.pool_recoveries,
}};
self.pool_owner = .{};
@@ -626,7 +625,7 @@ const Env = struct {
self.sink = .init(&self.log_controller, self.hub);
self.cache = try gpa.create(dns_cache.DnsCache);
self.cache.* = try .init(gpa, .{});
self.cache.* = try .init(gpa, .sized((model.Cache{}).size));
self.dns_limiter = try gpa.create(rate_limiter.RateLimiter);
self.dns_limiter.* = try .init(gpa, .{ .limit = 1000, .window_seconds = 60 });
@@ -682,6 +681,7 @@ const Env = struct {
self.state.proxies.deinit(gpa);
self.state.overview_cache.deinit(gpa);
self.tables.deinit(gpa);
self.hub.deinit();
gpa.destroy(self.hub);
self.limiter.deinit();
self.mgr.deinit(ioh);
+3 -3
View File
@@ -1659,9 +1659,9 @@ fn toolchainParity(ctx: *Ctx) ![]const u8 {
return CheckFailed;
};
// `node --version` prints `v24.19.0`; the workflow pins the number the way
// setup-node takes it, without the `v`. `npm --version` and `zig version`
// print the bare number already.
// `node --version` prints `v24.19.0`; the workflow pins the bare number,
// without the `v`. `npm --version` and `zig version` print the bare number
// already.
try assertVersion(ctx, "node", &.{ "node", "--version" }, "v", pins.node);
try assertVersion(ctx, "npm", &.{ "npm", "--version" }, "", pins.npm);
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.astro/
+3 -3
View File
@@ -9,8 +9,8 @@ Convince, then teach. A marketing front page sells the project; the docs sit one
## Domain and hosting
- Domain: `nxdns.sh` (already owned).
- Host: Cloudflare Pages.
- Deploy: Gitea Actions builds the site on push to master, then `wrangler` uploads the output. A Cloudflare API token lives as a Gitea secret. (Cloudflare Pages has no Gitea integration, so direct upload is the mechanism.)
- Host: Cloudflare, as a Worker serving the built site as static assets (`website/wrangler.jsonc`, which also binds the `nxdns.sh` custom domain). Cloudflare has folded Pages into Workers: wrangler's Pages commands now delegate to `wrangler deploy`.
- Deploy: `.gitea/workflows/site.yml` builds the site on push to master and runs `wrangler deploy`. The Cloudflare API token is the Gitea secret `CLOUDFLARE_API_TOKEN`, and the account id the variable `CLOUDFLARE_ACCOUNT_ID`. Cloudflare has no Gitea integration, so CI uploads directly.
## Stack and layout
@@ -22,7 +22,7 @@ Convince, then teach. A marketing front page sells the project; the docs sit one
Four sections, in this order:
1. Hero: the README pitch — "A DNS sinkhole for your LAN. It blocks the names you do not want, and forwards the rest over an encrypted connection." — with the README's flow diagram animated. The install commands sit in their own section near the end, in three tabs: docker, tarball, nix. Each tab is a complete path or names the guide that finishes it; the docker tab writes the smallest config that starts, so nobody faces an empty file. Commands use no shell variables and plain `curl -O`. The registry allows anonymous pulls (verified 2026-10-02 against v0.0.21), so the Docker tab works without an account. The site goes live only with a release that contains aa8c9e2 (the compose file pins its own version, so the tab ends in a plain `docker compose up -d`) and f351469 (the `services.nxdns` NixOS module the nix tab uses): 0.0.23 or later.
2. Screenshots of the web UI, taken from a running instance with realistic data: Overview, Activity, Query detail, Clients, Diagnostics. Each comes in the admin UI's light and dark theme, behind a light/dark switch with light first. Phones get their own set, captured at a 390px viewport where the admin UI switches to its drawer layout, served through `<picture>`, so a phone shows readable screens instead of a shrunken desktop. All twenty images come from one session on the release the site launches with; the current set is 0.0.21 desktop-only.
2. Screenshots of the web UI, taken from a running instance with realistic data: Overview, Activity, Query detail, Clients, Diagnostics. Each comes in the admin UI's light and dark theme, behind a light/dark switch with light first. Phones get their own set, captured at a 390px viewport where the admin UI switches to its drawer layout, served through `<picture>`, so a phone shows readable screens instead of a shrunken desktop. All twenty images come from one session on the release the site launches with; the current set is from 0.0.24, shot on 2026-10-21 with `website/demo/`.
3. Feature grid: filtering, config-as-code, DoH/DoT, per-client groups, metrics, query log.
4. Comparison table vs Pi-hole, AdGuard Home, Technitium — only to help people choose. Seven rows: how it ships; config as a file in git that the tool converges onto; encrypted upstreams built in; serves DoH/DoT to clients; signed, reproducible releases; DHCP server; DNSSEC validation. The last two are rows nxdns loses, kept because a table that only lists wins does not help anyone choose. Competitor cells were checked against Pi-hole 6.5 (FTL 6.8), AdGuard Home 0.107.79 and Technitium 15.6.2 on 2026-10-19.
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from "astro/config";
import { unified } from "@astrojs/markdown-remark";
import starlight from "@astrojs/starlight";
import { docsSidebarGroups } from "./src/lib/docs-sidebar";
import { remarkRepoLinks } from "./src/lib/remark-repo-links";
import { repoUrl } from "./src/lib/forge";
import { readRelease } from "./src/lib/repo";
export default defineConfig({
site: "https://nxdns.sh",
trailingSlash: "always",
markdown: { processor: unified({ remarkPlugins: [remarkRepoLinks] }) },
vite: { define: { __NXDNS_RELEASE__: JSON.stringify(readRelease()) } },
integrations: [
starlight({
title: "nxdns",
description: "A DNS sinkhole for your LAN.",
favicon: "/favicon.svg",
// ZON is Zig's literal syntax; Shiki ships no grammar under its own name.
expressiveCode: { shiki: { langAlias: { zon: "zig" } } },
social: [{ icon: "seti:git", label: "Source", href: repoUrl }],
customCss: ["./src/styles/fonts.css", "./src/styles/tokens.css", "./src/styles/starlight.css"],
components: {
SiteTitle: "./src/components/starlight/SiteTitle.astro",
ThemeProvider: "./src/components/starlight/ThemeProvider.astro",
ThemeSelect: "./src/components/starlight/ThemeSelect.astro",
},
sidebar: [{ label: "Overview", link: "/docs/" }, ...docsSidebarGroups(), { label: "Changelog", link: "/changelog/" }],
}),
],
});
+1 -1
View File
@@ -65,7 +65,7 @@ npm ci
npx playwright install chromium
```
Then shoot all five pages in light and dark at 1440×900, 2× scale, and build a contact sheet to review them:
Then shoot all five pages in light and dark, at 1440×900 and 2× scale for desktops and at 390×844 and 3× scale for phones, and build a contact sheet to review them:
```sh
node gen/shoot.mjs shots "$(./db.sh /gen/blocked_query_id.py /data)"
+7 -5
View File
@@ -13,14 +13,15 @@ PAGES = [
]
def img(name, scheme):
with open(f"{SHOTS}/{name}-{scheme}.png", "rb") as f:
def img(name, scheme, form=""):
with open(f"{SHOTS}/{name}-{scheme}{form}.png", "rb") as f:
data = base64.b64encode(f.read()).decode()
return f'<figure><img alt="{name} {scheme}" src="data:image/png;base64,{data}"><figcaption>{scheme}</figcaption></figure>'
return f'<figure><img alt="{name} {scheme}{form}" src="data:image/png;base64,{data}"><figcaption>{scheme}{form}</figcaption></figure>'
rows = "\n".join(
f'<section><h2>{html.escape(caption)}</h2><div class="pair">{img(name, "light")}{img(name, "dark")}</div></section>'
f'<section><h2>{html.escape(caption)}</h2><div class="pair">{img(name, "light")}{img(name, "dark")}</div>'
f'<div class="pair phone">{img(name, "light", "-phone")}{img(name, "dark", "-phone")}</div></section>'
for name, caption in PAGES
)
page = f"""<!doctype html>
@@ -33,12 +34,13 @@ body {{ margin: 0; padding: 24px 16px; background: var(--bg); color: var(--fg);
h1 {{ font-size: 20px; margin: 0 0 24px; }}
h2 {{ font-size: 15px; font-weight: 500; margin: 32px 0 12px; }}
.pair {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
.phone {{ grid-template-columns: repeat(2, minmax(0, 300px)); margin-top: 16px; }}
@media (max-width: 800px) {{ .pair {{ grid-template-columns: 1fr; }} }}
figure {{ margin: 0; }}
img {{ width: 100%; height: auto; border: 1px solid var(--line); border-radius: 6px; display: block; }}
figcaption {{ color: var(--muted); font-size: 13px; margin-top: 4px; }}
</style></head>
<body><h1>nxdns admin — marketing screenshots (1440×900 @2x)</h1>
<body><h1>nxdns admin — marketing screenshots (1440×900 @2x, phone 390×844 @3x)</h1>
{rows}
</body></html>
"""
+16 -5
View File
@@ -21,11 +21,17 @@ async function settle(page) {
await page.waitForTimeout(2500);
}
// The phone form is 390px wide, where the admin UI switches to its drawer layout, and touch-only, so
// no hover state is drawn.
const FORMS = [
["", { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }],
["-phone", { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true }],
];
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [suffix, form] of FORMS) for (const scheme of ["light", "dark"]) {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
...form,
colorScheme: scheme,
timezoneId: "Europe/Paris",
locale: "en-GB",
@@ -38,10 +44,15 @@ for (const scheme of ["light", "dark"]) {
await page.waitForURL((u) => !u.pathname.startsWith("/login"));
for (const [name, path] of PAGES) {
await page.goto(BASE + path);
await page.mouse.move(1430, 890);
if (!form.hasTouch) await page.mouse.move(1430, 890);
await settle(page);
if (name === "activity") await page.waitForTimeout(8000);
const file = `${outDir}/${name}-${scheme}.png`;
// A phone stacks the health cards and filters above the episodes, so the first screen shows none.
if (name === "diagnostics" && form.hasTouch) {
await page.getByRole("heading", { name: "Active" }).evaluate((h) => h.scrollIntoView({ block: "start" }));
await page.waitForTimeout(500);
}
const file = `${outDir}/${name}-${scheme}${suffix}.png`;
await page.screenshot({ path: file });
console.log(file);
}
+1 -1
View File
@@ -4,7 +4,7 @@
# docker twice. The data lives in a named volume so no file in this tree is owned by that uid.
set -eu
DEMO=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
IMAGE=${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:0.0.21}
IMAGE=${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:0.0.24}
docker network inspect nxdemo-lan >/dev/null 2>&1 ||
docker network create --driver bridge --subnet 192.168.50.0/24 --gateway 192.168.50.1 nxdemo-lan
if ! docker volume inspect nxdemo-data >/dev/null 2>&1; then
+6489
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "nxdns-website",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build && node scripts/check-links.mjs",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/markdown-remark": "7.3.2",
"@astrojs/starlight": "0.42.6",
"@fontsource-variable/figtree": "5.3.0",
"@fontsource-variable/fraunces": "5.3.0",
"@fontsource-variable/jetbrains-mono": "5.3.0",
"@types/mdast": "4.0.4",
"astro": "7.3.8",
"unist-util-visit": "5.1.0",
"vfile": "6.0.3"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#1c1b18"/><path d="M10 10l12 12M22 10L10 22" stroke="#f2a93b" stroke-width="3.5" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 214 B

+40
View File
@@ -0,0 +1,40 @@
// Fails if any page in dist/ links to a site path or an #anchor that does not exist.
// The docs are written for the forge and turned into routes at build time, so a
// renamed page or heading surfaces here instead of as a 404 for a reader.
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join, relative } from "node:path";
const dist = new URL("../dist/", import.meta.url).pathname;
const pages = readdirSync(dist, { recursive: true })
.filter((f) => f.endsWith(".html"))
.map((f) => join(dist, f));
const ids = new Map();
const idsOf = (file) => {
if (!ids.has(file)) ids.set(file, new Set([...readFileSync(file, "utf8").matchAll(/\sid="([^"]+)"/g)].map((m) => m[1])));
return ids.get(file);
};
const pageOf = (path) => {
const file = path.endsWith("/") ? join(dist, path, "index.html") : join(dist, path);
return existsSync(file) ? file : null;
};
const broken = [];
for (const page of pages) {
const from = "/" + relative(dist, page).replace(/index\.html$/, "");
for (const [, href] of readFileSync(page, "utf8").matchAll(/\shref="([^"]+)"/g)) {
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) continue;
const [path, hash] = href.split("#");
const target = path === "" ? page : pageOf(path);
if (!target) broken.push(`${from} -> ${href} (no page)`);
else if (hash && !idsOf(target).has(decodeURIComponent(hash))) broken.push(`${from} -> ${href} (no #${hash})`);
}
}
if (broken.length) {
console.error(`${broken.length} broken link(s):\n${[...new Set(broken)].join("\n")}`);
process.exit(1);
}
console.log(`links: ${pages.length} pages checked, no broken links`);
Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 KiB

After

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Some files were not shown because too many files have changed in this diff Show More