47 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
mokhtar 77ae1a189d ci: limit the frontend job to 8 minutes
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 7m14s
Release / guard (push) Successful in 38s
Gates / frontend (push) Successful in 2m30s
Gates / test (push) Successful in 2m38s
Gates / test-aarch64 (push) Successful in 7m43s
Gates / package (push) Successful in 46s
Gates / container (push) Successful in 18s
Release / gates (push) Successful in 11m29s
Release / publish (push) Successful in 5m19s
Gates / test (push) Successful in 2m24s
Gates / test-aarch64 (push) Successful in 7m45s
Gates / frontend (push) Successful in 2m4s
Gates / package (push) Successful in 4m44s
2026-10-21 13:37:33 +02:00
mokhtar a0d8af3170 build: bump version to 0.0.24
Gates / test (push) Successful in 2m23s
Gates / test-aarch64 (push) Successful in 7m54s
Gates / package (push) Canceled after 0s
Gates / container (push) Canceled after 0s
Gates / frontend (push) Canceled after 1h33m1s
CI / gates (push) Canceled after 1h32m56s
2026-10-21 11:02:17 +02:00
mokhtar 3e8cd0ea88 changelog: date the 0.0.24 section 2026-10-21 11:00:15 +02:00
mokhtar f26ee523c2 admin: tables fit a phone, their first cell carrying the hidden columns, and no positioned label widens the page 2026-10-21 10:21:17 +02:00
mokhtar 9a99890aea build: bump version to 0.0.23
Gates / package (push) Successful in 47s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 12m3s
Release / publish (push) Successful in 5m25s
Gates / frontend (push) Successful in 2m15s
Gates / test (push) Successful in 2m47s
Gates / test-aarch64 (push) Successful in 8m28s
Gates / package (push) Successful in 6m36s
Gates / container (push) Successful in 16s
CI / gates (push) Successful in 18m14s
Release / guard (push) Successful in 41s
Gates / frontend (push) Successful in 3m5s
Gates / test (push) Successful in 3m19s
Gates / test-aarch64 (push) Successful in 7m45s
2026-10-20 15:20:37 +02:00
mokhtar 4ad7ad6c32 changelog: date the 0.0.23 section 2026-10-20 15:18:03 +02:00
mokhtar b4ef7b8ed1 website: plan records the approved look, three install tabs and phone screenshots 2026-10-20 14:57:27 +02:00
mokhtar d9d1c1d60d docs: verify-a-release transcripts elide the signing key's email address 2026-10-20 14:57:27 +02:00
mokhtar f35146918b nix: a services.nxdns module that runs the packaged unit in file mode against a configuration the pinned binary accepted 2026-10-20 13:06:43 +02:00
mokhtar aa8c9e28af deploy: the compose file names its own release, and the cut moves it with the version
Gates / frontend (push) Successful in 2m41s
Gates / test (push) Successful in 3m2s
Gates / test-aarch64 (push) Successful in 8m23s
Gates / package (push) Successful in 4m29s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 16m14s
2026-10-20 11:46:13 +02:00
mokhtar ea9f16a989 admin: the overview's last bucket is drawn as still counting; the query detail names the device as the activity list does 2026-10-20 11:20:16 +02:00
mokhtar 8efe504c51 upstream: an unnamed connect errno fails over, local-resource exchanges are counted, doh faults carry the http status 2026-10-20 10:11:34 +02:00
mokhtar 2697adfa27 website: demo household and the admin screenshots it produced 2026-10-20 09:57:49 +02:00
mokhtar 4ef795a8a9 docs: config examples use cloudflare doh; quad9 doh needs http/2 and answers 505 2026-10-20 09:57:49 +02:00
mokhtar 0385c34ca3 build: bump version to 0.0.22
Gates / package (push) Successful in 7m56s
Gates / frontend (push) Successful in 3m15s
Gates / test (push) Successful in 4m4s
Gates / test-aarch64 (push) Successful in 10m25s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 22m50s
Release / guard (push) Successful in 38s
Gates / test (push) Successful in 2m22s
Gates / frontend (push) Successful in 2m25s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 50s
Gates / container (push) Successful in 14s
Release / gates (push) Successful in 11m6s
Release / publish (push) Successful in 10m2s
2026-10-19 23:10:59 +02:00
mokhtar 36e11ca71d metrics: peak gauges for in-flight queries, stream connections and the query log queue 2026-10-19 23:08:58 +02:00
mokhtar 9d2511ee58 specs: memory budget, measured owners and the fix order 2026-10-19 22:48:19 +02:00
mokhtar cab79c75e2 plan: nxdns.sh landing page settled; doq waits on a quic stack, dnscrypt is out 2026-10-19 17:25:44 +02:00
mokhtar 5acfec2bff docs: the reproducibility gate exists since 0.0.17; a fresh rebuild of v0.0.21 matches 2026-10-19 17:25:44 +02:00
mokhtar 534c620695 docs: the install guides ran against the published v0.0.21, no more no-release notes 2026-10-19 17:25:39 +02:00
mokhtar f2dd6ad1c7 build: bump version to 0.0.21
Gates / frontend (push) Successful in 2m1s
Gates / test (push) Successful in 2m25s
Gates / test-aarch64 (push) Successful in 8m19s
Gates / package (push) Successful in 4m22s
Gates / container (push) Successful in 12s
CI / gates (push) Successful in 28m50s
Release / guard (push) Successful in 35s
Gates / frontend (push) Successful in 1m57s
Gates / test (push) Successful in 2m24s
Gates / test-aarch64 (push) Successful in 7m38s
Gates / package (push) Successful in 54s
Gates / container (push) Successful in 18s
Release / gates (push) Successful in 11m18s
Release / publish (push) Successful in 4m56s
2026-09-12 20:26:58 +02:00
mokhtar 08cdf86ecd upstream: a diagnostics episode follows health, and a peer fault carries its cause
Gates / frontend (push) Successful in 2m5s
Gates / test (push) Successful in 2m43s
Gates / test-aarch64 (push) Successful in 8m19s
Gates / package (push) Successful in 4m21s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 30m43s
2026-09-12 20:24:49 +02:00
mokhtar 6e9a36903e build: bump version to 0.0.20
Gates / frontend (push) Successful in 1m52s
Gates / test (push) Successful in 2m22s
Gates / test-aarch64 (push) Successful in 8m10s
Gates / package (push) Successful in 4m22s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 28m22s
Release / guard (push) Successful in 35s
Gates / frontend (push) Successful in 1m53s
Gates / test (push) Successful in 2m20s
Gates / test-aarch64 (push) Successful in 7m26s
Gates / package (push) Successful in 51s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 10m52s
Release / publish (push) Successful in 4m56s
2026-09-12 15:10:09 +02:00
mokhtar f6fa43a8b4 filter: blocklist downloads never reuse a pooled connection, the peer closes it between passes
Gates / frontend (push) Successful in 2m3s
Gates / test (push) Successful in 2m34s
Gates / test-aarch64 (push) Successful in 8m20s
Gates / package (push) Successful in 4m20s
Gates / container (push) Successful in 12s
CI / gates (push) Successful in 30m24s
2026-09-12 15:07:55 +02:00
mokhtar f4562cac26 filter: skip the read-only directory test as root, ci's runner ignores mode bits
Gates / frontend (push) Successful in 2m3s
Gates / test (push) Successful in 2m33s
Gates / test-aarch64 (push) Successful in 8m14s
Gates / package (push) Successful in 52s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m0s
Release / guard (push) Successful in 33s
Gates / frontend (push) Successful in 2m36s
Gates / test (push) Successful in 3m47s
Gates / test-aarch64 (push) Successful in 7m24s
Gates / package (push) Successful in 50s
Gates / container (push) Successful in 15s
Release / gates (push) Successful in 12m19s
Release / publish (push) Successful in 6m37s
2026-09-12 10:43:02 +02:00
mokhtar 250bdca7e7 build: bump version to 0.0.19
Gates / frontend (push) Successful in 1m58s
Gates / test (push) Failing after 2m31s
Gates / test-aarch64 (push) Successful in 8m21s
Gates / package (push) Successful in 4m20s
Gates / container (push) Successful in 10s
CI / gates (push) Failing after 37m36s
2026-09-12 10:01:19 +02:00
mokhtar 63f4f96a48 changelog: date the 0.0.19 section
Gates / frontend (push) Successful in 1m56s
Gates / test (push) Failing after 2m22s
Gates / test-aarch64 (push) Successful in 7m30s
Gates / package (push) Successful in 47s
Gates / container (push) Successful in 9s
CI / gates (push) Failing after 39m22s
2026-09-12 09:59:13 +02:00
mokhtar 52158198cf filter: a failed blocklist download names its phase, cause, status, bytes and elapsed time
Gates / frontend (push) Successful in 2m20s
Gates / test (push) Failing after 2m41s
Gates / test-aarch64 (push) Successful in 8m19s
Gates / package (push) Successful in 4m24s
Gates / container (push) Successful in 16s
CI / gates (push) Failing after 41m33s
2026-09-12 09:58:59 +02:00
mokhtar bf79a22584 docs: renovate's nix manager does not bump a tag ref; the regex manager recipe that does 2026-09-12 00:38:45 +02:00
206 changed files with 19015 additions and 5565 deletions
+31 -7
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,16 +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. 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
@@ -211,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.
@@ -379,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:?}"
+10
View File
@@ -49,6 +49,16 @@ That function returns `null` for every input. `fetchAdd` is the only trigger: a
`Owner.published` in `src/upstream/owner.zig` is a plain `u64` under the owner's mutex for this reason. Do not "modernize" it to `std.atomic.Value(u64)`.
## Testing the NixOS module
`nix/module.nix` (`services.nxdns`) has two acceptance checks in `nix/tests.nix`, and no CI job runs them: the VM test needs KVM, and both download a nixpkgs closure that the shared Gitea runner must not fetch on every push. The cut's `nix flake check --no-build` evaluates them, which catches a module that no longer evaluates, but builds nothing. After any change to `nix/module.nix`, `nix/tests.nix`, the package in `flake.nix` or `deploy/systemd/nxdns.service`, build both on a host with KVM before the commit:
```
nix build --no-link -L .#checks.x86_64-linux.nixos-module .#checks.x86_64-linux.nixos-module-rejects-config
```
The checks run the package the flake pins, which is the last release, so a change to `deploy/systemd/nxdns.service` reaches the VM test only after the release that ships it.
## Regenerating the contract samples
`admin/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
+74
View File
@@ -4,6 +4,80 @@ 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
- **The admin pages fit a phone.** At 390 px, Activity, Clients, Diagnostics and every configuration table scrolled sideways, and Diagnostics and Clients made the whole page 826 px and 660 px wide, so the phone zoomed out. A table now shows all its columns only where they fit without wrapping. In a narrower table, the first cell carries the other columns' facts on short lines ("Priority 20 · TLS name dns.quad9.net", "Created 19 Oct 2026, 16:15:44"), and long names, URLs, IPv6 addresses and patterns wrap instead of widening the page. In the narrow Sources and Upstreams tables, Edit and Delete stack, which gives the first cell the width. Protection > Sources needs a 1440 px wide table for all its columns, so a 1440 px window now shows its narrow form; in 0.0.23 it made the page 1557 px wide there.
- **Overview is no longer taller than the window.** The charts' screen-reader tables extended the document below the app, so the page had a second scroll bar under the main one at desktop widths.
- **The Diagnostics count line no longer spreads its words across the page.**
- **The Diagnostics health cards no longer spread a short card's lines down its height.**
## [0.0.23] - 2026-10-20
### Fixed
- **A blackhole route to an upstream no longer stops failover.** On a route like that, `connect()` fails with an errno the runtime has no name for, and nxdns took it for a fault of its own: the query got SERVFAIL at once, the next upstream was never tried, and nothing was charged to the unreachable one. A connect failure of that kind now counts against that upstream as `ConnectFailed (cause Unexpected)`, and the query fails over.
- **The Overview charts no longer draw the current bucket as a fall in traffic.** Every Overview window ends at the end of the bucket that holds the present moment, so its last bucket is still counting. Queries over time draws the curve into it dashed over a fainter fill, and Client activity draws its column faint inside a dashed outline. Both legends name "Still counting", and the tooltip and the chart's data table give that bucket's time as "<time> · still counting".
- **The query detail page names the device.** The Client fact showed only the address, while the Activity list showed "iPhone (192.168.50.52)" for the same query. It now uses the list's label. The query log records only the address, so the record's intro says the device name is the one the address has today.
### Added
- **A NixOS module.** The flake exports `nixosModules.default`, which adds `services.nxdns` with four options: `enable`, `package`, `configFile` and `openFirewall`. It runs the release's own `nxdns.service` with a drop-in for file mode, checks `configFile` with the pinned binary's `nxdns import` at build time so a configuration that does not parse, validate or reconcile fails the build, and restarts the service when only the configuration changes. The flake's package now also installs `lib/systemd/system/nxdns.service`. See "Install nxdns with Nix".
- **`nxdns_upstream_local_resource_total`** counts exchanges that ended on this process's own resources (memory, file descriptors, an unnamed system error) before any upstream answered. Until now, such a SERVFAIL left no record of its cause anywhere.
### Changed
- **The compose file names its own release.** `deploy/docker/compose.yaml` defaults its image to the release it is tagged with instead of `:latest`, so `docker compose up -d` with the file from a tag runs that tag's image, and the install and upgrade steps no longer set `NXDNS_VERSION`. The variable still picks another release, and `NXDNS_IMAGE` still names a local image. Compose files from `v0.0.22` and earlier keep the `:latest` default.
- **A DoH upstream that answers with an error status says which.** The Diagnostics episode and `/api/health` read `HttpStatus (status 505 HTTP Version Not Supported)` instead of `HttpStatus (cause HttpStatus)`. A 505 is a server that requires HTTP/2, which nxdns does not speak.
## [0.0.22] - 2026-10-19
### Added
- **`/metrics` reports the most concurrent work the server has seen.** Five gauges hold peaks since start: `nxdns_dns_in_flight_peak` (queries handled at once, every listener together), `nxdns_tcp_server_connections_peak`, `nxdns_dot_server_connections_peak` and `nxdns_doh_server_connections_peak` (connections one listener held at once), and `nxdns_querylog_queue_peak` (entries waiting for the query log writer at once). The memory budget work sizes its limits from them; "Measure performance" shows how to read them beside the process's resident memory, thread count and worker stacks.
## [0.0.21] - 2026-09-12
### Changed
- **An upstream warning means the pool stopped trusting the endpoint.** A Diagnostics episode opens when an upstream reaches the health failure threshold and closes on the success that clears it, instead of one card per lost exchange. Failures during backoff raise the card's occurrence count. The episode detail and the `nxdns check` report name the concrete cause behind the classification, `SendFailed (cause BrokenPipe)`.
## [0.0.20] - 2026-09-12
### Fixed
- **A blocklist source no longer fails on a stale pooled connection.** The fetcher reused a keep-alive connection from an earlier pass that the server had closed, and the standard client never retries one; every later download from that host failed with `HttpConnectionClosing` at 0 ms. Downloads now send `connection: close` and never enter the pool.
## [0.0.19] - 2026-09-12
### Changed
- **A failed blocklist download says why.** The warning line and the Diagnostics event detail now carry the phase that failed, the concrete cause behind the classification (a reset connection, a truncated chunk, a TLS handshake fault), the HTTP status if a head arrived, the bytes received, and the elapsed time. The separate `http status` warning is folded into that line. Same one line per failed source per pass as before.
## [0.0.18] - 2026-09-09
### 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
+8 -6
View File
@@ -37,9 +37,10 @@ Serves a household LAN (≈2–20 devices). Portfolio-grade public repo with ext
- Partial-segment wildcards (`ads*.example.com`) as a rule kind. The regex kind covers the need without a second globbing dialect.
- DHCP server.
- DNSSEC validation (DO bit passthrough only).
- DoQ (QUIC), HTTP/2 upstream transport.
- DoQ (DNS over QUIC). Out of scope until the Zig stdlib, or an equivalent dependency, provides QUIC; neither the stdlib nor mbedTLS does at 0.16.0. This is the one conditional entry in this list.
- HTTP/2 upstream transport.
- DNSCrypt. It is older than DoH and DoT and not an IETF standard, and DoH and DoT already cover encrypted DNS.
- Clustering / distributed state.
- Project website. The repository, its README and `docs/` are the whole published surface.
---
@@ -55,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`.
@@ -91,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)
@@ -488,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.
@@ -553,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.
---
+9 -13
View File
@@ -28,13 +28,11 @@ One static Zig binary. SQLite holds the state and the query log, which the web U
## Install
**No release exists yet.** This repository has no tags, nothing has been published to <https://git.mial.net/mokhtar/nxdns/releases>, and no container image has been pushed. Every release URL on this page and in the how-to guides is a 404 today, and `docker pull` finds nothing. Until the first tag ships, building from source is the only way to get nxdns.
What a tag will publish, once one exists: five assets — two static musl tarballs (`nxdns-<version>-x86_64-linux-musl.tar.gz`, `nxdns-<version>-aarch64-linux-musl.tar.gz`), `IMAGE-DIGEST.txt` naming the multi-architecture container image by digest, `SHA256SUMS.txt` covering those three files, and `SHA256SUMS.txt.asc`, a detached OpenPGP signature over the checksum file. Verify what you downloaded before you run it: [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md), which also says what that signature does and does not prove.
Releases are published at <https://git.mial.net/mokhtar/nxdns/releases>, and the container image at `git.mial.net/mokhtar/nxdns`, which pulls without an account. Each tag publishes five assets — two static musl tarballs (`nxdns-<version>-x86_64-linux-musl.tar.gz`, `nxdns-<version>-aarch64-linux-musl.tar.gz`), `IMAGE-DIGEST.txt` naming the multi-architecture container image by digest, `SHA256SUMS.txt` covering those three files, and `SHA256SUMS.txt.asc`, a detached OpenPGP signature over the checksum file. Verify what you downloaded before you run it: [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md), which also says what that signature does and does not prove.
## Quickstart (docker compose)
Write a minimal configuration and start the published image. This is what the first release will make possible; it does not work today, because there is no image in the registry to pull:
Write a minimal configuration and start the published image:
```sh
cd deploy/docker
@@ -46,14 +44,14 @@ cat > etc-nxdns/config.zon <<'EOF'
.web = .{ .password = "choose-a-real-password" },
}
EOF
NXDNS_VERSION=<version> docker compose up -d
docker compose up -d
```
The compose file defaults to `:latest`; pin a version for anything you intend to keep running. To run it before a release exists, build the image yourself and name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) describes. DNS is on port 53, the web UI on <http://localhost:8080>.
The compose file names the release it belongs to, so a checkout of a release tag runs that release's image. `NXDNS_VERSION=<version> docker compose up -d` runs another release. To run an image you built yourself, name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) describes. DNS is on port 53, the web UI on <http://localhost:8080>.
The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes that file the configuration: every start reconciles the database onto it, and the UI refuses configuration edits. Edit the file and restart to change anything. Drop the `command:` line to run bare `nxdns run` instead, where the database is the configuration and changes go through the UI, the API, or `nxdns export` / `nxdns import` — the packaged systemd unit does that. Which mode is live is printed at every start (`authority: database` / `authority: file (<path>)`); see [docs/explanation/configuration-model.md](docs/explanation/configuration-model.md).
Full install instructions, including the systemd path and the Pi 5 recipe, are in [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
Full install instructions, including the systemd path and the Pi 5 recipe, are in [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md), [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) and, for NixOS, [docs/how-to/install-with-nix.md](docs/how-to/install-with-nix.md).
## Building from source
@@ -82,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,4 +1,5 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import * as stylex from "@stylexjs/stylex";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -7,6 +8,7 @@ import { createAppRouter } from "@/routes";
import type { QueryDetail } from "@/lib/types";
import { provenance } from "@/features/provenance/provenanceFixture";
import { health } from "@/lib/healthFixture";
import { styles as shared } from "@/ui/styles";
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
return { id, ...provenance(sections) };
@@ -282,24 +284,40 @@ async function relatedText(expected: string) {
await waitFor(() => expect(related.textContent?.replace(/\s+/g, " ")).toContain(expected));
}
test("the record keeps the address the query came from, and Related carries the name it has now", async () => {
/**
* The Activity list labels a client "Kids iPad (192.0.2.12)"; the detail page
* labels the same query the same way. The address is the recorded fact, and the
* intro says once that the device name is today's.
*/
test("the client is the device name with its address, as in the Activity list", async () => {
responses["/api/queries/12"] = detail(12, { request: { domain: "shop.example", client: "192.0.2.12" } });
responses["/api/clients"] = clientList({ ip: "192.0.2.12", name: "Kids iPad", learned_name: "ipad.lan" });
renderDetail(12);
await screen.findByRole("heading", { name: "shop.example" });
await relatedText("The client list currently names 192.0.2.12 “Kids iPad”.");
expect(factValue("Client")).toBe("192.0.2.12");
await waitFor(() => expect(factValue("Client")).toBe("Kids iPad (192.0.2.12)"));
expect(screen.getByText(/The device name is the one its address has today\./)).toBeTruthy();
});
test("a learned name is told as the reverse-DNS lookup it is, never as a recorded fact", async () => {
test("an address the client list does not know stays a bare address", async () => {
responses["/api/queries/14"] = detail(14, { request: { client: "192.0.2.14" } });
responses["/api/clients"] = clientList({ ip: "192.0.2.99", name: "Elsewhere", learned_name: "" });
renderDetail(14);
await screen.findByRole("heading", { name: "example.com" });
await relatedText("These read the current configuration");
expect(factValue("Client")).toBe("192.0.2.14");
});
test("a learned name is drawn as learned, as in the Activity list", async () => {
responses["/api/queries/13"] = detail(13, { request: { client: "192.0.2.13" } });
responses["/api/clients"] = clientList({ ip: "192.0.2.13", name: "", learned_name: "printer.lan" });
renderDetail(13);
await screen.findByRole("heading", { name: "example.com" });
await relatedText("Reverse DNS currently resolves 192.0.2.13 to printer.lan.");
expect(factValue("Client")).toBe("192.0.2.13");
await waitFor(() => expect(factValue("Client")).toBe("printer.lan (192.0.2.13)"));
const label = screen.getByText("Client").nextElementSibling?.firstElementChild;
expect(label?.className).toBe(stylex.props(shared.learnedName).className);
});
test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => {
@@ -57,6 +57,12 @@ const PRESETS = [
const CUSTOM_ITEM = "Custom…";
/**
* Narrower than the toolbar on one row: the search field's basis, Clients, the
* result group, the widest preset's Time label and Clear, with their gaps.
*/
const ONE_ROW_DOES_NOT_FIT = "@container (max-width: 48rem)";
const styles = stylex.create({
toolbar: {
marginTop: "1rem",
@@ -167,13 +173,22 @@ const styles = stylex.create({
color: colors.primaryText,
outlineColor: { default: null, ":focus-visible": colors.primaryText },
},
/** The toolbar's controls wrap by the width the form gets, not by the viewport. */
form: {
containerType: "inline-size",
},
/**
* Clear keeps its box when there is nothing to clear. It appears the moment a
* filter is set, and a control that appeared by widening the row would move
* every other control out from under the pointer that was reaching for it.
* filter is set, and on the one row the toolbar fits on, a control that
* appeared by widening it would shrink the growing search field and move
* every other control out from under the pointer reaching for it. A toolbar
* too narrow for one row wraps the search field onto a row of its own, so
* there a Clear that appears moves nothing, and a kept box would only wrap
* onto a row of nothing above the table.
*/
clearHidden: {
visibility: "hidden",
display: { default: null, [ONE_ROW_DOES_NOT_FIT]: "none" },
},
customRow: {
marginTop: "0.5rem",
@@ -500,7 +515,7 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
}
return (
<form onSubmit={flush}>
<form onSubmit={flush} {...stylex.props(styles.form)}>
<div {...stylex.props(styles.toolbar)}>
<div {...stylex.props(styles.searchWrap)}>
<span aria-hidden="true" {...stylex.props(styles.searchIcon)}>
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/provenance/provenanceFixture";
import { activityCell } from "./activityCell";
import { FakeEventSource } from "./fakeEventSource";
function client(id: number, ip: string, name: string, learnedName: string): Client {
@@ -185,17 +186,16 @@ test("renders the first page with the seven columns filled in", async () => {
"Route",
"Duration",
]);
const first = screen.getByText("first.example").closest("tr")!;
expect(within(first).getByText("HTTPS")).toBeTruthy();
expect(within(first).getByText("NOERROR")).toBeTruthy();
expect(within(first).getByText("Cache")).toBeTruthy();
expect(within(first).getByText("1.2 ms")).toBeTruthy();
expect(activityCell("first.example", "Type").textContent).toBe("HTTPS");
expect(activityCell("first.example", "Result").textContent).toBe("NOERROR");
expect(activityCell("first.example", "Route").textContent).toBe("Cache");
expect(activityCell("first.example", "Duration").textContent).toBe("1.2 ms");
const blocked = screen.getByText("ads.example").closest("tr")!;
// The Result cell says Blocked even though the client saw NOERROR, and the
// Route cell says how: this is the pair the old Status column could not show.
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
expect(within(blocked).getByText("—")).toBeTruthy();
expect(activityCell("ads.example", "Result").textContent).toBe("Blocked");
expect(activityCell("ads.example", "Route").textContent).toBe("Blocked");
expect(activityCell("ads.example", "Duration").textContent).toBe("—");
expect(countLine(/Showing 2 queries/)).toBeTruthy();
});
@@ -216,9 +216,10 @@ test("resolves each row's client to its display name, reading the IP out with it
});
renderPage();
await screen.findAllByText("Kitchen Pi");
// A hand-typed name wins outright; the learned name never surfaces for it.
const named = await screen.findByText("Kitchen Pi");
const named = within(activityCell("named.example", "Client")).getByText("Kitchen Pi");
// The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
@@ -227,14 +228,39 @@ test("resolves each row's client to its display name, reading the IP out with it
// A learned name reads muted and nothing more here: the "learned" tag would
// repeat on every row of the table, so the Clients page carries it instead.
const learned = screen.getByText("laptop.lan");
const learned = within(activityCell("learned.example", "Client")).getByText("laptop.lan");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
// A known client with neither name, and a client the loaded list has never
// seen, both fall back to the bare address with nothing standing in for it.
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
expect(within(activityCell("nameless.example", "Client")).getByText("192.0.2.12").textContent).toBe("192.0.2.12");
expect(within(activityCell("stranger.example", "Client")).getByText("192.0.2.99").textContent).toBe("192.0.2.99");
});
test("the Domain cell carries the hidden columns' facts for a table too narrow for them", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [row(20, "named.example", { client_ip: "192.0.2.10" })],
next_before: null,
coverage: COMPLETE,
} satisfies QueriesPage);
});
renderPage();
await screen.findAllByText("Kitchen Pi");
const domain = activityCell("named.example", "Domain");
expect(within(domain).getByText("Kitchen Pi").textContent).toBe("Kitchen Pi (192.0.2.10)");
const facts = [
activityCell("named.example", "Time").textContent,
`Type ${activityCell("named.example", "Type").textContent}`,
activityCell("named.example", "Route").textContent,
activityCell("named.example", "Duration").textContent,
].join(" · ");
expect(domain.textContent).toContain(facts);
});
test("load more appends the next page and stops at the end of the log", async () => {
@@ -36,9 +36,8 @@ const styles = stylex.create({
loading: {
minHeight: "24rem",
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
/** The shared scrolling wrapper, framed. */
framedTable: {
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
@@ -153,7 +152,7 @@ export default function HistoryActivity({ search, onClear }: Props) {
</div>
) : (
<>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, styles.framedTable, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
@@ -15,6 +15,7 @@ import { createAppRouter } from "@/routes";
import type { Client } from "@/lib/types";
import { provenance, queryRow } from "@/features/provenance/provenanceFixture";
import { health } from "@/lib/healthFixture";
import { activityCell } from "./activityCell";
import { FakeEventSource } from "./fakeEventSource";
import LiveActivity from "./LiveActivity";
import type { ActivitySearch } from "./search";
@@ -187,19 +188,20 @@ test("resolves each row's client to its display name, reading the IP out with it
sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } }));
});
const named = await screen.findByText("Kitchen Pi");
await screen.findAllByText("Kitchen Pi");
const named = within(activityCell("named.example", "Client")).getByText("Kitchen Pi");
// The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
expect(named.getAttribute("title")).toBeNull();
expect(screen.queryByText("pi.lan")).toBeNull();
const learned = screen.getByText("laptop.lan");
const learned = within(activityCell("learned.example", "Client")).getByText("laptop.lan");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
expect(within(activityCell("nameless.example", "Client")).getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(within(activityCell("stranger.example", "Client")).getByText("192.0.2.99").getAttribute("title")).toBeNull();
});
test("rows stream in as bare IPs while the client list is still loading", async () => {
@@ -222,11 +224,12 @@ test("rows stream in as bare IPs while the client list is still loading", async
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } })));
expect(screen.getByText("192.0.2.10")).toBeTruthy();
expect(within(activityCell("named.example", "Client")).getByText("192.0.2.10")).toBeTruthy();
expect(screen.queryByText("Kitchen Pi")).toBeNull();
releaseClients();
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
await screen.findAllByText("Kitchen Pi");
expect(within(activityCell("named.example", "Client")).getByText("Kitchen Pi")).toBeTruthy();
});
test("repeated connection failures show the viewer-cap state with a retry button", async () => {
+3 -4
View File
@@ -128,9 +128,8 @@ const styles = stylex.create({
marginTop: "1.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
/** The shared scrolling wrapper, framed. */
framedTable: {
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
@@ -351,7 +350,7 @@ export default function LiveActivity({
)
) : (
<>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, styles.framedTable, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
@@ -12,7 +12,7 @@ import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatMicros, formatTime } from "@/lib/format";
import type { PolicyReason, Provenance } from "@/lib/types";
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
import { ClientName, useClientNames } from "@/features/clients/clientNames";
import {
policyActionLabel,
policyReasonLabel,
@@ -178,7 +178,6 @@ interface Props {
export default function ProvenanceDetail({ provenance, persistedId, relatedActions }: Props) {
const { request, group, policy, rewrites, route, response } = provenance;
const clientNames = useClientNames();
const currentClient = clientLabel(request.client, clientNames);
return (
<>
@@ -192,7 +191,8 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
<div {...stylex.props(styles.record)}>
<p {...stylex.props(styles.recordNote)}>
What was recorded when this query was answered. Group and blocklist names are the ones in force at
that moment; they may have been renamed or deleted since.
that moment; they may have been renamed or deleted since. The device name is the one its address has
today.
{persistedId === null &&
" This is the event as it was streamed; the query log may not have written it yet."}
</p>
@@ -203,7 +203,7 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
<Mono>{request.domain}</Mono>
</Fact>
<Fact label="Client">
<Mono>{request.client}</Mono>
<ClientName ip={request.client} names={clientNames} />
</Fact>
<Fact label="Type">{qtypeName(request.qtype)}</Fact>
<Fact label="Class">{qclassName(request.qclass)}</Fact>
@@ -284,20 +284,6 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio
<p {...stylex.props(styles.relatedNote)}>
These read the current configuration, which may no longer be the one that decided this query.
</p>
{currentClient !== null && (
<p {...stylex.props(styles.relatedNote)}>
{currentClient.learned ? (
<>
Reverse DNS currently resolves <Mono>{request.client}</Mono> to{" "}
<Mono>{currentClient.text}</Mono>.
</>
) : (
<>
The client list currently names <Mono>{request.client}</Mono> “{currentClient.text}”.
</>
)}
</p>
)}
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
</section>
</>
@@ -0,0 +1,16 @@
import { screen, within } from "@testing-library/react";
import { ACTIVITY_COLUMNS } from "./cells";
/**
* Test helper: one column's cell in the row that shows `domain`. jsdom applies
* no stylesheet, so the compact Domain cell's copy of the other columns' facts
* sits in the document beside the wide cells, and a page-wide text query finds
* both.
*/
export function activityCell(domain: string, column: (typeof ACTIVITY_COLUMNS)[number]): HTMLElement {
const row = screen.getByText(domain).closest("tr");
if (row === null) throw new Error(`no row shows ${domain}`);
const cell = within(row).getAllByRole("cell")[ACTIVITY_COLUMNS.indexOf(column)];
if (cell === undefined) throw new Error(`the row for ${domain} has no ${column} cell`);
return cell;
}
+58 -11
View File
@@ -20,6 +20,7 @@ import { ClientName, type ClientNames } from "@/features/clients/clientNames";
import { rcodeShortName } from "@/features/provenance/provenanceCopy";
import { qtypeName } from "@/features/provenance/qtype";
import type { QuerySummary } from "@/features/provenance/querySummary";
import FactLine from "@/ui/FactLine";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
@@ -28,6 +29,15 @@ const DARK = "@media (prefers-color-scheme: dark)";
/** Wide enough for an ordinary hostname, narrow enough to leave the six other columns room. */
const DOMAIN_MAX_WIDTH = "24rem";
/**
* Narrower than the seven columns need with ordinary data, so the table keeps
* two: Domain, which takes the other columns' facts as lines under the name,
* and Result. Hiding cells keeps a native table; re-laying its rows out as
* blocks would put the table semantics at the mercy of each browser engine.
* The wrapper that scrolls is the container.
*/
const COMPACT = "@container (max-width: 50rem)";
const styles = stylex.create({
head: {
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
@@ -52,6 +62,16 @@ const styles = stylex.create({
domainCell: {
maxWidth: DOMAIN_MAX_WIDTH,
},
wide: {
display: { default: null, [COMPACT]: "none" },
},
/** A name can be one unbroken label as wide as the table; `anywhere` also lowers the cell's minimum width. */
compact: {
display: { default: "none", [COMPACT]: "block" },
marginTop: "0.125rem",
color: colors.textMuted,
overflowWrap: "anywhere",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
@@ -93,6 +113,7 @@ const styles = stylex.create({
*/
badge: {
display: "inline-block",
whiteSpace: "nowrap",
borderRadius: metrics.radius,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
@@ -152,6 +173,29 @@ export function ResultCellContent({ row }: { row: Pick<QuerySummary, "blocked" |
return <span {...stylex.props(styles.small, styles.muted)}>{label}</span>;
}
/** The facts the hidden columns hold, in their column order, for the compact Domain cell. */
function CompactFacts({ row, clientNames }: { row: QuerySummary; clientNames: ClientNames }) {
// The label stands in for the hidden Type header: a screen reader says a bare "A" as the article.
const qtype = (
<>
<span {...stylex.props(shared.srOnly)}>Type </span>
{qtypeName(row.qtype)}
</>
);
const facts: ReactNode[] = [formatTime(row.ts), qtype, routeLabel(row.route_kind)];
if (row.response_time_us !== null) facts.push(formatMicros(row.response_time_us));
return (
<>
<span {...stylex.props(styles.compact)}>
<ClientName ip={row.client_ip} names={clientNames} />
</span>
<span {...stylex.props(styles.compact, shared.tabularNums)}>
<FactLine facts={facts} />
</span>
</>
);
}
export function ActivityCells({
row,
clientNames,
@@ -163,22 +207,23 @@ export function ActivityCells({
}) {
return (
<>
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td
title={row.domain}
{...stylex.props(styles.cell, styles.small, styles.breakAll, styles.domainCell, shared.mono)}
>
{renderDomain(row.id, <span {...stylex.props(styles.domainClamp)}>{row.domain}</span>)}
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted, styles.wide)}>{formatTime(row.ts)}</td>
<td title={row.domain} {...stylex.props(styles.cell, styles.small, styles.domainCell)}>
{renderDomain(
row.id,
<span {...stylex.props(styles.domainClamp, styles.breakAll, shared.mono)}>{row.domain}</span>,
)}
<CompactFacts row={row} clientNames={clientNames} />
</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, styles.wide)}>
<ClientName ip={row.client_ip} names={clientNames} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, styles.wide)}>{qtypeName(row.qtype)}</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>
<ResultCellContent row={row} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{routeLabel(row.route_kind)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
<td {...stylex.props(styles.cell, styles.nowrap, styles.wide)}>{routeLabel(row.route_kind)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
</td>
</>
@@ -187,12 +232,14 @@ export function ActivityCells({
export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const;
const COMPACT_COLUMNS: ReadonlySet<string> = new Set(["Domain", "Result"]);
export function ActivityTableHead() {
return (
<thead {...stylex.props(styles.head)}>
<tr>
{ACTIVITY_COLUMNS.map((column) => (
<th key={column} {...stylex.props(styles.th)}>
<th key={column} {...stylex.props(styles.th, !COMPACT_COLUMNS.has(column) && styles.wide)}>
{column}
</th>
))}
@@ -42,21 +42,42 @@ afterEach(() => {
vi.unstubAllGlobals();
});
const CLIENT_COLUMNS = ["Address", "Name", "Group", "First seen", "Last seen", "Actions"] as const;
/**
* One column's cell in the row for `ip`. jsdom applies no stylesheet, so the
* compact Address cell's copy of the name and group sits beside the wide cells,
* and a page-wide text query finds both.
*/
function clientCell(ip: string, column: (typeof CLIENT_COLUMNS)[number]): HTMLElement {
const row = screen.getByText(ip).closest("tr")!;
return within(row).getAllByRole("cell")[CLIENT_COLUMNS.indexOf(column)]!;
}
test("renders the identity-first table: address, name, group, first and last seen", async () => {
await renderClientsPage();
const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent);
expect(headers).toEqual(["Address", "Name", "Group", "First seen", "Last seen", "Actions"]);
expect(headers).toEqual([...CLIENT_COLUMNS]);
expect(screen.getByText("192.168.1.10")).toBeTruthy();
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(screen.getByText("laptop")).toBeTruthy();
expect(within(clientCell("192.168.1.10", "Name")).getByText("laptop")).toBeTruthy();
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
});
test("the Address cell carries the name, group and last seen for a table too narrow for them", async () => {
await renderClientsPage();
const address = clientCell("192.168.1.10", "Address");
expect(within(address).getByText("laptop")).toBeTruthy();
expect(address.textContent).toContain("· default");
expect(address.textContent).toContain(`Last seen ${clientCell("192.168.1.10", "Last seen").textContent}`);
});
test("a named row shows the typed name and hides the learned one", async () => {
await renderClientsPage();
expect(screen.getByText("laptop")).toBeTruthy();
expect(within(clientCell("192.168.1.10", "Name")).getByText("laptop")).toBeTruthy();
expect(screen.queryByText("laptop-1.lan")).toBeNull();
});
@@ -65,7 +86,7 @@ test("an unnamed row shows the learned name with the learned marker", async () =
// The cell holds the learned name followed by the marker, so the match is on
// the containing span rather than on a bare text node.
const learned = screen.getByText(
const learned = within(clientCell("192.168.1.11", "Name")).getByText(
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
);
// The marker is text, not colour, so a screen reader announces it too.
+41 -11
View File
@@ -45,6 +45,13 @@ function clientLabel(client: Client): string {
return name === "" ? client.ip : `${name} (${client.ip})`;
}
/**
* Narrower than the six columns need, so the table keeps Address, which takes
* the name, group and last-seen time as lines under the link, and the actions.
* First seen stays on the client's own page. The wrapper is the container.
*/
const COMPACT = "@container (max-width: 50rem)";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
@@ -84,7 +91,6 @@ const styles = stylex.create({
},
table: {
width: "100%",
minWidth: "48rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
@@ -107,9 +113,24 @@ const styles = stylex.create({
right: {
textAlign: "right",
},
wide: {
display: { default: null, [COMPACT]: "none" },
},
/** A name can be one unbroken label as wide as the table; `anywhere` also lowers the cell's minimum width. */
compact: {
display: { default: "none", [COMPACT]: "block" },
marginTop: "0.125rem",
color: colors.textMuted,
overflowWrap: "anywhere",
},
compactName: {
color: colors.text,
},
/** A full IPv6 address is 39 characters of mono, wider than a phone's Address column. */
addressLink: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
overflowWrap: "anywhere",
},
actionGroup: {
display: "inline-flex",
@@ -190,10 +211,10 @@ export default function ClientsPage() {
<thead>
<tr {...stylex.props(styles.headRow)}>
<th {...stylex.props(styles.cell)}>Address</th>
<th {...stylex.props(styles.cell)}>Name</th>
<th {...stylex.props(styles.cell)}>Group</th>
<th {...stylex.props(styles.cell)}>First seen</th>
<th {...stylex.props(styles.cell)}>Last seen</th>
<th {...stylex.props(styles.cell, styles.wide)}>Name</th>
<th {...stylex.props(styles.cell, styles.wide)}>Group</th>
<th {...stylex.props(styles.cell, styles.wide)}>First seen</th>
<th {...stylex.props(styles.cell, styles.wide)}>Last seen</th>
<th {...stylex.props(styles.cell)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
@@ -202,23 +223,32 @@ export default function ClientsPage() {
<tbody>
{rows.map((client) => (
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, shared.mono)}>
<td {...stylex.props(styles.cell)}>
<Link
to="/clients/$id"
params={{ id: String(client.id) }}
{...stylex.props(styles.addressLink, shared.focusRing)}
{...stylex.props(styles.addressLink, shared.mono, shared.focusRing)}
>
{client.ip}
</Link>
<span {...stylex.props(styles.compact)}>
<span {...stylex.props(styles.compactName)}>
<ClientDisplayName client={client} />
</span>{" "}
· {client.group}
</span>
<span {...stylex.props(styles.compact)}>
Last seen {formatTime(client.last_seen)}
</span>
</td>
<td {...stylex.props(styles.cell)}>
<td {...stylex.props(styles.cell, styles.wide)}>
<span {...stylex.props(styles.name)}>
<ClientDisplayName client={client} />
</span>
</td>
<td {...stylex.props(styles.cell)}>{client.group}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
<td {...stylex.props(styles.cell, styles.wide)}>{client.group}</td>
<td {...stylex.props(styles.cell, styles.wide)}>{formatTime(client.first_seen)}</td>
<td {...stylex.props(styles.cell, styles.wide)}>{formatTime(client.last_seen)}</td>
<td {...stylex.props(styles.cell, styles.right)}>
<span {...stylex.props(styles.actionGroup)}>
{/* Naming a client writes configuration, so the affordance is
@@ -102,11 +102,14 @@ const styles = stylex.create({
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** A full IPv6 prefix, or a group name, can be one unbroken string wider than a phone's column. */
unbroken: {
overflowWrap: "anywhere",
},
});
/**
@@ -155,8 +158,8 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
<tbody>
{prefixes.map((prefix) => (
<tr key={prefix.id}>
<td {...stylex.props(shared.td, shared.mono)}>{prefix.prefix}</td>
<td {...stylex.props(shared.td)}>{prefix.group}</td>
<td {...stylex.props(shared.td, shared.mono, styles.unbroken)}>{prefix.prefix}</td>
<td {...stylex.props(shared.td, styles.unbroken)}>{prefix.group}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{prefix.priority}</td>
</tr>
))}
@@ -1,6 +1,7 @@
import * as stylex from "@stylexjs/stylex";
import type { ForwardZone, LocalRecord } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { local, RecordFacts, ZoneResolver } from "./localCells";
import { styles as config } from "./styles";
/**
@@ -26,18 +27,23 @@ export function RecordsReadOnly({ records }: { records: LocalRecord[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>Type</th>
<th {...stylex.props(shared.th)}>Value</th>
<th {...stylex.props(shared.th)}>TTL</th>
<th {...stylex.props(shared.th, local.wide)}>Type</th>
<th {...stylex.props(shared.th, local.wide)}>Value</th>
<th {...stylex.props(shared.th, local.wide)}>TTL</th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr key={record.id}>
<td {...stylex.props(shared.td, shared.mono)}>{record.name}</td>
<td {...stylex.props(shared.td)}>{record.rtype}</td>
<td {...stylex.props(shared.td, shared.mono)}>{record.value}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{record.ttl}</td>
<td {...stylex.props(shared.td, local.unbroken)}>
<span {...stylex.props(shared.mono)}>{record.name}</span>
<RecordFacts record={record} />
</td>
<td {...stylex.props(shared.td, local.wide)}>{record.rtype}</td>
<td {...stylex.props(shared.td, shared.mono, local.unbroken, local.wide)}>
{record.value}
</td>
<td {...stylex.props(shared.td, shared.tabularNums, local.wide)}>{record.ttl}</td>
</tr>
))}
</tbody>
@@ -66,14 +72,19 @@ export function ZonesReadOnly({ zones }: { zones: ForwardZone[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>Zone</th>
<th {...stylex.props(shared.th)}>Resolver</th>
<th {...stylex.props(shared.th, local.wide)}>Resolver</th>
</tr>
</thead>
<tbody>
{zones.map((zone) => (
<tr key={zone.id}>
<td {...stylex.props(shared.td, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(shared.td, shared.mono)}>{zone.resolver}</td>
<td {...stylex.props(shared.td, shared.mono, local.unbroken)}>
{zone.zone}
<ZoneResolver zone={zone} />
</td>
<td {...stylex.props(shared.td, shared.mono, local.unbroken, local.wide)}>
{zone.resolver}
</td>
</tr>
))}
</tbody>
@@ -19,6 +19,7 @@ import {
} from "@/lib/queries";
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import FactLine from "@/ui/FactLine";
import DefinitionList from "@/ui/DefinitionList";
import Select from "@/ui/Select";
import Switch from "@/ui/Switch";
@@ -43,6 +44,13 @@ const ACTION_OPTIONS = [
{ value: "block", label: "block" },
];
/**
* Narrower than a rule's four columns, or a source's name beside its URL: below
* it each table keeps its first column and the actions, and the first cell
* carries the rest.
*/
const COMPACT = "@container (max-width: 36rem)";
const styles = stylex.create({
createForm: {
display: "flex",
@@ -96,8 +104,27 @@ const styles = stylex.create({
block: {
color: colors.danger,
},
/** A pattern or a URL can be one unbroken string as wide as the table. */
pattern: {
fontWeight: 500,
overflowWrap: "anywhere",
},
unbroken: {
overflowWrap: "anywhere",
},
/** Read character by character, so a wrapped URL fills each line instead of stranding a "-" on one. */
urlText: {
wordBreak: "break-all",
},
wide: {
display: { default: null, [COMPACT]: "none" },
},
compact: {
display: { default: "none", [COMPACT]: "block" },
marginTop: "0.125rem",
color: colors.textMuted,
fontWeight: 400,
overflowWrap: "anywhere",
},
});
@@ -275,6 +302,10 @@ function GroupSourcesReadOnly({ group }: { group: Group }) {
);
}
function ActionLabel({ action }: { action: RuleAction }) {
return <span {...stylex.props(action === "allow" ? styles.allow : styles.block)}>{action}</span>;
}
function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalogue: Blocklist[] }) {
const assigned = catalogue.filter((source) => sourceIds.includes(source.id));
if (assigned.length === 0) return <p {...stylex.props(config.empty)}>This group is assigned no sources.</p>;
@@ -284,14 +315,17 @@ function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalo
<thead>
<tr>
<th {...stylex.props(shared.th)}>Source</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th, styles.wide)}>URL</th>
</tr>
</thead>
<tbody>
{assigned.map((source) => (
<tr key={source.id}>
<td {...stylex.props(shared.td)}>{source.name}</td>
<td {...stylex.props(shared.td, shared.mono)}>{source.url}</td>
<td {...stylex.props(shared.td, styles.unbroken)}>
{source.name}
<span {...stylex.props(styles.compact, shared.mono, styles.urlText)}>{source.url}</span>
</td>
<td {...stylex.props(shared.td, shared.mono, styles.urlText, styles.wide)}>{source.url}</td>
</tr>
))}
</tbody>
@@ -471,9 +505,9 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>Pattern</th>
<th {...stylex.props(shared.th)}>Kind</th>
<th {...stylex.props(shared.th)}>Action</th>
<th {...stylex.props(shared.th)}>Created</th>
<th {...stylex.props(shared.th, styles.wide)}>Kind</th>
<th {...stylex.props(shared.th, styles.wide)}>Action</th>
<th {...stylex.props(shared.th, styles.wide)}>Created</th>
{editable && (
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
@@ -484,14 +518,23 @@ function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td {...stylex.props(shared.td)}>{rule.kind}</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
{rule.action}
<td {...stylex.props(shared.td, styles.pattern)}>
{rule.pattern}
<span {...stylex.props(styles.compact)}>
<FactLine
facts={[
<ActionLabel key="action" action={rule.action} />,
rule.kind,
`Created ${formatTime(rule.created_at)}`,
]}
/>
</span>
</td>
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
<td {...stylex.props(shared.td, styles.wide)}>{rule.kind}</td>
<td {...stylex.props(shared.td, styles.wide)}>
<ActionLabel action={rule.action} />
</td>
<td {...stylex.props(shared.td, styles.wide)}>{formatTime(rule.created_at)}</td>
{editable && (
<td {...stylex.props(shared.td)}>
<button
@@ -1,4 +1,5 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { formatTime } from "@/lib/format";
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";
/**
@@ -23,6 +24,15 @@ function writes(method: string): Call[] {
return calls.filter((call) => call.method === method);
}
/**
* The first cell of the row that shows `text`. jsdom applies no stylesheet,
* so a narrow table's copy of the hidden columns sits in that cell beside the
* columns themselves.
*/
function firstCell(text: string): HTMLElement {
return within(screen.getByText(text).closest("tr")!).getAllByRole("cell")[0]!;
}
test("the group list is the master, and the selected group is the detail", async () => {
await openProtection();
@@ -122,6 +132,15 @@ test("only the selected group's rules are listed", async () => {
expect(screen.queryByText("ads.example.com")).toBeNull();
});
test("a rule's Pattern cell carries its action, kind and creation time for a table too narrow for them", async () => {
await openProtection(2);
await screen.findByText("*.social.example");
expect(firstCell("*.social.example").textContent).toBe(
`*.social.example${["block", "wildcard", `Created ${formatTime(1700000100)}`].join(" · ")}`,
);
});
test("a new rule is created in the selected group, with the pattern posted verbatim for a regex", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "Create rule in kids", level: 4 });
@@ -244,6 +263,23 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
});
test("a source's Name cell carries its URL, counts and last update for a table too narrow for them", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
await screen.findByText("Trackers");
const trackers = firstCell("Trackers").textContent;
expect(trackers).toContain("https://example.com/trackers.txt");
expect(trackers).toContain("50 domains · 0 wildcards · 0 exceptions");
expect(trackers).toContain("3 skipped regex · 4 skipped unsupported");
expect(trackers).toContain(`Updated ${formatTime(1700000000)}`);
// One of each reads in the singular.
expect(firstCell("Ads").textContent).toContain("100 domains · 2 wildcards · 1 exception");
expect(firstCell("Ads").textContent).toContain("Never updated");
// Two zero counts say nothing a reader needs to parse.
expect(firstCell("Ads").textContent).toContain("Nothing skipped");
});
test("a source switch resends the whole row immediately, with no Save step", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
@@ -12,6 +12,7 @@ import {
} from "@/lib/queries";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import FactLine from "@/ui/FactLine";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
@@ -22,17 +23,24 @@ import QueryPanel from "./QueryPanel";
import { styles as config } from "./styles";
const SKIPPED_NOTE =
"Both “Skipped” columns count lines nxdns read and did not take. Skipped regex lines are patterns nxdns accepts " +
"Both skipped counts are lines nxdns read and did not take. Skipped regex lines are patterns nxdns accepts " +
"only from you — adopt one you trust as a regex rule. Skipped unsupported lines are syntax nxdns cannot translate " +
"into a DNS decision: cosmetic element hiding, browser-only modifiers. A skipped unsupported count that dwarfs the " +
"domain count usually means the list is written for a browser extension, and its DNS or hosts variant will block " +
"more here.";
/**
* Narrower than the ten columns side by side: below it the table keeps Name,
* Enabled and the actions, and the Name cell carries the rest.
*/
const COMPACT = "@container (max-width: 90rem)";
const styles = stylex.create({
name: {
fontWeight: 500,
},
badge: {
whiteSpace: "nowrap",
marginLeft: "0.5rem",
borderRadius: metrics.radius,
backgroundColor: colors.border,
@@ -49,15 +57,72 @@ const styles = stylex.create({
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
unbroken: {
overflowWrap: "anywhere",
},
/** Read character by character, so a wrapped URL fills each line instead of stranding a "-" on one. */
urlText: {
wordBreak: "break-all",
},
wide: {
display: { default: null, [COMPACT]: "none" },
},
/** A URL or a name can be one unbroken string as wide as the table. */
compact: {
display: { default: "none", [COMPACT]: "block" },
marginTop: "0.125rem",
color: colors.textMuted,
overflowWrap: "anywhere",
},
/** Stacked in the narrow form, which gives the first cell the width; the row is several lines tall there already. */
actions: {
display: "flex",
gap: "0.75rem",
flexDirection: { default: null, [COMPACT]: "column" },
alignItems: { default: null, [COMPACT]: "flex-start" },
gap: { default: "0.75rem", [COMPACT]: "0.5rem" },
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
function counted(count: number, one: string, many: string): string {
return `${formatCount(count)} ${count === 1 ? one : many}`;
}
/** The hidden columns' facts, in their column order, for the compact Name cell. */
function SourceFacts({ source }: { source: Blocklist }) {
return (
<>
<span {...stylex.props(styles.compact, shared.mono, styles.urlText)}>{source.url}</span>
<span {...stylex.props(styles.compact, shared.tabularNums)}>
<FactLine
facts={[
counted(source.domain_count, "domain", "domains"),
counted(source.wildcard_count, "wildcard", "wildcards"),
counted(source.exception_count, "exception", "exceptions"),
]}
/>
</span>
<span {...stylex.props(styles.compact, shared.tabularNums)}>
{source.skipped_regex_count === 0 && source.skipped_unsupported_count === 0 ? (
"Nothing skipped"
) : (
<FactLine
facts={[
`${formatCount(source.skipped_regex_count)} skipped regex`,
`${formatCount(source.skipped_unsupported_count)} skipped unsupported`,
]}
/>
)}
</span>
<span {...stylex.props(styles.compact)}>
{source.last_updated === null ? "Never updated" : `Updated ${formatTime(source.last_updated)}`}
</span>
</>
);
}
/**
* A runtime action: it re-downloads the sources the running process already
* knows about, so it stays enabled under file authority and while
@@ -142,41 +207,44 @@ function SourcesReadOnly({ blocklists }: { blocklists: Blocklist[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th, styles.wide)}>URL</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th, styles.wide)}>Domains</th>
<th {...stylex.props(shared.th, styles.wide)}>Wildcards</th>
<th {...stylex.props(shared.th, styles.wide)}>Exceptions</th>
<th {...stylex.props(shared.th, styles.wide)}>Skipped regex</th>
<th {...stylex.props(shared.th, styles.wide)}>Skipped unsupported</th>
<th {...stylex.props(shared.th, styles.wide)}>Last updated</th>
</tr>
</thead>
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.unbroken)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
<SourceFacts source={b} />
</td>
<td {...stylex.props(shared.td, shared.mono, styles.urlText, styles.wide)}>
{b.url}
</td>
<td {...stylex.props(shared.td, shared.mono)}>{b.url}</td>
<td {...stylex.props(shared.td)}>{String(b.enabled)}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.domain_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.wildcard_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.exception_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.skipped_regex_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.skipped_unsupported_count)}
</td>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.wide)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
</tr>
@@ -235,14 +303,14 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th, styles.wide)}>URL</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th, styles.wide)}>Domains</th>
<th {...stylex.props(shared.th, styles.wide)}>Wildcards</th>
<th {...stylex.props(shared.th, styles.wide)}>Exceptions</th>
<th {...stylex.props(shared.th, styles.wide)}>Skipped regex</th>
<th {...stylex.props(shared.th, styles.wide)}>Skipped unsupported</th>
<th {...stylex.props(shared.th, styles.wide)}>Last updated</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
@@ -251,11 +319,12 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.unbroken)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
<SourceFacts source={b} />
</td>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.wide)}>
<span {...stylex.props(styles.url)} title={b.url}>
{b.url}
</span>
@@ -268,22 +337,22 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
onChange={() => toggleEnabled(b)}
/>
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.domain_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.wildcard_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.exception_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.skipped_regex_count)}
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>
{formatCount(b.skipped_unsupported_count)}
</td>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.wide)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
<td {...stylex.props(shared.td)}>
@@ -12,6 +12,7 @@ import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm";
import { local, RecordFacts } from "./localCells";
import QueryPanel from "./QueryPanel";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -230,13 +231,13 @@ export default function RecordsTab() {
<th scope="col" {...stylex.props(styles.headCell)}>
Name
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
Type
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
Value
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
TTL
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
@@ -266,10 +267,22 @@ export default function RecordsTab() {
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td {...stylex.props(styles.cell, local.unbroken)}>
<span {...stylex.props(shared.mono)}>{record.name}</span>
<RecordFacts record={record} />
</td>
<td {...stylex.props(styles.cell, local.wide)}>{record.rtype}</td>
<td
{...stylex.props(
styles.cell,
shared.mono,
local.unbroken,
local.wide,
)}
>
{record.value}
</td>
<td {...stylex.props(styles.cell, local.wide)}>{record.ttl}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
@@ -1,5 +1,5 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { DATABASE, contentArea, renderPage, stubApi, type Call } from "./testFixtures";
import { DATABASE, MANAGED_FILE, contentArea, renderPage, stubApi, type Call } from "./testFixtures";
/**
* Resolution in database mode: the upstream pool, the local records and the
@@ -22,6 +22,15 @@ async function openResolution(tab?: string, options: Parameters<typeof stubApi>[
return renderPage(`/configuration/resolution${suffix}`, "Resolution");
}
/**
* The first cell of the row that shows `text`. jsdom applies no stylesheet,
* so a narrow table's copy of the hidden columns sits in that cell beside the
* columns themselves.
*/
function firstCell(text: string): HTMLElement {
return within(screen.getByText(text).closest("tr")!).getAllByRole("cell")[0]!;
}
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
async function openDeleteDialog(index = 0) {
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[index]!);
@@ -40,6 +49,15 @@ test("the upstream pool is the default tab and lists every field", async () => {
expect(screen.getByText(/applies to the next query/)).toBeTruthy();
});
test("an upstream's URL cell carries its priority and TLS name for a table too narrow for them", async () => {
await openResolution();
await screen.findByText("tls://9.9.9.9:853");
expect(firstCell("tls://9.9.9.9:853").textContent).toBe("tls://9.9.9.9:853Priority 200 · TLS name dns.quad9.net");
// An empty TLS name has nothing to say in a line of facts.
expect(firstCell("udp://1.1.1.1:53").textContent).toBe("udp://1.1.1.1:53Priority 100");
});
test("adding an upstream posts every field", async () => {
await openResolution();
await screen.findByRole("heading", { name: "Add upstream" });
@@ -236,6 +254,31 @@ test("the record delete dialog names the record and only deletes on confirm", as
expect(writes()[0]).toMatchObject({ method: "DELETE", url: "/api/local-records/1" });
});
test("a record's Name cell carries its answer and TTL for a table too narrow for them", async () => {
await openResolution("records");
await screen.findByText("nas.lan.home");
expect(firstCell("nas.lan.home").textContent).toBe("nas.lan.homeType A value 192.168.1.5TTL 300");
});
test("a zone's cell carries its resolver for a table too narrow for it", async () => {
await openResolution("zones");
await screen.findByText("lan.home");
expect(firstCell("lan.home").textContent).toBe("lan.homeResolver udp://192.168.1.1:53");
});
test("under file authority the records and zones tables carry the same narrow form", async () => {
calls = stubApi(MANAGED_FILE);
await renderPage("/configuration/resolution?tab=records", "Resolution");
await screen.findByText("nas.lan.home");
expect(firstCell("nas.lan.home").textContent).toBe("nas.lan.homeType A value 192.168.1.5TTL 300");
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
expect(firstCell("lan.home").textContent).toBe("lan.homeResolver udp://192.168.1.1:53");
});
test("the forward zone delete dialog names the zone and only deletes on confirm", async () => {
await openResolution("zones");
await screen.findByText("lan.home");
@@ -5,6 +5,7 @@ import InlineError from "@/lib/InlineError";
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
import type { Upstream, UpstreamInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import FactLine from "@/ui/FactLine";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
@@ -16,18 +17,41 @@ import { styles as config } from "./styles";
const INTRO = "The pool is rebuilt as you save, so an edit here applies to the next query.";
/**
* Narrower than the four columns side by side: below it the table keeps URL,
* Enabled and the actions, and the URL cell carries the rest.
*/
const COMPACT = "@container (max-width: 42rem)";
const styles = stylex.create({
/** Cut to one line beside the other columns; whole, wrapping, where it is the row's only text column. */
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
whiteSpace: { default: "nowrap", [COMPACT]: "normal" },
overflowWrap: "anywhere",
fontWeight: 500,
},
unbroken: {
overflowWrap: "anywhere",
},
wide: {
display: { default: null, [COMPACT]: "none" },
},
compact: {
display: { default: "none", [COMPACT]: "block" },
marginTop: "0.125rem",
color: colors.textMuted,
overflowWrap: "anywhere",
},
/** Stacked in the narrow form, which gives the first cell the width; the row is several lines tall there already. */
actions: {
display: "flex",
gap: "0.75rem",
flexDirection: { default: null, [COMPACT]: "column" },
alignItems: { default: null, [COMPACT]: "flex-start" },
gap: { default: "0.75rem", [COMPACT]: "0.5rem" },
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
@@ -37,6 +61,17 @@ const styles = stylex.create({
},
});
/** The hidden columns' facts for the compact URL cell; an empty TLS name has nothing to say there. */
function UpstreamFacts({ upstream }: { upstream: Upstream }) {
const facts = [`Priority ${upstream.priority}`];
if (upstream.tls_name !== "") facts.push(`TLS name ${upstream.tls_name}`);
return (
<span {...stylex.props(styles.compact, shared.tabularNums)}>
<FactLine facts={facts} />
</span>
);
}
export default function UpstreamsTab() {
const upstreams = useQuery(upstreamsQuery());
return (
@@ -78,18 +113,21 @@ function UpstreamsReadOnly({ upstreams }: { upstreams: Upstream[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Priority</th>
<th {...stylex.props(shared.th, styles.wide)}>Priority</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>TLS name</th>
<th {...stylex.props(shared.th, styles.wide)}>TLS name</th>
</tr>
</thead>
<tbody>
{upstreams.map((u) => (
<tr key={u.id}>
<td {...stylex.props(shared.td, shared.mono)}>{u.url}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td, styles.unbroken)}>
<span {...stylex.props(shared.mono)}>{u.url}</span>
<UpstreamFacts upstream={u} />
</td>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>{String(u.enabled)}</td>
<td {...stylex.props(shared.td)}>
<td {...stylex.props(shared.td, styles.unbroken, styles.wide)}>
{u.tls_name === "" ? (
<span {...stylex.props(styles.absent)}>empty</span>
) : (
@@ -151,9 +189,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
<thead>
<tr>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Priority</th>
<th {...stylex.props(shared.th, styles.wide)}>Priority</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>TLS name</th>
<th {...stylex.props(shared.th, styles.wide)}>TLS name</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
@@ -166,8 +204,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
<span {...stylex.props(styles.url)} title={u.url}>
{u.url}
</span>
<UpstreamFacts upstream={u} />
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td, shared.tabularNums, styles.wide)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>
<Switch
aria-label={`${u.url} enabled`}
@@ -176,7 +215,9 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
onChange={() => toggleEnabled(u)}
/>
</td>
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td {...stylex.props(shared.td, styles.unbroken, styles.wide)}>
{u.tls_name === "" ? "—" : u.tls_name}
</td>
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<button
+16 -3
View File
@@ -11,6 +11,7 @@ import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm";
import { local, ZoneResolver } from "./localCells";
import QueryPanel from "./QueryPanel";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -200,7 +201,7 @@ export default function ZonesTab() {
<th scope="col" {...stylex.props(styles.headCell)}>
Zone
</th>
<th scope="col" {...stylex.props(styles.headCell)}>
<th scope="col" {...stylex.props(styles.headCell, local.wide)}>
Resolver
</th>
<th scope="col" {...stylex.props(styles.headCellLast)}>
@@ -230,8 +231,20 @@ export default function ZonesTab() {
</td>
) : (
<>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
<td {...stylex.props(styles.cell, shared.mono, local.unbroken)}>
{zone.zone}
<ZoneResolver zone={zone} />
</td>
<td
{...stylex.props(
styles.cell,
shared.mono,
local.unbroken,
local.wide,
)}
>
{zone.resolver}
</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
@@ -0,0 +1,56 @@
import * as stylex from "@stylexjs/stylex";
import type { ForwardZone, LocalRecord } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
/**
* The compact forms of the local-records and forward-zones tables, for both
* the editable tabs and their file-mode renderings. Below the width a record's
* four columns, or a zone beside its resolver, need, a table keeps its first
* column and the actions, and the first cell carries the rest. The scrolling
* wrapper is the container.
*/
const COMPACT = "@container (max-width: 28rem)";
export const local = stylex.create({
/** A name, a value or a resolver can be one unbroken string as wide as the table. */
unbroken: {
overflowWrap: "anywhere",
},
wide: {
display: { default: null, [COMPACT]: "none" },
},
compact: {
display: { default: "none", [COMPACT]: "block" },
marginTop: "0.125rem",
color: colors.textMuted,
overflowWrap: "anywhere",
},
});
/**
* The answer, then how long it is cached. The labels stand in for the hidden
* headers: a screen reader says a bare "A" as the article, and a CNAME's value
* is a second name beside the record's own.
*/
export function RecordFacts({ record }: { record: LocalRecord }) {
return (
<>
<span {...stylex.props(local.compact)}>
<span {...stylex.props(shared.srOnly)}>Type </span>
{record.rtype} <span {...stylex.props(shared.srOnly)}>value </span>
<span {...stylex.props(shared.mono)}>{record.value}</span>
</span>
<span {...stylex.props(local.compact, shared.tabularNums)}>TTL {record.ttl}</span>
</>
);
}
export function ZoneResolver({ zone }: { zone: ForwardZone }) {
return (
<span {...stylex.props(local.compact)}>
<span {...stylex.props(shared.srOnly)}>Resolver </span>
<span {...stylex.props(shared.mono)}>{zone.resolver}</span>
</span>
);
}
+2 -1
View File
@@ -77,7 +77,6 @@ export const styles = stylex.create({
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
@@ -102,8 +101,10 @@ export const styles = stylex.create({
flexDirection: "column",
gap: "0.25rem",
},
/** A group name can be one unbroken string wider than a phone's line. */
masterLink: {
display: "block",
overflowWrap: "anywhere",
borderRadius: metrics.radius,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
@@ -44,7 +44,13 @@ const ACTIVE = page([
]);
const RESOLVED = page([
event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
event(30, {
code: "disk.space",
component: "disk",
subject: "data",
first_seen: NOW_S - 9000,
resolved_at: NOW_S - 7200,
}),
]);
/**
@@ -165,6 +171,16 @@ test("active episodes come first, each with its title, subject, age and count",
expect(footerLine("Showing 1 resolved entry — end of history")).toBeTruthy();
});
test("the Event cell carries the severity, subject and timing for a table too narrow for them", async () => {
renderRoute();
const row = (await screen.findByText("Disk space low")).closest("tr")!;
const event = within(row).getAllByRole("cell")[1]!;
expect(within(event).getByText("Warning")).toBeTruthy();
expect(within(event).getByText("data")).toBeTruthy();
expect(event.textContent).toMatch(/Resolved .+ · lasted 30m · 3 occurrences/);
});
test("nothing open reads as good news, not as a broken page", async () => {
responses["/api/diagnostics?state=active"] = page([]);
renderRoute();
@@ -14,6 +14,7 @@ import { formatCount, formatDuration, formatTime } from "@/lib/format";
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import FactLine from "@/ui/FactLine";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors, metrics } from "@/ui/tokens.stylex";
@@ -41,6 +42,13 @@ const COMPONENT_OPTIONS = [
...DIAGNOSTIC_COMPONENTS.map((component) => ({ value: component, label: componentLabel(component) })),
];
/**
* Narrower than the seven history columns need, so the table keeps Event, which
* takes the severity, the subject and the timing as lines around the link, as
* an active card does, and the Purge action. The wrapper is the container.
*/
const COMPACT = "@container (max-width: 56rem)";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
@@ -161,9 +169,9 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textSecondary,
},
tableWrap: {
/** The shared scrolling wrapper, framed. */
framedTable: {
marginTop: "0.75rem",
overflowX: "auto",
borderRadius: metrics.radius,
borderWidth: 1,
borderStyle: "solid",
@@ -197,6 +205,17 @@ const styles = stylex.create({
nowrap: {
whiteSpace: "nowrap",
},
wide: {
display: { default: null, [COMPACT]: "none" },
},
compactBadge: {
display: { default: "none", [COMPACT]: "inline-block" },
marginInlineEnd: "0.5rem",
},
compact: {
display: { default: "none", [COMPACT]: "block" },
marginTop: "0.125rem",
},
rowLink: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
@@ -207,6 +226,9 @@ const styles = stylex.create({
alignItems: "center",
gap: "0.75rem",
},
count: {
marginTop: "0.75rem",
},
note: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
@@ -340,10 +362,13 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
const copy = copyFor(event.code);
return (
<tr {...stylex.props(styles.row)}>
<td {...stylex.props(styles.cell)}>
<td {...stylex.props(styles.cell, styles.wide)}>
<SeverityBadge severity={event.severity} />
</td>
<td {...stylex.props(styles.cell)}>
<span {...stylex.props(styles.compactBadge)}>
<SeverityBadge severity={event.severity} />
</span>
<Link
to="/diagnostics/$id"
params={{ id: String(event.id) }}
@@ -351,13 +376,31 @@ function HistoryRow({ event, onPurge, busy }: { event: DiagnosticEvent; onPurge:
>
{copy.title}
</Link>
<span {...stylex.props(styles.compact, styles.subject)}>{event.subject}</span>
<span {...stylex.props(styles.compact, styles.meta, shared.tabularNums)}>
<FactLine
facts={[
...(event.resolved_at === null
? [`Started ${formatTime(event.first_seen)}`]
: [
`Resolved ${formatTime(event.resolved_at)}`,
`lasted ${formatDuration(event.resolved_at - event.first_seen)}`,
]),
occurrenceText(event.occurrences),
]}
/>
</span>
</td>
<td {...stylex.props(styles.cell)}>{event.subject}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatTime(event.first_seen)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
<td {...stylex.props(styles.cell, styles.wide)}>{event.subject}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
{formatTime(event.first_seen)}
</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
{event.resolved_at === null ? "—" : formatTime(event.resolved_at)}
</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>{formatCount(event.occurrences)}</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums, styles.wide)}>
{formatCount(event.occurrences)}
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>
<button
type="button"
@@ -495,16 +538,16 @@ export default function DiagnosticsPage() {
<p {...stylex.props(styles.empty)}>Nothing has failed and recovered in the retained window.</p>
) : (
<>
<div tabIndex={0} {...stylex.props(styles.tableWrap, shared.focusRing)}>
<div tabIndex={0} {...stylex.props(shared.tableWrap, styles.framedTable, shared.focusRing)}>
<table {...stylex.props(styles.table)}>
<thead {...stylex.props(styles.head)}>
<tr>
<th {...stylex.props(styles.th)}>Severity</th>
<th {...stylex.props(styles.th, styles.wide)}>Severity</th>
<th {...stylex.props(styles.th)}>Event</th>
<th {...stylex.props(styles.th)}>Subject</th>
<th {...stylex.props(styles.th)}>Started</th>
<th {...stylex.props(styles.th)}>Resolved</th>
<th {...stylex.props(styles.th)}>Occurrences</th>
<th {...stylex.props(styles.th, styles.wide)}>Subject</th>
<th {...stylex.props(styles.th, styles.wide)}>Started</th>
<th {...stylex.props(styles.th, styles.wide)}>Resolved</th>
<th {...stylex.props(styles.th, styles.wide)}>Occurrences</th>
<th {...stylex.props(styles.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
@@ -522,7 +565,7 @@ export default function DiagnosticsPage() {
</tbody>
</table>
</div>
<p {...stylex.props(styles.footer, styles.note)}>
<p {...stylex.props(styles.count, styles.note)}>
Showing{" "}
<span {...stylex.props(shared.tabularNums)}>{formatCount(historyRows.length)}</span>{" "}
resolved {historyRows.length === 1 ? "entry" : "entries"}
@@ -38,11 +38,11 @@ const styles = stylex.create({
gap: "0.5rem",
// Five is prime, so every count between one and five leaves a short last
// row; three columns made it 3 then 2, which reads as a layout that ran out
// of room rather than one that chose. So the strip goes from one column
// straight to two and then to a single row of five, and is never ragged.
// of room rather than one that chose. Two columns, then a single row of
// five. One column on a phone stacked five cards over the episodes the
// page exists to show.
gridTemplateColumns: {
default: "minmax(0, 1fr)",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1100px)": "repeat(5, minmax(0, 1fr))",
},
listStyleType: "none",
@@ -51,6 +51,8 @@ const styles = stylex.create({
fact: {
display: "flex",
flexWrap: "wrap",
// A card stretches to the tallest card in its row; the lines stay at the top.
alignContent: "flex-start",
alignItems: "baseline",
gap: "0.375rem",
borderRadius: metrics.radius,
@@ -73,8 +75,10 @@ const styles = stylex.create({
label: {
color: colors.textSecondary,
},
/** Below 640px a value always starts its own line, so every card has the same shape. */
value: {
fontWeight: 500,
flexBasis: { default: null, "@media (max-width: 639px)": "100%" },
},
detail: {
flexBasis: "100%",
@@ -4,6 +4,7 @@ import { createQueryClient } from "@/lib/queryClient";
import { formatTime } from "@/lib/format";
import ClientChart, { type ClientChartData } from "./ClientChart";
import { OTHER_KEY, clientSeriesColor, seriesColor } from "./seriesColors";
import { STILL_COUNTING_OPACITY } from "./chartKit";
const SINCE = 1_700_000_000;
const BUCKET = 1800;
@@ -73,7 +74,10 @@ test("the columns are timestamped from this response's own window", () => {
render(TWO_BUCKETS);
const rows = within(screen.getByRole("table")).getAllByRole("rowheader");
expect(rows.map((row) => row.textContent)).toEqual([formatTime(SINCE), formatTime(SINCE + BUCKET)]);
expect(rows.map((row) => row.textContent)).toEqual([
formatTime(SINCE),
`${formatTime(SINCE + BUCKET)} · still counting`,
]);
});
/**
@@ -100,7 +104,7 @@ test("the series are coloured by rank, and Other in its own gray", () => {
),
);
const fills = Array.from(container.querySelectorAll("rect"))
const fills = Array.from(container.querySelectorAll("rect:not([data-still-counting])"))
.map((rect) => rect.getAttribute("fill"))
.filter((fill) => fill !== "transparent");
expect(fills).toEqual([clientSeriesColor(0), clientSeriesColor(1), seriesColor(OTHER_KEY)]);
@@ -209,8 +213,9 @@ test("a refresh that rolls the window takes the tooltip down instead of relabell
rerender({ ...TWO_BUCKETS, since: SINCE + BUCKET });
expect(container.querySelectorAll("dl")).toHaveLength(0);
// No dimming is left; the last column stays faint because it is still counting.
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", String(STILL_COUNTING_OPACITY)]);
});
/** The placement the hand-rolled tooltip had, restored over visx's 10px defaults. */
@@ -261,7 +266,7 @@ test("pointing at a bucket names its busiest clients, then Other, then the total
expect(swatches[2]).toContain(seriesColor(OTHER_KEY));
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", String(0.55 * STILL_COUNTING_OPACITY)]);
});
/**
@@ -294,8 +299,9 @@ test("leaving the chart takes the tooltip and the dimming with it", () => {
fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
expect(container.querySelectorAll("dl")).toHaveLength(0);
// No dimming is left; the last column stays faint because it is still counting.
const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", String(STILL_COUNTING_OPACITY)]);
});
/**
@@ -310,6 +316,7 @@ test("a window where Other counted nothing keeps it in the legend and the table,
expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
"192.0.2.30",
"Other",
"Still counting",
]);
expect(
within(screen.getByRole("table"))
@@ -326,3 +333,28 @@ test("the hidden table groups its counts like every other figure on the page", (
render(clients([{ client: "192.0.2.30", buckets: [12345] }], [0]));
expect(within(screen.getByRole("table")).getByRole("cell", { name: "12,345" })).toBeTruthy();
});
/**
* The API ends every window at the end of the bucket that holds "now", so the
* last column is still filling. Drawn like the others it reads as a collapse in
* traffic; it is drawn faint inside a dashed outline, and every text says so.
*/
test("the last column is still counting, and says so in the drawing, the tooltip and the table", () => {
const { container } = render(TWO_BUCKETS);
const outlines = container.querySelectorAll("rect[data-still-counting]");
expect(outlines).toHaveLength(1);
expect(outlines[0].getAttribute("stroke-dasharray")).toBeTruthy();
expect(screen.getByRole("img").getAttribute("aria-label")).toMatch(/the last still counting$/);
fireEvent.mouseOver(overlayRects(container)[1]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe(`${formatTime(SINCE + BUCKET)} · still counting`);
});
test("a last column with nothing in it yet draws no outline", () => {
const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 0] }], [2, 0]));
expect(container.querySelectorAll("rect[data-still-counting]")).toHaveLength(0);
expect(Array.from(container.querySelectorAll("ul li")).at(-1)?.textContent).toBe("Still counting");
});
+34 -6
View File
@@ -12,9 +12,10 @@
import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group";
import { BarStack } from "@visx/shape";
import { formatCount, formatTime } from "@/lib/format";
import { formatCount } from "@/lib/format";
import type { OverviewClientSeries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { clientLabel, useClientNames, type ClientNames } from "@/features/clients/clientNames";
import {
CHART_HEIGHT,
@@ -25,8 +26,11 @@ import {
EmptyChart,
HitBands,
STACK_BLEED,
STILL_COUNTING_DASH,
STILL_COUNTING_OPACITY,
StackSegment,
bandScale,
bucketTitle,
labelTickValues,
plotArea,
slotCenter,
@@ -38,6 +42,15 @@ import {
} from "./chartKit";
import { OTHER_KEY, clientKey, clientSeriesColor, seriesColor } from "./seriesColors";
const styles = stylex.create({
/** The column's outline, over its faint fill: the bucket that is still filling. */
stillCounting: {
fill: "none",
stroke: colors.textSecondary,
strokeWidth: 1,
},
});
/** How many named clients a bucket's tooltip lists before the aggregate and the total. */
const TOOLTIP_CLIENTS = 4;
@@ -110,6 +123,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
const yTicks = valueTicks(yScale);
const colorOf = new Map(series.map((one) => [one.key, one.color]));
const centers = timestamps.map((ts) => slotCenter(xScale, ts, plot));
const last = bucketCount - 1;
/**
* The bucket's busiest clients, then Other, then the total — eight named
@@ -131,7 +145,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
value: formatCount(column[one.key]),
}));
return {
title: formatTime(column.ts),
title: bucketTitle(column.ts, index === last),
rows: [...rows, { key: "total", label: "All clients", value: formatCount(totals[index]) }],
};
}
@@ -140,7 +154,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
<ChartRoot containerRef={containerRef}>
<svg
role="img"
aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series`}
aria-label={`Client activity over time, ${formatCount(bucketCount)} buckets, ${formatCount(series.length)} series, the last still counting`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
@@ -166,7 +180,10 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
columns.map((column, index) => (
<Group
key={column.ts}
opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
opacity={
(hovered.index === null || hovered.index === index ? 1 : 0.55) *
(index === last ? STILL_COUNTING_OPACITY : 1)
}
>
{stacks.map((stack) => {
const bar = stack.bars[index];
@@ -187,6 +204,17 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
))
}
</BarStack>
{totals[last] > 0 && (
<rect
data-still-counting=""
x={xScale(columns[last].ts) ?? plot.x}
y={yScale(totals[last])}
width={xScale.bandwidth()}
height={plot.bottom - yScale(totals[last])}
strokeDasharray={STILL_COUNTING_DASH}
{...stylex.props(styles.stillCounting)}
/>
)}
<HitBands plot={plot} centers={centers} onEnter={hovered.show} />
</svg>
{hovered.index !== null && (
@@ -207,9 +235,9 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
</tr>
</thead>
<tbody>
{columns.map((column) => (
{columns.map((column, index) => (
<tr key={column.ts}>
<th scope="row">{formatTime(column.ts)}</th>
<th scope="row">{bucketTitle(column.ts, index === last)}</th>
{series.map((one) => (
<td key={one.key}>{formatCount(column[one.key])}</td>
))}
@@ -450,7 +450,7 @@ test("the legend names the two curves, and the table adds the remainder", () =>
);
const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
expect(legend).toEqual(["Total queries", "Blocked"]);
expect(legend).toEqual(["Total queries", "Blocked", "Still counting"]);
expect(
within(screen.getByRole("table"))
.getAllByRole("columnheader")
@@ -470,3 +470,42 @@ test("the hidden table groups its counts like every other figure on the page", (
.map((cell) => cell.textContent),
).toEqual(["12,345", "1,234", "11,111"]);
});
/**
* The API ends every window at the end of the bucket that holds "now", so the
* last bucket is still filling, and a curve falling into it read as a collapse
* in traffic. The stretch into the last point is drawn dashed over a fainter
* fill, the settled curve stops where it starts, and every text says so.
*/
test("the curve into the last bucket is drawn as still counting", () => {
const { container } = render(
<TimeseriesChart
data={timeseries([
{ ts: SINCE, queries: 900, blocked: 90, cached: 0 },
{ ts: SINCE + 1800, queries: 800, blocked: 80, cached: 0 },
{ ts: SINCE + 3600, queries: 130, blocked: 13, cached: 0 },
])}
/>,
);
const counting = container.querySelector('g[data-series="queries"] g[data-still-counting]') as SVGGElement;
const [area, line] = Array.from(counting.querySelectorAll("path"));
expect(line.getAttribute("stroke-dasharray")).toBeTruthy();
expect(Number(area.getAttribute("fill-opacity"))).toBeLessThan(0.13);
expect(seriesPaths(container, "queries").line.getAttribute("stroke-dasharray")).toBeNull();
// The two clips meet at the second-to-last point, where the settled curve ends.
const middleX = pathPoints(seriesPaths(container, "queries").line.getAttribute("d") ?? "")[3][0];
const clips = Array.from(container.querySelectorAll("defs rect"));
expect(clips).toHaveLength(2);
expect(clips[0].getAttribute("width")).toBe(clips[1].getAttribute("x"));
expect(Number(clips[0].getAttribute("width"))).toBeCloseTo(middleX, 0);
expect(screen.getByRole("img").getAttribute("aria-label")).toMatch(/the last still counting$/);
fireEvent.mouseOver(overlayRects(container)[2]);
const tooltip = container.querySelector("dl") as HTMLElement;
expect(tooltip.previousElementSibling?.textContent).toBe(`${formatTime(SINCE + 3600)} · still counting`);
const rows = within(screen.getByRole("table")).getAllByRole("rowheader");
expect(rows.at(-1)?.textContent).toBe(`${formatTime(SINCE + 3600)} · still counting`);
expect(rows[0].textContent).toBe(formatTime(SINCE));
});
+51 -19
View File
@@ -6,8 +6,9 @@
* the reader is looking at.
*/
import { useId } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatCount, formatTime } from "@/lib/format";
import { formatCount } from "@/lib/format";
import type { Bucket } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -19,11 +20,15 @@ import {
ChartTooltip,
EmptyChart,
HitBands,
STILL_COUNTING_DASH,
STILL_COUNTING_OPACITY,
areaPath,
bucketTitle,
labelTickValues,
plotArea,
pointScale,
smoothPath,
svgId,
useActiveIndex,
useMeasuredWidth,
valueScale,
@@ -75,9 +80,9 @@ function columnsOf(buckets: Bucket[]): Column[] {
}));
}
function tooltipOf(column: Column): TooltipContent {
function tooltipOf(column: Column, stillCounting: boolean): TooltipContent {
return {
title: formatTime(column.ts),
title: bucketTitle(column.ts, stillCounting),
rows: [
...SERIES.map((series) => ({
key: series.key,
@@ -105,6 +110,7 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
// A hover survives a re-render only while it still names the same bucket at
// the same place: a poll that rolls the window, or a resize, retires it.
const hovered = useActiveIndex(`${data.since}:${data.bucket_seconds}:${data.buckets.length}:${width}`);
const clipId = svgId(useId());
if (data.buckets.length === 0 || data.buckets.every((bucket) => bucket.queries === 0)) {
return <EmptyChart containerRef={containerRef} />;
@@ -120,12 +126,16 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
const centers = timestamps.map((ts) => xScale(ts) ?? plot.x);
const pointsOf = (key: SeriesKey): [number, number][] =>
columns.map((column, i) => [centers[i], yScale(column[key])]);
const last = columns.length - 1;
// The curve into the last point belongs to the bucket that is still
// filling, so everything right of the point before it is drawn as such.
const settledEdge = last > 0 ? centers[last - 1] : plot.x;
return (
<ChartRoot containerRef={containerRef}>
<svg
role="img"
aria-label={`Queries over time, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket`}
aria-label={`Queries over time, ${formatCount(data.buckets.length)} buckets: total and blocked queries per bucket, the last still counting`}
width="100%"
height={CHART_HEIGHT}
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
@@ -139,23 +149,45 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
xTickValues={labelTickValues(timestamps, plot.width)}
bucketSeconds={data.bucket_seconds}
/>
<defs>
<clipPath id={`${clipId}-settled`}>
<rect x={0} y={0} width={settledEdge} height={CHART_HEIGHT} />
</clipPath>
<clipPath id={`${clipId}-counting`}>
<rect x={settledEdge} y={0} width={Math.max(0, width - settledEdge)} height={CHART_HEIGHT} />
</clipPath>
</defs>
{SERIES.map((series) => {
const points = pointsOf(series.key);
const line = smoothPath(points, plot.y, plot.bottom);
const area = areaPath(line, points, plot);
return (
<g key={series.key} data-series={series.key}>
<path
d={areaPath(line, points, plot)}
fill={series.color}
fillOpacity={series.fillOpacity}
/>
<path
d={line}
fill="none"
stroke={series.color}
strokeWidth={LINE_WIDTH}
strokeLinejoin="round"
/>
<g clipPath={`url(#${clipId}-settled)`}>
<path d={area} fill={series.color} fillOpacity={series.fillOpacity} />
<path
d={line}
fill="none"
stroke={series.color}
strokeWidth={LINE_WIDTH}
strokeLinejoin="round"
/>
</g>
<g clipPath={`url(#${clipId}-counting)`} data-still-counting="">
<path
d={area}
fill={series.color}
fillOpacity={series.fillOpacity * STILL_COUNTING_OPACITY}
/>
<path
d={line}
fill="none"
stroke={series.color}
strokeWidth={LINE_WIDTH}
strokeLinejoin="round"
strokeDasharray={STILL_COUNTING_DASH}
/>
</g>
</g>
);
})}
@@ -185,7 +217,7 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
{hovered.index !== null && (
<ChartTooltip
index={hovered.index}
content={tooltipOf(columns[hovered.index])}
content={tooltipOf(columns[hovered.index], hovered.index === last)}
left={centers[hovered.index]}
top={yScale(columns[hovered.index].queries)}
beside
@@ -206,9 +238,9 @@ export default function TimeseriesChart({ data }: { data: TimeseriesData }) {
</tr>
</thead>
<tbody>
{columns.map((column) => (
{columns.map((column, index) => (
<tr key={column.ts}>
<th scope="row">{formatTime(column.ts)}</th>
<th scope="row">{bucketTitle(column.ts, index === last)}</th>
<td>{formatCount(column.queries)}</td>
<td>{formatCount(column.blocked)}</td>
<td>{formatCount(column.allowed)}</td>
+34 -1
View File
@@ -15,7 +15,7 @@ import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
import { GridRows } from "@visx/grid";
import { scaleBand, scaleLinear, scalePoint } from "@visx/scale";
import { TooltipWithBounds } from "@visx/tooltip";
import { formatBucketTime, formatCount } from "@/lib/format";
import { formatBucketTime, formatCount, formatTime } from "@/lib/format";
import { styles as shared } from "@/ui/styles";
import { colors, layers, metrics } from "@/ui/tokens.stylex";
@@ -405,6 +405,30 @@ export function StackSegment({
/** The amount one upper segment overlaps the one under it, in plot units. */
export const STACK_BLEED = 0.5;
/**
* The name for the last bucket of every window. The API ends a window at the
* end of the bucket that holds "now" (overview.zig `window`), so that bucket is
* still filling, and drawn like the others it reads as a collapse in traffic.
* Both charts draw it faint and dashed, and the legend says what that means, so
* the meaning does not depend on a hover.
*/
export const STILL_COUNTING = "Still counting";
/** Faint enough to read as unfinished beside the settled buckets, not as absent. */
export const STILL_COUNTING_OPACITY = 0.45;
export const STILL_COUNTING_DASH = "4 3";
/** A tooltip or table title for a bucket, naming the one that is still filling. */
export function bucketTitle(ts: number, stillCounting: boolean): string {
return stillCounting ? `${formatTime(ts)} · ${STILL_COUNTING.toLowerCase()}` : formatTime(ts);
}
/** `useId` output made safe inside an SVG `url(#…)` reference. */
export function svgId(reactId: string): string {
return reactId.replace(/[^A-Za-z0-9_-]/g, "");
}
/**
* The transparent hit targets: one per bucket, spanning the whole plot height so
* that pointing at the empty space above a short column or a low point still
@@ -607,6 +631,11 @@ const legendStyles = stylex.create({
borderRadius: "0.125rem",
},
swatchColor: (color: string) => ({ backgroundColor: color }),
swatchCounting: {
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.textSecondary,
},
});
/** The key under a chart: one swatch and one name per drawn series, in drawing order. */
@@ -622,6 +651,10 @@ export function ChartLegend({ entries }: { entries: { key: string; label: string
{entry.label}
</li>
))}
<li {...stylex.props(legendStyles.item)}>
<span aria-hidden="true" {...stylex.props(legendStyles.swatch, legendStyles.swatchCounting)} />
{STILL_COUNTING}
</li>
</ul>
);
}
+7
View File
@@ -213,7 +213,14 @@ const styles = stylex.create({
paddingInline: "0.5rem",
paddingBlock: "0.5rem",
},
/**
* Positioned, so it is the containing block of every `srOnly` box on a page,
* such as a chart's data table. A scroll container clips only the absolutely
* positioned boxes it contains; the others extend the document past the
* shell, a second scroll under the one the page already has.
*/
main: {
position: "relative",
flex: 1,
minHeight: { default: null, [WIDE]: 0 },
minWidth: { default: null, [WIDE]: 0 },
+7
View File
@@ -32,8 +32,15 @@
* chevron, and Segoe UI does not carry that glyph, so on Windows the stack
* has to reach a font that does before it falls back to a substitute box.
*/
/*
* `overflow-wrap: break-word` breaks a word only where it cannot fit its line:
* a group or source name is the user's text and can be one unbroken string
* wider than a phone. It leaves min-content alone, so a table cell or a flex
* item that holds such a name still needs `anywhere` of its own.
*/
html {
line-height: 1.5;
overflow-wrap: break-word;
-webkit-text-size-adjust: 100%;
/* The tokens carry both schemes; this tells the UA to match its own chrome — form controls, scrollbars. */
color-scheme: light dark;
+3 -1
View File
@@ -55,7 +55,8 @@ const styles = stylex.create({
/**
* The whole label is the hit area, so it carries the 44px pointer-target
* floor the dialog's Close button already sets, on both axes. The text is
* 20px tall, and a one-word source name is narrower than 44px again.
* 20px tall, and a one-word source name is narrower than 44px again. A
* source name can also be one unbroken string wider than a phone's line.
*/
label: {
minHeight: 44,
@@ -65,6 +66,7 @@ const styles = stylex.create({
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
overflowWrap: "anywhere",
},
box: {
flexShrink: 0,
+25
View File
@@ -0,0 +1,25 @@
import { Fragment, type ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
const styles = stylex.create({
fact: {
display: "inline-block",
},
});
/**
* Short facts joined by " · ". A narrow line breaks after a separator, and a
* fact moves to the next line whole: "1" and "occurrence" on two lines read as
* two facts. Only a fact wider than the whole line breaks inside itself.
*/
export default function FactLine({ facts }: { facts: readonly ReactNode[] }) {
return facts.map((fact, index) => (
<Fragment key={index}>
{index > 0 && " "}
<span {...stylex.props(styles.fact)}>
{fact}
{index < facts.length - 1 && " ·"}
</span>
</Fragment>
));
}
+12 -1
View File
@@ -264,10 +264,21 @@ export const styles = stylex.create({
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
/** Focusable: a table wider than a phone scrolls sideways, and a keyboard must be able to reach the scroll. */
/**
* Focusable: a table whose data is wider than its compact form still
* scrolls sideways, and a keyboard must be able to reach the scroll. It is
* also the size container each table's compact form queries.
*
* Positioned, so it is the containing block of the `srOnly` labels in its
* header cells. A scroll container clips only the absolutely positioned
* boxes it contains; an unclipped label at the far end of a wide table
* widens the whole document, and a phone then zooms the page out to fit it.
*/
tableWrap: {
position: "relative",
marginTop: "1rem",
overflowX: "auto",
containerType: "inline-size",
},
/**
* Not a faithful port: `space-y-3` set sibling margins on a block container,
+1 -1
View File
@@ -1,6 +1,6 @@
.{
.name = .nxdns,
.version = "0.0.18",
.version = "0.0.25",
.minimum_zig_version = "0.16.0",
.paths = .{""},
.fingerprint = 0x3307b311dded1d91,
+6 -5
View File
@@ -1,10 +1,11 @@
services:
nxdns:
# Published multi-architecture image. Pin an exact version in production:
# NXDNS_VERSION=0.0.1 docker compose up -d. 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:-latest}}
# Published multi-architecture image. The default is the release this file
# is tagged with: `zig build cut` moves it in the bump commit, so the file
# 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.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, 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,86 +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. It does not exist. It is a recorded deferral (`specs/milestone-14.md` ruling 12), not something nobody thought of, and until it exists no document here describes the build as reproducible: nobody has measured whether it is. The cheap inputs to reproducibility are already in place — `gzip -n`, `--mtime=@0`, `LC_ALL=C`, `TZ=UTC`, exact Zig and Node pins — which makes the gate cheap to add later and proves nothing on its own.
What the release pipeline is required to hold to is narrower: two runs of `zig build dist` on the same commit **in the same directory** produce byte-identical tarballs. Same-directory determinism is a much weaker property than reproducibility, and conflating the two is exactly the kind of claim this page exists to refuse.
[Verify a release](../how-to/verify-a-release.md) states the same limits where an operator will actually meet them, and gives the rebuild-and-compare recipe with the caveat that a differing hash is not evidence of tampering while this gap is open.
+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.
+61 -200
View File
@@ -1,89 +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: the failure modes, the run and the two checks in step 3 were run
> on the machine that wrote an earlier revision of this page, against an image
> built from this checkout rather than pulled from the registry — no release is
> published yet, so nothing on this page could be run against a pulled image,
> and step 1 could not be run at all. One command was run in altered form: host
> port 8080 was occupied there, so the run and the verification commands in
> step 3 were executed with the host side of the port mappings moved to 25353
> and 28088 rather than the 53 and 8080 printed below. The container side was
> unchanged. See the note in step 3. The `chown` to uid 65532 needs root and was
> not run.
>
> **Not re-run 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.
> Not verified on this host: no image and no release are published yet, so
> `docker pull` and every URL here fail today, and the `releases/latest` lookup
> returns 404 and leaves `VERSION` empty. That lookup was run against
> `gitea.com/gitea/tea` on Gitea `1.27.0+dev` and printed `0.15.1`. The
> `docker buildx imagetools inspect --format` shape was run here against
> `alpine:3.22` on Docker Hub and printed that image's index digest.
## 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
```
> Not verified against nxdns: there is no tag yet. The Gitea raw-file URL shape
> `<repo>/raw/tag/<tag>/<path>` was run here against `gitea.com/gitea/tea` on
> Gitea `1.27.0+dev` and returned the file with a 200.
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
.{
@@ -93,170 +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
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
docker compose -f deploy/docker/compose.yaml logs -f
```
`compose.yaml` reads the image from two variables: `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}`. Set `NXDNS_VERSION` to pin a release; set `NXDNS_IMAGE` to run something else entirely, which is what the build-from-source section at the bottom does.
> Verified on this host with `docker compose -f deploy/docker/compose.yaml
> config`, which resolves the variables without contacting a registry: no
> variables gives `git.mial.net/mokhtar/nxdns:latest`, `NXDNS_VERSION=0.0.1`
> gives `git.mial.net/mokhtar/nxdns:0.0.1`, and `NXDNS_IMAGE=nxdns` gives
> `nxdns`.
Every block on this page runs from the repository root, and none of them change directory, so they can be pasted in order. `-f` is what makes that work: Compose resolves the relative paths inside `compose.yaml` — the `etc-nxdns` bind mount — against the directory holding the file, not against your shell, and it takes the project name `docker` from that directory either way, which is why the container is `docker-nxdns-1`.
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 +45 ~0 -0;
settings keys changed: dns.bind_ipv4 dns.bind_ipv6 dns.port web.bind web.port …
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +46 ~0 -0;
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/
```
> On the machine that wrote this page, host port 8080 was already taken by an
> unrelated process, so the container was verified with the host side of the
> port mappings moved to 25353 and 28088 — everything inside the container was
> unchanged, and the log still reads `serving on udp [::]:53`. Against those
> ports `dig` returned the A records for `example.com` and `curl` returned 200.
> `docker compose up -d` fails with
> `failed to bind host port 0.0.0.0:8080/tcp: address already in use` when a
> host port is occupied; free the port or edit the `ports:` list. The image
> under test was built locally, not pulled: there is nothing published to pull.
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
```
> Not verified on this host: nothing is published to pull, and this host is
> x86_64 with no emulation, so an arm64 image could not be started here even
> if it were.
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).
+74 -42
View File
@@ -1,78 +1,110 @@
# Install nxdns with Nix
# Install with Nix
Adds nxdns to a NixOS machine as a flake input pinned to a release tag. At the end `pkgs`-style references to `inputs.nxdns.packages.${system}.default` resolve to the published release binary, and Renovate 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`. The consumer snippets in step 3 and step 4 are copied from the shape `rpi.mial.net` already uses for another flake input of the same author; they were not evaluated from this checkout, which is not a NixOS configuration.
## 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
```
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:
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. Use the package in a NixOS configuration
Pass the flake inputs to the module system, then reference the package:
```nix
{ inputs, pkgs, ... }:
{
environment.systemPackages = [ inputs.nxdns.packages.${pkgs.stdenv.hostPlatform.system}.default ];
```zon
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}
```
The derivation installs `bin/nxdns`, plus `LICENSE` and `THIRD-PARTY-NOTICES` under `share/doc/nxdns`. It sets `meta.mainProgram`, so `lib.getExe` resolves to the binary. Run it as a service with the unit from [install with systemd](install-with-systemd.md), or write your own module around it.
Import the module and enable it:
## 4. Let Renovate bump the tag
```nix
{ inputs, ... }:
{
imports = [ inputs.nxdns.nixosModules.default ];
Enable the Nix manager in the consuming repository's `renovate.json`:
services.nxdns = {
enable = true;
configFile = ./nxdns-config.zon;
openFirewall = true;
};
}
```
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, 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. 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:
- **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. 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.
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`.
## 3. Upgrade
Read the release notes, change the tag in the input URL, then:
```sh
nix flake lock --update-input nxdns
nixos-rebuild switch
```
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
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"nix": {
"enabled": true
}
"customManagers": [
{
"customType": "regex",
"managerFilePatterns": ["/^flake\\.nix$/"],
"matchStrings": ["git\\+https://git\\.mial\\.net/mokhtar/nxdns\\.git\\?ref=refs/tags/(?<currentValue>v\\d+\\.\\d+\\.\\d+)"],
"depNameTemplate": "mokhtar/nxdns",
"datasourceTemplate": "gitea-tags",
"registryUrlTemplate": "https://git.mial.net",
"versioningTemplate": "semver"
}
]
}
```
Renovate reads the flake inputs and the lock file, sees the `refs/tags/v0.0.16` ref, and opens a pull request when a newer tag exists. It 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 -289
View File
@@ -1,133 +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 could not be run at all: this repository has no tags
> and no published release, so every release URL on this page is a 404 today.
> Its commands are the ones [Verify a release](verify-a-release.md) covers in
> full, and the URL shapes — including the `releases/latest` redirect the
> version is read from — were probed there against `gitea.com`, a public
> instance running the same Gitea series.
>
> 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
> Not verified on this host: no release exists yet, so none of these commands
> could be run against one — the `releases/latest` lookup returns 404 for this
> repository and leaves `VERSION` empty. The same lookup was run against
> `gitea.com/gitea/tea` on Gitea `1.27.0+dev` and printed `0.15.1`.
## 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
.{
@@ -137,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
@@ -278,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
@@ -302,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
@@ -324,100 +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.
> Not verified on this host: no release exists to download, `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.
-119
View File
@@ -1,119 +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.
## 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.
-375
View File
@@ -1,375 +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.
## 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.
+50 -309
View File
@@ -1,366 +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.
>
> Step 2 could not be run at all: no nxdns release is published yet, so every
> release URL and the `docker compose pull` on this page fail today. The URL
> shapes and the verification commands are covered by
> [Verify a release](verify-a-release.md), which says what was probed and how.
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.
> Not verified on this host: no release exists yet, so the `releases/latest`
> lookup returns 404 and leaves `VERSION` empty, and every `curl` below it is a
> 404 too. The lookup form was run against `gitea.com/gitea/tea` on Gitea
> `1.27.0+dev` and printed `0.15.1`.
## 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 fetch the new release's copy over yours, then pull and recreate the container:
```sh
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml pull
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
cd ~/nxdns
curl -fLO https://git.mial.net/mokhtar/nxdns/raw/tag/v<version>/deploy/docker/compose.yaml
docker compose pull
docker compose up -d
```
Compose recreates the container against the same `nxdns-data` volume. What happens to the file in `etc-nxdns` depends on the `command:` in your compose file: with the shipped `run --config=/etc/nxdns/config.zon` the file is the configuration and the restart reconciles onto it; without it, the database in the volume is the configuration and the file is read by nothing.
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`.
Set `NXDNS_VERSION` on both lines, or export it. Without it the compose file falls back to `:latest`, and `pull` and `up` could then land on different images if a release happens between them.
The container is recreated on the same `nxdns-data` volume, so the configuration and history stay.
> Not run on this host: `pull` needs a published image, and there is none.
> What was run is `docker compose -f deploy/docker/compose.yaml config`, which
> resolves the variables without contacting a registry: `NXDNS_VERSION=0.0.1`
> gave `image: git.mial.net/mokhtar/nxdns:0.0.1`, and no variable at all gave
> `:latest`.
### Nix
## 4. Confirm the upgrade
Change the tag in the flake input and switch; see [Install with Nix](install-with-nix.md#3-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`.
+63 -208
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,54 +42,32 @@ 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) <mokhtar@mial.net>"
> 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) <mokhtar@mial.net>" [unknown]
gpg: Good signature from "Mokhtar Mial (pc) <…>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
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) <mokhtar@mial.net>" [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 against `v0.0.1`, before the build was reproducible: the recipe ran from a fresh clone, `git verify-tag v0.0.1` printed `Good signature` under the release subkey, and the rebuilt tarball hashes did not match the published `SHA256SUMS.txt` (Node 24.14.1 against the pinned 24.19.0, a different build path, and an archive written by the host's `tar`). Releases from 0.0.17 on are built and checked by the pinned pipeline this page describes, and the cut records the local hashes in `flake.nix` before CI rebuilds them.
## 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).
+208 -330
View File
@@ -1,524 +1,402 @@
# 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 smallest file that starts is a `default` group and one upstream:
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.
```zon
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}
```
## What is not in the file
## Settings
Storage paths are process arguments, not configuration:
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 }`.
| 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 }`.
Some settings take effect only after a restart; the web interface marks them **needs restart**.
### upstream
Timeouts for talking to upstream resolvers.
How long nxdns waits for 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 |
| 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. |
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.
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 (UDP and TCP).
The plain DNS listener, over 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 |
| 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. |
`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.
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 | 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 |
| 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 | 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 |
| 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'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.
The cache cannot be turned off; the smallest is `cache.size = 1`.
### web
The web interface and REST API.
The web interface.
| 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 |
| 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
The DNS-over-HTTPS listener (server side, for clients on the LAN). See [enable DoH and DoT](../how-to/enable-doh-and-dot.md).
DNS over HTTPS for clients on your network. 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 |
| 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
The DNS-over-TLS listener. Same shape as `doh_server`; only the default port differs.
DNS over TLS for clients on your network.
| 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 |
| 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 | 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 |
| 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
Process log and query log behavior.
The process log and the query history.
| 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 |
| 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. |
#### What `query_log_flush_interval_s` costs and buys
#### Why the query history is written once a minute
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.
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.
What it costs:
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.
- **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.
`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. 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.
Free-space thresholds for the data directory. DNS keeps working whatever the free space.
| 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 |
| 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 | 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 |
| 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. |
## Collections
## Lists
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.
The rest of the configuration is lists of entries. Fields with no default are required.
### 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.
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 | Validation |
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `name` | string | required | non-empty, unique |
| `safe_search` | bool | false | — |
| `name` | string | required | not empty, at most 64 bytes, 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.
`safe_search` makes search engines that support it return their safe results to the group's clients.
### upstreams
Upstream resolvers. At least one enabled upstream is required.
The resolvers nxdns forwards queries to, over DoH (`https://`) or DoT (`tls://`). At least one must be enabled.
| Field | Type | Default | Validation |
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `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 |
| `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 |
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`).
nxdns tries the upstreams in order of priority and moves to the next when one fails.
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`:
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:
```zig
```zon
.{ .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.
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
Known clients with a fixed group assignment.
Devices with a fixed group, by address.
| Field | Type | Default | Validation |
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `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 |
| `ip` | string | required | an IP address; unique |
| `name` | string | `""` | the name shown in the web interface |
| `group` | string | `"default"` | a declared group |
Consumed by the filter engine's exact address-to-group lookup (`src/filter/matcher.zig`).
#### Names nxdns learns
#### Learned names
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:
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.
```zon
.forward_zones = .{
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
},
```
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.
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
Group assignment by CIDR prefix, for clients without an exact entry.
Groups for whole address ranges, for devices without a `clients` entry.
| Field | Type | Default | Validation |
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `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) |
| `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 |
Consumed by the filter engine's longest-prefix match (`src/filter/matcher.zig`).
A `clients` entry beats any range, and a narrower range beats a wider one.
### blocklist_sources
Downloadable blocklists.
Blocklists nxdns downloads.
| Field | Type | Default | Validation |
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `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) |
| `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 |
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 source blocks nothing until a group uses it through `group_sources`; `nxdns check` warns about one that no group uses.
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.
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.
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`.
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 groups consult which blocklist sources.
Which group uses which blocklist.
| Field | Type | Default | Validation |
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `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.
| `group` | string | required | a declared group |
| `source_url` | string | required | the `url` of a declared blocklist source; each pair once |
### rules
Per-group allow and block overrides, checked before the blocklists.
Your own allow and block rules for a group. They are checked before any blocklist.
| Field | Type | Default | Validation |
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `group` | string | required | must name a declared group |
| `group` | string | required | a declared group |
| `pattern` | string | required | see below |
| `kind` | enum `.exact` \| `.wildcard` \| `.regex` | required | — |
| `action` | enum `.allow` \| `.block` | required | — |
| `kind` | `.exact`, `.wildcard` or `.regex` | required | |
| `action` | `.allow` or `.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.
- `.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 `$`.
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 `$`.
Exact rules are checked first, then wildcards, then regexes; within each kind, allow rules win over block rules.
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.
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
Local DNS answers, served without touching any upstream.
Answers nxdns gives itself, without asking an upstream.
| Field | Type | Default | Validation |
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `name` | string | required | a valid domain name |
| `rtype` | enum `.a` \| `.aaaa` \| `.cname` | required | stored as `A` / `AAAA` / `CNAME` |
| `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` | u32 | 300 | 1–604800 seconds |
| `ttl` | integer | 300 | 1–604800 s |
The (name, rtype, value) triple is unique. Consumed by the local records table in the query path (`src/local/records.zig`).
Each name, type and value together appear once.
### forward_zones
Zones resolved by a specific resolver instead of the configured upstreams, for LAN or corporate domains.
Domains that go to a resolver of your choice instead of the upstreams, such as your router's local names.
| Field | Type | Default | Validation |
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `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 |
| `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 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.
The most specific zone wins.
Reverse zones are declared the same way, and one is the prerequisite for [learned client names](#learned-names):
```zig
```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" },
},
```
## Password and hash
## Passwords
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".
`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 hash |
| The file says | What happens to the stored password |
| --- | --- |
| 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). |
| 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. |
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:
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 web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
FAIL groups: no group named 'default'; every unknown client is assigned to it
FAIL rules[0].pattern: '(' is not a valid regex pattern
```
- `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 `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.
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.
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`.
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.
## A fuller example
```zon
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://dns.quad9.net/dns-query" } },
}
```
.dns = .{ .port = 53, .rate_limit = 1000 },
## Fuller annotated 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,
},
// 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://dns.quad9.net/dns-query", .priority = 10 },
.{ .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.
+14 -4
View File
@@ -4,14 +4,14 @@
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ nixpkgs, ... }:
{ self, nixpkgs, ... }:
let
# `zig build cut` rewrites the lines between the delimiters by text replacement.
# BEGIN GENERATED BY zig build cut
version = "0.0.18";
version = "0.0.25";
hashes = {
"aarch64-linux" = "sha256-7TmuJr6TkgRdPkYvNiEOFVfkzkp+djjWSml+vAUee78=";
"x86_64-linux" = "sha256-uBWjl8FvP4VgPisJgydNazyPu40jjQa7+i64+M/B50A=";
"aarch64-linux" = "sha256-zK2i51cRX+5q+9rIRav4m6NhVZJJYwg9OJj0WstKd/M=";
"x86_64-linux" = "sha256-tJ0/mA0Zhic0SqYhwarNKLlK0s5QBQEzN/52QUGxYv0=";
};
# END GENERATED BY zig build cut
@@ -46,6 +46,9 @@
runHook preInstall
install -Dm755 nxdns $out/bin/nxdns
install -Dm644 nxdns.service $out/lib/systemd/system/nxdns.service
substituteInPlace $out/lib/systemd/system/nxdns.service \
--replace-fail "ExecStart=/usr/local/bin/nxdns run" "ExecStart=$out/bin/nxdns run"
install -Dm644 LICENSE $out/share/doc/nxdns/LICENSE
install -Dm644 THIRD-PARTY-NOTICES $out/share/doc/nxdns/THIRD-PARTY-NOTICES
@@ -110,5 +113,12 @@
packages = nixpkgs.lib.genAttrs systems (system: {
default = package system;
});
nixosModules.default = import ./nix/module.nix { inherit self; };
checks.x86_64-linux = import ./nix/tests.nix {
inherit self;
pkgs = nixpkgs.legacyPackages.x86_64-linux;
};
};
}
+105
View File
@@ -0,0 +1,105 @@
# `services.nxdns`: the packaged unit, run in file mode against a configuration
# that the pinned binary accepted at build time.
#
# The unit is `deploy/systemd/nxdns.service` from the release tarball, installed
# by the package and picked up through `systemd.packages`. This module only adds
# a drop-in, so the hardening lives in that one file and a change to it reaches
# NixOS with the release that ships it.
{ self }:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.nxdns;
# Nix treats the ZON as opaque data, so without this a configuration the
# pinned binary rejects evaluates, builds and deploys, and fails only when
# the service starts. `import` into a throwaway data directory is the offline
# validator: it shares the parser, the validator and the reconcile engine
# with `run`, and its delete gate is inert on an empty database. `check` is
# not usable here: it probes every upstream, and the build sandbox has no
# network.
validatedConfig =
pkgs.runCommand "nxdns-config.zon"
{
nativeBuildInputs = [ cfg.package ];
src = cfg.configFile;
}
''
mkdir -p "$TMPDIR/validate"
nxdns import --data-dir "$TMPDIR/validate" "$src"
cp "$src" "$out"
'';
in
{
options.services.nxdns = {
enable = lib.mkEnableOption "nxdns, a DNS sinkhole for a household network";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalExpression "nxdns.packages.\${pkgs.stdenv.hostPlatform.system}.default";
description = "The nxdns release to run. The default is the release of the flake revision this module comes from.";
};
configFile = lib.mkOption {
type = lib.types.path;
example = lib.literalExpression "./nxdns-config.zon";
description = ''
The nxdns configuration, in ZON. nxdns runs in file mode: every start
reconciles the database onto this file, and the web interface refuses
configuration edits. The build fails when `nxdns import` from
`package` rejects the file.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open port 53 over UDP and TCP. The web interface port and the DoH and DoT ports stay closed.";
};
};
config = lib.mkIf cfg.enable {
environment.etc."nxdns/config.zon".source = validatedConfig;
users.users.nxdns = {
isSystemUser = true;
group = "nxdns";
description = "nxdns DNS sinkhole";
};
users.groups.nxdns = { };
systemd.packages = [ cfg.package ];
systemd.services.nxdns = {
wantedBy = [ "multi-user.target" ];
# The unit file does not change when only the configuration does, so
# without this a configuration-only switch restarts nothing and the old
# configuration keeps serving with no error anywhere.
restartTriggers = [ validatedConfig ];
serviceConfig = {
# The empty entry clears the packaged ExecStart: a drop-in cannot
# replace it otherwise.
ExecStart = [
""
"${lib.getExe cfg.package} run --config=/etc/nxdns/config.zon"
];
# `environment.etc` creates /etc/nxdns and owns what is in it. A
# ConfigurationDirectory would make systemd adjust the same directory
# at every start.
ConfigurationDirectory = "";
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedUDPPorts = [ 53 ];
allowedTCPPorts = [ 53 ];
};
};
}
+97
View File
@@ -0,0 +1,97 @@
# The acceptance checks for `services.nxdns`. Neither runs in CI: the VM test
# needs KVM, and both need a nixpkgs closure the shared runner should not
# download on every push. AGENTS.md says when to run them.
{ self, pkgs }:
let
lib = pkgs.lib;
# A local record answers without any upstream, which the test VM cannot reach.
configWith =
address:
pkgs.writeText "nxdns-test.zon" ''
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "tls://192.0.2.1:853", .tls_name = "dns.example" } },
.local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "${address}" } },
}
'';
rejected = pkgs.writeText "nxdns-rejected.zon" ''
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "tls://192.0.2.1:853", .tls_name = "dns.example" } },
.rules = .{ .{ .group = "default", .pattern = "(", .kind = .regex, .action = .block } },
}
'';
machineWith = configFile: {
imports = [ self.nixosModules.default ];
services.nxdns = {
enable = true;
inherit configFile;
};
boot.loader.grub.enable = false;
fileSystems."/" = {
device = "none";
fsType = "tmpfs";
};
system.stateVersion = lib.trivial.release;
};
in
{
nixos-module = pkgs.testers.runNixOSTest {
name = "nxdns-module";
nodes.machine =
{ pkgs, ... }:
{
imports = [ self.nixosModules.default ];
services.nxdns = {
enable = true;
configFile = configWith "192.0.2.5";
openFirewall = true;
};
environment.systemPackages = [ pkgs.dnsutils ];
specialisation.changed.configuration.services.nxdns.configFile = lib.mkForce (
configWith "192.0.2.6"
);
};
testScript = ''
machine.wait_for_unit("nxdns.service")
machine.wait_until_succeeds("dig +short @127.0.0.1 nas.lan A | grep -x 192.0.2.5")
# The packaged unit runs, with the module's drop-in on top of it.
unit = machine.succeed("systemctl cat nxdns.service")
assert "/lib/systemd/system/nxdns.service" in unit, unit
assert "ProtectSystem=strict" in unit, unit
assert "run --config=/etc/nxdns/config.zon" in unit, unit
machine.succeed("systemctl show -p User --value nxdns.service | grep -x nxdns")
machine.succeed("test -z \"$(systemctl show -p ConfigurationDirectory --value nxdns.service)\"")
machine.succeed("systemctl show -p ReadOnlyPaths --value nxdns.service | tr ' ' '\\n' | grep -Fx /etc/nxdns")
machine.succeed("stat -c %U /etc/nxdns | grep -x root")
machine.succeed("iptables -S nixos-fw | grep -- '-p udp .*--dport 53 '")
machine.succeed("iptables -S nixos-fw | grep -- '-p tcp .*--dport 53 '")
# A switch that changes only the configuration restarts the service.
before = machine.succeed("systemctl show -p InvocationID --value nxdns.service").strip()
machine.succeed("/run/current-system/specialisation/changed/bin/switch-to-configuration test")
machine.wait_for_unit("nxdns.service")
after = machine.succeed("systemctl show -p InvocationID --value nxdns.service").strip()
assert before != after, "a configuration-only switch did not restart nxdns"
machine.wait_until_succeeds("dig +short @127.0.0.1 nas.lan A | grep -x 192.0.2.6")
'';
};
# A configuration the pinned binary rejects must fail the system build, and
# the build log must carry the binary's own reason.
nixos-module-rejects-config =
let
system = pkgs.nixos (machineWith rejected);
failure = pkgs.testers.testBuildFailure system.config.environment.etc."nxdns/config.zon".source;
in
pkgs.runCommand "nxdns-module-rejects-config" { } ''
grep -F 'FAIL rules[0].pattern' ${failure}/testBuildFailure.log
touch "$out"
'';
}
+104
View File
@@ -0,0 +1,104 @@
# 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. 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 |
|---|---|---|
| Blocklist snapshot | 71 MiB, of which ~32 MiB is ABP domains stored twice | Pi smaps; `.list` and `.wild` bodies of the ABP sources are byte-identical (md5) |
| Fixed buffers | ~52 MiB: query-log writer queue 17.5, two TCP listeners 2 × 8.7, two UDP listeners 2 × 4.8, SSE hub 3.6, cache slots 3.3 | Sizes from source; resident at once because safe builds fill new allocations with 0xaa |
| Idle worker threads | ~41 MiB for ~200 parked workers | `std.Io.Threaded` never retires a worker; one burst of 64 in-flight UDP queries × 3 tasks each grows the pool for good (replica: 14 → 206 threads) |
| Heap slabs kept after peaks | ~61 MiB free + ~8 MiB live | Decoded `SmpAllocator` free lists from two `/proc/<pid>/mem` dumps; owners from each slot's size header and a local tracing build |
| Peak during refresh | +120 MiB over steady state | Old snapshot + new snapshot + every compiled body read at once; replica reproduced 350 MiB |
Heap owners, from the dumps and the trace:
- 8 KiB class, 25 MiB free: SQLite page-cache blocks (4,376 B requests in 8 KiB slots). Seven connections, no `cache_size` pragma, so each keeps the 2 MiB default, which costs ~3.7 MiB of slabs once filled.
- 128 B + 256 B classes, ~21 MiB free: `std.Io.Threaded` `Group.Task` blocks — one per UDP query (`UdpServer.receiveLoop`), two per upstream exchange (`transport.raceUntilTagged`), two per query-log wait (`Logger.getWithin`).
- 512 B class, 7.7 MiB free: `DnsCache.put` copies of cached responses (`src/cache/dns_cache.zig:356`).
Why freed small blocks stay resident: with libc linked, `malloc` is zig's `lib/c/malloc.zig` on `std.heap.SmpAllocator`, which never unmaps a 64 KiB slab. `free` pushes onto the freeing thread's slot and `alloc` searches one other slot before it maps a new slab (`max_alloc_search = 1`), so producer/consumer pairs map more slabs than their live peak needs. Upstream: ziglang/zig#23592. Fewer tasks and smaller peaks shrink this retained memory; nothing in this spec frees it after the fact.
## Decisions
- 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 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 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. 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. 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
Within one source, as compiled entries, an exact `x` in `.list` together with `x` in `.wild` means exactly what `||x^` compiles to. The compiled files, their checksum and their counts stay as they are.
- The snapshot builder merges one source's sorted `.list` and `.wild` bodies into one name set with two flags per name: `exact` and `subdomains`. A name in both bodies is stored once. Sources are never merged with each other, so group eligibility and the reported source do not change.
- `.allow` stays a separate set, and level 7 (exceptions) still runs before both flag checks.
- Level 8 tests the full name for `exact`; level 9 tests proper parents for `subdomains`. Sources still walk in ascending index order. The reported result stays what it is today: when the `x` entry wins, the apex reports `blocklist_domain` with matched `x` and a proper subdomain reports `blocklist_wildcard` with matched parent `x`. An exception, an exact entry for the queried name or a nearer matching parent still wins first.
### 2. Lower the refresh and boot peak
- Boot keeps one synchronous initial load before the listeners start (`src/app.zig:692`). The startup pass uses that result for its refresh decisions and reloads only after a source actually refreshed, instead of rebuilding unconditionally (`src/filter/manager.zig:1823`).
- `Snapshot` gets a pure `Builder`: add one source's bodies, then free them before reading the next. The old snapshot is still freed only after the swap, so a reload holds at most old snapshot + new snapshot + one source's bodies. That is the reload bound, not the process peak: refresh preparation (download, compile) can run beside a reload and holds its own allocations, and the Pi peak measurement includes it.
### 3. Bound concurrent DNS work
- 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
- The writer queue default (`logging.query_log_buffer_max`) comes from the item-0 occupancy high-water, with headroom stated in the reference. Drop-oldest backpressure and its counter stay.
- The SSE hub allocates a subscriber's ring on subscribe and frees it on unsubscribe; `max_subscribers` stays a cap, not a reservation. `subscribe` becomes fallible: an allocation failure is distinct from "all slots occupied", is counted, answers with its own error rather than "live stream is full" (`src/web/handlers/live.zig:75`), and leaves the slot reusable. Overflow keeps today's contract: the hub ends that subscriber's stream so `EventSource` reconnects (`src/web/sse.zig:8`).
### 5. SQLite caches chosen per connection
- An explicit runtime-connection initialization step sets `cache_size` from a constant per role (query-log writer, retention, readers, config). `Db.open` stays free of pragmas, because migration callers control when pragmas apply (`src/storage/db.zig:302`). Every runtime handle reads its value back.
### 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.
- 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: 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: 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
- [ ] Matcher equivalence: a deterministic corpus (hosts, domains, ABP with `||x^`, `*.x` alone, `x` alone, `x` with `*.x`, `@@`, an exact entry for a subdomain of a wildcard, nested wildcards `*.x` and `*.y.x`, overlapping sources) gives the same `Decision` for every probe name before and after item 1.
- [ ] 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 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 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.
+36
View File
@@ -810,3 +810,39 @@ The implementation matches the spec with these review-driven refinements (three
- `dot_client.zig`: handshake `ReadFailed`/`WriteFailed` unwrap the stream's stored cause through `transport.mapLocal` first, and certificate-bundle `OutOfMemory` is a local resource, not a peer fault.
- `transport.zig`: `Endpoint.parse` rejects userinfo/query/fragment delimiters (`@`, `?`, `#`) in the authority, and `?`/`#` in a DoH path.
- `dot_client.zig` (follow-up, tls_name commit): `DotClient.init` takes a `tls_name`; it is the SNI and certificate-verification name, while the dial target stays `endpoint.host`. Empty keeps the endpoint host, which is the behavior described above. The same follow-up fixed a send bug this file had from the start, invisible until a DoT handshake first succeeded: `tls.Client.flush` only encrypts into the socket writer's buffer and never flushes it, so the query never left the process and the peer eventually closed the connection (`ReceiveFailed`/`EndOfStream`). `TlsStream.flush` now does both flushes and `exchange` calls it; a hermetic loopback test in `tls_client_integration_test.zig` covers it.
## Addendum (2026-09-12): an upstream episode follows health, and a peer fault carries its cause
Observed on the Pi over three days (nxdns 0.0.17 to 0.0.19): 20 `upstream.exchange` warning episodes, 17 with one occurrence, 15 resolved within ten seconds; since the 0.0.20 restart 1229 successes, 0 failures, 401 silent stale-session redials. `Pool.recordFailure` reports an episode on every failed exchange and `recordSuccess` resolves it on the next success, while `health.State` trips backoff only at `failure_threshold` consecutive failures. Two rules over one failure stream, and the Diagnostics page shows the looser one. Second gap: the episode detail is `upstream 'tls://1.1.1.1:853' failed: SendFailed`; the DoT client unwraps the concrete cause into `Transact.Failure.cause` and then returns a bare `PeerFault` error to the pool, so the cause is lost before anything records it. Codex reviewed the design (thread 01a09648) and its eight findings are folded in below.
### 1. Health owns "failing"; the episode is a projection of its transitions
- `health.State.recordFailure(at, fault, cfg, rand)` and `recordSuccess(at, cfg)` return an `Effect`: `{ revision: u64, state: union(enum) { clear, tripped: Fault } }`. `revision` is a per-state counter incremented by every mutation. The effect is the complete desired state of the episode after the mutation, never a transition: `tripped` when `consecutive_failures >= cfg.failure_threshold` (the predicate line 142 already uses, exposed as `tripped(cfg)`; not `available()`, which turns true when a backoff expires before recovery is proven), `clear` otherwise. The fault in `tripped` is the effective last fault the state holds after the mutation, not the incoming one: a stale failure (case 1 at `recordFailure`) never displaces a newer cause, so the episode detail cannot regress to an older one. A tagged union, not an action enum with a payload field, so a report without a fault is unrepresentable.
- The pool projects every effect onto the store: `tripped` reports, `clear` resolves. A resolve when nothing is open issues no SQL (`storage/events.zig`), so the steady state costs nothing. Because every effect carries the whole desired state, last-writer-wins by revision is correct: the pool applies effects per entry in revision order under a separate `diagnostics_mutex` with a per-entry `applied_revision`, and drops an effect whose revision is below the applied one. Codex's reordering case (a failure trips health, a newer success clears it and its resolve reaches the store first, then the delayed failure arrives) is dropped instead of opening an episode nothing will resolve; and the converse cases, where a `none` transition would have overtaken a required report or resolve, cannot occur because there is no `none`. Health mutates under `Pool.mutex` as today and the effect is computed there; the store call runs after that mutex is released. Lock order is pool mutex, then nothing; diagnostics mutex, then store mutex. No task holds the pool mutex while it takes either of the other two.
- Projection also reconciles a latched store failure for what an entry owns: a `clear` that failed to write is retried by the next `clear` the entry projects, and a `tripped` by the next failure. What no entry owns is handled by the reconciliation points below.
- A success that leaves the state tripped (a stale success behind a newer failure, health case 1) projects nothing: `recordSuccess` returns `?Effect`, null in that case. The card is already open from the failure that tripped it, a report on a success would count a successful exchange as an occurrence, and a no-op that advanced the revision would reintroduce the dropped-report hole. Such a success does not touch `applied_revision`.
- Reconciliation at the two points revision order cannot reach. (1) At boot, once the first generation is built: `store.resolveExcept(.upstream_exchange, kept = the enabled urls of the pool)` closes persisted episodes for upstreams that are gone, disabled or failed to build, and `Pool.reconcile(io)` projects every entry's current effect, which on a fresh pool resolves the rest. A re-asserted `tripped` must not count as an occurrence: the store gains `ensureOpen`, which opens the episode with the detail when none is active and otherwise leaves `occurrences` and `last_seen` untouched; `reconcile` uses it, a recorded failure keeps using `report`. (2) At every retirement of a displaced generation, both the pinned path (`Owner.release` at `refs == 0`, the point after which no exchange of it can still project) and the idle path (`Owner.replace` handing a zero-ref generation back to the caller), the same two calls run against the current generation. One owner function does the retire-and-reconcile and both paths call it, so a third retirement site cannot forget it. A removed or disabled upstream's episode is closed at the next reconciliation point; if the store had latched a failure at that moment, the next reconciliation point retries it. Before this addendum such an episode was never closed at all. `resolveExcept` is the right call for this code: the pool is the only reporter of `upstream_exchange`, unlike `configuration.load`, whose scoped rule in owner.zig stays. The previous "one exchange of lag" claim is withdrawn; the retirement point is exact.
- Occurrences are the count of applied reports. Under concurrent reordering an older report behind a newer one is dropped, so the count can undercount; the health counters on `/metrics` are exact. Stated, not fixed: exact occurrence counts would need the store to accept out-of-order increments, and nobody reads the count as a metric.
- The store folds a repeated report into the open episode (`occurrences`, `last_seen`, detail), so failures during backoff raise counts on one card. A warning card now means exactly what the pool means: it stopped trusting this endpoint.
- Boundary, stated on purpose: an endpoint failing every other exchange never trips, because every newest success clears the count, so it opens no episode and `/api/health` stays `ok`. The rolling success rate on `/metrics` shows it. Diagnostics is for episodes, not chronic rates; a second predicate for that is not added.
- No new configuration. The threshold that exists is the threshold, and it is at least 1: `health.Config` is validated where it is constructed (a comptime check on the default; if it is user-configurable, the config loader refuses 0), because a tripped state with no recorded failure has no fault to report.
### 2. A peer fault is a value between the leaf client and the pool
- `transport.zig` gains `Fault = struct { kind: PeerFault, cause: anyerror }`, `Outcome = union(enum) { reply: []u8, fault: Fault }`, and a leaf interface `Leaf` with `exchangeFn(ptr, io, query, response_buf) (LocalResource || Cancellation)!Outcome`. `kind` is the taxonomy as today; `cause` is the concrete unwrapped error (`BrokenPipe`, `ConnectionResetByPeer`, `TlsAlert`, `HttpConnectionClosing`, ...). No phase field: the taxonomy already names it for every kind that has one, and `TlsFailed` cannot say where it failed. No HTTP status number for `HttpStatus`; the claim is limited to concrete I/O causes. No optional metadata bag.
- `dot_client.zig` and `doh_client.zig` implement `Leaf`; a `Transact.Failure` becomes a returned `Fault` instead of a thrown error. The DoT stale-session redial is unchanged and still counted through `reuse_recoveries`, never as a fault. The retry list is not widened; with the cause recorded, a stale-session cause outside the four lifecycle errors shows in the next episode and the list grows on evidence.
- `Pool` consumes `Leaf` for its slots and keeps implementing `transport.Client` toward the handler, the forward client, and the fakes that sit on that side. The outer error set, `transport.group`, and the handler are untouched; the pool returns `last_fault.kind` as the error it returns today. The race harness stays generic: the pool races the leaf through a function whose error set adds `Timeout`; a leaf never returns `error.Timeout` itself (its own timeouts are faults), so `error.Timeout` out of the race is the harness. With `race == .expired and !truncated` the pool synthesizes `Fault{ .kind = error.Timeout, .cause = error.Timeout }` and records it: an untruncated attempt expiry is peer evidence. With `truncated` it stays `error.BudgetExhausted`, unattributed, exactly as today.
- Health stores the effective fault (kind and cause names, `error_name_capacity` sized for both). Every surface that printed the taxonomy prints one text: `<Kind> (cause <Cause>)`, e.g. `SendFailed (cause BrokenPipe)`. Those surfaces are the episode detail, `upstream 'tls://1.1.1.1:853' failed: SendFailed (cause BrokenPipe)`, `Snapshot.last_error` and its one reader, the `nxdns check` FAIL line, and the pool's debug `AttemptFailure` line. `/api/health`, `/api/upstreams` and `/metrics` carry no fault text today and gain none here.
- `transport.raceUntilTagged` becomes generic over the raced function's own error set: it returns `RacedError(f) || error{ Timeout, SystemResources, Canceled }`, so the pool's attempt wrapper returns `LeafError || error{Timeout}` with no `@errorCast` and no `unreachable`; `error.Timeout` out of the race is the harness by type, which sets `race = .expired` before returning it; the loop's catch asserts that and handles it in the expiry branch only, so a regression of the harness contract traps in ReleaseSafe instead of blaming an endpoint. The compiler enforces at the race boundary that a leaf cannot throw a `PeerFault` or `BudgetExhausted`. A leaf's own timeout is a returned `Fault{ .kind = error.Timeout, .cause = <leaf cause> }` with `race == .completed`, and a pool test proves it is recorded with the leaf's cause and never confused with the harness expiry.
- The blocklist fetcher's `last_failure` side field stays as it is; same shape, other subsystem, its own addendum.
### Tests
- health: one failure returns `clear`; the second returns `tripped`; a success on a tripped state returns `clear`; a stale success behind a newer failure projects nothing; a stale failure returns the newer effective fault; revisions strictly increase.
- pool: a `tripped` effect delivered after a newer `clear` effect is dropped and no episode is open afterwards; a `clear` delivered after a newer `tripped` is dropped and the episode stays open; an episode open in the store before the pool exists is resolved by boot reconciliation, one for a url the pool does not have and one for a url it has; a retired generation's late `tripped` is corrected by the reconciliation at its last release, and the idle-replace path reconciles too; a reconcile of a tripped entry with an open episode moves neither `occurrences` nor `last_seen`; a stale success while tripped projects nothing and the occurrence count does not move; one failed exchange opens no episode and the second opens one that the next success resolves; a truncated expiry records nothing and returns `BudgetExhausted`; an untruncated expiry records `Timeout (cause Timeout)`; a leaf fault of kind `Timeout` is recorded with the leaf's cause.
- clients: the DoT integration tests assert the returned `Fault` with kind and cause, not the kind alone; a DoH integration test drives `DohClient.exchange` against a loopback port that is bound but never listens and stays bound for the test's duration, so the connect is refused deterministically with no port reuse race, and asserts the cause survives to the returned `Fault`; the stub-connection helper tests keep their causes.
- surfaces: the `nxdns check` test asserts the `<Kind> (cause <Cause>)` text.
### Out of scope
An idle timer on the DoT session, a chronic-rate predicate, the fetcher migration, and any change to the DoT retry list.
+2
View File
@@ -81,6 +81,8 @@ Deviation (Session D): the pin stage is tested in pieces rather than on a fixtur
Signing the flake outputs, a Hydra or cachix binary cache, Darwin packages, and a NixOS module. Renovate tag discovery on Gitea is a consumer-side acceptance test run once on `rpi.mial.net` after the first tagged release with a flake.
Result of that test (2026-09-09, v0.0.18, Renovate 42.99.0): no pull request. Renovate's `nix` manager advances `flake.lock` along the ref an input already tracks and never moves a `refs/tags/vX.Y.Z` ref; it returned an empty release list for every flake input, GitHub-hosted ones included. The flake itself is correct and v0.0.17 deployed through it. Tag bumps need a regex custom manager with the `gitea-tags` datasource plus a lock refresh in the same pull request, which the consumer owns; `docs/how-to/install-with-nix.md` section 4 now says so instead of claiming the `nix` manager does it. With that manager in place, Renovate opened rpi.mial.net PR 5 the same day, bumping the input to v0.0.18 and refreshing `flake.lock`: the acceptance test passes.
## Acceptance
- `zig build test` green, including the two-path reproducibility test.
+23
View File
@@ -1377,3 +1377,26 @@ Deviations from the text above, recorded after the first six sessions verified.
**S9 integration + fuzz.** Case 3 splits into the two real failure modes: a checksum-mismatched file marks the source `.load_failed` and the reload succeeds without it (generation advances); only a checksum-clean but malformed body fails `reload` and leaves the previous snapshot serving. Case 7 proves the cap from the response head: an explicit `content-length` of 100 MiB returns `error.BodyTooLarge` before any body streams. Case 8 back-dates `last_updated` through `updateSourceStats` and asserts inode and mtime of the compiled file are unchanged. Case 15 asserts `budget/2 <= elapsed < 2*budget` (the POSIX backend wakes ~0.8 ms early on a 200 ms deadline). The fuzz corpus is inline — `tests/fuzz/corpus.zig` imports the `dns` module, which the blocklist-fuzz module does not have. Fuzz targets assert properties, not only absence of crashes: `Line.text` windows the input (pointer containment), `covers_apex` only on `.wildcard`, `detectFormat`'s answer survives `parseLine` over the same bytes, and `matches` is exercised on rejected patterns.
**Final wiring.** `build.zig` gained the `blocklist-fuzz` artifact (module import `parsers` → `src/filter/parsers.zig`, LLVM backend under `-Dfuzz`), hung off `test_step` beside the dns fuzz artifact. Evaluation: `zig build test`, `zig build test -Dintegration` and `zig build cross` all exit 0; both cross executables are statically linked.
## Addendum (2026-09-12): a download failure names its cause
Observed on the Pi (nxdns 0.0.17): four of eight sources fail on every refresh pass since 2026-09-11, the same four each time, and the log line for each is `download failed: TlsFailed` one pass and `download failed: ReceiveFailed` the next. The same binary loads all eight from another machine. The line cannot tell a TLS alert from a reset connection from a truncated chunk, because `fetcher.mapError` collapses the cause into the six-member taxonomy and `reportDownloadFailure` logs only the taxonomy name. The Diagnostics event carries the same text. A reader of either has nothing to act on. `upstream/doh_client.zig` already unwraps the concrete cause that `std.http.Client` stashes on the connection (`sendCause`, `headCause`, `bodyCause`, `readCause`); the fetcher does not.
Change:
- `fetcher.zig`: `Fetcher` gains `last_failure: ?Failure`, cleared at the top of `fetch` beside `last_status`, set on every error return. `Failure` is `{ phase: Phase, cause: anyerror, status: ?std.http.Status, bytes_read: u64 }`. `Phase` gains `receive_body`, distinct from the head. `cause` is the unwrapped concrete error: the three unwrap helpers move from `doh_client.zig` into `upstream/transport.zig` (or a sibling file both import; the coder picks the smaller diff), keep their tests, and both clients call them. The taxonomy `Error` and `mapError` are unchanged; a caller that ignores `last_failure` sees exactly what it saw before. `bytes_read` counts body bytes delivered to the writer before the failure, so a body that dies at byte 0 and one that dies at 5 MB read differently.
- `manager.zig`: `fetchWithin` measures elapsed milliseconds on the awake clock. `reportDownloadFailure` takes the failure and elapsed time and logs one line: `blocklist <label>: download failed: <taxonomy> (<phase>, cause <CauseName>, http <status or none>, <bytes> bytes, <ms> ms)`. On the `Timeout` outcome of the expiry race the losing fetch is cancelled before the line is built, and it records itself on the way out, so the line names the phase it was in, `cause Canceled`, the status if a head arrived, and the bytes delivered so far: `Timeout (receive_body, cause Canceled, http 200, 5242880 bytes, 300000 ms)`. `download` clears `last_failure` and `last_status` before anything else runs, so a null record means no fetch reached the network for this source (a local file failure, or no concurrency to spawn the race); that prints as `<taxonomy> (no fetch, http none, - bytes, <ms> ms)`. Elapsed is captured when the race resolves, before the loser is cancelled, so a timeout's milliseconds are the time the transfer was given. `SourceStatus.fail` receives the same compact text, so the Diagnostics event detail shows it; `max_error_len` rises from 128 to 192 so the longest cause name and the numbers fit without truncation (check the longest `std.crypto.tls.Client.ReadError` and `std.http.Client` error names; `events.Store.max_detail_len` is 512 and is not touched).
- Bound: this changes the content of an existing warning line, not its frequency. One line per failed source per pass, at most `sources × passes` per day; a pass runs once per `blocklist_update.interval_hours` or on a manual update. No per-chunk or per-retry logging is added. Nothing new is written at info or debug level.
- Tests: `mapError` tests unchanged; a test that a head failure with a stashed `ConnectionResetByPeer` yields `last_failure.cause == error.ConnectionResetByPeer` and phase `receive` (the doh tests' stub connection pattern); a test that a body failure records `bytes_read` and `receive_body`; a `reportDownloadFailure` format test with the fixed text `blocklist source 3 'ads' 'https://lists.example': download failed: ReceiveFailed (receive_body, cause HttpChunkTruncated, http 200, 1048576 bytes, 812 ms)`; the existing secrecy assertions on the label stay.
Out of scope: a TLS handshake failure stays `TlsInitializationFailed`, because `std.http.Client` collapses every handshake fault at `Client.zig:1470` before this code sees it; distinguishing an alert from a certificate failure would mean driving `std.crypto.tls.Client` by hand. Retries and a per-source backoff are not added.
## Addendum (2026-09-12): a blocklist download never reuses a pooled connection
The line above found the cause the same day nxdns 0.0.19 reached the Pi. A manual refresh at 14:28 failed the same four sources with one record each: `ReceiveFailed (receive, cause HttpConnectionClosing, http none, 0 bytes, 0 ms)`. Under one millisecond of elapsed time, and no response head byte after the request was sent, means the peer had already closed the socket. `std.http.Client` returns `HttpConnectionClosing` when a reused connection hits EOF before the first head byte (`std/http.zig:400`), and `std/http/test.zig:1331` pins that a client never retries a stale pooled connection. The startup pass at 13:03 had fetched `big.oisd.nl` successfully and the client parked that keep-alive connection in `fetch_http`'s pool; the server closed it long before 14:28, and the next request to the same host picked it up. The same shape explains every earlier observation: the first fetch to a host in a process succeeds, a later fetch to that host fails, and the daily pass reuses connections a day old. The `TlsFailed` variant of the older lines is the same dead socket failing inside the TLS layer instead of at the head.
Change: `fetcher.fetch` requests with `.keep_alive = false`, and `fetch_http` in app.zig is created with `connection_pool.free_size = 0`, so `release` destroys every connection regardless of the request flag (`Client.zig:133`), including the one a failed send leaves in the `.ready` state that `Request.deinit` would otherwise pool, and no connection is ever taken from that pool. The flag alone is not enough: `Client.request` takes a pooled connection before it stores the flag (`Client.zig:1725`, `:1742`). The request then carries `connection: close`, the client marks the connection closing at the head, and `release` destroys it instead of pooling it (`Client.zig:133`, `:1167`). A blocklist download is one bulk transfer per source per day; there is nothing to gain from a pooled connection and a stale one costs a source for a day. `fetch_http` stays a separate client from `dns_http`, so the DoH pool is untouched.
Tests: an integration test against `HttpFixture` where the server closes the socket after each response: two consecutive fetches to the same URL both succeed. Without the change the second fails with `HttpConnectionClosing`, which is the exact record the Pi produced. A unit test that the request header the fetcher sends carries `connection: close` if the fixture route is the cheaper path.
Out of scope: a retry on `HttpConnectionClosing` for the DoH client, which keeps pooling by design and has its own health logic.
+30
View File
@@ -0,0 +1,30 @@
# NixOS module: `services.nxdns`
Owner ruling (2026-10-20): ship `nixosModules.default` from the flake, with the options `enable`, `package`, `configFile` and `openFirewall`, file mode only, no `settings` attrset, and the shipped `nxdns.service` reused through `systemd.packages` plus a drop-in. The owner left Nix in CI, the VM test and a distinct exit code for `SchemaTooNew` to the implementer, within one constraint: the self-hosted Gitea runner (`../mial.net/manifests/gitea/`) is shared and must not be monopolized.
## Why
The flake already shipped a package and had one consumer, `rpi.mial.net`, which wrote its own module. Four parts of that module are not specific to the Pi, and two encode incidents: the build-time `nxdns import` validation, and `restartTriggers`, without which a configuration-only deploy restarted nothing (2026-08-16). A package with no service is half an install on NixOS.
## What ships
- `nix/module.nix`. `configFile` goes through `nxdns import --data-dir $TMPDIR/validate` from `package` in a `runCommand`, and the result is `/etc/nxdns/config.zon`. The `nxdns` system user and group. `systemd.packages = [ package ]`, and a drop-in that clears `ExecStart` and sets `run --config=/etc/nxdns/config.zon`, clears `ConfigurationDirectory` because `environment.etc` owns `/etc/nxdns`, adds `wantedBy` and `restartTriggers`. `openFirewall` opens 53/udp and 53/tcp and nothing else.
- The package installs the tarball's `nxdns.service` to `lib/systemd/system/` with `ExecStart` pointed at its own binary. `--replace-fail` makes a changed `ExecStart` line in the unit a package build failure, not a silent mismatch.
- Not shipped: database mode, a Nix-to-ZON renderer, and the Pi's choices about its own resolver, which stay in `rpi.mial.net`.
## Decisions left to the implementer
- **No Nix in CI.** No job installs Nix today. The checks need a nixpkgs closure and, for the VM test, KVM; downloading that on every push costs the shared runner disk and network, and the runner's cache already caused a disk-pressure eviction on 2026-08-23. The cut's existing `nix flake check --no-build` evaluates both checks at every release, which catches a module that no longer evaluates.
- **The VM test runs on the developer's host.** `checks.x86_64-linux.nixos-module` and `checks.x86_64-linux.nixos-module-rejects-config` are built locally, with KVM, after any change to the module, the checks, the package or the unit. AGENTS.md says so. Whether the runner node exposes `/dev/kvm` was not checked, and nothing here depends on it.
- **No new exit code.** `SchemaTooNew` is already in `config/faults.zig`, so `run` exits 2 for it and the unit's `RestartPreventExitStatus=2 64` stops instead of restart-looping (`src/app.zig` tests the mapping). A NixOS rollback onto a migrated database therefore stops the unit, and `docs/how-to/upgrade.md` describes the recovery. The exit-1 behaviour in the Pi module's notes dates from 0.0.2.
## Acceptance
- `nix flake check --no-build` passes.
- `nixos-module`: a VM with the module and `openFirewall` answers `nas.lan` from a local record; `systemctl cat` shows the packaged unit and the drop-in; the service runs as `nxdns`; the effective `ConfigurationDirectory` is empty, `ReadOnlyPaths` still holds `/etc/nxdns`, and `/etc/nxdns` belongs to root; port 53 is open over UDP and TCP; `switch-to-configuration test` into a specialisation that changes only `configFile` gives a new `InvocationID` and the new answer. Without `restartTriggers` the test fails on the restart assertion, and without the `ConfigurationDirectory` clear it fails on that one (each checked once by removing the line).
- `nixos-module-rejects-config`: a system build with an invalid regex rule fails, and its log carries `FAIL rules[0].pattern`.
- `docs/how-to/install-with-nix.md` documents the module, the options, the store-readability of `configFile`, and rollback.
## After the release
`rpi.mial.net` replaces its module with `services.nxdns` and keeps its Pi-only parts, through the rpi-nixos-iac peer.
+10
View File
@@ -102,3 +102,13 @@ On a resumed cut — the bump commit exists and its tag does not — the pin sta
### Tests
The `gates.yml` env parser against a fixture and against the real file; the environment builder, asserting the nine keys and nothing else; and the `sh` wrapper, asserting it applies the umask, keeps the real command as the direct child, and reports its exit code. The stage's remaining steps are process spawns of `npm`, `zig` and `nix`, which are not mocked — the same rule the rest of this file follows.
## Addendum: the compose file names its release (post-0.0.22)
Owner question (2026-10-20), on the site's `NXDNS_VERSION=0.0.22 docker compose up -d`: "why do we need the NXDNS_VERSION to pull up a docker compose file? shouldnt the version be specified in the file already??" Until 0.0.22, `deploy/docker/compose.yaml` read `${NXDNS_VERSION:-latest}`, so the file fetched from a tag ran whatever `:latest` was that day, and every install and upgrade command had to carry the variable.
The image default is now the release itself: `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-<version>}}`. `NXDNS_VERSION` and `NXDNS_IMAGE` stay as overrides.
- **The bump moves it.** This amends step 2 of the pin stage: the cut reads the compose file and computes its rewrite before anything is written, so a file without exactly one `${NXDNS_VERSION:-…}` default is a refusal on a clean tree. Then `build.zig.zon` and the compose file are both written atomically and read back. Step 5's commit takes `build.zig.zon`, `flake.nix` and `deploy/docker/compose.yaml`, and the working-tree diff is asserted to be a subset of those three paths.
- **One guard, on every commit.** A cut-tool test reads the real `build.zig.zon` and `deploy/docker/compose.yaml` and asserts that the default equals the declared version. It runs in `zig build test`, so a hand edit of either file fails CI on that commit. The tag's tree is the bump commit's tree, which passed CI before the tag was made, so `verify-dist` gets no second copy of the check.
- **The docs drop the variable from the plain flow.** README, install-with-docker and upgrade run `docker compose up -d`; the variables are documented as overrides, and the pages say that a compose file from `v0.0.22` or earlier still defaults to `:latest`.
+109 -58
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: {
@@ -482,7 +492,19 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// Two HTTP clients on purpose. A blocklist download streams tens of
// megabytes and holds its connection for the whole of it; DoH queries must
// not queue behind that, and the two have nothing to share but a type.
var fetch_http: std.http.Client = .{ .allocator = gpa, .io = io };
// `free_size = 0` makes `release` destroy every connection and the pool
// hand none back (`Client.zig:133`). The fetcher's `.keep_alive = false` is
// what sends `connection: close`, but it cannot carry this alone: `request`
// takes a pooled connection before it stores the flag (`Client.zig:1725`,
// `:1742`), and a failed send leaves a `.ready` connection that
// `Request.deinit` would pool (`Client.zig:890`). A pass runs once a day,
// so any kept connection is closed at the peer by the next one, and the
// client never retries a stale one — it fails the source for the day.
var fetch_http: std.http.Client = .{
.allocator = gpa,
.io = io,
.connection_pool = .{ .free_size = 0 },
};
defer fetch_http.deinit();
var dns_http: std.http.Client = .{ .allocator = gpa, .io = io };
defer dns_http.deinit();
@@ -545,6 +567,13 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
var upstreams: upstream_owner.Owner = .init(upstream_generation);
defer upstreams.deinit(io);
upstreams.diagnostics = event_store;
// The store outlived the process that wrote it, so every `upstream.exchange`
// episode in it describes a pool that no longer exists. This closes the ones
// no enabled upstream of this boot can justify, and projects the health of
// the ones that remain — a fresh pool is all clear, so a warning that
// survives this boot is one this process opened.
upstreams.reconcileDiagnostics(io, upstream_generation);
// -----------------------------------------------------------------------
// per-query state
@@ -563,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)
@@ -634,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;
@@ -703,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);
@@ -777,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;
@@ -790,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;
@@ -868,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);
}
@@ -922,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;
@@ -976,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,
@@ -1096,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,
@@ -1105,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;
@@ -1119,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,
@@ -1128,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;
@@ -1637,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;
@@ -1741,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);
}
+33 -6
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 {
@@ -978,14 +1005,14 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
// what keeps the close off `dot` while it is still undefined.
var dot_wired = false;
defer if (dot_wired) dot.close(r.io);
const client: transport.Client = switch (endpoint.scheme) {
const client: transport.Leaf = switch (endpoint.scheme) {
.doh => doh: {
doh = doh_client.DohClient.init(&http, endpoint, &request_buf, &transfer_buf) catch {
try r.out.print("FAIL upstreams[{d}] {f}: not a usable DoH url\n", .{ i, safe_url.redactQuoted(server.url) });
failures += 1;
continue;
};
break :doh doh.client();
break :doh doh.leaf();
},
.dot => dot: {
dot = dot_client.DotClient.init(endpoint, server.tls_name, r.gpa, &bundle, &bundle_lock, null, .{
@@ -995,7 +1022,7 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
.stream_write = tls_buffers[3 * chunk ..],
});
dot_wired = true;
break :dot dot.client();
break :dot dot.leaf();
},
};
@@ -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);
@@ -1792,8 +1819,8 @@ test "a failed DoT probe reaches close through the per-iteration defer without a
try testing.expectEqual(@as(usize, 2), try probeUpstreams(r, cfg));
try testing.expectEqualStrings(
"FAIL upstreams[0] 'tls://dns.example:853': ConnectFailed\n" ++
"FAIL upstreams[1] 'tls://other.example:853': ConnectFailed\n",
"FAIL upstreams[0] 'tls://dns.example:853': ConnectFailed (cause ConnectFailed)\n" ++
"FAIL upstreams[1] 'tls://other.example:853': ConnectFailed (cause ConnectFailed)\n",
captured.out.written(),
);
}
+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, .{});
}
+312 -33
View File
@@ -1,16 +1,21 @@
//! Blocklist download over HTTP/1.1.
//!
//! One `Fetcher` wraps a caller-owned `std.http.Client`, which owns the
//! connection pool and the CA bundle, exactly as `upstream/doh_client.zig`
//! does. This file knows nothing about parsing, files or the database: it GETs
//! a URL and streams the bytes into a writer the caller supplies.
//! One `Fetcher` wraps a caller-owned `std.http.Client`, which owns the CA
//! bundle, as `upstream/doh_client.zig` does. Nothing here is pooled: every
//! request asks for `connection: close`, and the caller is expected to give
//! this file a client whose pool holds nothing (`free_size = 0`, set at the
//! one construction site in `app.zig`). This file knows nothing about parsing,
//! files or the database: it GETs a URL and streams the bytes into a writer
//! the caller supplies.
//!
//! The body is never held whole. A blocklist can reach `max_body_bytes`, and
//! the caller writes into a temporary file anyway, so nothing here allocates.
//!
//! There is no timeout parameter and no sleep. `std.http.Client` has no
//! per-request deadline, so the caller runs `fetch` under `io.concurrent` and
//! cancels the future; this file only propagates `error.Canceled`.
//! cancels the future. A cancel during the body copy surfaces as
//! `error.ReceiveFailed` with `Canceled` in `last_failure`; elsewhere it
//! propagates as `error.Canceled`.
const std = @import("std");
const transport = @import("../upstream/transport.zig");
@@ -43,6 +48,30 @@ pub const Result = struct {
status: std.http.Status,
};
/// The concrete fault behind an `Error`. `cause` is unwrapped from whatever
/// `std.http.Client` stashed on the connection or the response, so it names a
/// record-layer TLS fault or an HTTP framing fault rather than the collapsed
/// `error.ReadFailed`.
pub const Failure = struct {
phase: Phase,
cause: anyerror,
/// The response status if a head arrived before the failure.
status: ?std.http.Status,
/// Body bytes delivered to the caller's writer before the failure, and a
/// lower bound rather than an exact count: at least this many reached `w`.
/// A single `Reader.stream` call can hand bytes to `w` and then fail, and
/// that partial delivery is not counted. Zero on every phase before
/// `receive_body`.
bytes_read: u64,
};
/// Which call failed. The phase is what decides the classification, and only
/// the call site knows it — guessing it from an error name would be wrong the
/// first time two phases shared an error. `receive_body` is distinct from
/// `receive`: the head arrived, so the fault is in the transfer, not the
/// response.
pub const Phase = enum { connect, send, receive, receive_body };
pub const Fetcher = struct {
/// Caller-owned; shared across sources, pools connections.
http: *std.http.Client,
@@ -55,6 +84,12 @@ pub const Fetcher = struct {
/// one. `error.HttpStatus` carries no `Result`, and the operator's message
/// needs the number, so it is readable here after a failed `fetch`.
last_status: ?std.http.Status = null,
/// What the most recent `fetch` failed on, or null if it succeeded or has
/// not run. The taxonomy `Error` a caller receives names six outcomes; this
/// names the one concrete fault behind the outcome, so an operator reading
/// the log can tell a TLS alert from a reset connection from a truncated
/// chunk.
last_failure: ?Failure = null,
/// GETs `url` and streams the body into `w`.
pub fn fetch(
@@ -71,37 +106,110 @@ pub const Fetcher = struct {
std.debug.assert(self.transfer_buf.len >= min_transfer_buf);
std.debug.assert(self.redirect_buf.len >= redirect_buffer_len);
const uri = try parseUrl(url);
self.last_status = null;
self.last_failure = null;
const uri = parseUrl(url) catch |err| return self.record(err, .connect, err, null, 0);
var req = self.http.request(.GET, uri, .{
.keep_alive = true,
// Sends `connection: close`, so the peer ends the connection and
// `release` destroys it rather than pooling it (`Client.zig:133`,
// `:1167`). A pass runs once a day: a kept connection is one the
// peer has already closed by the next pass, and `std.http.Client`
// never retries a reused connection that reaches EOF before the
// first head byte — it returns `error.HttpConnectionClosing` and
// the source fails for the day. This flag cannot carry that alone,
// because `request` takes a pooled connection before it reads the
// flag; `app.zig` empties the pool itself.
.keep_alive = false,
.headers = .{
// Identity only: a compressed transfer encoding would need
// `Response.readerDecompressing`, a decompression buffer and a
// second failure surface, for a download that runs once a day.
.accept_encoding = .{ .override = "identity" },
},
}) catch |err| return mapError(err, .connect);
}) catch |err| return self.record(mapError(err, .connect), .connect, err, null, 0);
defer req.deinit();
req.sendBodiless() catch |err| return mapError(err, .send);
req.sendBodiless() catch |err|
return self.record(mapError(err, .send), .send, transport.sendCause(&req, err), null, 0);
var resp = req.receiveHead(self.redirect_buf) catch |err| return mapError(err, .receive);
var resp = req.receiveHead(self.redirect_buf) catch |err|
return self.record(mapError(err, .receive), .receive, transport.headCause(&req, err), null, 0);
self.last_status = resp.head.status;
if (resp.head.status != .ok) return error.HttpStatus;
if (resp.head.status != .ok) {
return self.record(error.HttpStatus, .receive, error.HttpStatus, resp.head.status, 0);
}
// `content-type` is deliberately not checked: blocklists are served as
// text/plain, application/octet-stream and text/html alike, and the
// compiler's invalid-line counters are the honest signal about content.
if (resp.head.content_length) |declared| {
if (declared > max_body_bytes) return error.BodyTooLarge;
if (declared > max_body_bytes) {
return self.record(
error.BodyTooLarge,
.receive,
error.BodyTooLarge,
resp.head.status,
0,
);
}
}
const body = resp.reader(self.transfer_buf);
return .{ .bytes_read = try pumpBody(body, w, max_body_bytes), .status = resp.head.status };
var pump: Pump = .{};
const bytes_read = pumpBody(body, w, max_body_bytes, &pump) catch |err| return self.record(
err,
.receive_body,
transport.bodyCause(&resp, pump.cause),
resp.head.status,
pump.delivered,
);
return .{ .bytes_read = bytes_read, .status = resp.head.status };
}
/// Stores the failure and hands back `mapped` unchanged.
///
/// `mapped` is the taxonomy error the call site already decided, never one
/// this function derives. `mapError` answers for a raw std error, and the
/// call sites that raise a taxonomy member directly — a non-200 head, a
/// declared length over the cap — pass that member: feeding either back
/// through `mapError` would find no case for it and fall to the phase
/// default, turning `error.HttpStatus` into `error.ReceiveFailed`.
///
/// `cause` never reaches the returned error. The unwrap is for the
/// operator's line, and a caller that ignores `last_failure` sees exactly
/// what it saw before the unwrap existed.
fn record(
self: *Fetcher,
mapped: Error,
phase: Phase,
cause: anyerror,
status: ?std.http.Status,
bytes_read: u64,
) Error {
self.last_failure = .{
.phase = phase,
.cause = cause,
.status = status,
.bytes_read = bytes_read,
};
return mapped;
}
};
/// What `pumpBody` reports alongside its error: the reader error before
/// `mapError` collapses it, and the bytes already handed to `w`. `pumpBody`
/// knows nothing of `std.http`, so the unwrap of `cause` happens in `fetch`,
/// which holds the response.
const Pump = struct {
cause: anyerror = error.Unexpected,
delivered: u64 = 0,
fn fail(self: *Pump, cause: anyerror, mapped: Error) Error {
self.cause = cause;
return mapped;
}
};
@@ -116,17 +224,19 @@ pub const Fetcher = struct {
/// `self.transfer_buf` — makes `@memcpy` copy the buffer onto itself
/// (Reader.zig:677) and panics the process on the first read that finds bytes
/// already buffered.
fn pumpBody(body: *std.Io.Reader, w: *std.Io.Writer, cap: usize) Error!u64 {
fn pumpBody(body: *std.Io.Reader, w: *std.Io.Writer, cap: usize, out: *Pump) Error!u64 {
var total: usize = 0;
while (total < cap) {
out.delivered = total;
total += body.stream(w, .limited(cap - total)) catch |err| switch (err) {
error.EndOfStream => return total,
// The caller owns `w` and can read the concrete failure from its
// own writer; this taxonomy has no member for a failing sink.
error.WriteFailed => return error.Unexpected,
else => |e| return mapError(e, .receive),
error.WriteFailed => return out.fail(err, error.Unexpected),
else => |e| return out.fail(e, mapError(e, .receive_body)),
};
}
out.delivered = total;
// `cap` written exactly. One more byte separates a body that fits from one
// that was cut off, and it must not reach `w`.
@@ -134,10 +244,10 @@ fn pumpBody(body: *std.Io.Reader, w: *std.Io.Writer, cap: usize) Error!u64 {
var probe: std.Io.Writer.Discarding = .init(&probe_buf);
const extra = body.stream(&probe.writer, .limited(1)) catch |err| switch (err) {
error.EndOfStream => return total,
error.WriteFailed => return error.Unexpected,
else => |e| return mapError(e, .receive),
error.WriteFailed => return out.fail(err, error.Unexpected),
else => |e| return out.fail(e, mapError(e, .receive_body)),
};
return if (extra == 0) total else error.BodyTooLarge;
return if (extra == 0) total else out.fail(error.BodyTooLarge, error.BodyTooLarge);
}
/// A scheme other than `http`/`https`, an unparseable URL and a URL with no
@@ -151,11 +261,6 @@ fn parseUrl(url: []const u8) Error!std.Uri {
return uri;
}
/// Which call failed. The phase is what decides the classification, and only
/// the call site knows it — guessing it from an error name would be wrong the
/// first time two phases shared an error.
const Phase = enum { connect, send, receive };
// `error.X` in an expression names a member into existence rather than
// referring to one, so the switch in `mapError` would keep compiling — and
// silently stop matching — if std renamed either of these. This is what fails
@@ -179,9 +284,10 @@ fn errorSetHas(comptime Set: type, comptime name: []const u8) bool {
/// `std.http.Client` collapses every handshake fault into
/// `error.TlsInitializationFailed` (Client.zig:1470) and every bundle fault into
/// `error.CertificateBundleLoadFailure`, and this file never unwraps a
/// connection's stashed read cause — it maps the collapsed `error.ReadFailed` by
/// phase — so the record-layer members of `std.crypto.tls.Client.ReadError`
/// cannot arrive here. `doh_client.zig` does unwrap, and names them.
/// connection's stashed read cause *for classification* — it maps the collapsed
/// `error.ReadFailed` by phase — so the record-layer members of
/// `std.crypto.tls.Client.ReadError` cannot arrive here. `fetch` does unwrap
/// them, into `Failure.cause`, which no classification reads.
fn mapError(err: anyerror, phase: Phase) Error {
if (transport.mapLocal(err)) |local| return narrowLocal(local);
switch (err) {
@@ -198,7 +304,7 @@ fn mapError(err: anyerror, phase: Phase) Error {
return switch (phase) {
.connect => error.ConnectFailed,
.send => error.SendFailed,
.receive => error.ReceiveFailed,
.receive, .receive_body => error.ReceiveFailed,
};
}
@@ -230,7 +336,8 @@ test "pumpBody streams a fully buffered body without aliasing its source" {
var out: [payload.len]u8 = undefined;
var sink: std.Io.Writer = .fixed(&out);
const n = try pumpBody(&body, &sink, max_body_bytes);
var pump: Pump = .{};
const n = try pumpBody(&body, &sink, max_body_bytes, &pump);
try testing.expectEqual(@as(u64, payload.len), n);
try testing.expectEqualStrings(payload, out[0..payload.len]);
}
@@ -241,7 +348,8 @@ test "pumpBody accepts a body of exactly the cap" {
var out: [payload.len]u8 = undefined;
var sink: std.Io.Writer = .fixed(&out);
const n = try pumpBody(&body, &sink, payload.len);
var pump: Pump = .{};
const n = try pumpBody(&body, &sink, payload.len, &pump);
try testing.expectEqual(@as(u64, payload.len), n);
try testing.expectEqualStrings(payload, out[0..payload.len]);
}
@@ -252,10 +360,12 @@ test "pumpBody refuses a body one byte over the cap" {
var out: [payload.len]u8 = undefined;
var sink: std.Io.Writer = .fixed(&out);
var pump: Pump = .{};
try testing.expectError(
error.BodyTooLarge,
pumpBody(&body, &sink, payload.len - 1),
pumpBody(&body, &sink, payload.len - 1, &pump),
);
try testing.expectEqual(error.BodyTooLarge, pump.cause);
}
test "pumpBody reports an empty body as zero bytes" {
@@ -263,7 +373,8 @@ test "pumpBody reports an empty body as zero bytes" {
var sink_buf: [0]u8 = .{};
var discarding: std.Io.Writer.Discarding = .init(&sink_buf);
try testing.expectEqual(@as(u64, 0), try pumpBody(&body, &discarding.writer, max_body_bytes));
var pump: Pump = .{};
try testing.expectEqual(@as(u64, 0), try pumpBody(&body, &discarding.writer, max_body_bytes, &pump));
}
fn undefinedFetcher(transfer_buf: []u8, redirect_buf: []u8) Fetcher {
@@ -359,3 +470,171 @@ test "caps are the values the memory budget was sized against" {
try testing.expectEqual(@as(usize, 64 * 1024 * 1024), max_body_bytes);
try testing.expectEqual(@as(usize, 8192), redirect_buffer_len);
}
/// Only the fields the unwrap helpers read are set; the rest of a `Connection`
/// is two buffered streams, a host name and a pool node. `.plain` for the
/// reason `doh_client.zig` gives: `getReadError` reaches a TLS connection's
/// cause through `@fieldParentPtr`, which on a stub would read memory that was
/// never a `Tls`.
fn stubConnection(read_err: ?std.Io.net.Stream.Reader.Error) std.http.Client.Connection {
var connection: std.http.Client.Connection = undefined;
connection.protocol = .plain;
connection.stream_reader.err = read_err;
connection.stream_writer.err = null;
return connection;
}
test "a head failure records the stashed cause, not the collapsed read error" {
var connection = stubConnection(error.ConnectionResetByPeer);
var req: std.http.Client.Request = undefined;
req.connection = &connection;
var transfer_buf: [min_transfer_buf]u8 = undefined;
var redirect_buf: [redirect_buffer_len]u8 = undefined;
var f = undefinedFetcher(&transfer_buf, &redirect_buf);
const err = error.ReadFailed;
const mapped = f.record(mapError(err, .receive), .receive, transport.headCause(&req, err), null, 0);
// The taxonomy the caller sees is what it was before the unwrap existed.
try testing.expectEqual(error.ReceiveFailed, mapped);
const failure = f.last_failure.?;
try testing.expectEqual(Phase.receive, failure.phase);
try testing.expectEqual(error.ConnectionResetByPeer, failure.cause);
try testing.expectEqual(@as(?std.http.Status, null), failure.status);
try testing.expectEqual(@as(u64, 0), failure.bytes_read);
}
test "a body failure records the bytes delivered before it and the body phase" {
const payload = "0.0.0.0 ads.example.com\n";
var buffer: [payload.len]u8 = (payload ++ "").*;
// Buffered bytes reach `w` first; the vtable is only asked for more once
// the buffer is drained, which is the shape of a transfer cut off mid-body.
var body: std.Io.Reader = .{
.vtable = std.Io.Reader.failing.vtable,
.buffer = &buffer,
.seek = 0,
.end = buffer.len,
};
var sink_buf: [0]u8 = .{};
var discarding: std.Io.Writer.Discarding = .init(&sink_buf);
var pump: Pump = .{};
try testing.expectError(
error.ReceiveFailed,
pumpBody(&body, &discarding.writer, max_body_bytes, &pump),
);
try testing.expectEqual(@as(u64, payload.len), pump.delivered);
var connection = stubConnection(null);
var req: std.http.Client.Request = undefined;
req.connection = &connection;
req.reader.body_err = error.HttpChunkTruncated;
const resp: std.http.Client.Response = .{ .request = &req, .head = undefined };
var transfer_buf: [min_transfer_buf]u8 = undefined;
var redirect_buf: [redirect_buffer_len]u8 = undefined;
var f = undefinedFetcher(&transfer_buf, &redirect_buf);
const mapped = f.record(
mapError(error.ReadFailed, .receive_body),
.receive_body,
transport.bodyCause(&resp, pump.cause),
.ok,
pump.delivered,
);
try testing.expectEqual(error.ReceiveFailed, mapped);
const failure = f.last_failure.?;
try testing.expectEqual(Phase.receive_body, failure.phase);
try testing.expectEqual(error.HttpChunkTruncated, failure.cause);
try testing.expectEqual(@as(?std.http.Status, .ok), failure.status);
try testing.expectEqual(@as(u64, payload.len), failure.bytes_read);
}
test "a rejected url records the phase without claiming a connect failure" {
var transfer_buf: [min_transfer_buf]u8 = undefined;
var redirect_buf: [redirect_buffer_len]u8 = undefined;
var f = undefinedFetcher(&transfer_buf, &redirect_buf);
var sink_buf: [0]u8 = .{};
var discarding: std.Io.Writer.Discarding = .init(&sink_buf);
try testing.expectError(
error.BadUrl,
f.fetch(undefined, "ftp://example.com/list.txt", &discarding.writer),
);
try testing.expectEqual(error.BadUrl, f.last_failure.?.cause);
}
test "record returns the taxonomy member the call site raised, not a remapped one" {
// `error.HttpStatus` and `error.BodyTooLarge` are raised by `fetch` itself
// rather than by std, so `mapError` has no case for either and would fall
// to the phase default. `record` must not run them through it.
var transfer_buf: [min_transfer_buf]u8 = undefined;
var redirect_buf: [redirect_buffer_len]u8 = undefined;
var f = undefinedFetcher(&transfer_buf, &redirect_buf);
try testing.expectEqual(
error.HttpStatus,
f.record(error.HttpStatus, .receive, error.HttpStatus, .not_found, 0),
);
try testing.expectEqual(@as(?std.http.Status, .not_found), f.last_failure.?.status);
try testing.expectEqual(error.HttpStatus, f.last_failure.?.cause);
try testing.expectEqual(
error.BodyTooLarge,
f.record(error.BodyTooLarge, .receive, error.BodyTooLarge, .ok, 0),
);
try testing.expectEqual(error.BodyTooLarge, f.last_failure.?.cause);
// This is why: `mapError` is unchanged and does collapse both to the phase
// default. `fetch` must never route these two through it.
try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpStatus, .receive));
try testing.expectEqual(error.ReceiveFailed, mapError(error.BodyTooLarge, .receive));
}
test "a cancelled body read records the cancellation and the progress so far" {
// This is the shape the expiry race leaves behind. `Select.cancelDiscard`
// cancels the fetch, the cancellation surfaces from the socket as a
// stashed `error.Canceled` under a collapsed `error.ReadFailed`, and the
// fetch records it on its way out. The manager then reports its own
// `error.Timeout` beside this record.
const payload = "0.0.0.0 ads.example.com\n";
var buffer: [payload.len]u8 = (payload ++ "").*;
var body: std.Io.Reader = .{
.vtable = std.Io.Reader.failing.vtable,
.buffer = &buffer,
.seek = 0,
.end = buffer.len,
};
var sink_buf: [0]u8 = .{};
var discarding: std.Io.Writer.Discarding = .init(&sink_buf);
var pump: Pump = .{};
try testing.expectError(
error.ReceiveFailed,
pumpBody(&body, &discarding.writer, max_body_bytes, &pump),
);
var connection = stubConnection(error.Canceled);
var req: std.http.Client.Request = undefined;
req.connection = &connection;
req.reader.body_err = null;
const resp: std.http.Client.Response = .{ .request = &req, .head = undefined };
var transfer_buf: [min_transfer_buf]u8 = undefined;
var redirect_buf: [redirect_buffer_len]u8 = undefined;
var f = undefinedFetcher(&transfer_buf, &redirect_buf);
// `pumpBody` maps the collapsed `error.ReadFailed` it was handed, not the
// unwrapped cause, so the taxonomy stays `ReceiveFailed` here. That is what
// this path returned before `Failure` existed, and the addendum keeps it:
// only the recorded cause is new. The manager reports `error.Timeout` from
// the expiry race anyway, so nothing reads this return value on this path.
const cause = transport.bodyCause(&resp, error.ReadFailed);
const mapped = f.record(error.ReceiveFailed, .receive_body, cause, .ok, pump.delivered);
try testing.expectEqual(error.ReceiveFailed, mapped);
try testing.expectEqual(Phase.receive_body, f.last_failure.?.phase);
try testing.expectEqual(error.Canceled, f.last_failure.?.cause);
try testing.expectEqual(@as(?std.http.Status, .ok), f.last_failure.?.status);
try testing.expectEqual(@as(u64, payload.len), f.last_failure.?.bytes_read);
}
+193 -25
View File
@@ -421,7 +421,7 @@ const Env = struct {
// fixtures: the loopback http server
// ---------------------------------------------------------------------------
const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall, changed };
const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall, changed, stale_keep_alive };
/// How long the `stall` route holds a reply open when nothing releases it.
///
@@ -542,9 +542,10 @@ const HttpFixture = struct {
}
}
/// Every reply closes the connection. A keep-alive reply would leave the
/// fetcher holding the connection open while this server waits to accept a
/// second one that never comes (milestone-5 spec, S9 note from S8).
/// Every reply but `stale_keep_alive` closes the connection. A keep-alive
/// reply would leave the fetcher holding the connection open while this
/// server waits to accept a second one that never comes (milestone-5 spec,
/// S9 note from S8).
fn respond(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void {
switch (@as(Route, @enumFromInt(self.route.load(.acquire)))) {
.body => try request.respond(self.body, .{ .keep_alive = false }),
@@ -566,6 +567,11 @@ const HttpFixture = struct {
.transfer_encoding = .none,
.extra_headers = &.{.{ .name = "content-length", .value = oversize_length }},
}),
// Advertises keep-alive and then closes anyway, because `serve`
// takes one request per connection. That is the Pi's failure in
// miniature: a client that pools this connection reuses one the
// peer has already closed on its next request to the host.
.stale_keep_alive => try request.respond(self.body, .{ .keep_alive = true }),
.chunked => try self.respondChunked(request),
.stall => try self.respondStalled(io, request),
}
@@ -909,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();
@@ -934,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();
@@ -991,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);
@@ -1003,6 +1009,19 @@ test "5: a redirect is followed to the same result" {
try testing.expect(decision.blocked);
}
/// The download failure line ends in numbers no test can predict — an elapsed
/// time always, and a byte count where the failure is a cancelled transfer. The
/// fixed part is compared whole; the tail only has to be digits, spaces and the
/// two words those numbers carry.
fn expectFailureText(expected_prefix: []const u8, actual: []const u8) !void {
try testing.expectEqualStrings(expected_prefix, actual[0..@min(expected_prefix.len, actual.len)]);
const tail = actual[expected_prefix.len..];
try testing.expect(std.mem.endsWith(u8, tail, " ms)"));
for (tail[0 .. tail.len - " ms)".len]) |c| {
try testing.expect(std.ascii.isDigit(c) or c == ' ' or c == ',' or std.ascii.isAlphabetic(c));
}
}
test "6: a 404 leaves the compiled files and the snapshot untouched" {
if (!build_options.integration) return error.SkipZigTest;
@@ -1021,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();
@@ -1030,11 +1049,17 @@ 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);
try testing.expectEqualStrings("HttpStatus", failed.errorText());
// The whole line, not just the taxonomy: a 404 must stay `HttpStatus` and
// name the status it saw. Routing it back through `fetcher.mapError` would
// read `ReceiveFailed (receive, cause ReceiveFailed, ...)` here.
try expectFailureText(
"HttpStatus (receive, cause HttpStatus, http 404, 0 bytes, ",
failed.errorText(),
);
var after = try Bodies.read(gpa, io, dir, "1");
defer after.deinit(gpa);
@@ -1046,6 +1071,51 @@ test "6: a 404 leaves the compiled files and the snapshot untouched" {
try testing.expect(decision.blocked);
}
test "6b: a local failure reports itself, not the previous source's network fault" {
if (!build_options.integration) return error.SkipZigTest;
// Mode bits do not apply to root, so the denial the test needs cannot happen.
if (std.c.geteuid() == 0) 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);
fixture.setRoute(.not_found);
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);
// First a real network failure, so the fetcher is holding a record.
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(),
);
// Then a failure that never reaches the network: the raw file cannot be
// created. Without the clear at the top of `download` this would report the
// 404 above as this refresh's cause.
var dir = try env.blocklistDir();
defer dir.close(io);
try dir.setPermissions(io, .fromMode(0o500));
defer dir.setPermissions(io, .fromMode(0o700)) catch {};
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);
try expectFailureText("AccessDenied (no fetch, http none, - bytes, ", failed.errorText());
try testing.expect(!std.mem.containsAtLeast(u8, failed.errorText(), 1, "HttpStatus"));
try testing.expect(!std.mem.containsAtLeast(u8, failed.errorText(), 1, "404"));
}
test "7: a body over the cap fails the refresh and leaves no temporary file" {
if (!build_options.integration) return error.SkipZigTest;
@@ -1065,11 +1135,14 @@ 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);
try testing.expectEqualStrings("BodyTooLarge", failed.errorText());
try expectFailureText(
"BodyTooLarge (receive, cause BodyTooLarge, http 200, 0 bytes, ",
failed.errorText(),
);
var dir = try env.blocklistDir();
defer dir.close(io);
@@ -1097,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);
@@ -1121,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.
@@ -1187,6 +1260,44 @@ fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []c
});
}
test "8b: a second download to the same host never reuses a dead pooled connection" {
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);
// The server says keep-alive and then drops the socket, which is what the
// Pi's upstreams do between one daily pass and the next.
fixture.setRoute(.stale_keep_alive);
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);
var sink_buf: [0]u8 = .{};
var first: std.Io.Writer.Discarding = .init(&sink_buf);
const one = try env.f.fetch(io, url, &first.writer);
try testing.expectEqual(@as(u64, http_body.len), one.bytes_read);
// The whole test is this second call. With a pooling client the request
// goes out over a connection the peer has already closed, no response head
// byte arrives, and `error.HttpConnectionClosing` comes back with no retry
// — the exact record the Pi produced at 0 bytes and under a millisecond.
var second: std.Io.Writer.Discarding = .init(&sink_buf);
const two = try env.f.fetch(io, url, &second.writer);
try testing.expectEqual(@as(u64, http_body.len), two.bytes_read);
try testing.expectEqual(@as(?fetcher.Failure, null), env.f.last_failure);
// Two connections, not one reused: the proof the first was not pooled.
try testing.expectEqual(@as(u32, 2), fixture.accepted.load(.monotonic));
}
test "9: a reload swaps under a held handle and the new generation follows the release" {
if (!build_options.integration) return error.SkipZigTest;
@@ -1353,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);
@@ -1478,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.
@@ -1515,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();
@@ -1644,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;
@@ -1998,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);
@@ -2070,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
@@ -2260,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);
@@ -2321,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;
{
@@ -2353,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;
{
@@ -2368,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);
@@ -2437,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",
@@ -2451,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"),
);
}
+493 -78
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");
@@ -69,8 +70,13 @@ const log = std.log.scoped(.blocklist_manager);
const Sha256 = std.crypto.hash.sha2.Sha256;
/// `SourceStatus.last_error` is fixed-size so the failure path allocates
/// nothing.
pub const max_error_len: usize = 128;
/// nothing. Sized so the longest `downloadFailureText` fits whole: the widest
/// cause name the unwraps can produce is `DetectingNetworkConfigurationFailed`
/// at 35 bytes, which with a 64 MiB byte count and an hour of milliseconds
/// makes a 111-byte line. The test below recomputes that worst case from the
/// std error sets, so a wider name std adds fails the build's test run rather
/// than silently truncating an operator's only diagnostic.
pub const max_error_len: usize = 192;
/// `SourceStatus.url` is fixed-size so a copied status borrows nothing. A
/// blocklist url longer than this is truncated in the status only; the row
@@ -131,6 +137,55 @@ const SourceLabel = struct {
}
};
/// The one place the download failure text is formatted. `phase`, `cause` and
/// the byte count come from the fetcher's record of the concrete fault.
///
/// A timeout carries a record like any other failure: `fetchWithin` cancels the
/// fetch on its way out of the race, and the cancelled fetch records the phase
/// it was in, the status if a head had arrived and the bytes delivered so far,
/// all before this function reads it. So an expiry reads
/// `Timeout (receive_body, cause Canceled, http 200, 5242880 bytes, 300000 ms)`.
/// `no fetch` and `-` are for a failure that never reached the network at all:
/// `download` clears the record before anything can fail, so a local fault —
/// the temporary file, an allocation, a refused task — reads as its own
/// taxonomy name with nothing borrowed from the source fetched before it.
fn downloadFailureText(
buf: []u8,
err: anyerror,
failure: ?fetcher.Failure,
last_status: ?std.http.Status,
elapsed_ms: u64,
) []const u8 {
var status_buf: [8]u8 = undefined;
const status_text = if (if (failure) |f| f.status else last_status) |code|
std.fmt.bufPrint(&status_buf, "{d}", .{@intFromEnum(code)}) catch "none"
else
"none";
var bytes_buf: [24]u8 = undefined;
const bytes_text = if (failure) |f|
std.fmt.bufPrint(&bytes_buf, "{d}", .{f.bytes_read}) catch "-"
else
"-";
if (failure) |f| {
return std.fmt.bufPrint(buf, "{s} ({s}, cause {s}, http {s}, {s} bytes, {d} ms)", .{
@errorName(err),
@tagName(f.phase),
@errorName(f.cause),
status_text,
bytes_text,
elapsed_ms,
}) catch @errorName(err);
}
return std.fmt.bufPrint(buf, "{s} (no fetch, http {s}, {s} bytes, {d} ms)", .{
@errorName(err),
status_text,
bytes_text,
elapsed_ms,
}) catch @errorName(err);
}
pub const Paths = struct {
/// `<data_dir>`, owned by the caller and left open for the manager's life.
dir: std.Io.Dir,
@@ -838,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
@@ -857,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);
@@ -900,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,
});
@@ -960,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.
@@ -1037,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
@@ -1058,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
@@ -1102,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
@@ -1169,11 +1230,12 @@ pub const Manager = struct {
raw_name: []const u8,
tmp: TempNames,
) Error!Prepared {
self.download(io, dir, raw_name, row) catch |err| switch (err) {
var elapsed_ms: u64 = 0;
self.download(io, dir, raw_name, row, &elapsed_ms) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.Canceled => return error.Canceled,
else => {
self.reportFetchFailure(row, status, err);
self.reportDownloadFailure(row, status, err, elapsed_ms);
return .failed;
},
};
@@ -1205,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.
@@ -1217,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,
};
@@ -1253,7 +1314,7 @@ pub const Manager = struct {
.checksum = stored,
});
status.succeed(now, compiled.result.counts);
return false;
return .unchanged;
}
}
@@ -1269,7 +1330,7 @@ pub const Manager = struct {
error.Canceled => return error.Canceled,
else => {
self.reportCompileFailure(row, status, err);
return false;
return .failed;
},
};
@@ -1283,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
@@ -1297,7 +1358,18 @@ pub const Manager = struct {
dir: std.Io.Dir,
raw_name: []const u8,
row: sources_repo.SourceRow,
/// Milliseconds the download itself took, set whether it succeeded or
/// failed, so the failure line can say how long the peer had.
elapsed_ms: *u64,
) !void {
// Ahead of everything that can fail. The record is the fetcher's, not
// this source's, and a local failure here — the create, the allocation,
// a refused task — would otherwise leave the PREVIOUS source's network
// cause in place for `reportDownloadFailure` to print as this one's.
// Cleared here, a null record means no fetch reached the network.
self.fetcher.last_failure = null;
self.fetcher.last_status = null;
const file = try dir.createFile(io, raw_name, .{ .permissions = .fromMode(0o600) });
defer file.close(io);
@@ -1305,16 +1377,14 @@ pub const Manager = struct {
defer self.gpa.free(buffer);
var fw = file.writer(io, buffer);
const result = self.fetchWithin(io, row.url, &fw.interface) catch |err| {
const result = self.fetchWithin(io, row.url, &fw.interface, elapsed_ms) catch |err| {
// `fetcher.Error.Unexpected` is what a failing sink surfaces as;
// the concrete cause is on this writer, which the fetcher does not
// own.
if (fw.err) |cause| return cause;
if (err == error.HttpStatus) {
if (self.fetcher.last_status) |status| {
log.warn("blocklist {f}: http status {d}", .{ SourceLabel.of(row), @intFromEnum(status) });
}
}
// The status used to get a second warning of its own. It is a field
// of the one download-failure line now, so a second line would only
// repeat it.
return err;
};
try fw.interface.flush();
@@ -1332,19 +1402,35 @@ pub const Manager = struct {
io: std.Io,
url: []const u8,
w: *std.Io.Writer,
elapsed_ms: *u64,
) fetcher.Error!fetcher.Result {
// `awake` and not `real`: an operator reading "812 ms" wants the time
// the transfer was given, which a stepped wall clock would misreport.
const started = std.Io.Clock.awake.now(io);
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
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;
},
};
switch (try race.await()) {
// Read before the deferred `cancelDiscard`, which tears the loser down:
// the number is the time the peer had, not that plus the teardown.
const outcome = race.await();
elapsed_ms.* = @intCast(@max(0, started.durationTo(std.Io.Clock.awake.now(io)).toMilliseconds()));
switch (try outcome) {
.fetch => |result| return result,
.expiry => |result| {
// A canceled sleep means this task is being torn down, not that
@@ -1514,15 +1600,21 @@ pub const Manager = struct {
return compiledBodiesMatch(list_bytes, wild_bytes, allow_bytes, expected);
}
fn reportFetchFailure(
/// One line per failed source per pass, and the only place the download
/// failure text is built. The Diagnostics event detail gets the same text,
/// so an operator reading either can tell a TLS alert from a reset
/// connection from a truncated chunk.
fn reportDownloadFailure(
self: *Manager,
row: sources_repo.SourceRow,
status: *SourceStatus,
err: anyerror,
elapsed_ms: u64,
) void {
_ = self;
log.warn("blocklist {f}: download failed: {s}", .{ SourceLabel.of(row), @errorName(err) });
status.fail(.fetch_failed, @errorName(err));
var buf: [max_error_len]u8 = undefined;
const text = downloadFailureText(&buf, err, self.fetcher.last_failure, self.fetcher.last_status, elapsed_ms);
log.warn("blocklist {f}: download failed: {s}", .{ SourceLabel.of(row), text });
status.fail(.fetch_failed, text);
}
fn reportCompileFailure(
@@ -1737,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;
@@ -1749,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 {
@@ -2066,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) } };
}
@@ -2528,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();
@@ -3043,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);
@@ -3077,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);
@@ -3580,3 +3894,104 @@ test "setSchedule is what the live schedule readers see" {
try testing.expectEqual(@as(u16, 6), live.interval_hours);
try testing.expect(manager.schedule_event.isSet());
}
test "a download failure line names the concrete cause behind the taxonomy" {
var buf: [1024]u8 = undefined;
const row: sources_repo.SourceRow = .{
.id = 3,
.url = "https://lists.example/download/token/hunter2/hosts.txt?apikey=s3cr3t",
.name = "ads",
.enabled = true,
.last_updated = null,
.domain_count = 0,
.wildcard_count = 0,
.skipped_regex_count = 0,
.skipped_unsupported_count = 0,
.checksum = null,
};
var text_buf: [max_error_len]u8 = undefined;
const text = downloadFailureText(&text_buf, error.ReceiveFailed, .{
.phase = .receive_body,
.cause = error.HttpChunkTruncated,
.status = .ok,
.bytes_read = 1024 * 1024,
}, .ok, 812);
const printed = try std.fmt.bufPrint(&buf, "blocklist {f}: download failed: {s}", .{
SourceLabel.of(row),
text,
});
try testing.expectEqualStrings(
"blocklist source 3 'ads' 'https://lists.example': download failed:" ++
" ReceiveFailed (receive_body, cause HttpChunkTruncated, http 200, 1048576 bytes, 812 ms)",
printed,
);
try testing.expect(!std.mem.containsAtLeast(u8, printed, 1, "hunter2"));
try testing.expect(!std.mem.containsAtLeast(u8, printed, 1, "s3cr3t"));
}
test "an expiry names the phase and the progress the cancelled fetch had made" {
// `fetchWithin` cancels the fetch before it reports, so a timeout normally
// does have a record: the cancellation the fetch saw, under the manager's
// own `error.Timeout`.
var text_buf: [max_error_len]u8 = undefined;
try testing.expectEqualStrings(
"Timeout (receive_body, cause Canceled, http 200, 5242880 bytes, 300000 ms)",
downloadFailureText(&text_buf, error.Timeout, .{
.phase = .receive_body,
.cause = error.Canceled,
.status = .ok,
.bytes_read = 5 * 1024 * 1024,
}, .ok, 300000),
);
}
test "a failure that never reached the network says so and borrows nothing" {
// `download` clears the fetcher's record before anything can fail, so a
// local fault reads as itself. Printing a phase and a cause here would be
// printing the previous source's network fault against this source.
var text_buf: [max_error_len]u8 = undefined;
try testing.expectEqualStrings(
"SystemResources (no fetch, http none, - bytes, 0 ms)",
downloadFailureText(&text_buf, error.SystemResources, null, null, 0),
);
try testing.expectEqualStrings(
"AccessDenied (no fetch, http none, - bytes, 3 ms)",
downloadFailureText(&text_buf, error.AccessDenied, null, null, 3),
);
}
test "max_error_len holds the widest download failure line whole" {
// `@errorName` of the unwrapped cause is the only unbounded-looking part.
// The two sets below are every set the unwraps in `upstream/transport.zig`
// can return a member of.
const widest_cause = comptime blk: {
var widest: []const u8 = "";
for (@typeInfo(std.crypto.tls.Client.ReadError).error_set.?) |member| {
if (member.name.len > widest.len) widest = member.name;
}
for (@typeInfo(std.http.Client.RequestError).error_set.?) |member| {
if (member.name.len > widest.len) widest = member.name;
}
for (@typeInfo(std.http.Reader.BodyError).error_set.?) |member| {
if (member.name.len > widest.len) widest = member.name;
}
break :blk widest;
};
var buf: [max_error_len]u8 = undefined;
const text = std.fmt.bufPrint(
&buf,
"{s} ({s}, cause {s}, http {d}, {d} bytes, {d} ms)",
.{
"SystemResources",
@tagName(fetcher.Phase.receive_body),
widest_cause,
@as(u16, 599),
@as(u64, fetcher.max_body_bytes),
@as(u64, std.time.ms_per_hour),
},
) catch unreachable;
try testing.expect(text.len <= max_error_len);
try testing.expectEqualStrings("DetectingNetworkConfigurationFailed", widest_cause);
}
+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);
}
+48 -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 = .{},
@@ -148,6 +150,10 @@ pub const DohServer = struct {
return self.core.boundAddress();
}
pub fn connectionsPeak(self: *const DohServer) u64 {
return self.core.connectionsPeak();
}
pub fn snapshotStats(self: *const DohServer) Snapshot {
const core = &self.core.stats;
return .{
@@ -167,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;
@@ -188,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
@@ -216,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) {
@@ -381,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),
@@ -622,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,
@@ -655,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;
@@ -669,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();
@@ -1212,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.
+94 -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,15 +121,24 @@ 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;
}
pub fn connectionsPeak(self: *const DotServer) u64 {
return self.core.connectionsPeak();
}
pub fn snapshotStats(self: *const DotServer) StatsSnapshot {
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),
@@ -153,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 => {
@@ -177,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 => {
@@ -204,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);
@@ -212,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.
@@ -229,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;
@@ -322,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.
@@ -571,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 });
@@ -587,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" {
@@ -612,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);
@@ -626,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" {
@@ -651,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);
@@ -665,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
@@ -738,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 });
@@ -756,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
@@ -829,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 });
@@ -845,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" {
@@ -870,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,
});
@@ -878,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 });
@@ -886,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,
};
}
+82 -22
View File
@@ -130,6 +130,11 @@ pub const Handler = struct {
pause: ?*pause.Pause = null,
tracker: ?*clients.Tracker = null,
stats: Stats = .{},
/// Queries inside `handle` right now, across every listener, and the most
/// there have been at once (specs/memory-budget.md item 0). Gauges, so they
/// stay out of `Stats`, which `/metrics` exports field by field as counters.
in_flight: std.atomic.Value(u64) = .init(0),
in_flight_peak: std.atomic.Value(u64) = .init(0),
pub const Stats = struct {
/// Queries answered by the upstream pool.
@@ -229,6 +234,21 @@ pub const Handler = struct {
query: []const u8,
response_buf: []u8,
scratch: *Scratch,
) Outcome {
const now_in_flight = self.in_flight.fetchAdd(1, .monotonic) + 1;
_ = self.in_flight_peak.fetchMax(now_in_flight, .monotonic);
defer _ = self.in_flight.fetchSub(1, .monotonic);
return self.answer(io, which, from, query, response_buf, scratch);
}
fn answer(
self: *Handler,
io: std.Io,
which: Transport,
from: address.NetAddress,
query: []const u8,
response_buf: []u8,
scratch: *Scratch,
) Outcome {
std.debug.assert(response_buf.len >= udp_limit_min);
std.debug.assert(response_buf.len <= transport.max_message_len);
@@ -448,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,
@@ -917,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
@@ -1459,6 +1479,24 @@ test "a response arriving on the listener port is dropped" {
try testing.expectEqual(@as(usize, 0), fake.calls);
}
test "every query counts in flight for exactly its own handling" {
var t: TestIo = .init();
defer t.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bare(h_owner.client(fake.client()));
// Two queries other listeners are still handling.
h.in_flight.store(2, .monotonic);
var buf: [udp_limit_min]u8 = undefined;
try testing.expect(udp(&h, t.io(), query_bytes, &buf) == .reply);
try testing.expectEqual(Handler.Outcome.drop, udp(&h, t.io(), response_bytes, &buf));
try testing.expectEqual(@as(u64, 2), h.in_flight.load(.monotonic));
try testing.expectEqual(@as(u64, 3), h.in_flight_peak.load(.monotonic));
}
test "a query shorter than a header is dropped" {
var t: TestIo = .init();
defer t.deinit();
@@ -2615,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
@@ -2654,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)));
@@ -2779,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 };
@@ -2812,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(
@@ -2876,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 };
@@ -3088,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 };
@@ -3164,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 };
@@ -3297,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;
@@ -3507,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(&.{});
@@ -3548,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(),
@@ -3640,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(&.{});
@@ -4243,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);
@@ -4257,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;
@@ -4396,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.
@@ -4419,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,
@@ -4456,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 };
+57 -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.
@@ -83,6 +91,10 @@ pub const CoreStats = struct {
accept_errors: std.atomic.Value(u64) = .init(0),
connection_errors: std.atomic.Value(u64) = .init(0),
idle_timeouts: std.atomic.Value(u64) = .init(0),
/// The most slots that have been taken at once (specs/memory-budget.md
/// item 0). A gauge, so no listener's `Snapshot` carries it: those export
/// every field as a counter. Written only under `Core.mutex`.
connections_peak: std.atomic.Value(u64) = .init(0),
};
// ---------------------------------------------------------------------------
@@ -94,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
@@ -110,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) {
@@ -243,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();
@@ -255,6 +280,9 @@ pub fn Core(comptime Cfg: type) type {
/// that shuts the active connections down, so a stream that arrives
/// after that scan can never claim a slot the scan will not visit again.
shutdown_begun: bool,
/// Guarded by `mutex`. Slots that are not `.free`: a slot is taken from
/// `claim` until `finish` frees it, closing included.
taken: u64,
stats: CoreStats,
run_state: std.atomic.Value(State),
stopped: std.Io.Event,
@@ -299,6 +327,7 @@ pub fn Core(comptime Cfg: type) type {
.conns = conns,
.mutex = .init,
.shutdown_begun = false,
.taken = 0,
.stats = .{},
.run_state = .init(.idle),
.stopped = .unset,
@@ -307,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
@@ -336,6 +366,10 @@ pub fn Core(comptime Cfg: type) type {
return self.listener.socket.address;
}
pub fn connectionsPeak(self: *const Self) u64 {
return self.stats.connections_peak.load(.monotonic);
}
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
pub fn serve(self: *Self, io: std.Io) void {
if (self.run_state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
@@ -409,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;
},
@@ -443,6 +477,10 @@ pub fn Core(comptime Cfg: type) type {
self.conns[index].stream = stream;
self.conns[index].peer = stream.socket.address;
self.conns[index].state = .active;
self.taken += 1;
if (self.taken > self.stats.connections_peak.load(.monotonic)) {
self.stats.connections_peak.store(self.taken, .monotonic);
}
},
.at_capacity, .shutting_down => {},
}
@@ -464,6 +502,7 @@ pub fn Core(comptime Cfg: type) type {
self.mutex.lockUncancelable(io);
conn.state = .free;
self.taken -= 1;
self.mutex.unlock(io);
}
@@ -507,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);
}

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