43 Commits
Author SHA1 Message Date
mokhtar 13458a980e ci: run the frontend gate on the light runner
Gates / test (push) Successful in 1m37s
Gates / frontend (push) Failing after 3m23s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test-aarch64 (push) Successful in 6m2s
CI / gates (push) Failing after 7m41s
2026-08-12 23:46:07 +02:00
mokhtar e576704fad spec milestone 23: record the readme and conversion-order findings as closed by decision 2026-08-12 23:45:34 +02:00
mokhtar 4aaf6d3815 milestone 23: close the remaining codex findings
the vitest "something prevents Vite server from exiting" warning was
@stylexjs/unplugin, not our code: configureServer starts a 150ms polling
interval for HMR and clears it from server.httpServer's close event, which
vitest resolves to null in middleware mode. drop that one hook under VITEST
and keep the transform.

textMuted was swept rather than judged. measured against their grounds, the
39 flat text-zinc-500 sites went 4.12:1 -> 7.56:1 in dark mode, repairing a
WCAG AA failure, so that stands; the 4 already-adaptive sites went 7.40:1 ->
4.62:1 in light mode for nothing, so a textSecondary token restores their
original zinc-600/zinc-400 pair.

append the symbol families to the reset's font stack: Select renders U+25BE
and Segoe UI does not carry it.

correct two acceptance criteria that were literally false: both greps match
only comments.
2026-08-12 23:43:22 +02:00
mokhtar 0ea2e2905a milestone 23: restore the focus ring on select options, check layer order, close preflight gaps 2026-08-12 23:27:28 +02:00
mokhtar 9d5120cbad milestone 23: put the reset in a cascade layer, guard that it stays there 2026-08-12 23:06:26 +02:00
mokhtar 65f76d4427 milestone 23 s3: plan names stylex and react aria 2026-08-12 22:50:34 +02:00
mokhtar be12587b87 milestone 23 s3: remove tailwind, replace its preflight with an explicit reset 2026-08-12 22:50:25 +02:00
mokhtar 122044e6af milestone 23 s3: delete ui/classes.ts, its last importer is gone 2026-08-12 22:46:29 +02:00
mokhtar 0e9433d53b milestone 23 s3: convert features/queries/QueryLogPage.tsx to stylex 2026-08-12 22:45:53 +02:00
mokhtar ae9f7fb9f3 milestone 23 s3: convert features/lookup/LookupPage.tsx to stylex 2026-08-12 22:44:23 +02:00
mokhtar 1cd8f71a9b milestone 23 s3: convert features/dashboard/TimeseriesChart.tsx to stylex 2026-08-12 22:42:11 +02:00
mokhtar 15c895e180 milestone 23 s3: convert features/settings/SettingsPage.tsx to stylex 2026-08-12 22:40:50 +02:00
mokhtar 3b33d7afee milestone 23 s3: convert features/dashboard/UpstreamHealthTable.tsx to stylex 2026-08-12 22:38:15 +02:00
mokhtar f2b4582c0a milestone 23 s3: convert shell/AppShell.tsx to stylex 2026-08-12 22:37:16 +02:00
mokhtar 02bf1ca310 milestone 23 s3: convert features/live/LiveLogPage.tsx to stylex 2026-08-12 22:36:09 +02:00
mokhtar 4379d7599b milestone 23 s3: convert features/groups/GroupsPage.tsx to stylex 2026-08-12 22:33:58 +02:00
mokhtar 1246791fe1 milestone 23 s3: convert features/dashboard/DiskCard.tsx to stylex 2026-08-12 22:32:56 +02:00
mokhtar 2f3115117f milestone 23 s3: convert features/blocklists/SourceStatusSection.tsx to stylex 2026-08-12 22:31:14 +02:00
mokhtar e98896fc0a milestone 23 s3: convert features/dashboard/DashboardPage.tsx to stylex 2026-08-12 22:30:25 +02:00
mokhtar 1de608f9bc milestone 23 s3: convert auth/LoginPage.tsx to stylex 2026-08-12 22:28:56 +02:00
mokhtar 38d34453bb milestone 23 s3: convert features/groups/GroupSourcesEditor.tsx to stylex 2026-08-12 22:28:01 +02:00
mokhtar 368149a358 milestone 23 s3: convert features/blocklists/BlocklistForm.tsx to stylex 2026-08-12 22:27:00 +02:00
mokhtar ac0699faa1 milestone 23: amend two rulings the s2 review found the code in breach of 2026-08-12 22:26:42 +02:00
mokhtar d5b351d7fe milestone 23 s3: convert routes.tsx to stylex 2026-08-12 22:25:56 +02:00
mokhtar cddefa87e4 milestone 23 s3: convert features/pause/PauseWidget.tsx to stylex 2026-08-12 22:25:08 +02:00
mokhtar 9e12683acf milestone 23 s3: convert features/dashboard/StatCards.tsx to stylex 2026-08-12 22:23:16 +02:00
mokhtar 0fdbe25f7a milestone 23 s3: convert features/settings/RestartBanner.tsx to stylex 2026-08-12 22:21:53 +02:00
mokhtar 7e7358b3c2 milestone 23 s3: convert features/settings/ReadOnlyConfigBanner.tsx to stylex 2026-08-12 22:21:13 +02:00
mokhtar d5c4a8d978 milestone 23 s3: convert lib/InlineError.tsx to stylex 2026-08-12 22:19:59 +02:00
mokhtar 73bc180c67 milestone 23 s3: convert features/dashboard/HealthBanners.tsx to stylex 2026-08-12 22:18:23 +02:00
mokhtar 0f58966b31 milestone 23 s3: move srOnly into the shared style vocabulary 2026-08-12 22:16:58 +02:00
mokhtar bb39539fdf milestone 23: correct three comments the s1 review found inaccurate 2026-08-12 22:13:49 +02:00
mokhtar 01e455c8af milestone 23 s2: react aria primitives and their cluster 2026-08-12 22:12:18 +02:00
mokhtar 994bbf922c milestone 23 s1: stylex build integration, tokens and shared styles 2026-08-12 21:41:32 +02:00
mokhtar 7b0527d271 milestone 22: typescript 7 2026-08-12 21:30:40 +02:00
mokhtar c5e6ab9180 spec milestones 22 and 23: typescript 7, stylex and react aria 2026-08-12 21:30:35 +02:00
mokhtar e3529e4e61 ci: gates build the web bundle and the dist payload once, pass them between jobs
Gates / test (push) Successful in 1m37s
Gates / test-aarch64 (push) Successful in 6m46s
Gates / frontend (push) Successful in 1m6s
Gates / package (push) Successful in 4m4s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m54s
2026-08-12 20:20:30 +02:00
mokhtar 91a0aa92c6 ci: smoke test runs the container in file mode, surface its logs on failure
Gates / test-aarch64 (push) Successful in 5m55s
Gates / test (push) Successful in 1m27s
Gates / frontend (push) Successful in 50s
Gates / package (push) Successful in 3m52s
Gates / container (push) Successful in 2m28s
CI / gates (push) Successful in 14m34s
2026-08-12 00:15:22 +02:00
mokhtar 79a161e73a spec milestone 21: re-verify anchors against m20, record authority-mode interaction
Gates / test (push) Successful in 1m27s
Gates / test-aarch64 (push) Successful in 5m56s
Gates / frontend (push) Successful in 49s
Gates / package (push) Successful in 3m54s
Gates / container (push) Failing after 2m20s
CI / gates (push) Failing after 14m27s
2026-08-11 23:37:20 +02:00
mokhtar d76afc147a milestone 20: declarative configuration for iac 2026-08-11 23:31:40 +02:00
mokhtar 2f29121e27 spec milestone 21: abp list exceptions and operator regex rules 2026-08-11 19:03:58 +02:00
mokhtar 9aa396a65a spec milestone 20: declarative configuration for iac 2026-08-11 18:49:03 +02:00
mokhtar 929626b1dc milestone 14: verify-a-release walkthrough run against v0.0.1, acceptance closed 2026-08-09 01:35:43 +02:00
131 changed files with 14523 additions and 3432 deletions
+121 -55
View File
@@ -14,9 +14,28 @@ name: Gates
# token. A moved tag upstream would execute there. ci.yml itself still uses no
# third-party action; its only `uses:` is this workflow.
#
# Resolved with `git ls-remote <repo> refs/tags/vN 'refs/tags/vN^{}'` on
# 2026-08-07. All three are lightweight tags — no `^{}` peel row — so the
# listed SHA is the commit.
# Resolved with `git ls-remote <repo> refs/tags/vN 'refs/tags/vN^{}'` — the
# three originals on 2026-08-07, the two artifact actions on 2026-08-12. Every
# tag pinned here is lightweight — no `^{}` peel row — so the listed SHA is the
# commit. download-artifact's `v3-node20` is the exception that proves the
# rule: it is annotated, and its peel row names a different commit than the tag
# object. This file pins `v3.1.0-node20`, which is lightweight.
#
# The artifact actions are pinned to v3, and to the node20 rebuilds of v3, for
# two separate reasons.
#
# v4 and v5 abort in the *client*, not the server. @actions/artifact asks
# isGhes(), Gitea answers the way GitHub Enterprise Server does, and the action
# refuses before it uploads anything. The server side is present: Gitea 1.27.1
# routes the whole v4 twirp service — CreateArtifact, FinalizeArtifact,
# ListArtifacts, GetSignedArtifactURL and DeleteArtifact all answer 401, where
# an unrouted path answers 404. The forks that patch isGhes() out are
# third-party code, which is what the pinning rule above exists to keep away
# from the job that holds the signing subkey.
#
# The stock v3 tags — upload ff15f030 (v3.2.1), download 9bc31d5c (v3.0.2) —
# declare `using: node16`, and a runner is not required to still ship a node16.
# The `-node20` rebuilds are that same action code on a runtime that exists.
on:
workflow_call:
@@ -85,7 +104,11 @@ jobs:
run: zig build test-aarch64 -fqemu
frontend:
runs-on: ubuntu-24.04
# The light runner, a second act_runner at capacity 1 that advertises only
# this label. This job peaks around 355 MB (tsc), well inside that runner's
# 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
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -130,7 +153,30 @@ jobs:
working-directory: web
run: npm run assert-bundled
# The package and container jobs consume this bundle instead of building
# their own. The upload is last on purpose: a bundle that failed the
# licence inventory or any check above must not reach the jobs that
# package it into a tarball and an image.
#
# Artifact names are scoped to the run, so the ci.yml and release.yml
# runs that share this runner cannot read each other's.
#
# A later move to v4 has to add `include-hidden-files: true` here.
# `npm run build` writes the freshness stamp to the hidden file
# web/dist/.src-hash (milestone-15 ruling 5), and v4.4.0 and later drop
# dotfiles by default. It is inactive today — v3 keeps them, and the
# package job's target path skips the stamp check regardless — but a move
# to v4 that also pointed the download back at web/dist would fail with
# "web/dist is stale".
- name: Upload the built web UI
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with:
name: web-dist
path: web/dist
if-no-files-found: error
package:
needs: [frontend]
runs-on: ubuntu-24.04
steps:
@@ -149,20 +195,22 @@ jobs:
- name: Create the fetch temp dir zig assumes
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
# `dist` refuses web/dist-placeholder (ruling 4), so a real bundle has to
# exist before the packaging gate runs. It arrives from the frontend job,
# already formatted, linted, typechecked, tested and licence-checked.
#
# The target is deliberately not `web/dist`: build.zig runs the freshness
# stamp check for that exact path and no other (milestone-15 ruling 5),
# and the check shells out to `node`. Here it would buy nothing — the
# stamp hashes the web/ sources, not the bundle, so against a checkout of
# the same commit that built the bundle it can only agree. An explicit
# path is the case build.zig documents for a bundle built elsewhere, and
# taking it keeps node out of this job entirely.
- name: Download the web UI built by the frontend job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
# `dist` refuses web/dist-placeholder (ruling 4), so the real bundle has
# to exist before the packaging gate runs.
- name: Build the web UI
working-directory: web
run: |
npm ci
npm run build
name: web-dist
path: web-dist-ci
# Ruling 2: build.zig.zon is the only place besides the tag that carries
# the version, and ruling 5 makes verify-dist assert the two agree. The
@@ -180,7 +228,7 @@ jobs:
zig build dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web/dist \
-Dweb-dist=web-dist-ci \
-Doptimize=ReleaseSafe
# verify-dist owns every assert the CI shell used to make: ELF static
@@ -196,42 +244,51 @@ jobs:
zig build verify-dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web/dist \
-Dweb-dist=web-dist-ci \
-Doptimize=ReleaseSafe
# deploy/docker/Dockerfile copies both of these trees and nothing else
# out of zig-out/dist: the binary comes from dist/bin/<triple>/, and
# /LICENSE and /THIRD-PARTY-NOTICES come from the matching dist/stage/
# directory, which the container job then hashes against the image. The
# tarballs and SHA256SUMS stay behind — no later job reads them, and
# verify-dist has already checked them here.
#
# The zip round-trip drops the executable bit. That is survivable only
# because the Dockerfile chmods the binary itself and the contents
# assertion compares sha256 of file contents, never modes. The archive
# modes are asserted by verify-dist, above, on the originals.
- name: Upload the staged payload for the container job
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with:
name: dist-payload
path: |
zig-out/dist/bin
zig-out/dist/stage
if-no-files-found: error
container:
needs: [package]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
# Restored under zig-out/dist because that is where the Dockerfile's
# COPY lines look, with the repository root as the build context. This
# job compiles nothing and bundles nothing: the payload is the one the
# package job already built and verify-dist already checked, which is
# also the point — an image built from a second, independent `dist` run
# would prove nothing about the artifacts the release publishes.
- name: Download the staged payload built by the package job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
version: ${{ env.ZIG_VERSION }}
name: dist-payload
path: zig-out/dist
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
# setup-zig's restored cache never contains tmp/. Without this, every
# dependency fetch dies with "failed to create temporary zip file:
# FileNotFound" before any network I/O.
- name: Create the fetch temp dir zig assumes
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build the web UI
working-directory: web
run: |
npm ci
npm run build
# Same single source of truth as the package job (rulings 2 and 5).
# Same single source of truth as the package job (rulings 2 and 5). This
# job still needs the version for the stage directory name it hashes
# against the image and for the VERSION build arg.
- name: Read the version from build.zig.zon
run: |
set -euo pipefail
@@ -239,15 +296,6 @@ jobs:
test -n "$version"
echo "CI_VERSION=$version" >> "$GITHUB_ENV"
- name: Build the release artifacts
run: |
set -euo pipefail
zig build dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web/dist \
-Doptimize=ReleaseSafe
# Image tags, container names and published host ports are all
# daemon-global. This workflow is called by both ci.yml and release.yml
# and the self-hosted runners share one docker daemon, so the fixed
@@ -347,18 +395,36 @@ jobs:
# `-p 127.0.0.1::8080` takes an ephemeral host port instead of a
# fixed 18080, which two concurrent runs on this daemon cannot both
# bind. The actual port is read back with `docker port`.
#
# The command and the sysctl mirror deploy/docker/compose.yaml,
# because that is the invocation this gate exists to prove. The
# invocation is the sole configuration authority (milestone-20 ruling
# 1): the image's bare `run` grades the database, and a fresh
# /var/lib/nxdns volume holds no upstream, so it exits 2 with
# NoUsableUpstreams before it ever binds a port.
net=$(docker inspect "$(hostname)" \
-f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' \
2>/dev/null || true)
cid=$(docker create --name "$SMOKE_NAME" \
${net:+--network "$net"} \
-p 127.0.0.1::8080 \
"$CI_IMAGE")
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
"$CI_IMAGE" run --config=/etc/nxdns/config.zon)
trap 'docker rm -f "$SMOKE_NAME" >/dev/null 2>&1 || true' EXIT
docker cp etc-nxdns/config.zon "$SMOKE_NAME:/etc/nxdns/config.zon"
docker start "$SMOKE_NAME"
hostport=$(docker port "$SMOKE_NAME" 8080/tcp 2>/dev/null | head -1 | awk -F: '{ print $NF }')
# Before anything that assumes a live container. `docker port` fails
# on one that already exited, and under `set -e` that failure is the
# whole diagnosis the log gets — the container's own stderr never
# reaches CI.
if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then
echo "container exited during startup"
docker logs "$cid" || true
exit 1
fi
hostport=$(docker port "$SMOKE_NAME" 8080/tcp | head -1 | awk -F: '{ print $NF }')
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid")
echo "published host port: ${hostport:-none}, container ip: ${ip:-none}"
healthy=""
+1
View File
@@ -4,3 +4,4 @@ zig-pkg/
web/node_modules/
web/dist/
web/dist-sourcemap/
web-dist-ci/
+40
View File
@@ -10,6 +10,46 @@ subject rarely does.
## [Unreleased]
### Added
- **Declarative configuration for IaC.** `nxdns run --config=<file>` makes the
file the sole source of configuration: every boot converges the database to
it in one transaction, preserving blocklist downloads, compiled lists and
client history, so an unchanged file costs zero downloads and zero writes.
Bare `nxdns run` keeps the database (and the web UI) in charge, exactly as
before. In file mode the web UI is read-only for configuration and says so;
runtime actions (pause, blocklist refresh, certificate reload) stay live.
`GET /api/settings` reports which authority governs the process.
- `nxdns import` now refuses a file whose application would delete
configuration rows, names the tables and counts, and applies it only with
the new `--allow-delete` flag. Additive and edit-in-place imports need no
flag.
### Changed
- **Breaking: `nxdns run --config <file>` changed meaning.** It used to seed
the database once and then ignore the file; it now makes the file the
authority on every boot, which deletes any configuration the file does not
declare — including edits made through the web UI since the seed. Before
upgrading a unit that carries `--config`: either drop the flag to keep the
database in charge, or adopt file mode with the sequence in the upgrade
guide. Order matters there: export the file with the NEW binary (stopped).
- **Breaking: 0.0.1 exports are refused by this version.** A 0.0.1
`nxdns export` writes both `.password = ""` and the stored
`.password_hash`, and this version refuses a file that carries both. This
bites any old export — an adoption file or a configuration backup fed to
`nxdns import` alike. Fix an existing export by deleting its
`.password = ""` line (keep the `.password_hash` line). Take fresh backups
with the new binary.
- **Breaking: the offline password-change recipe changed.** Setting
`.password = "new"` together with `.password_hash = ""` is now refused
(empty `password_hash` is an explicit "disable authentication", and the two
fields cannot both be present). To change the password in the file: set
`.password` and delete the `.password_hash` line entirely.
- `nxdns import --force` is renamed `--allow-delete`.
- A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence.
Use `nxdns import` once, or run in file mode with `--config`.
## [0.0.1] - 2026-08-09
First release. Everything below is new.
+19 -9
View File
@@ -41,10 +41,9 @@ 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`.
## 2. Write the seed configuration
## 2. Write the configuration
nxdns starts from an empty database only if a configuration file tells it what
to forward to. Write `/etc/nxdns/config.zon`:
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`:
```zon
.{
@@ -76,16 +75,27 @@ A good file ends with `OK: no problems found`. Exit 2 means `check` found
something to fix and printed every problem it found. The upstream probe sends a
real query, so this needs working DNS on the host.
The file seeds the database once. From the second start onwards it is ignored
and the database is the configuration. The seed's `web.password` is hashed at
import time and the plaintext is never stored, so once you have logged in you
can delete the file:
Load it into the database:
```sh
nxdns import /etc/nxdns/config.zon
```
The packaged unit runs `nxdns run` with no `--config`, so from here the database
is the configuration and nothing reads the file again. `web.password` is hashed
and the plaintext is never stored, so once you have logged in you can delete the
file:
```sh
rm /etc/nxdns/config.zon
```
A kept seed is not a backup. `nxdns export` is.
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`.
## 3. Start it
@@ -107,7 +117,7 @@ dig @<server-ip> example.com A +short
```
The admin interface is on port 8080 by default; log in with the password from
the seed file. `http://<server-ip>:8080/api/health` reports upstream
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
availability and disk state without a login.
## More
+2 -2
View File
@@ -135,7 +135,7 @@ IPv4 + IPv6 full parity for: client identity, rate limiting, logging, group assi
### 3.14 Frontend Stack (Decision I)
- Vite + React + TypeScript + Tailwind; TanStack Router + TanStack Query. SPA, no SSR.
- Vite + React + TypeScript + StyleX + React Aria; TanStack Router + TanStack Query. SPA, no SSR.
- **Built assets embedded in the binary** at compile time: single self-contained artifact, no asset-path config, no binary/UI skew. `zig build` accepts the dist path; the release pipeline runs `npm run build` first (CI always does).
- Dev-mode flag serves assets from disk so UI iteration needs no Zig rebuild.
@@ -232,7 +232,7 @@ src/
rules.zig local.zig lookup.zig pause.zig settings.zig
upstream_health.zig certs.zig health.zig version.zig
web/ # Vite + React + TS + Tailwind + TanStack
web/ # Vite + React + TS + StyleX + React Aria + TanStack
vendor/ # sqlite3 amalgamation, mbedtls (pinned)
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
tests/ # dns/ integration/ fuzz/
+31 -10
View File
@@ -1,14 +1,26 @@
# nxdns
A self-hosted DNS sinkhole for a household LAN, written in Zig 0.16. One
static musl binary, SQLite for state, a Raspberry Pi 5 as the reference
target. It answers your network's DNS, blocks what you tell it to, and shows
you what asked for what.
nxdns is a DNS sinkhole for your LAN. It blocks the names you do not want, and
forwards the rest over an encrypted connection.
```mermaid
flowchart LR
devices["Your devices"] -- "DNS query" --> nxdns["nxdns"]
nxdns -- "answer" --> devices
nxdns -- "allowed" --> upstream["Upstream resolvers<br/>DoH / DoT"]
upstream -- "answer" --> nxdns
nxdns -- "blocked" --> sink["0.0.0.0 / NXDOMAIN"]
```
One static Zig binary. SQLite holds the state and the query log, which the web
UI, the REST API and `/metrics` read.
## Features
- Blocklist filtering: subscribe to hosts/domain lists, plus your own allow
and block rules with wildcard support (`*.example.com`)
- Two configuration modes: a database the web UI edits, or a ZON file you keep
in git and converge onto at every start
- Per-client policy groups: different filtering for the kids' tablet and
your workstation
- Local DNS records and conditional forwarding for internal zones
@@ -39,7 +51,7 @@ says what that signature does and does not prove.
## Quickstart (docker compose)
Seed a minimal configuration and start the published image. This is what the
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:
@@ -60,11 +72,20 @@ 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 config
file seeds the database on first boot only; from then on the database is the
truth and changes go through the UI, the API, or `nxdns export` /
`nxdns import`. Full install instructions, including the systemd path and
the Pi 5 recipe, are in
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).
+11 -4
View File
@@ -6,10 +6,17 @@ services:
# NXDNS_IMAGE=nxdns.
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}
restart: unless-stopped
# First boot needs ./etc-nxdns/config.zon with a `default` group and at
# least one enabled upstream, or the container exits with code 2. The file
# seeds the database once; after that the database is the truth and the
# file is ignored.
# `run --config` makes the file the sole source of configuration: nxdns
# reconciles the database onto ./etc-nxdns/config.zon at every start, and
# rejects configuration writes from the admin UI. Edit the file and restart
# the container to change anything. The file needs a `default` group and at
# least one enabled upstream, or the container exits with code 2. A
# recreated nxdns-data volume rebuilds itself from the file on next start.
#
# Drop this line to let the database be the truth instead, and load a first
# configuration once with:
# docker compose run --rm nxdns import /etc/nxdns/config.zon
command: ["run", "--config=/etc/nxdns/config.zon"]
volumes:
- ./etc-nxdns:/etc/nxdns:ro
- nxdns-data:/var/lib/nxdns
+8
View File
@@ -16,6 +16,11 @@ StateDirectoryMode=0700
LogsDirectory=nxdns
ConfigurationDirectory=nxdns
# ConfigurationDirectory creates /etc/nxdns owned by the service user. nxdns
# never writes there in either authority mode, and in file mode that directory
# holds the source of truth, so deny the write outright rather than rely on it.
ReadOnlyPaths=/etc/nxdns
# Port 53 (and 443/853 when the DoH/DoT listeners are enabled).
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
@@ -44,6 +49,9 @@ SystemCallArchitectures=native
Restart=on-failure
RestartSec=2
# Exit 2 is a configuration fault and 64 is a usage error. Neither clears on a
# retry, so a restart loop only buries the diagnostics already in the journal.
RestartPreventExitStatus=2 64
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -65,7 +65,7 @@ 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
the file seeds the database once and the database is the truth afterwards.
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.
+12 -6
View File
@@ -35,7 +35,7 @@ Directories:
| `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` (open-or-recreate), 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), `bootstrap.zig` (first-start seeding — a policy wrapper over import). |
| `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`). |
@@ -47,7 +47,7 @@ main.zig ── cli.zig ── app.zig (composition root)
│ injects std.Io + collaborators
┌──────────────────────┴───────────────────────┐
│ server/ web/ upstream/ storage/ │ I/O edge
│ platform/ config/{import,export,bootstrap} │
│ platform/ config/{loader,reconcile,import} │
├──────────────────────────────────────────────┤
│ dns/ filter/* local/* cache/ │ pure core:
│ config/{model,validate} │ bytes in, bytes out
@@ -162,14 +162,20 @@ working resolver over a correct-looking failure.
Two databases with opposite contracts, in one data directory (see
[reference/files-and-directories.md](../reference/files-and-directories.md)).
**`config.db` is the truth.** Its schema is versioned: `migrations.zig` holds
**`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. A config file seeds this database exactly
once at first start. Why it works that way is
[configuration-model.md](configuration-model.md).
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 expendable.** It is never migrated. Its schema carries a
fingerprint derived from the DDL text, and at open, a missing, corrupt,
+183 -146
View File
@@ -1,8 +1,9 @@
# The configuration model
nxdns is configured two ways — a ZON file and a web UI — and only one of them
can be the truth. This page explains which, and why that choice is the one
that leaves the fewest ways to lose an operator's work.
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.
For the fields themselves see
[reference/configuration.md](../reference/configuration.md); for the commands
@@ -10,146 +11,169 @@ and their exit codes see [reference/cli.md](../reference/cli.md).
## The rule
The database is the truth. The file is a seed.
**Authority is the invocation.**
`config.db` in the data directory holds the running configuration. The ZON
file (default `/etc/nxdns/config.zon`, overridable with `--config`) is read on
`nxdns run` only while the database is still empty: if the file exists and the
database holds no configuration, it is imported. Once the import has put rows
in, the file is not opened again.
```
nxdns run the database is the truth
nxdns run --config /etc/nxdns/config.zon the file is the truth
```
The condition is the state of the database, not a one-shot flag. A `run` whose
seed fails — unreadable file, parse error, failed validation, a constraint
violation inside the import transaction — leaves the database empty, so the
next `run` reads the file again. That is what makes fixing a typo and starting
again work.
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.
`src/config/bootstrap.zig` is that policy and nothing else — a wrapper over
the same import path `nxdns import` uses. Three outcomes:
Two properties fall out of that, and both were chosen on purpose.
- no file: the database is used as it is;
- file present, database empty: seed it;
- file present, database already configured: skip, without reading the file.
**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 file that exists but is unreadable, unparseable or invalid fails the start,
with every problem printed. nxdns never falls back to silent defaults over a
file an operator wrote — a resolver that boots "successfully" with a
configuration nobody chose is the worst outcome available, because it looks
like it worked.
**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.
## Why the database wins
## Database mode
The alternative designs all lose data.
`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.
If the file were the truth, the admin UI could not write. Every change would
be an SSH session and a restart, which defeats the reason the UI exists: the
household member who wants to unblock one domain is not going to edit ZON.
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:
If both were the truth, they would disagree. The UI writes a rule; the file
still says otherwise; the next restart either silently reverts the rule or
silently ignores the file. Both are silent, and both destroy work someone
intended to keep. There is no merge rule that fixes this, because the system
cannot know which of two conflicting statements is the newer intention.
```
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>`
```
So the file's authority ends the moment the database has content. Editing
`config.zon` after first boot does nothing — no partial effect, no warning
that some fields took and others did not. That is a blunt rule, and it is the
point: the failure mode is "my edit did nothing", which is visible the first
time you look at the UI, rather than "my edit was applied and then quietly
undone next Tuesday".
## File mode
The emptiness check is a real query over the content tables, not a flag: the
database counts as configured when a content table holds rows, or when the
default group has been altered. What it measures is intent, not activity, and
the client table is where those two come apart. Clients are auto-materialised
when they first send a query — the DNS path writes a row per device it sees —
and such a row records what the network did, not what an operator decided. It
carries `hand_edited = 0`, the emptiness check counts only the `hand_edited = 1`
rows, and `export` omits them. So answering queries never turns an unconfigured
database into a configured one; naming a device does.
`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.
Counting traffic here would have been a quiet trap: a server that resolved one
name would have declared itself configured and ignored a seed file placed
afterwards, and the operator would have had no line of output saying why.
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".
The same distinction survives an import. `import` replaces the content in one
transaction, which empties `clients` along with every other table, so every
client row is saved before the wipe. The materialised ones are put back after it
unchanged, and restoring a backup does not make the server forget the devices it
has met.
Three properties make it usable rather than merely correct.
An address the imported file names belongs to the file — the operator's
statement wins over the discovered row — with one carve-out. First-seen and
last-seen are not configuration: they record when a device was heard from, the
configuration model has no field for either, and an import is not a query. So
they follow the address rather than the row. If the database already knew that
address, its two timestamps are carried onto the new row; only an address the
database has never seen takes the import's clock. Without that, re-importing a
backup would stamp every device the operator had bothered to name as though it
had just arrived — and those are exactly the devices whose history is worth
something.
**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.
**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.
**An unchanged file writes nothing at all.** Not "writes the same bytes" —
performs zero write statements, and reports it:
```
reconciled '/etc/nxdns/config.zon': no changes
```
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.
**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.
## What the file cannot take away
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.
- **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).
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.
## Why the database is the substrate in both modes
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
Losing the file as an editing surface would be a real loss — text is
diffable, reviewable and easy to back up — so the file is kept as a
*rendering* of the database rather than a rival to it. That is what
`export`/`import` are for:
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 --force → config.db
nxdns export → canonical ZON → edit → nxdns import → config.db
```
`nxdns export` renders the database as 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 — first-seen, last-seen, per-source counters — are
absent from the configuration model on purpose, so two exports taken from a
live, busy server are identical. The round trip `export → import → export` is
byte-identical, and a test asserts it.
`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.
`nxdns import` replaces the whole database content in one transaction. Not a
merge, not a patch: delete every content table in foreign-key-safe order, then
insert what the file says — with the one exception described above. Every client
row is lifted out first. The materialised ones are put back rather than
recreated from a file that never held them, and the observed timestamps of an
address the file *does* name are merged onto its new row. A client the file
leaves out is gone, history included: nothing puts a `hand_edited = 1` row back.
A failed import — bad syntax, failed
validation, a constraint violation halfway through — leaves the database exactly
as it was, because everything happens inside a single `BEGIN IMMEDIATE`.
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.
Without `--force`, import refuses a database that already holds configuration.
That
check runs *inside* the transaction, after the lock is taken, so it cannot be
raced by a concurrent write. The effect is that a plain `import` can never
clobber a configured server by accident, and clobbering it deliberately takes
one visible extra word on the command line. See
[how-to/back-up-and-restore.md](../how-to/back-up-and-restore.md).
## One model, three surfaces
`src/config/model.zig` defines exactly one `Config` type. The ZON parser
produces it, the database reader produces it, the validator consumes it, the
composition root consumes it, and the settings API derives its key list and
its patch struct from its type information rather than mirroring the fields by
hand. Adding a field in one place therefore cannot leave the other surfaces
behind; the alternative — a file schema, a DB schema and an API schema kept in
step by discipline — is the standard way configuration systems rot.
The model has two shapes of field, and the difference is structural rather
than stylistic. Struct-typed fields are scalar sections (`dns`, `cache`,
`web`, …) and live in a single key/value `settings` table as
`section.field` text rows. Slice-typed fields are collections (groups,
upstreams, clients, rules, local records, forward zones, …) and each gets its
own table with foreign keys. `toSettings` and `fromSettings` are the two
halves of the scalar bridge, generated by an `inline for` over the model, so
the key list is a consequence of the type rather than a second list to
maintain.
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
@@ -157,52 +181,65 @@ 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.
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.
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.
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.
`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.
At import time, a non-empty `web.password` is hashed with argon2id (PHC
encoding, OWASP argon2id parameters) into `web.password_hash`, and the
plaintext field is cleared before anything is written. There is no settings
row that can hold it: the model explicitly skips `web.password` in both
directions of the settings bridge, so the plaintext has nowhere to go even by
accident. `nxdns export` always writes `.password = ""` and carries the hash
instead — which is also what makes the round trip stable, since an export that
tried to reproduce a plaintext it never had could not be byte-identical.
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.**
Setting both `password` and `password_hash` in one file is an error rather
than a precedence rule. The two say different things about what the password
is, and picking a winner would mean the operator's other statement was
silently discarded. The file has to say one thing.
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 practical shape of a password change is therefore export, set `.password`
to the new value, clear `.password_hash`, import with `--force`. The procedure
is in
[how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md).
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`, `--web-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
`--config`, `--web-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).
+106 -58
View File
@@ -1,9 +1,22 @@
# Back up and restore
The configuration database is the only state worth keeping. `nxdns export`
writes it out as a ZON file and `nxdns import` writes one back. The query log is
deliberately not part of a backup: it is expendable history, and if it is
missing it gets recreated empty.
The configuration is the only state worth keeping. `nxdns export` writes it out
as a ZON file and `nxdns import` writes one back. The query log is deliberately
not part of a backup: it is expendable history, and if it is missing it gets
recreated empty.
**Which authority the service runs under decides what the backup *is*.** Check
the start log:
- `authority: database``config.db` holds the configuration. Back it up with
`nxdns export`, and restore with `nxdns import` or by replacing the database
file.
- `authority: file (<path>)` — that file holds the configuration, and it is
already a text file you can keep in git. **The file is the backup.** Restoring
means putting the file back and restarting; the database rebuilds itself from
it. `config.db` is a cache of the file in this mode, not the thing to preserve.
The rest of this page covers database mode unless it says otherwise.
The commands below use the scratch lab from
[enable DoH and DoT](enable-doh-and-dot.md), data directory
@@ -40,13 +53,16 @@ grep password /tmp/nxdns-lab/backup.zon
```
```
.password = "",
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$Gh+zg9xke6BqSVOiouRbqG50+Bs8ZGcXA6oKgs7lrKg$crTNMu5OI8yKBkp31r4+Y1OUQmLiAlH/qvsIxjBQRq4",
.password = null,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$hOjjnTrTZ6kU8XrwQuJ7ZFC3B5LumaB4hRe7kBbzJ6Q$YsC2rCTDKv96eEwPhs+D6vbDliLogEZph8EkagKSuy8",
```
Treat backups as secrets. `.password` is always exported as `""` — the
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.
knowing the password. `null` and `""` are different statements here: `null`
means the file says nothing about the password, while an empty `password_hash`
would disable authentication. See
[Password and hash](../reference/configuration.md#password-and-hash).
Without `--out` the export goes to stdout, where the file mode is your
redirect's problem:
@@ -57,7 +73,7 @@ nxdns export --data-dir /tmp/nxdns-lab/data | head -10
```
// nxdns configuration
// generated by `nxdns export` the database is the source of truth
// generated by `nxdns export` from the running configuration
.{
.upstream = .{
.attempt_timeout_ms = 2500,
@@ -75,8 +91,8 @@ exports taken minutes apart differ.
## Restore onto a fresh data directory
This is the normal restore: new machine, new disk, empty data directory. No
`--force`, because there is nothing to overwrite.
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
@@ -92,64 +108,95 @@ before writing to it.
## Restore over an existing database
`import` refuses a database that already holds configuration, so a plain
`import` can never clobber a configured server by accident:
```sh
nxdns import /tmp/nxdns-lab/backup.zon --data-dir /tmp/nxdns-lab/data
```
```
import failed: DatabaseNotEmpty
```
That exits 2. Say `--force` when replacing is what you mean:
```sh
nxdns import /tmp/nxdns-lab/backup.zon --force --data-dir /tmp/nxdns-lab/data
```
```
imported /tmp/nxdns-lab/backup.zon
```
Stop the server first. `import` replaces the whole configuration underneath a
process that has already read it, and a running server will not notice.
What `--force` does to the client list is worth knowing before you restore an
old backup. A client the backup does not name is removed, and its first-seen
and last-seen go with it — restoring a month-old file drops the devices you
named since. Devices the server discovered from traffic are kept, and a device
the backup does name keeps the first-seen and last-seen the database already
held, so a restore does not restamp your whole network as newly arrived.
On a real install that is the systemd unit:
**Stop the server first.** `import` rewrites configuration underneath a process
that read it at startup, and a running server picks up only part of it —
filtering follows the new rows at the next reload, while upstreams, listeners
and settings stay at their boot values until a restart.
```sh
systemctl stop nxdns
nxdns import /var/backups/nxdns-config.zon --force
nxdns import /var/backups/nxdns-config.zon
systemctl start nxdns
```
**Not verified on this host.** Those three lines are the only commands on this
page that were not run: this machine has no installed nxdns systemd unit
(`systemctl status nxdns` answers `Unit nxdns.service could not be found.`) and
`systemctl stop`/`start` need root. The lab equivalent below was run, and it
exercises the same stop-import-start sequence. In the lab the server is a
foreground `nxdns run`, so stopping it is Ctrl-C in its own terminal:
`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 export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/pre-restore.zon
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
nxdns import /tmp/nxdns-lab/pre-restore.zon --force --data-dir /tmp/nxdns-lab/data
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
nxdns import /tmp/nxdns-lab/old-backup.zon --data-dir /tmp/nxdns-lab/data
```
```
wrote /tmp/nxdns-lab/pre-restore.zon
imported /tmp/nxdns-lab/pre-restore.zon
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:
```sh
nxdns import /tmp/nxdns-lab/old-backup.zon --allow-delete --data-dir /tmp/nxdns-lab/data
```
```
imported /tmp/nxdns-lab/old-backup.zon
```
A restore that only puts back what is already there needs no flag at all, and
neither does one that only adds rows. The flag is about deletion specifically —
including deletion in disguise: renaming a group, or correcting a typo in an
upstream URL, changes the row's identity, so the engine sees one row gone and
one arrived.
What survives a restore is worth knowing before you take an old backup out of
the drawer. Blocklist download state is kept for every source whose URL the file
still names — checksum, counters, compiled files, and the row id they are keyed
by — so restoring does not cost a re-download. Devices the server discovered
from traffic are kept whole, and one the backup names keeps the first-seen and
last-seen the database already held, so a restore never restamps your network as
newly arrived. What does go is a client the backup does not name and that was
named by hand: that row is declarative, and it is deleted with the rest.
> The three `systemctl` lines are the only commands on this page that were not
> run: this machine has no installed nxdns unit (`systemctl status nxdns`
> 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.
```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
```
> Not verified on this host: these need root, an installed unit and
> `/var/lib/nxdns`, none of which exist here.
None of this applies in file mode. There `config.db` is derived state — restore
the configuration file and start the service, and the first reconcile rebuilds
the database from it.
## Verify a backup
The round trip is byte-stable: exporting, importing and exporting again gives
@@ -200,5 +247,6 @@ 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 command on this page was executed on this host as written, except the
`systemctl` block marked **Not verified on this host** above.
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.
+6 -4
View File
@@ -62,10 +62,12 @@ to:
}
```
Write that to `/tmp/nxdns-lab/etc/config.zon`. The configuration file seeds an
empty database and is then ignored; to change these settings on a server that
already has a database, edit them through the API or through
`export`/`import` — see [the configuration model](../explanation/configuration-model.md).
Write that to `/tmp/nxdns-lab/etc/config.zon`. The lab runs
`nxdns run --config`, which makes that file the configuration: every start
reconciles the database onto it, so editing the file and restarting is how these
settings change here. A real install may instead run bare `nxdns run` and keep
the configuration in the database — see
[the configuration model](../explanation/configuration-model.md).
## 3. Check the files before starting
+77 -31
View File
@@ -10,16 +10,29 @@ supported and is the last section of this page.
For what each configuration field means, see
[the configuration reference](../reference/configuration.md).
> Verification: the seed-file failure modes, the run and the two checks in
> step 3 were run on the machine that wrote this page, against an image built
> from this checkout rather than pulled from the registry — no release is
> 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 here, 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.
> 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 `web/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
@@ -65,7 +78,7 @@ does and does not prove.
> `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 seed configuration
## 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,
@@ -82,7 +95,7 @@ curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
> Gitea `1.27.0+dev` and returned the file with a 200.
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create
it and put the seed file in it:
it and put the configuration in it:
```sh
mkdir -p deploy/docker/etc-nxdns
@@ -100,15 +113,25 @@ upstream:
}
```
Without that file the container exits with code 2 on a fresh volume: an empty
database has nothing to forward to. The log is
`no configuration file at '/etc/nxdns/config.zon'; using the database as it is`
followed by `nxdns run failed: NoUsableUpstreams`.
**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. Both were run here against a locally built
image. A seed file whose only group was named `other`:
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
@@ -116,18 +139,31 @@ nxdns run failed: MissingDefaultGroup
run `nxdns check` to see the configuration in full
```
and an empty `/etc/nxdns`:
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).
```
info(config_bootstrap): no configuration file at '/etc/nxdns/config.zon'; using the database as it is
nxdns run failed: NoUsableUpstreams
run `nxdns check` to see the configuration in full
### 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:
```sh
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
```
Under `restart: unless-stopped` either one is a restart loop, and the exit code
alone no longer tells them apart: read the lines above the failure, which either
name the diagnostic in the file or say there was no file at all. See
[Troubleshoot nxdns](troubleshoot.md).
The file is positional; add `--allow-delete` when re-running it against a
populated volume and the diff deletes rows. Without this step, `restart:
unless-stopped` plus exit 2 is a crash loop with no way out.
> Not run in a container on this host, for the reason in the verification note
> at the top: no image could be staged here. The `nxdns import <file>` and
> `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
@@ -140,9 +176,12 @@ chmod 0600 deploy/docker/etc-nxdns/config.zon
```
> Not verified on this host: `chown` to a uid you do not own needs root. What
> was verified is the failure it prevents — a seed file at 0600 owned by
> another uid makes the container log `nxdns run failed: AccessDenied` and
> restart in a loop. See [Troubleshoot nxdns](troubleshoot.md).
> 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
@@ -169,14 +208,21 @@ 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 seeding and the bound sockets:
A healthy first start logs the reconcile, the authority and the bound sockets:
```
info(config_bootstrap): seeded the database from '/etc/nxdns/config.zon'
info(migrations): config.db migrated from schema version 0 to 2
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 …
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.
Confirm it answers and that the admin interface is up:
```sh
+182 -28
View File
@@ -154,11 +154,11 @@ 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 seed configuration
## 4. Write the configuration
nxdns starts from an empty database only if a configuration file tells it what
to forward to. Write `/etc/nxdns/config.zon`. The smallest file that starts is
one group named `default` and one enabled upstream:
nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`.
The smallest file that starts is one group named `default` and one enabled
upstream:
```zon
.{
@@ -213,36 +213,49 @@ The upstream probe sends a real query, so this needs working DNS on the host at
the time you run it. Exit 2 means `check` found something to fix and printed
every problem it found, not only the first.
The file seeds the database once. From the second start onwards it is ignored
and the database is the configuration; see
[the configuration model](../explanation/configuration-model.md) and
[Upgrade nxdns](upgrade.md) for how to change settings after that.
Now load it into the database:
Once the seed has been consumed — after step 6 confirms you can log in — the
plaintext in it is dead weight that only carries risk. The seed's
`web.password` is hashed into `web.password_hash` at import time and the
plaintext is never stored; `nxdns export` writes `.password = ""` back out
alongside the hash. Nothing downstream ever reads the plaintext again, so
delete the file:
```sh
nxdns import /etc/nxdns/config.zon
```
```
info(migrations): config.db migrated from schema version 0 to 2
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:
```sh
rm /etc/nxdns/config.zon
```
Keep it only if you want the seed as a record of the intended starting
configuration, and if you keep it, leave it at 0640 root:nxdns. Note that a
kept seed 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.
A kept file is not a backup — `nxdns export` is (see
[Back up and restore](back-up-and-restore.md)), and the export carries the
password hash rather than the password. If you keep it, leave it at 0640
root:nxdns.
That is the **database mode** install, which is what the packaged unit runs:
`ExecStart=/usr/local/bin/nxdns run`, no `--config`, so nothing reads a file
after this step. Change settings afterwards through the admin interface, the
API, or an exporteditimport 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`: a seed written under umask 022 came out
> 0644, `nxdns check --config` on it printed `OK: no problems found` with no
> mode warning, and after `nxdns import` of that seed an `nxdns export` wrote
> `.password = ""` next to a populated `.password_hash =
> "$argon2id$v=19$..."`. The `chown`, `chmod` and `rm` lines above are the
> ordinary root-owned-file operations and were not run against a real
> `/etc/nxdns`, which this host does not have.
> `/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
@@ -263,9 +276,16 @@ nxdns writes to stderr and systemd captures that into the journal; logging
needs no further configuration. Port 53 is privileged, and the unit grants
`CAP_NET_BIND_SERVICE` through `AmbientCapabilities`.
The unit does not restart the service after exit 2 or exit 64
(`RestartPreventExitStatus=2 64`). Those are a wrong configuration and a wrong
command line, and neither clears on a retry — restarting every two seconds until
`StartLimitBurst` gives up would only bury the diagnostics that are already in
the journal. `systemctl status nxdns` shows the failed state; fix the cause and
start it again.
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
seed file that does not parse.
configuration file that does not parse.
## 6. Confirm it answers
@@ -276,7 +296,7 @@ dig @<server-ip> example.com A +short
```
The admin interface is on port 8080 by default; log in with the password from
the seed file. `http://<server-ip>:8080/api/health` reports upstream
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
availability and disk state without a login.
> Not verified on this host as written: `<server-ip>` is a placeholder, and a
@@ -287,6 +307,140 @@ availability and disk state without a login.
> port returned 200. Only the address and the port differ from the lines
> above.
## 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.
The packaged unit is flagless on purpose — it is correct as shipped, and a
commented-out alternative `ExecStart` in a unit file is documentation
masquerading as configuration. File mode is a drop-in.
### Adopt file mode on a box that is already running
Run these in order. **Stop first**, and do not skip that: any edit made through
the UI between an export and the restart would be silently reverted by the first
reconcile, and `nxdns check` against a live database refuses to grade it (below).
If you are arriving here from an upgrade, the binary must already be the new
one before you export. An export written by 0.0.1 carries a `.password = ""`
line this binary refuses; see
[the order trap](upgrade.md#the-order-trap-export-with-the-new-binary-not-the-old-one).
```sh
systemctl stop nxdns
nxdns export --out /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:
```sh
mkdir -p /etc/systemd/system/nxdns.service.d
cat > /etc/systemd/system/nxdns.service.d/file-mode.conf <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/local/bin/nxdns run --config=/etc/nxdns/config.zon
EOF
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:
```
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.
The file now carries `web.password_hash`, so restrict it the same way step 4
does — `chown root:nxdns`, `chmod 0640`. The unit's `ReadOnlyPaths=/etc/nxdns`
denies the service write access to that directory, so the process that reads the
file cannot modify it.
### Change the configuration from now on
Edit the file, validate it, restart:
```sh
$EDITOR /etc/nxdns/config.zon
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:
```
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +0 ~2 -0;
settings keys changed: dns.port web.port
```
### Leave file mode
Remove the drop-in and restart. The database already holds the last reconciled
state, so nothing else is needed and the server comes back serving the same
configuration:
```sh
rm /etc/systemd/system/nxdns.service.d/file-mode.conf
systemctl daemon-reload
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
+86 -21
View File
@@ -11,7 +11,7 @@ data directory is `/var/lib/nxdns` and the web port is 8080.
## 1. Set the password
Put it in the seed configuration file, under `web`:
Put it in the configuration file, under `web`:
```zon
.{
@@ -21,17 +21,60 @@ Put it in the seed configuration file, under `web`:
}
```
At import time the plaintext is hashed with argon2id into `web.password_hash`
and discarded. It becomes no database row and appears in no log line. Setting
both `password` and `password_hash` in one file is refused:
The plaintext is hashed with argon2id into `web.password_hash` and discarded. It
becomes no database row and appears in no log line. Setting both `password` and
`password_hash` in one file is refused:
```
web.password: password and password_hash are both set; ambiguity in a security setting is refused
import failed: PasswordAndHashBothSet
```
The seed file is read only while the database is empty. On a server that
already has a database, use step 4 or step 5 instead.
Applying that file — with `nxdns import`, or with a `nxdns run --config` start —
announces the change:
```
web authentication is now enabled
```
### Absent, empty, and set are three different things
The two fields are optional, and the difference between leaving one out and
setting it to `""` is the difference between keeping your password and removing
it:
| The file says | Effect on 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. |
| `.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:
```
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:
```
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
@@ -63,7 +106,7 @@ 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 1785770178 nxdns_session <redacted>
#HttpOnly_127.0.0.1 FALSE / FALSE 1786559938 nxdns_session <redacted>
```
The cookie is named `nxdns_session` and carries `HttpOnly; SameSite=Lax;
@@ -123,6 +166,9 @@ 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.
@@ -161,7 +207,7 @@ Log back in with the new password. That is the whole rotation.
If you have lost the password, the admin interface cannot help — go through the
database instead. Export, edit, import. `nxdns export` always writes
`.password = ""` and carries the hash, so an exported file re-imports without
`.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`:
@@ -169,11 +215,22 @@ clear `.password_hash`:
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/rekeyed.zon
```
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon` so it reads:
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon`: set `.password` to the
new value and **delete the `.password_hash` line entirely**, so the `web` block
carries one password field and not two:
```zon
.password = "offline-password",
.password_hash = "",
```
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
@@ -184,14 +241,17 @@ terminal stops it, and it goes back up with the same command:
```sh
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
nxdns import /tmp/nxdns-lab/rekeyed.zon --force --data-dir /tmp/nxdns-lab/data
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
nxdns import /tmp/nxdns-lab/rekeyed.zon --data-dir /tmp/nxdns-lab/data
nxdns run --data-dir /tmp/nxdns-lab/data
```
```
imported /tmp/nxdns-lab/rekeyed.zon
```
No flag is needed: replacing a password edits a settings value and deletes no
rows.
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`
@@ -202,7 +262,7 @@ Once it is back up the old password is refused and the new one works:
```sh
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
-H 'content-type: application/json' -d '{"password":"a-new-password"}' \
-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"}' \
@@ -217,19 +277,19 @@ curl -sS -b /tmp/nxdns-lab/c5.txt -o /dev/null -w 'stats: %{http_code}\n' \
stats: 200
```
The next export shows the new hash and an empty `password` again:
The next export shows the new hash and a null `password` again:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data | grep password
```
```
.password = "",
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$kvlRj1tdGul3MlfbvzLncLKWirNpJRJ3howFA9/ysgg$7elW7PPQ3WXHwI4YOmOpZ/1KNEQo7ZDLRJhnYOPMjqw",
.password = null,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$xqzK66LgiWGvyCmCl6ZRa3GHH0nS5qZnRgVfWmeGadc$1mafhflKFIg3vcHjJaDMAXGQiOjtym2UADZsPW1xkfw",
```
`--force` is required because the database already holds configuration. See
[back up and restore](back-up-and-restore.md).
See [back up and restore](back-up-and-restore.md) for when `import` does need
`--allow-delete`.
## What happens with no password set
@@ -279,6 +339,11 @@ interface at the very least, and preferably set a password.
[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, except the
`systemctl` stop and start named in step 5 and marked **not verified on this
host** there.
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.
+105 -30
View File
@@ -37,10 +37,22 @@ checked on its first line.
**Fixes by cause.**
- `NoUsableUpstreams` — the database has no enabled upstream. On a fresh
install this means the seed file was missing or in the wrong place; the start
log says `no configuration file at '/etc/nxdns/config.zon'; using the
database as it is`. Write the seed file and start again against the still
empty database, or `nxdns import <file> --force`.
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:
@@ -79,10 +91,10 @@ checked on its first line.
- `BadBindAddress``dns.bind_ipv4` or `dns.bind_ipv6` is not an address of
that family.
## A seed file you just wrote is rejected
## A configuration file you just wrote is rejected
**Symptom.** A first start against an empty database prints the validation
problem and stops with exit 2:
**Symptom.** `nxdns run --config`, `nxdns check --config` or `nxdns import`
prints the validation problem and stops with exit 2:
```
FAIL groups: no group named 'default'; every unknown client is assigned to it
@@ -98,7 +110,7 @@ nxdns run failed: ParseZon
run `nxdns check` to see the configuration in full
```
So does a seed file whose upstream list is empty or all disabled:
So does a file whose upstream list is empty or all disabled:
```
FAIL upstreams: at least one upstream must be enabled
@@ -106,9 +118,9 @@ nxdns run failed: NoUpstreams
run `nxdns check` to see the configuration in full
```
`NoUpstreams` from a seed file is not the same fault as `NoUsableUpstreams`
above: the first is a file `run` refused, the second is a database `run`
accepted and found empty. Both are exit 2.
`NoUpstreams` from a file is not the same fault as `NoUsableUpstreams` above:
the first is a file `run` refused, the second is a database `run` accepted and
found empty. Both are exit 2.
**Diagnosis.** Run the same file through `check`, which reports the same
problems and exits 2:
@@ -117,11 +129,21 @@ problems and exits 2:
nxdns check --config /etc/nxdns/config.zon
```
**Fix.** Correct the file the diagnostics name and start again. The database is
still empty after a failed seed, so the next start re-reads the file. The exit
code no longer depends on which command read the file: all three of these files
were run through `run`, `check` and `import` here, and every one of the nine
combinations exited 2 with the same diagnostic.
**Fix.** Correct the file the diagnostics name and start again. Nothing was
applied — a file-mode reconcile happens in one transaction that rolls back, and
a failed `import` leaves the database untouched. The exit code does not depend
on which command read the file: all three of these files were run through `run`,
`check` and `import` here, and every one of the nine combinations exited 2 with
the same diagnostic.
Under the shipped systemd unit an exit 2 stops the service rather than
restarting it (`RestartPreventExitStatus=2 64`), so the journal holds the
diagnostics instead of drowning them in a restart loop. `systemctl start nxdns`
once the file is fixed.
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
@@ -210,10 +232,11 @@ startup cycle, not a fix.
## The container restarts in a loop
**Symptom.** `docker compose ps` shows the container restarting, and the log is
one line repeated:
the same failure repeated. Docker has no start limit, so this goes on forever.
```
nxdns run failed: AccessDenied
FAIL /etc/nxdns/config.zon: not readable
nxdns run failed: ManagedConfigUnreadable
```
**Diagnosis.**
@@ -223,10 +246,10 @@ docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}} restarts={{.Restar
stat -c '%a %u:%g %n' deploy/docker/etc-nxdns/config.zon
```
Exit 1 with `AccessDenied` means the container could not read the seed file.
The container runs as uid 65532 and `/etc/nxdns` is mounted read-only, so a
file at mode 0600 owned by your own uid is unreadable to it and the container
cannot repair it.
Exit 2 naming the configuration path means the container could not read the
file the shipped `command:` makes its configuration. The container runs as uid
65532 and `/etc/nxdns` is mounted read-only, so a file at mode 0600 owned by
your own uid is unreadable to it and the container cannot repair it.
**Fix.** Either make the file world-readable, when it holds no secret:
@@ -241,14 +264,65 @@ chown 65532:65532 deploy/docker/etc-nxdns/config.zon
chmod 0600 deploy/docker/etc-nxdns/config.zon
```
The 0644 path was verified here, including the recovery: after the `chmod` the
container started and answered queries. The `chown` needs root and was not run
here.
The 0644 path was verified against an earlier revision of this page, including
the recovery: after the `chmod` the container started and answered queries. The
`chown` needs root and was not run here.
A container that exits 2 instead `nxdns run failed: NoUsableUpstreams` after
`no configuration file at '/etc/nxdns/config.zon'` — has no seed file at all on
a fresh volume. Create `deploy/docker/etc-nxdns/config.zon` and bring it up
again; see [Install with Docker](install-with-docker.md).
`FAIL /etc/nxdns/config.zon: no such file` instead of `not readable` means there
is no configuration file at all. Create `deploy/docker/etc-nxdns/config.zon` and
bring it up again; see [Install with Docker](install-with-docker.md).
A container that exits 2 with `NoUsableUpstreams` is in database mode — the
`command:` line naming `--config` was removed — on a volume whose database is
still empty. Load one and bring it back up:
```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
@@ -324,7 +398,8 @@ 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 in the seed file before the first start.
a source in the admin interface, or a `blocklist_sources` entry to the
configuration file with a `group_sources` link naming a group.
## A database stamped by a newer binary
+179 -27
View File
@@ -21,6 +21,105 @@ Upgrading a build you made yourself is the last section of this page.
> shapes and the verification commands are covered by
> [Verify a release](verify-a-release.md), which says what was probed and how.
## Breaking change: `run --config` now means file authority
**Read this before upgrading if anything on your box passes `--config` to
`nxdns run`** — a systemd drop-in, a wrapper script, or a `command:` in a
compose file.
`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:
```sh
sed -i '/^ \.password = "",$/d' /etc/nxdns/config.zon
nxdns check --config /etc/nxdns/config.zon
```
Keep the `.password_hash` line — that is the password, and deleting it as well
would leave the file saying nothing about authentication, which means "keep
whatever is stored" rather than anything you would notice.
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:
@@ -33,6 +132,13 @@ nxdns export --out /some/backup/nxdns-config.zon
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`:
@@ -131,9 +237,11 @@ NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml pull
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
```
Compose recreates the container against the same `nxdns-data` volume. The seed
file in `etc-nxdns` is not read again; the database in the volume is the
configuration.
Compose recreates the container against the same `nxdns-data` volume. What
happens to the file in `etc-nxdns` depends on the `command:` in your compose
file: with the shipped `run --config=/etc/nxdns/config.zon` the file is the
configuration and the restart reconciles onto it; without it, the database in
the volume is the configuration and the file is read by nothing.
Set `NXDNS_VERSION` on both lines, or export it. Without it the compose file
falls back to `:latest`, and `pull` and `up` could then land on different
@@ -184,11 +292,12 @@ OK: no problems found
> zig 0.16.0
> ```
>
> Against a running server whose database had just been migrated and seeded,
> `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`. The `dig` line was not run in this
> round: nothing is listening on 127.0.0.1:53 here, and port 53 needs root.
> 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
@@ -235,46 +344,89 @@ nxdns run failed: SchemaTooNew
That run exits 1. Recovering means importing the export you took in step 1 into
a fresh data directory with the older binary.
### Rolling back from file mode
Putting an older binary back needs no unit edit. The old binary accepts
`run --config` — it just reads it as the old seed-once flag — and against a
database that already holds configuration it ignores the file entirely and
serves the last state the new binary reconciled. So the service comes back up
on the configuration it was running.
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
An upgrade never re-reads `/etc/nxdns/config.zon`. After the first successful
seed the file is ignored, and the start log says so:
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(config_bootstrap): configuration file ignored; the database is already configured
info(nxdns): authority: database
info(nxdns): authority: file (/etc/nxdns/config.zon)
```
Change settings through the admin interface, through the API, or with an
exporteditimport cycle against a stopped server:
**In file mode**, edit the file, validate it, restart. The admin interface will
refuse the change with a 403 naming the file, so there is nothing to get wrong:
```sh
$EDITOR /etc/nxdns/config.zon
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 exporteditimport cycle against a stopped server:
```sh
nxdns export --out config-backup.zon
$EDITOR config-backup.zon
systemctl stop nxdns
nxdns import config-backup.zon --force
nxdns import config-backup.zon
systemctl start nxdns
```
`--force` is required here. A plain `import` into a database that already holds
configuration fails with `import failed: DatabaseNotEmpty` and exits 2, so it
cannot clobber a configured server by accident. What counts is what an operator
set: client rows the DNS path materialised from traffic never trigger the
refusal on their own.
`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:
> Verified on this host for the two `nxdns` lines, against a populated scratch
> data directory:
```
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/nxdns-config.zon --data-dir $SCRATCH/data
> import failed: DatabaseNotEmpty
> $ 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/nxdns-config.zon --data-dir $SCRATCH/data --force
> imported /…/scratchpad/nxdns-config.zon
> $ nxdns import $SCRATCH/etc/smaller.zon --data-dir $SCRATCH/dbmode --allow-delete
> imported /…/smaller.zon
> (exit 0)
> ```
>
> The `systemctl stop`/`start` lines around them need root and an installed
> service and were not run; `$EDITOR` is yours to run.
> 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
+60 -106
View File
@@ -8,41 +8,12 @@ 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: no nxdns release exists yet. The repository has no tags, no
> release page and no pushed image, so nothing on this page could be run against
> a real release asset and no command here was pointed at
> `git.mial.net/mokhtar/nxdns` with any expectation of success. Substitutes were
> used, and every block says which one applies to it.
>
> The URL shapes were probed against `gitea.com`, a public instance of the same
> Gitea series running `1.27.0+dev-652-g0571722545`, using `gitea/tea`, which
> does have releases. `git.mial.net` reports `1.27.1`, and its
> `/mokhtar/nxdns/releases/latest` answers 404 — no release to redirect to. On
> `gitea/tea`, `releases/latest` answered 303 to the tag page of `v0.15.1`;
> `releases/download/v0.15.1/checksums.txt` and
> `releases/download/latest/checksums.txt` both answered 303 to the same stored
> object and delivered the same 1,842-byte file under `-L`;
> `releases/latest/download/checksums.txt` — GitHub's spelling — answered 404.
>
> The `gpg --verify` and `sha256sum -c` blocks were run on this host against
> stand-in files: two random-byte files named like the release tarballs, an
> `IMAGE-DIGEST.txt` holding one image reference, and a `SHA256SUMS.txt`
> computed over the three, signed by a **throwaway demonstration key generated
> for this page**. That key has the shape the real one will have — an ed25519
> primary key plus a separate ed25519 signing subkey, with the signature made by
> the subkey — so the `gpg --verify` output on this page has the two-fingerprint
> structure a subkey-signed release produces. The fingerprints printed in those
> transcripts are the throwaway key's, they are not the project's, and they will
> not match anything you download. The only edit to that run's output is the
> version in every filename, which became `<version>`.
>
> The container blocks were not run against nxdns — there is no published image.
> The two `docker buildx imagetools inspect --format` shapes were run here
> against `alpine:3.22` on Docker Hub, the base this project's builder stage
> pins; the digest form printed
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce` and
> the platform form printed a list. The `docker create`/`docker cp` comparison
> was run against an image built from this checkout rather than a pulled one.
> 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.
## What a release contains
@@ -94,11 +65,8 @@ want for the placeholder:
VERSION=<version>
```
> Not verified against nxdns: there is no release to redirect to, so the first
> block prints an empty line here and every URL built from it is a 404. The
> exact two-command form was run against `gitea.com/gitea/tea`, a public
> repository on Gitea `1.27.0+dev` that does have releases, and printed
> `0.15.1`.
> 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.
@@ -136,11 +104,9 @@ That is the Gitea spelling, and it is not GitHub's. `releases/latest/download/`
position, as `releases/download/latest/`. The tarball filenames contain the
version, so this alias never saves you from knowing it for those two.
> Not verified against nxdns: no release, so every URL above is a 404 today.
> Both URL forms, including the 404 for GitHub's spelling, were exercised
> against `gitea.com/gitea/tea` on Gitea `1.27.0+dev`; the versioned path and
> the `latest` alias each answered 303 to the same stored object and delivered
> the same 1,842-byte `checksums.txt` when the redirect was followed.
> 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`
@@ -157,11 +123,9 @@ curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346
gpg --import
```
> Not verified: the key is not published yet. Run on this host, that URL
> returned 404, and so did the `by-email` lookup for the same address. The
> endpoint itself is live: the same `by-fingerprint` path returned 200 for an
> unrelated key that is on keys.openpgp.org. Until this key is published there,
> get it from a source you can check some other way.
> 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`.
Then verify:
@@ -170,22 +134,21 @@ gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
```
```
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
gpg: Good signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
gpg: Signature made Sun 09 Aug 2026 01:26:42 AM CEST
gpg: using EDDSA key 019D00DF8417EBFDA5471E5EF7319CC024FB5A96
gpg: Good signature from "Mokhtar Mial (pc) <mokhtar@mial.net>" [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: 6643 13AA F527 DDAE 1C1E 516C A36F F8DA 4E6C 1C07
Subkey fingerprint: 9D1E A241 DAEA 89E0 9381 A21B DC27 E8A3 D53C 32D6
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
```
**Those two fingerprints and that user id belong to a throwaway key generated
to produce this transcript.** They are not the project's, and what you see will
carry the project's uid and the fingerprint in this page instead. The
*structure* is what to read: three lines, not one. `using EDDSA key` and
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.
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
@@ -210,22 +173,18 @@ including one an attacker talked you into importing.
A tampered `SHA256SUMS.txt` looks like this, and exits 1:
```
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
gpg: BAD signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
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 on this host. A throwaway ed25519 primary key was generated into a
> temporary `GNUPGHOME`, an ed25519 **signing subkey** was added to it, and the
> stand-in `SHA256SUMS.txt` was signed with `--local-user <subkey-fingerprint>!`
> the same construction the release workflow uses — so the transcripts above
> are what a subkey-signed release actually prints, rather than what a key
> signing with its primary would. The verification ran from a second
> `GNUPGHOME` holding only that key's public half, which is why the `[unknown]`
> trust marker and the warning are there rather than being written in by hand.
> The second transcript is the same command after one newline was appended to
> `SHA256SUMS.txt`. The `sed`/`tr` pipeline was run against that same output and
> printed `664313AAF527DDAE1C1E516CA36FF8DA4E6C1C07`, the throwaway primary.
> 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.
## 4. Check the hashes
@@ -263,12 +222,11 @@ 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 on this host against the stand-in files: all three transcripts are
> real `sha256sum` output over two random-byte files named like the release
> tarballs plus an `IMAGE-DIGEST.txt` holding one image reference, with one
> tarball deleted for the first two blocks and one byte appended to the other
> for the third. Only the version in the filenames was replaced with
> `<version>`.
> 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
@@ -294,7 +252,11 @@ tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
Zig version. The version has to match the tag you downloaded, and the commit
has to match the commit the tag points at.
> Not verified on this host: there is no release tarball to list or extract.
> 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
> (3c2d0d41f04570038e805b759da4541e198eae17)` — the commit `v0.0.1` points at —
> then `zig 0.16.0`.
## 6. Verify the container image
@@ -343,23 +305,13 @@ docker rm nxdns-verify
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
```
> Not verified against nxdns: no image is published, so no command here was run
> against `git.mial.net/mokhtar/nxdns`. The two
> `docker buildx imagetools inspect --format` shapes were run on this host
> against `alpine:3.22` on Docker Hub — the digest form printed
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`,
> which is the digest this project's builder stage pins, and the platform form
> printed `linux/amd64 unknown/unknown linux/arm unknown/unknown ...`. That
> `unknown/unknown` is exactly what the paragraph above says nxdns's own index
> must not contain: Alpine's index carries attestation entries, and nxdns's
> build turns them off. Nothing was checked about how nxdns's index will
> actually look.
>
> The `docker create` / `docker cp` / `sha256sum` comparison at the end was run
> here against an image built from this checkout rather than a pulled one, and
> the two hashes matched: the binary copied out of the image and
> `zig-out/dist/stage/nxdns-<version>-x86_64-linux-musl/nxdns` were the same
> file.
> 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.
## What the signature proves, and what it does not
@@ -431,14 +383,16 @@ release used. The Zig version is the second line of `nxdns version`, and both
it and the Node version are pinned to exact patch releases at the top of
`.gitea/workflows/gates.yml`, which is the workflow the release runs.
> Partly verified on this host. `zig build dist` and `sha256sum` on its output
> were run to completion, with the version read out of `build.zig.zon`: `dist`
> exited 0 and wrote the two tarballs, `SHA256SUMS` and the staged payloads
> described above. `zig build verify-dist` was run on the result too and exited
> 0. What could not be run is everything that needs a release: the clone, the
> checkout and `git verify-tag` need a tag that does not exist, and there is no
> published `SHA256SUMS.txt` to compare a local build against, so the comparison
> this section is about has never been performed.
> Verified against `v0.0.1`, and the result is the caveat above in action. The
> whole recipe ran from a fresh clone: `git verify-tag v0.0.1` printed
> `Good signature` under the same signing subkey as the release, and
> `zig build dist` produced both tarballs. The hashes did **not** match the
> published `SHA256SUMS.txt` — the binaries themselves already differ. The Zig
> version matched the pin exactly; the Node version did not (24.14.1 against
> the pinned 24.19.0) and the build path differed, two of the ordinary causes
> listed above. That is a measurement of what an unpinned rebuild gives you,
> not evidence of tampering: the signature, checksum and image checks earlier
> on this page all passed against the same release.
## If a check fails
+137 -59
View File
@@ -31,6 +31,9 @@ below carries all 56 of its entries.
- Mutations to groups, blocklists, rules, local records, forward zones, clients
and client prefixes take effect live. Upstreams and `/api/settings` are
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
@@ -113,70 +116,140 @@ to the capacity is admitted and the long-run rate holds.
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/settings` 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/settings` carries an `authority` object:
| Field | Meaning |
| --- | --- |
| `mode` | `"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. |
All three keys are always present; the two nullable ones carry `null` rather
than being omitted, so a client can read `authority.mode` without probing.
```json
{"mode": "database", "path": null, "reconciled_at": null}
{"mode": "managed_file", "path": "/etc/nxdns/config.zon", "reconciled_at": 1786474016}
```
The route requires a session, which is why the filesystem path is here rather
than on the open `/api/version` and `/api/health`.
`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.
`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 | Purpose |
|---|---|---|---|---|
| GET | `/metrics` | open | exempt | Prometheus metrics |
| GET | `/api/health` | open | exempt | Health rollup |
| GET | `/api/version` | open | counted | Build and uptime |
| GET | `/api/openapi.yaml` | open | counted | This API's OpenAPI document |
| POST | `/api/auth/login` | open | counted | Log in |
| POST | `/api/auth/logout` | session | counted | Log out |
| GET | `/api/queries` | session | counted | Query log page |
| GET | `/api/queries/live` | session | exempt | Live query stream (server-sent events) |
| GET | `/api/stats` | session | counted | Totals for a period |
| GET | `/api/stats/timeseries` | session | counted | Bucketed counts for a period |
| GET | `/api/lookup` | session | counted | Explain a domain |
| GET | `/api/upstream/health` | session | counted | Upstream pool health |
| GET | `/api/groups` | session | counted | List groups |
| POST | `/api/groups` | session | counted | Create a group |
| GET | `/api/groups/{id}` | session | counted | Read a group |
| PUT | `/api/groups/{id}` | session | counted | Update a group |
| DELETE | `/api/groups/{id}` | session | counted | Delete a group |
| GET | `/api/groups/{id}/sources` | session | counted | Blocklist sources assigned to a group |
| PUT | `/api/groups/{id}/sources` | session | counted | Replace the assignment |
| GET | `/api/blocklists` | session | counted | List blocklist sources |
| POST | `/api/blocklists` | session | counted | Add a blocklist source |
| POST | `/api/blocklists/update` | session | counted | Refresh every enabled source now |
| GET | `/api/blocklists/{id}` | session | counted | Read a blocklist source |
| PUT | `/api/blocklists/{id}` | session | counted | Update a blocklist source |
| DELETE | `/api/blocklists/{id}` | session | counted | Delete a blocklist source |
| GET | `/api/rules` | session | counted | List rules |
| POST | `/api/rules` | session | counted | Create a rule |
| GET | `/api/rules/{id}` | session | counted | Read a rule |
| PUT | `/api/rules/{id}` | session | counted | Update a rule |
| DELETE | `/api/rules/{id}` | session | counted | Delete a rule |
| GET | `/api/local-records` | session | counted | List local DNS records |
| POST | `/api/local-records` | session | counted | Create a local record |
| GET | `/api/local-records/{id}` | session | counted | Read a local record |
| PUT | `/api/local-records/{id}` | session | counted | Update a local record |
| DELETE | `/api/local-records/{id}` | session | counted | Delete a local record |
| GET | `/api/forward-zones` | session | counted | List forward zones |
| POST | `/api/forward-zones` | session | counted | Create a forward zone |
| GET | `/api/forward-zones/{id}` | session | counted | Read a forward zone |
| PUT | `/api/forward-zones/{id}` | session | counted | Update a forward zone |
| DELETE | `/api/forward-zones/{id}` | session | counted | Delete a forward zone |
| GET | `/api/clients` | session | counted | List clients |
| GET | `/api/clients/{id}` | session | counted | Read a client |
| PUT | `/api/clients/{id}` | session | counted | Rename or regroup a client |
| DELETE | `/api/clients/{id}` | session | counted | Forget a client |
| GET | `/api/client-prefixes` | session | counted | List client prefixes |
| PUT | `/api/client-prefixes` | session | counted | Replace the prefix table |
| GET | `/api/upstreams` | session | counted | List upstream resolvers |
| POST | `/api/upstreams` | session | counted | Add an upstream |
| GET | `/api/upstreams/{id}` | session | counted | Read an upstream |
| PUT | `/api/upstreams/{id}` | session | counted | Update an upstream |
| DELETE | `/api/upstreams/{id}` | session | counted | Delete an upstream |
| GET | `/api/pause` | session | counted | Read the pause state |
| POST | `/api/pause` | session | counted | Pause or resume blocking |
| GET | `/api/settings` | session | counted | Read the scalar settings |
| PUT | `/api/settings` | session | counted | Update settings |
| POST | `/api/certs/reload` | session | counted | Reload the TLS certificates from disk |
| 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 page |
| GET | `/api/queries/live` | session | exempt | read | Live query stream (server-sent events) |
| GET | `/api/stats` | session | counted | read | Totals for a period |
| GET | `/api/stats/timeseries` | session | counted | read | Bucketed counts for a period |
| GET | `/api/lookup` | session | counted | read | Explain a domain |
| GET | `/api/upstream/health` | session | counted | read | Upstream pool health |
| 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 |
| 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.
@@ -194,6 +267,11 @@ 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:
+212 -48
View File
@@ -9,9 +9,10 @@ of truth: `src/cli.zig`.
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.
`--force` is boolean and takes no value at all, so `--force=1` is not a spelling
of any flag this program has. A flag is rejected by the subcommand that has no
use for it: `--web-dev` outside `run` is an unknown flag, not a no-op.
`--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: `--web-dev` outside `run` is an unknown flag,
not a no-op.
## `run`
@@ -20,12 +21,81 @@ Serves DNS until SIGINT or SIGTERM.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory (default `/var/lib/nxdns`). Created at mode 0700 if missing. |
| `--config FILE` | Seed configuration file (default `/etc/nxdns/config.zon`). Read only when the database has never been configured. |
| `--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. |
| `--web-dev DIR` | Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
A seed file that is unparseable, oversized or invalid prints its diagnostics and
exits 2 — the same code `check` and `import` give for the same file. See
[exit codes](#exit-codes).
### Which authority the invocation selects
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:
```
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:
```
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +45 ~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:
```
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` and `<id>.wild`, 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.
### 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.
```
FAIL /etc/nxdns/config.zon: no such file
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`
@@ -52,7 +122,7 @@ first. What it checks, in order:
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory to look for `config.db` in. |
| `--config FILE` | Check this file instead of the database. |
| `--config FILE` | Grade this file instead of the database. |
### Failures and warnings
@@ -71,23 +141,38 @@ The last line is a summary, and it never contradicts the lines above it:
### Source selection
- `--config FILE` given explicitly: check that file, nothing else.
- Otherwise, if `<data-dir>/config.db` exists: check the database — the right
default, since the database is the truth on a configured server. It is opened
immutable, so `check` writes nothing to it; see
[what `check` does not do](#what-check-does-not-do).
- Otherwise, if the default config file path exists: check it.
- Otherwise: "nothing to check", exit 2.
The flag decides, exactly as it does for `run`. There is no fallback and no
probing of a default path:
The first line of output always names which source was checked. A file larger
than 4 MiB fails with `larger than 4194304 bytes`; a ZON syntax error is
- `--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:
```
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.
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. That is the same code the implicit path
gives when there is nothing to check, so naming the file does not change what a
missing file costs.
`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
@@ -133,6 +218,15 @@ 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).
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db`. |
@@ -142,25 +236,79 @@ See [back up and restore](../how-to/back-up-and-restore.md).
## `import FILE`
Validates FILE and replaces the whole configuration with it in one transaction.
Prints every validation problem; a failed import leaves the database untouched.
Refuses a database that already holds configuration unless `--force` is given
(`DatabaseNotEmpty`, exit 2). The check measures what an operator set, not what
the network did: client rows the DNS path materialised from traffic never
trigger the refusal on their own. Creates the data directory at mode 0700 if it
is missing.
Client history survives the replacement. An address the database already knew
keeps its first-seen and last-seen even when FILE names it; only an address it
has never seen takes the import's clock. A client FILE leaves out is removed,
history included.
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.
| Flag | Meaning |
| --- | --- |
| `--data-dir DIR` | Data directory holding `config.db` (created if missing). |
| `--force` | Replace a database that already holds configuration. |
| `--allow-delete` | Apply a file whose diff deletes rows. |
**`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.
### The delete gate
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:
```
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.
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:
| Table | Identity |
| --- | --- |
| `blocklist_sources`, `upstreams` | `url` |
| `groups` | `name` |
| `clients` | `ip` |
| `client_prefixes` | `prefix` |
| `forward_zones` | `zone` |
| `local_records` | `(name, rtype, value)` |
| `rules` | `(group, pattern, kind, 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`.
### What survives an import
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.
## `version`
@@ -185,28 +333,44 @@ 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` and `BadCertificate`. In
practice that covers a seed 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,
and `password` and `password_hash` set together.
`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.
When `run` exits 2 it points at the diagnosis on stderr, whether the fault came
from the seed file or from the database it loaded:
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:
```
run `nxdns check` to see the configuration in full
```
`check` exits 2 for those faults and also when a probed upstream failed, when a
named configuration file is missing or unreadable, when the database cannot be
read or is not at this binary's schema version, and when there was nothing to
check. Warnings never contribute.
On a box with no configuration at all, `run` and `check` add the line that names
both ways to get one:
`import` exits 2 for those faults and for `DatabaseNotEmpty`. That last one is
deliberately not a configuration fault — it reports the state of the database
rather than the content of a file — and `import` decides it for itself; the
answer to it is `--force`, not an edit.
```
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.
`OutOfMemory` is exit 1 even when problems were recorded, because the report is
then incomplete. Every other error is 1.
+50 -18
View File
@@ -4,7 +4,7 @@ Every section, field and collection nxdns accepts, with its type, default,
unit, validation rule and the subsystem that consumes it.
Source of truth: `src/config/model.zig` (the model and the defaults),
`src/config/validate.zig` (the rules), `src/config/{bootstrap,import,export}.zig`
`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
@@ -17,7 +17,7 @@ reference](cli.md).
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/import.zig`); beyond that the error is
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.
Absent fields keep their defaults, both in the file and in the database. A
@@ -38,7 +38,7 @@ Storage paths are process arguments, not configuration:
| Argument | Default | Meaning |
| --- | --- | --- |
| `--data-dir DIR` | `/var/lib/nxdns` | Holds `config.db` and `querylog.db`; see [files and directories](files-and-directories.md). |
| `--config FILE` | `/etc/nxdns/config.zon` | Names the seed file. |
| `--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. |
| `--web-dev DIR` | — | `run` only; serves the web interface from a directory instead of the embedded assets. |
## Scalar sections
@@ -115,8 +115,8 @@ The web interface and REST API.
| `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 | 165535 (0 is refused) | web listener port |
| `web.password` | string | `""` | — | must not be set together with `web.password_hash` | operator input only; hashed at import and discarded. Never a settings row — see [Password and hash](#password-and-hash) |
| `web.password_hash` | string | `""` | — | — | argon2id PHC string verified at login (`src/web/auth.zig`); `""` disables authentication |
| `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 |
@@ -341,27 +341,59 @@ deadline.
## Password and hash
Exactly one of `web.password` and `web.password_hash` may be set; setting both
is refused (`PasswordAndHashBothSet` — ambiguity in a security setting).
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` is operator input only. At import time 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 = ""`.
| The file says | What happens to the stored hash |
| --- | --- |
| Neither field | Untouched. Authentication stays exactly as it was. |
| `.password = "some-password"` | Verified against the stored hash; kept when it matches, replaced with a fresh hash when it does not. |
| `.password = ""` | Refused, with a diagnostic naming the remedy. |
| `.password_hash = "$argon2id$…"` | Written verbatim. |
| `.password_hash = ""` | Cleared, which disables authentication. |
| Both fields | Refused (`PasswordAndHashBothSet` — ambiguity in a security setting). |
Silence has to mean "keep", because the alternative is a trap. An operator who
exports a configuration and trims the long PHC string out of it before
committing the file to git means "leave the password alone", not "open the admin
interface to the LAN". So disabling authentication takes the explicit empty
string, and the empty *plaintext* — which would otherwise hash into a real hash
that no login can ever satisfy — is refused outright:
```
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
```
- `web.password` is operator input only. It is hashed with argon2id (OWASP
parameters: t=2, m=19 MiB, p=1, PHC encoding) into `web.password_hash` and
discarded. There is no `web.password` settings row, and `nxdns export` always
writes `.password = null`.
- `web.password_hash` is the stored argon2id PHC string. Supplying it directly,
for example from a previous export, is how a backup restores authentication
without knowing the password.
- Both empty disables web authentication entirely.
Because the export carries the hash and re-importing an exported file takes the
"password is empty" branch, the export/import round trip preserves the hash
byte for byte. See [set up admin
A plaintext password that has not changed is verified rather than re-hashed, so
applying the same file twice leaves the same bytes in the database. That is what
keeps the export/import round trip byte-stable with a password in the file. It
costs a full argon2id computation either way — the verification is not a
shortcut, and caching the plaintext to skip it would be a security bug.
Any change to whether a password is set is announced at startup, never left as a
count:
```
web authentication is now enabled
web authentication is now disabled
```
See [set up admin
authentication](../how-to/set-up-admin-authentication.md).
## Validation errors
`nxdns check`, `nxdns import` and the `nxdns run` that seeds a database from the
file all print one `FAIL path: message` line per problem, and report every
`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
@@ -461,7 +493,7 @@ upstream. Everything else keeps its default.
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
// Web UI on 8080. The password is hashed at import and never stored;
// 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,
+18 -9
View File
@@ -10,11 +10,9 @@ snapshots), `src/platform/logging.zig` (the log file).
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 on the branch that resolved to the database, so an
absent data directory is not in itself a failure: an explicit `--config FILE`
never looks at the directory, and without one `check` falls back to the default
configuration file, or prints "nothing to check" and exits 2 when neither source
exists.
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
@@ -39,7 +37,7 @@ older ones from the main file. That is the "uncheckpointed changes" failure in
| Path | What it is | Mode |
| --- | --- | --- |
| `config.db` | The configuration database — the single source of truth, including `web.password_hash`. | 0600 |
| `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. Expendable — if it is missing or unusable it is recreated empty. | 0600 |
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
@@ -112,9 +110,20 @@ than being created world-readable.
## The configuration file
Default `/etc/nxdns/config.zon`, overridable with `--config FILE`. nxdns reads
it and never writes it: it seeds an unconfigured database once and is ignored
afterwards. nxdns does not create the file or its directory.
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 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
+98 -36
View File
@@ -9,8 +9,13 @@ 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. The only thing substituted during that run
was the tutorial directory.
24.14.1, dig 9.20.26 and curl 8.21.0. Steps 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
two build commands in steps 1 and 2, which had already produced the binary under
test. The ZON block at the end of step 14 was checked with `nxdns check
--config` rather than started.
## What you need
@@ -30,7 +35,7 @@ cd web && npm ci && npm run build && cd ..
```
This produces `web/dist`. Do not skip it. A plain `zig build` embeds
`web/dist-placeholder`, a one-page status stub, and you would reach step 10 and
`web/dist-placeholder`, a one-page status stub, and you would reach step 12 and
find no admin interface there.
## 2. Build nxdns
@@ -75,6 +80,11 @@ 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
@@ -92,33 +102,44 @@ 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. Start the server
In your first terminal:
## 5. Load it into the database
```sh
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
zig-out/bin/nxdns import ~/nxdns-tutorial/config.zon --data-dir ~/nxdns-tutorial/data
```
```
info(migrations): config.db migrated from schema version 0 to 2
info(config_bootstrap): seeded the database from '/home/you/nxdns-tutorial/config.zon'
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, 199 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
```
The data directory did not exist; nxdns created it at mode 0700 along with
`config.db` and `querylog.db`. The line that matters is `seeded the database
from`: the configuration file was read into the database this once. From now on
the database is the truth and the file is ignored on every later start, which
you will see for yourself in step 11. Configuration changes go through the API,
the web interface, or `nxdns import`.
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.
## 6. Resolve a name
## 7. Resolve a name
```sh
dig @127.0.0.1 -p 15353 example.com A +noall +answer
@@ -131,10 +152,10 @@ example.com. 90 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 5
You now have a working resolver. It blocks nothing yet: the log line in step 6
said `0 of 0 sources loaded`.
## 7. Add a blocklist source
## 8. Add a blocklist source
```sh
curl -s -X POST http://127.0.0.1:8080/api/blocklists \
@@ -152,7 +173,7 @@ That is fine for a localhost tutorial and wrong for anything else; see
Note the `"id":1` in the response. You need it in the next step.
## 8. Attach the source to the `default` group
## 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
@@ -190,7 +211,7 @@ curl -s -X PUT http://127.0.0.1:8080/api/groups/1/sources \
{"source_ids":[1]}
```
## 9. Download the list
## 10. Download the list
Adding a source registers it; it does not fetch it. Ask for a refresh:
@@ -199,25 +220,25 @@ curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
```
```json
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1785683777,"last_success":1785683777,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99277,"wildcards":0,"skipped_regex":0}]}
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786473715,"last_success":1786473715,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99559,"wildcards":0,"skipped_regex":0}]}
```
The download is about 3 MB and takes a few seconds. Watch the first terminal
until this appears:
```
info(blocklist_manager): blocklist snapshot generation 5: 1 of 1 sources loaded, 3088703 bytes
info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 3096006 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 — 99277 on the day this was run — is live.
domain count in the JSON above — 99559 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.
## 10. Watch a domain get blocked
## 11. Watch a domain get blocked
```sh
dig @127.0.0.1 -p 15353 doubleclick.net A +noall +answer
@@ -249,7 +270,7 @@ yourself can, with a wildcard pattern such as `*.doubleclick.net`. So when you
pick a domain to test, pick one that is literally in the file.
`www.google-analytics.com` is another that is.
## 11. Open the web interface
## 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 dashboard shows query and
@@ -257,7 +278,7 @@ block counts, and the Blocklists page 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.)
## 12. Stop it
## 13. Stop it
In the first terminal, press Ctrl-C.
@@ -267,21 +288,61 @@ info(nxdns): shutting down
nxdns catches SIGINT and SIGTERM, stops serving and exits 0.
Start it again with the same command as in step 5 and read the first log line:
Start it again with the same command as in step 6 and read the first log lines:
```
info(config_bootstrap): configuration file ignored; the database is already configured
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3088703 bytes
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3096006 bytes
info(nxdns): authority: database
```
The configuration file was not even opened, and the blocklist came off disk
rather than the network. The blocklist source, the group it is attached to, and
the query log all survived the restart because they live in
`~/nxdns-tutorial/data`.
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, 199 bytes
info(nxdns): authority: file (/home/you/nxdns-tutorial/config.zon)
```
**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 compiled list is still on disk and 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 99000 domains
@@ -292,9 +353,10 @@ directory you can delete:
rm -rf ~/nxdns-tutorial
```
You also saw the two things that surprise people most: the configuration file
seeds the database once and is ignored afterwards, and a blocklist source does
nothing until a group uses it.
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
@@ -305,4 +367,4 @@ nothing until a group uses it.
- [reference/configuration.md](../reference/configuration.md) — every field you
did not set.
- [explanation/configuration-model.md](../explanation/configuration-model.md) —
why the file seeds and the database rules.
why there are two authority modes and what each one is for.
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+24 -1
View File
@@ -39,6 +39,12 @@ mbedtls url=https://github.com/Mbed-TLS/mbedtls/archive/refs/tags/mbedtls-3.6.7.
sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8AAGVtrgCcOcmjrOJnagmnRyMrcKaOo09KbU-vu8w8
[npm runtime closure]
@internationalized/date 3.12.3 Apache-2.0
@internationalized/number 3.6.7 Apache-2.0
@internationalized/string 3.2.10 Apache-2.0
@react-types/shared 3.36.1 Apache-2.0
@stylexjs/stylex 0.19.0 MIT
@swc/helpers 0.5.23 Apache-2.0
@tanstack/history 1.162.0 MIT
@tanstack/query-core 5.101.4 MIT
@tanstack/react-query 5.101.4 MIT
@@ -46,24 +52,37 @@ sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8A
@tanstack/react-store 0.9.3 MIT
@tanstack/router-core 1.171.15 MIT
@tanstack/store 0.9.3 MIT
aria-hidden 1.2.6 MIT
client-only 0.0.1 MIT
clsx 2.1.1 MIT
cookie-es 3.1.1 MIT
css-mediaquery 0.1.2 BSD
invariant 2.2.4 MIT
isbot 5.2.1 Unlicense
js-tokens 4.0.0 MIT
loose-envify 1.4.0 MIT
react 19.2.8 MIT
react-aria 3.51.0 Apache-2.0
react-aria-components 1.20.0 Apache-2.0
react-dom 19.2.8 MIT
react-stately 3.49.0 Apache-2.0
scheduler 0.27.0 MIT
seroval 1.5.6 MIT
seroval-plugins 1.5.6 MIT
styleq 0.2.1 MIT
tslib 2.8.1 0BSD
use-sync-external-store 1.6.0 MIT
[npm build-time generators whose output ships]
rolldown 1.1.5 MIT
tailwindcss 4.3.3 MIT
vite 8.1.5 MIT
[container base image]
alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
[npm packages bundled into web/dist]
@internationalized/string
@stylexjs/stylex
@tanstack/history
@tanstack/query-core
@tanstack/react-query
@@ -71,7 +90,11 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
@tanstack/react-store
@tanstack/router-core
@tanstack/store
clsx
react
react-aria
react-aria-components
react-dom
react-stately
scheduler
use-sync-external-store
+59 -10
View File
@@ -39,11 +39,42 @@
// stay listed in licenses/dependency-identity.txt so that a future build which
// does pull them in trips the guard.
//
// Four more joined that list with the StyleX build integration: css-mediaquery,
// invariant, and loose-envify with js-tokens under it. @stylexjs/stylex
// declares them, but its published runtime imports none of them — a sourcemap
// build of a component calling stylex.props shows no trace of any of the four,
// so they are dead entries of the published manifest rather than code with a
// job here. css-mediaquery is the one that matters: it is BSD, and it reaching
// web/dist would force a licence decision this project has not made.
//
// Seven more joined that list with React Aria, and the sourcemap build is again
// what settled which: @internationalized/date, @internationalized/number,
// @react-types/shared, @swc/helpers, aria-hidden, client-only and tslib are in
// the runtime closure but contribute no module to any chunk. The date and number
// packages serve the calendar and number-field components ruling 4 keeps out of
// scope; @react-types/shared is types only and emits no runtime code;
// client-only is a build-time marker for React Server Components; @swc/helpers
// and tslib are downlevelling helpers the published ESM builds never reach for;
// and aria-hidden sits behind a react-aria path the four adopted components do
// not take. Their licences differ and are recorded one by one in
// src/licenses_drift_test.zig rather than lumped together: @internationalized/
// date, @internationalized/number, @react-types/shared and @swc/helpers are
// Apache-2.0, tslib is 0BSD, aria-hidden and client-only are MIT. Keeping all
// seven out of the bundle is a fact worth re-checking rather than assuming, so
// the guard fails if any of them turns up in a `sources` list.
//
// StyleX now ships: the converted pages call stylex.props, and a sourcemap build
// puts @stylexjs/stylex in the bundle. What that build settles is not the
// obvious answer, though: only @stylexjs/stylex appears as a bundled package,
// and styleq arrives inside it. The published lib/es/stylex.mjs vendors styleq's source
// instead of importing it, so styleq's bytes ship while styleq never appears in
// a `sources` list — no guard would raise it, which is why it is written down
// here.
//
// Of the remaining devDependencies, none puts a byte in web/dist: @vitejs/
// plugin-react and typescript only transform our own sources (react-refresh is
// dev-server only, and tslib is optional and unused), @tailwindcss/vite
// orchestrates tailwindcss without contributing CSS of its own, lightningcss
// only minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
// dev-server only, and tslib is optional and unused), lightningcss only
// minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
// never touch the build output.
.{
.{
@@ -94,18 +125,36 @@
.note = "Bundled into the admin UI JavaScript. Grouped because all five carry the identical MIT text and copyright line.",
.file = "tanstack-mit.txt",
},
.{
.component = "React Aria (react-aria-components, react-aria, react-stately, @internationalized/string)",
.version = "react-aria-components 1.20.0, react-aria 3.51.0, react-stately 3.49.0, @internationalized/string 3.2.10",
.note = "The dialog, alert dialog, tab and select behaviour of the admin UI, bundled into the JavaScript embedded in the binary. The first Apache-2.0 npm dependency this project has taken, and the first non-MIT one: Mokhtar Mial accepted Apache-2.0 inbound for nxdns on 2026-08-12, which is the decision that let these four ship. Apache-2.0 Section 4 attribution is satisfied by carrying the licence text in THIRD-PARTY-NOTICES, which the file below does; none of the four ships a NOTICE file, so 4(d) adds nothing. All four carry a byte-identical LICENSE. In the tarballs and in the image.",
.file = "react-aria-apache-2.0.txt",
},
.{
.component = "clsx",
.version = "clsx 2.1.1",
.note = "The class-name joiner React Aria calls when it merges a render-prop class name with its own. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
.file = "clsx-mit.txt",
},
.{
.component = "StyleX",
.version = "@stylexjs/stylex 0.19.0",
.note = "The style runtime for the admin UI. Its published dist vendors styleq's source, so the styleq entry below covers bytes that arrive inside this package. The npm tarball ships no licence file of its own; this text is the LICENSE of facebook/stylex at tag 0.19.0, which is byte-identical to the one on main.",
.file = "stylex-mit.txt",
},
.{
.component = "styleq",
.version = "styleq 0.2.1",
.note = "The class-name merger StyleX calls at runtime. Never a bundled package in its own right: @stylexjs/stylex vendors its source into lib/es/stylex.mjs, so it reaches web/dist without appearing in any sourcemap `sources` list. Separate entry from StyleX: same MIT text, different copyright line.",
.file = "styleq-mit.txt",
},
.{
.component = "TanStack Store",
.version = "@tanstack/store 0.9.3, @tanstack/react-store 0.9.3",
.note = "Bundled into the admin UI JavaScript. Separate entry from the other TanStack packages: same MIT text, different copyright line.",
.file = "tanstack-store-mit.txt",
},
.{
.component = "Tailwind CSS",
.version = "tailwindcss 4.3.3",
.note = "A devDependency whose output ships: the admin UI stylesheet is generated from Tailwind's own CSS sources, so the generated CSS in the binary is a derivative of them.",
.file = "tailwindcss-mit.txt",
},
.{
.component = "Vite",
.version = "vite 8.1.5",
@@ -115,7 +164,7 @@
.{
.component = "Rolldown",
.version = "rolldown 1.1.5",
.note = "A devDependency whose own runtime source ships: the bundler Vite 8 runs prepends its CommonJS interop helpers verbatim to web/dist/assets/classes-*.js, which is embedded in the binary.",
.note = "A devDependency whose own runtime source ships: the bundler Vite 8 runs prepends its CommonJS interop helpers verbatim to web/dist/assets/styles-*.js, which is embedded in the binary.",
.file = "rolldown-mit.txt",
},
.{
+4 -1
View File
@@ -51,7 +51,10 @@ pub const texts: []const Text = &.{
.{ .name = "react-mit.txt", .body = @embedFile("react-mit.txt") },
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
.{ .name = "tailwindcss-mit.txt", .body = @embedFile("tailwindcss-mit.txt") },
.{ .name = "react-aria-apache-2.0.txt", .body = @embedFile("react-aria-apache-2.0.txt") },
.{ .name = "clsx-mit.txt", .body = @embedFile("clsx-mit.txt") },
.{ .name = "stylex-mit.txt", .body = @embedFile("stylex-mit.txt") },
.{ .name = "styleq-mit.txt", .body = @embedFile("styleq-mit.txt") },
.{ .name = "vite-mit.txt", .body = @embedFile("vite-mit.txt") },
.{ .name = "rolldown-mit.txt", .body = @embedFile("rolldown-mit.txt") },
.{ .name = "mozilla-ca-bundle-mpl-2.0.txt", .body = @embedFile("mozilla-ca-bundle-mpl-2.0.txt") },
+25
View File
@@ -0,0 +1,25 @@
React Aria: react-aria-components, react-aria, react-stately and
@internationalized/string, bundled into the admin UI JavaScript that is
embedded in every nxdns binary.
Copyright 2019 Adobe
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
these files except in compliance with the License. You may obtain a copy of the
License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
The full text of the Apache License, Version 2.0 is reproduced above, in the
Mbed TLS section of this file. The LICENSE file each of these four packages
ships is that same document. The Mbed TLS copy carries an eight-line project
preamble of its own; setting that aside, the two differ on one line, the sample
copyright inside the "APPENDIX: How to apply the Apache License to your work"
boilerplate, which is not part of the licence terms. None of the four ships a
NOTICE file, so Section 4(d) attaches nothing further.
@@ -1,6 +1,6 @@
MIT License
Copyright (c) Tailwind Labs, Inc.
Copyright (c) Nicolas Gallagher
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24 -10
View File
@@ -695,13 +695,16 @@ was reproduced before it was fixed.
- [x] Two runs of `zig build dist` on the same commit produce byte-identical
tarballs **in the same directory**. (Cross-directory reproducibility is
ruling 12 and is not claimed here.)
- [ ] The image builds for both platforms with no qemu, carries `/LICENSE` and
- [x] The image builds for both platforms with no qemu, carries `/LICENSE` and
`/THIRD-PARTY-NOTICES` and the OCI labels, and its binaries are
byte-identical to the tarball binaries. Verified for the native amd64
image only; the arm64 half needs a runner with buildx.
- [ ] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
on `master`; `origin/main` is gone. The first two are in the files; no
workflow has run and `origin/main` still exists (manual prerequisite).
byte-identical to the tarball binaries. The v0.0.1 run built and pushed
both platforms on the runner; the published index lists exactly
`linux/amd64 linux/arm64`, and `release verify-image-binaries` compared
both binaries against the tarballs before publication.
- [x] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
on `master`; `origin/main` is gone. Proven live: pushes to `master` run
the gates through `ci.yml`, and release runs 484-493 ran them through
`release.yml`.
- [x] `THIRD-PARTY-NOTICES` covers musl, the Zig runtime, SQLite, Mbed TLS with
its Apache-2.0 selection line and full text, Everest, p256-m and the web
runtime closure. The dependency drift guard was proven able to fail:
@@ -715,10 +718,21 @@ was reproduced before it was fixed.
multi-architecture image — and was then deleted (release, git tag, both
registry versions). Five attempts; the failures and their fixes are
deviations 23-26.
- [ ] `v0.0.1` is published: five assets, a verifying signature, and an image at
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`.
- [ ] `docs/how-to/verify-a-release.md` was followed end to end against the
published release, from a clean directory, on this host.
- [x] `v0.0.1` is published: five assets, a verifying signature, and an image at
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`. Run 493, all jobs green
on the first attempt after the dry-run fixes.
- [x] `docs/how-to/verify-a-release.md` was followed end to end against the
published release, from a clean directory, on this host, with a clean
`GNUPGHOME` holding only the key fetched from keys.openpgp.org. Every
command on the page passed: the `releases/latest` redirect printed
`0.0.1`, both tarball downloads and the `latest` alias worked (and
GitHub's spelling answered 404 as documented), the signature verified
with matching primary and subkey fingerprints, `sha256sum -c` said OK for
all three files, the tarball layout and modes matched, `nxdns version`
printed the tag's commit, the tag digest equalled `IMAGE-DIGEST.txt`, the
platform list was exactly `linux/amd64 linux/arm64`, and the binary
copied out of the pulled-by-digest image hashed identical to the tarball
binary.
- [x] No `zig build cross` or source-only-distribution text remains on any
**active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and
`docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text —
+921
View File
@@ -0,0 +1,921 @@
# Milestone 20: declarative configuration for IaC
Goal: a config file an operator can keep in git and deploy with Ansible, where
the file is the sole declarative source of truth, converged at every boot —
without re-downloading every blocklist on every boot, and without the UI
silently diverging from the file. Two authority modes, selected by the
presence of one flag: bare `run` serves the DB; `run --config=<path>` makes
the file authority.
Design finalized 2026-08-09 after three adversarial rounds (red team ops 1-16
and debt 1-14; Codex cross-validation F1-F12; a Codex round on the premise
revision). All findings are folded in below or declined with written reasons;
all file:line anchors were re-verified at the pre-implementation HEAD
(7039a9f). A premise revision replaced the earlier `--config-source` mode
enum with presence-of-`--config` and deleted the persisted authority marker;
rulings 1, 6 and 8 record the reasons — do not reintroduce either.
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- Session order: R1 → R2 sequential; R3 and R4 parallel to both (Sessions,
below). File ownership is write-exclusivity; the interfaces between
sessions (`reconcile.Summary`, `WebState.authority` + `reconciled_at`,
`RouteInfo.policy`) are fixed in this spec and are not renegotiable
mid-build.
- Gates: `zig build test` and `zig build test -Dintegration` with 0 failed,
plus the drift-guard regenerations the Tests section names. Skip counts are
reported with their reasons (plain-suite skips are integration-gated; the 4
integration skips are the live-network TLS tests excluded by milestone-1
design). One `-Dlive` run covers the real download path (Tests).
- No `std.log.err` in new code. No secrets in logs — url redaction goes
through `src/safe_url.zig` as everywhere else.
- Every fix or behaviour claim lands with a test the author watched fail
against the reverted implementation (milestone-13 ruling F-f applies).
- Doc pages touched by R4 follow milestone-13 ruling 3: every command block
in `tutorial/` and `how-to/` is executed on this host by the session that
writes it, or marked in-page as unverified with the reason.
- The final commit is GPG-signed by the user (`git commit -S`, lowercase,
single line); stage the work and hand the command over.
## Rulings (binding)
### 1. Authority is the invocation: `--config` present means the file governs
`nxdns run` — the DB is authority, today's appliance behaviour.
`nxdns run --config=/etc/nxdns/config.zon` — the file is authority. The
flag's presence selects the mode; there is no mode enum and no default path.
`check` keeps its existing surface under the same rule: bare `check` grades
the DB, `check --config <file>` grades the file. The DB-exists-wins heuristic
in `checkImpl` (cli.zig:559-588) and `CheckArgs.config_explicit` are deleted.
Two principles, separated deliberately, because conflating them is what
produced the rejected `--config-source` enum:
- **Authority must be explicit in the invocation.** An operator reads
`ExecStart` and knows which authority is live. Probing `/etc/nxdns` for a
file and switching behaviour on its existence is ambient magic — the same
class of heuristic that made seed-once bootstrap a source of doc lies (the
compose comment, the first-run tutorial) — and stays banned: a file on disk
that no flag names changes nothing.
- **A mode enum is the wrong shape for a two-state choice a path already
expresses.** `--config-source=db` names an implementation, not an operator
intent, and a mode flag beside a path flag manufactures invalid
combinations (path without mode, mode without path) that then need pairing
rules and usage errors to defend. Presence-of-path has no invalid
combinations and nothing to defend.
Breaking change, named: `run --config <file>` today means seed-once; the same
syntax now means file authority — reconcile on every boot, UI config writes
rejected. For an operator who seeded once and then configured through the UI,
the first post-upgrade restart converges the DB to that old seed file,
deleting the UI edits. The upgrade doc's breaking-changes section leads with
this and gives the two exits (ruling 9): drop the flag, or re-export to the
file path first. `check --config` keeps its meaning exactly. Greenfield rules
apply — the seed-once interface does not survive for compatibility — but the
break is loud in the docs, never silent-by-omission.
A rename (`--managed-config`) that would make the old invocation fail loudly
was considered twice and declined: it trades a worse name and a permanent
asymmetry with `check --config` against a one-time hazard whose exposed
population is the pre-change install base of a project whose first release is
days old. The hazard is real and the docs lead with it; the interface does
not carry the scar. This is a judgment, recorded so it is revisited only with
new facts (a real install base would be one).
### 2. File mode fails closed, and *declarative* failure is exit 2
File mode contract: the file is the sole declarative source; the DB stays the
runtime substrate and the effective-config read path. Startup sequence,
replacing the `seedFromFile` call at app.zig:196:
1. `DataDir.open` → open config DB → `migrate` (unchanged).
2. Read the file (existing 4 MiB cap), ZON parse (arena, never freed — keep
the import.zig discipline), `validate.validate`.
3. Reconcile into the DB in one `BEGIN IMMEDIATE` transaction, `errdefer`
rollback (ruling 3).
4. `config_export.readConfig` (app.zig:206) → serve, unchanged from there on.
A missing, unreadable, or invalid file fails startup. Never fall back to the
DB: a fallback turns a deploy typo into a silently stale config.
Exit codes keep the 2am contract — exit 2 means "your config is wrong, run
`nxdns check`"; exit 1 means "the box is wrong". `faults.isConfigFault`
deliberately excludes `FileNotFound`/`AccessDenied` (faults.zig:107-108), and
that stays true in general. The file-mode loader is the seam, and it maps
**path-class open failures only**: `FileNotFound`, `AccessDenied`,
`PermissionDenied`, `NotDir`, `IsDir`, `SymLinkLoop`, `NameTooLong`,
`BadPathName` become `error.ManagedConfigUnreadable` (message includes the
path), which joins the `ConfigFault` set beside `ParseZon`/`ConfigTooLarge`.
Every other member of `ReadFileAllocError``SystemResources`,
`ProcessFdQuotaExceeded`, `SystemFdQuotaExceeded`, I/O errors, `OutOfMemory`
propagates unmapped, exit 1: those are box faults a retry can clear, and once
ruling 9 adds `RestartPreventExitStatus=2 64`, mapping them to exit 2 would
stop the unit permanently on a transient fault. The mapping is an explicit
named error set in the loader, switched exhaustively with
`else => |other| return other`, so a std error added in a Zig bump defaults to
exit 1 rather than silently to exit 2 — the same closed-set discipline
faults.zig already documents. The mapping lives in **one shared helper** used
by both `run` and `check`; two copies would let the two grade the same
unreadable file differently, exactly the divergence faults.zig:1-8 exists to
abolish.
**Scope of the check/run agreement claim**: `check --config=<file>`
grades exactly the declarative faults `run` would hit — read (path-class),
parse, size, validate — through the same shared loader helper. Reconcile-time
faults (FK violations, `SQLITE_BUSY` from a restart race, disk full) are
runtime faults, exit 1, and structurally invisible to `check`, which needs no
DB. The acceptance encodes the scoped claim, not "check passing guarantees run
converges". The one *systematic* gap the red team found — a group removal
tripping the un-cascaded `clients.group_id` FK against observed rows `check`
cannot see — is eliminated in the engine itself (ruling 3's reassign rule),
not papered over in `check`.
Reconcile-time diagnostics include the SQLite error (`SQLITE_FULL` by name
when that is the cause) so a full SD card reads as "disk", not as a bare
exit 1. Diagnostics render to `r.err` and flush immediately, keeping the
current `seedFromFile` discipline (app.zig:137-178) — `serve` never returns,
so a buffered error line is a lost error line.
Because every boot revalidates the file, a latent file error is no longer a
first-boot-only hazard: a bad push that skips its restart handler detonates at
the next power blip. Two mitigations, both in ruling 9: the shipped unit gains
`RestartPreventExitStatus=2 64` (retrying a config fault every 2 s is pure
loop; the journal holds the diagnostics), and the deployment docs mandate
`nxdns check --config=<file>` as the pre-restart gate in any Ansible
handler.
Db mode: steps 2-3 are skipped entirely; the DB is truth exactly as today.
Bare `check` on a box with no `config.db` exits 2: `no config database at <path>`
plus the ruling-6 hint line — the deleted heuristic's "nothing to check"
branch (cli.zig:582-587) is replaced, not dropped.
### 3. Reconcile engine: replace declarative state, preserve runtime state by stable identity
New module `src/config/reconcile.zig`, replacing `applyToDb`'s wipe+reinsert.
The defect it exists to fix: today's import deletes and reinserts
`blocklist_sources` including runtime columns (checksum, `last_updated`,
counters — config_schema.zig:46-56), and compiled blocklists are keyed by
source row id (`<id>.list`/`<id>.wild`). A naive re-import every boot would
force a full re-download and recompile of every blocklist on every restart.
Contract, per table: match rows by identity key ⇒ UPDATE declarative columns
in place (row id survives); present in DB but absent from the file ⇒ DELETE;
present in file but not DB ⇒ INSERT. Never wipe. One transaction. The
`clients` table refines the match rule with promotion (below): an observed row
whose IP the file declares is matched and updated, never deleted.
**Identity matching is on canonical forms.** Import already canonicalizes
client IPs and prefixes before insert (import.zig:226-237; `FD00:0:0:0:0:0:0:1`
stores as `fd00::1`) and the schema documents the columns as canonical text.
The engine canonicalizes file values *before* matching; matching the raw file
string against the canonical column would churn ids under an unchanged
non-canonical file, violating ruling 5 in a way an exported-config test
(export emits canonical forms) cannot catch. The idempotence test includes a
non-canonical file.
**Writes only on difference.** A matched row whose declarative columns already
equal the file's values gets no UPDATE. This is stronger than byte-stability:
reconciling an unchanged file performs **zero writes**, so a no-op boot needs
no WAL headroom and a querylog-full SD card cannot brick a file-mode restart
that db mode would survive. Testable: the second reconcile returns an all-zero
summary and `sqlite3_total_changes` does not move.
| Table | Identity key | Declarative columns | Runtime-owned (preserved on match) |
|---|---|---|---|
| `blocklist_sources` | `url` | `name`, `enabled`, `is_suggested` | **`id`**, `checksum`, `last_updated`, `domain_count`, `wildcard_count`, `skipped_regex_count` |
| `groups` | `name` (`default` pinned to id 1, existing assert kept) | `safe_search` | `id` |
| `clients` | canonical `ip` | `name`, `group_id`, `hand_edited=1` | `first_seen`, `last_seen`; observed rows (`hand_edited=0`) are kept wholesale; an observed row whose IP is now declared in the file is **promoted in place** (UPDATE `name`, `group_id`, `hand_edited` 0→1 — `first_seen`/`last_seen` untouched, row id survives, counts as `updated`); observed rows whose group is deleted are **reassigned to the default group** (below) |
| `rules` | the full tuple `(group_id, pattern, kind, action)` — no natural key exists; the identity is a **multiset**, exact duplicate tuples pair off by count | (the tuple) | `id`, `created_at` (unmatched inserts stamp `now`) |
| `upstreams` / `client_prefixes` / `forward_zones` / `local_records` | `url` / canonical `prefix` / `zone` / `(name, rtype, value)` | everything else | `id` — preserved as a consequence of the idempotence invariant (ruling 5), **not** for FK stability: nothing references these ids by FK and id-based REST mutations are rejected in file mode |
| `group_sources` | `(group_id, source_id)`, resolved via the name/url maps | whole row | — |
| `settings` | key | value via `settings_repo.putSetting` upsert, on difference only | delete keys not produced by `toSettings`, **except** `web.password_hash`, which the reconciler owns directly (ruling 4) |
**Pass order (binding).** `delete_order` alone under-specifies the engine:
inserts need parents before children, and a group DELETE cascades through
`rules`, `client_prefixes`, `group_sources` (config_schema.zig:35, :60, :67),
which could silently undo child-table work and corrupt the diff counts if
deletes ran first or interleaved. The order is:
- **Phase A — upserts, parents first**: `groups`, `blocklist_sources`, then
the referrers (`clients`, `client_prefixes`, `rules`, `group_sources`,
`upstreams`, `local_records`, `forward_zones`, `settings`), with the
name→id / url→id maps built after the parent passes. Client promotion
happens here, in the `clients` pass.
- **Phase B — delete-absent, child first**, in `delete_order`
(config_schema.zig:99). Because every declarative child of a dying group is
itself absent from the file (validate guarantees file rules/prefixes
reference file groups), the child passes have already deleted and counted
them by the time the parent DELETE runs; the FK cascades become a safety
net, never the accountant.
- Immediately before the `groups` delete pass: observed clients
(`hand_edited=0`) whose `group_id` belongs to a dying group are reassigned
to the default group (id 1). `clients.group_id` has **no** ON DELETE clause
(config_schema.zig:26) — without this step, removing or renaming a group
that observed devices had been assigned to trips the FK mid-transaction:
exit 1, restart loop, and a `check` that said the file was fine. The
reassignment is the semantics we want anyway: the operator un-declared the
group, not the devices.
Consequences that make the fix complete: an unchanged URL keeps id **and**
checksum + counters + `last_updated` together, so `loadSource` never sees
`.never_fetched`, `needsRefresh` does not fire spuriously (preserving
`last_updated` matters — checksum alone is not enough), `sweepOrphans` never
orphans `<id>.list`/`<id>.wild`, and a restart costs zero downloads. A changed
URL is a new identity: new row, new id, fresh download — consistent with
artifacts keyed by id. **Accepted trade, stated**: the old row's compiled
`<old-id>.list`/`<old-id>.wild` are orphaned at commit and swept before the
new source's first fetch succeeds, so a URL edit whose new host is down leaves
that list unenforced until a fetch lands. Deferring the sweep until a
successor fetch would need a cross-artifact lifecycle for an event that is
rare, operator-initiated, and bounded by the scheduler's retry — not worth the
machinery on a household box. The REST `updateSource` keeps runtime columns
across a URL edit; in file mode that route is rejected (ruling 7), so the
divergence is unreachable; in db mode the reconciler only runs via explicit
`import`.
Related clock fix, same subsystem: `needsRefresh` is wall-clock arithmetic
(`now - last >= interval`, manager.zig:1201-1202) and the Pi has no RTC. A
fetch stamped while the clock was ahead (pre-NTP boot, restored image)
suspends refresh until real time catches the future timestamp — and this
design's idempotence would faithfully preserve the poison forever, having
deleted the wipe that used to be the accidental reset lever. The engine's
sibling fix: `needsRefresh` treats `last_updated > now` as refresh-due. One
comparison, with a test.
Client promotion in place makes the `saved_clients` lift/merge/restore
scaffolding (import.zig:268-361, including `merge_observed_timestamps_sql`)
unnecessary: that machinery exists only because the wipe destroyed
`first_seen`/`last_seen` and had to smuggle them across. With no wipe, the
semantics it encodes — observed history survives declaration — are a plain
UPDATE that never touches the timestamp columns. The scaffolding dies with
the wipe (Deletions); its tests' semantics move to the promotion tests.
**Ordering invariant**: reconcile commits before `manager.reload`
(app.zig:400) runs and before the scheduler's `sweepOrphans` can fire, so
preserved ids and checksums are visible to the filter layer before any
pruning. This holds by construction — reconcile completes inside `serve()`
before the manager exists — and is enforced *behaviorally*, not by a
statement-order unit test with nothing to grip: the restart-no-redownload
integration test (Tests, below) fails if anything between reconcile and
reload re-orders or wipes. No `serve()` restructuring is chartered for this.
The engine returns a summary (ruling 8's input and the cross-session
contract):
```zig
pub const TableCounts = struct { inserted: u32, updated: u32, deleted: u32 };
pub const Summary = struct {
groups: TableCounts, sources: TableCounts, clients: TableCounts,
client_prefixes: TableCounts, rules: TableCounts, group_sources: TableCounts,
upstreams: TableCounts, local_records: TableCounts, forward_zones: TableCounts,
settings: TableCounts,
auth_transition: enum { none, enabled, disabled, rotated },
};
```
`updated` counts only rows actually written (writes-on-difference);
observed-client preservation counts nothing; promotion and reassignment count
as `updated` on `clients`. `deleted` on `clients` means exactly one thing: a
formerly declared (`hand_edited=1`) row absent from the file. An unchanged
file yields an all-zero summary.
New repo verbs carry the engine (`sources_repo.upsertByUrl`,
per-table `deleteWhereNotIn`-style helpers), not new call ordering. `applyToDb`
and its wipe loop are deleted; import.zig keeps its parse/validate/diagnostics
plumbing.
### 4. Password: the file overrides when it speaks, and only then
`model.Web` changes to `password: ?[]const u8 = null` and
`password_hash: ?[]const u8 = null`. The settings bridge splits its skip
policy by direction — `isSkipped` becomes two functions, because encode and
decode need different sets:
- `isEncodeSkipped` = {`web.password`, `web.password_hash`}: `toSettings`
stops emitting `web.password_hash` (the reconciler owns that settings row
directly) and continues to never emit `web.password`.
- `isDecodeSkipped` = {`web.password`} only: `fromSettings` **still loads**
`web.password_hash` from the settings table — the reconciler owning the
write does not mean the read path stops seeing it. Skipping it on decode
would leave `cfg.web.password_hash` null on every read path
(`config_export.readConfig` at export.zig:52, `mutations.loadConfig`), turn
`authEnabled` false, and silently disable auth in both modes.
- `decodeValue` gains an `.optional => try decodeValue(child, text)` arm
(model.zig:408-421 has none today; an unskipped optional field is currently
a `@compileError`). Absent key ⇒ default `null`; present ⇒ non-null.
`auth.authEnabled` becomes `(web.password_hash orelse "").len != 0`
(auth.zig:62-63); app.zig:475's `.live_hash = .init(...)` unwraps with
`orelse ""`. The cases:
- **Both set**: existing error (`PasswordAndHashBothSet`, import.zig:250,
validate.zig:395 — the check ports to `!= null` on both fields).
- **`password` present and empty**: rejected by `validate` with a new
diagnostic naming the remedy — `password_hash = ""` is how auth is disabled
declaratively. Without this rejection, `.password = ""` would hash the
empty string into a non-empty PHC (`authEnabled` true) while auth.zig:90
refuses every empty-password login: auth on, unreachable. A declarative
fault, exit 2, so ruling 2's check/run agreement holds.
- **`password` set (non-empty plaintext)**: verify against the stored
`web.password_hash` with argon2; on match keep the stored hash, on mismatch
or absent stored hash, hash fresh. The justification is ruling 5 alone:
hashing unconditionally generates a fresh salt per apply and breaks
byte-stability. It is **not** a cost saving — argon2 verification recomputes
the full function (same t=2, m=19 MiB) with the stored salt, so
verify-and-keep costs exactly what hashing costs. Do not "optimize" the
verify away with any cached-plaintext scheme; that would be a security bug.
- **`password_hash` set**: written verbatim. An explicit `password_hash = ""`
is the declarative way to disable auth (auth.zig's documented empty-hash
state).
- **Neither set**: the stored hash is untouched. This is the deliberate
carve-out from file-as-sole-truth, because the naive alternative is a trap
the red team walked straight into: `toSettings` today always emits
`web.password_hash` with default `""` (model.zig:534, :93), so an operator
who hand-trims the ugly PHC string out of an exported file — meaning "keep
the current password" — would silently reconcile `""` over the stored hash
and open the admin UI to the LAN (`authEnabled` is `len != 0`, auth.zig:63).
Silence must mean "keep", and disabling auth must require the explicit
empty string.
**Export's canonical form**: `password = null`, `password_hash = <stored
value, including "">`. A non-null `password` is never emitted — the current
`cfg.web.password = "";` at export.zig:71 ported literally would make every
export carry a present-empty password beside a stored hash, tripping
`PasswordAndHashBothSet` on re-import: export's own output failing its own
rule, breaking ruling 9's adoption walkthrough and ruling 5's round trip. The
comment at export.zig:67-70 and the header sample change with it. With
`password = null`, the empty-plaintext rejection above does not fire and the
"hash written verbatim" branch keeps idempotence.
Any auth transition (enabled/disabled/rotated) is reported in the summary and
printed by ruling 8 — an auth change is never a silent line item in a count.
### 5. Idempotence is the invariant
Reconciling the same file twice produces a byte-identical database — ids,
checksums, timestamps, `created_at`, password hash, the whole settings
table — **and** the second pass performs zero writes (all-zero summary,
`total_changes` unmoved). The unit test asserts
byte-stability via the `dump()` helper (import.zig:469), which is rewritten to
iterate an explicit all-tables list (`config_schema.table_names`, all ten
content-bearing tables) because its current driver, `content_tables`, is
deleted (ruling 6). Anything that churns under an unchanged file — including a
*non-canonical but equivalent* file — is a bug in the engine, by definition.
(This invariant is what forces ruling 3's rules-multiset, canonical matching,
and writes-on-difference, and ruling 4's password handling — the places a
naive design silently violates it. It is also half of why ruling 8 persists
no authority state at all.)
### 6. `import` becomes a thin wrapper over reconcile; the guard becomes diff-gated
`nxdns import` is db mode's one-shot apply and the restore tool, reimplemented
on the reconcile engine. Reconcile is non-destructive of *runtime* state, but
deletion of declarative rows absent from the file is still a first-class
outcome (ruling 3) — a mistaken `nxdns import ./wrong.zon` against a
configured DB would still remove every group, rule, upstream, and source not
in that file. So the guard is **retargeted, not deleted**: emptiness-gating
(`isEmpty`) becomes diff-gating.
- After the reconcile passes, still inside the same `BEGIN IMMEDIATE`
preserving import.zig:199-203's deliberate check-inside-the-write-lock
property, no TOCTOU — if any table's `deleted != 0` and no override flag,
roll back and fail exit 2 with the per-table delete counts in the message.
Observed-client reassignment is not declarative data and never trips the
gate (and under ruling 3's promotion rule, observed rows are never deleted
by reconcile at all).
- The flag is `--allow-delete` (`Options.allow_delete`), the renamed
`--force`; the error is `error.DestructiveImport`, the renamed
`DatabaseNotEmpty`, exit-2-mapped where cli.zig:533 maps today. This is
strictly better than the emptiness guard: additive and edit-only re-imports
stop needing a flag at all, and the flag now names what it permits.
"Edit-only" means edits to declarative columns on a matched identity; an
edit that *changes an identity column* — a group name, a source or upstream
url, a prefix, a zone, a rule tuple, a local-record identity — is a delete
plus an insert to the engine (ruling 3) and needs the flag. cli.md states
the distinction.
- `import.isEmpty` and `content_tables` still die — the diff-gate needs no
table list.
`import` does not detect a file-managed DB, because nothing records one:
authority lives in the invocation (ruling 1) and the DB carries no marker
(ruling 8). On a box whose unit runs file mode, `import` behaves like any
other import — the diff-gate guards deletion, and the next boot's reconcile
converges the DB back to the file, its summary reporting what it corrected.
The docs own this story plainly: `import` is a **stop-first operation**, on a
file-mode box doubly so — against a running instance its effect is partial
(the runtime divergence below) and lasts only until the next restart. The
file-authority contract is stated with the same precision everywhere: the
file is the sole declarative source, **converged at every boot** — not a
lock on the database between boots. A detect-and-warn variant was
designed and deleted (ruling 8) — a per-invocation warning cannot *prevent*
db-side writes anyway, the CLI user is root on their own box, and the
asymmetry with the web layer's 403 is intentional: the web UI has
anonymous-ish LAN users, the CLI has the operator.
What a mid-run import against a *running* file-mode instance actually does —
documented, because the divergence is partial, not merely deferred:
`Manager.reload` re-reads `blocklist_sources`, `groups`, `group_sources`,
`rules`, `clients`, `client_prefixes` from the DB at runtime
(manager.zig:405-489) and the scheduler runs `sweepOrphans` before every pass
(manager.zig:1095, :1115), so filtering follows the imported rows on the next
reload and can unlink the compiled `<id>.list`/`<id>.wild` of sources the
import deleted — while upstreams, listeners, and settings stay at boot
values, and the web UI shows the imported state under the file-authority
banner. The next restart's reconcile re-inserts deleted sources from the file
with new ids: a full re-download, the exact cost this design exists to
prevent. That is why the docs name the restart requirement and the
re-download cost. The settings envelope's `reconciled_at` cannot see a CLI
import — ruling 7's weakened claim covers exactly this.
The startup-vs-import race needs no code: both paths take `BEGIN IMMEDIATE`
under the 5 s busy timeout (db.zig:252), so the outcome is ordering, not
corruption — an import racing a restart may be reverted by the reconcile that
wins the lock second. Documented, not engineered around.
First-run story in db mode, after bootstrap dies: a fresh empty DB fails
validation naturally (`NoUsableUpstreams`, exit 2). The remediation hint —
one fixed line naming `nxdns import` and `run --config` — is owned by
**cli.zig's db-source fault renderer** (one arm, beside the exit-code mapping;
Zig errors carry no text and validate.zig must stay mode-blind), and the same
line serves bare `check` with no DB (ruling 2). One location, R2's charter.
`export` is unchanged in role: the diagnostic/capture tool in both modes, and
the file-mode adoption tool (its canonical password form changes per
ruling 4).
### 7. Web layer: policy as data, rejected after auth, plain 403
`WebState` gains `authority: union(enum) { database, managed_file: []const u8 }`
and `reconciled_at: ?i64`, built where `WebState` is assembled (app.zig:468).
The `managed_file` path slice is owned by `serve`'s arena, which outlives
`WebState` — R2 provides it, R3 consumes it, neither copies. `reconciled_at`
is stamped by `serve` immediately after the reconcile commits — same process,
same frame, a local — and is `null` in db mode, which never reconciles.
`RouteInfo` gains `policy: enum { read, config_write, runtime_action }` beside
`auth` and `rate_limit` — policy-as-data, matching the table's existing style.
No default value: all 56 route entries state their class explicitly, and
router.zig's `test_table` (:198) gains the field too. Classification is
per-route, not per-prefix: `POST /api/blocklists/update` is a
`runtime_action`; its CRUD siblings are `config_write`.
- Config writes, rejected in file mode: all group / blocklist / rule /
local-record / forward-zone / upstream / client-prefix mutations,
`PUT /api/settings` (password changes go through the file; its
`live_hash.installAndRevoke` side effect never fires in file mode), and
client PUT — naming or regrouping an observed client is declarative drift
(open question 1).
- Client DELETE is a **runtime action**: deleting an observed
(`hand_edited=0`) row discards runtime state the file never declared —
without this, a mis-identified or departed device's row is immortal in file
mode, since the file can only promote IPs, never remove them. Deleting a
*declared* client contradicts the file: the handler answers the same 403
envelope. This is the one policy decision that needs a row read; it lives
in the client handler, not the router. (After ruling 3's promotion, a
declared IP's row *is* declared — DELETE answers 403, the intended
reading.)
- Runtime actions, always live: pause, blocklist refresh, cert reload,
login/logout.
Enforcement lives in `router.dispatch` **after** `check_auth`, before the
handler — match → rate limit → auth → policy. Pre-auth rejection would leak
route existence; the codebase answers 401 first and this design keeps that.
Rejection is **403**, body the existing single-field envelope:
`{"error":"configuration is managed by /etc/nxdns/config.zon; edit the file and restart"}`.
Not 409 — that status already means constraint conflict four ways in
openapi.yaml — and **no `code` field**: 403 is unused today, so the status
alone is machine-readable; the UI learns authority declaratively from
`GET /api/settings`, not by probing errors; and the single-property `Error`
schema, golden contract samples, and `api.ts` stay untouched. An optional field
with no consumer is machinery, not a contract.
The envelope must survive its own path: `respondError` today builds into a
fixed 512-byte buffer and **silently downgrades to `text/plain`** on overflow
(http_util.zig:315-327) — a long managed-file path (nested bind mounts) would
demote the documented JSON envelope. Fix at the root: `respondError` gets the
arena treatment `respondJson` already uses (`Writer.Allocating` over
`request.arena`, :337-339) and the `respondPlain` silent-downgrade path is
deleted — which fixes every long error message, not just this one.
`src/web/http_util.zig` joins R3's ownership.
Authority discovery: the `GET /api/settings` envelope gains
`authority: {mode, path, reconciled_at}` — an authenticated route, so the
filesystem path never leaks through open `/api/version`/`/api/health`. All
three come from `WebState` (live truth); `path` is present in file mode only
and `reconciled_at` is nullable — `null` in db mode. Its meaning is exactly
"**this process loaded the file at T**" — restart-pending detection: an mtime
newer than `reconciled_at` means the running process has not loaded the
current file. The comparison is one-directional and non-authoritative — a
stepped clock (pre-NTP boot stamping the future, ruling 3's clock fix
territory) or a preserved mtime (`git checkout`, `rsync -a`) can make a newer
file look older, and the DB can move without either timestamp moving
(ruling 6's `import`, this ruling's `runtime_action` routes). It does not
answer "is the file what the server uses"; answering that would take content
hashing, which the anti-requirements refuse. The UI renders a read-only
banner (RestartBanner slot precedent) and disables mutation controls, with
the 403 as backstop.
### 8. The operator can see what happened
- `logStartup` (after `logging.install`) logs the authority:
`authority: database` / `authority: file (/etc/nxdns/config.zon)`.
- In file mode, the reconcile summary (ruling 3's `Summary`) prints through
the Runner at startup, matching `seedFromFile`'s existing output discipline:
per-table inserted/updated/deleted counts, the changed settings **keys**
(never values), and the auth transition when there is one. It is the answer
to "what did that restart change" without opening sqlite.
- **Authority is never persisted.** The DB carries no record of which mode
wrote it: authority lives in the invocation (ruling 1), per-process state
(`WebState.authority`, `reconciled_at`) serves the API (ruling 7), and the
journal holds the history. A persisted marker was designed twice and
deleted twice. A per-boot `reconciled_at` settings row breaks ruling 5
outright — `dump()` iterates the settings table, so any row rewritten per
reconcile forfeits byte-identity, and `putSetting` moves `total_changes`,
forfeiting zero-writes. The write-on-difference `authority.mode`/`path`
variant survived idempotence but cost a reserved key namespace, a
`fromSettings` decode filter, and a reconciler sweep exemption — three
mechanisms whose only consumer was one `import` warning (ruling 6). State
that exists to power a courtesy message is not worth a namespace. The
settings sweep's exemption list is therefore exactly one key:
`web.password_hash` (ruling 4).
No `std.log.err` in any new code, per the standing spec rule for new code
(the pre-existing calls in db.zig/tls_server.zig are out of scope).
### 9. Deployment and migration
- **systemd**: the shipped unit stays flagless (db default) and gains two
lines. `RestartPreventExitStatus=2 64` — a config fault or usage error must
not restart-loop every 2 s until StartLimitBurst; the journal holds the
diagnostics and the fix is a file edit, not a retry. (Correct in db mode
too: exit 2 means the config is wrong in either mode. And it is why
ruling 2 keeps exit 2 to *declarative* faults only — a transient box fault
mapped to exit 2 would stop the unit permanently.) And
`ReadOnlyPaths=/etc/nxdns``ConfigurationDirectory=nxdns` makes systemd
create the directory owned by the service user, so without this line the
source-of-truth file is writable by the very process whose mutation routes
file mode exists to disable; nxdns never writes `/etc/nxdns` in either mode,
so the base unit ships the enforcement rather than recommending it. Docs
show a drop-in for file mode: `ExecStart=` reset plus
`--config=/etc/nxdns/config.zon`.
- **Ansible / config-management**: the docs' deployment guidance mandates
`nxdns check --config=<file>` as the handler precondition — validate
the pushed file *before* restarting, so a typo is a failed deploy at noon,
not a dead resolver at the next 3am power blip.
- **docker**: compose ships file mode as its example —
`command: ["run", "--config=/etc/nxdns/config.zon"]` (resolving old open
question 4; the `:ro` mount at compose.yaml:15 already suggests it). This keeps the
fresh-install and volume-loss stories working after bootstrap dies: a
recreated `nxdns-data` volume reconciles from the mounted file on next
start, which is *better* than the old seed-once self-heal. The binary's
default stays db. The seed-once comment (compose.yaml:9-12) is rewritten.
For db-mode-in-docker, the docs show the recovery one-liner
(`docker compose run --rm nxdns import /etc/nxdns/config.zon` — the file is
positional, `ImportArgs.file` at cli.zig:61, plus `--allow-delete` when the
diff deletes) — without it, `restart: unless-stopped` plus exit 2 is an
infinite crash loop (docker has no start limit) with no documented way out.
- **Adopt file mode** on a UI-configured box: **stop the service first**, then
`nxdns export --out /etc/nxdns/config.zon`
`nxdns check --config=/etc/nxdns/config.zon` → add the flag → start.
Stop-first is load-bearing twice: any UI edit landing between a live
export and the restart would be silently reverted by the first reconcile,
and `check` refuses to grade a live database (immutable open, `WalPending`
against the steady-state WAL, db.zig:222-226) so the pre-flight gate only
works stopped. (Sync note, found in R4: the spec originally claimed
`export` itself refuses against a live instance — it does not; `export`
opens read/write and succeeds. The claim was corrected to name `check`.)
Stopped,
the first reconcile's summary is all-zero and writes nothing — blocklist
state, compiled files, and client history all survive. Every unchanged boot
after it writes nothing either.
- **Leave file mode**: drop the flag (remove the drop-in), restart. The DB
already holds the last reconciled state; nothing else needed.
- **Binary downgrade from file mode**: no unit edit is needed — the old
binary accepts `run --config` with seed-once semantics, and against the
already-configured DB it ignores the file and serves the last-reconciled
state. The rollback note states the consequence: file edits stop applying
until the binary is upgraded again.
- **Restore from backup**: db mode is stop → restore `config.db` → start,
with the WAL caveat db.zig itself documents: take backups only from a
stopped instance (or use `export`), and on restore delete any stale
`config.db-wal`/`config.db-shm` beside the target — a mismatched WAL is
silently discarded by SQLite, which turns "restore" into "lose the tail".
Restoring an *exported file* into a populated DB via `import` is exactly
the deleting case: back-up-and-restore.md documents `--allow-delete` there
(ruling 6) — the flag choreography is rewritten, not removed. In file mode
`config.db` is not the config backup — the file is; restore = redeploy the
file. The query-log DB restores independently in both modes.
Migration honesty, replacing the earlier blanket claim: a db-mode install
that never passed `--config` needs nothing. Anyone whose unit, wrapper, or
compose `command` carries `run --config` (the documented seed-once invocation
in first-run.md and three how-to labs) gets file authority at the first
post-upgrade start — the seed file becomes the config, and UI edits made
since seeding are deleted by the first reconcile. The upgrade doc's
breaking-changes section leads with this and gives the two exits: drop the
flag to keep the DB, or re-export to the file path first to adopt file mode
cleanly (the walkthrough above). `check --config` keeps its meaning. Docker
db-mode fresh installs must use the new compose or run `import` once. No
schema migration.
### 10. Documentation is part of the change
`explanation/configuration-model.md` is rewritten around the two-mode model
(its "why the database wins" argument becomes mode-scoped, not superseded).
Seed-once claims are corrected in: the first-run tutorial, install-with-systemd,
install-with-docker, upgrade (breaking-changes + rollback sections,
ruling 9), back-up-and-restore (new restore semantics, WAL sidecars,
`--allow-delete`), set-up-admin-authentication (the ruling 4
absence/empty-string rule), troubleshoot, cli.md
(`run`/`check`/`import`/`export`, `--allow-delete`), configuration.md
(optional password fields), files-and-directories.md, api.md (settings
envelope, 403, per-route rejectability), the compose.yaml comment,
README/INSTALL.
## Deletions (complete list)
`config/bootstrap.zig` (+2 tests, S7 cases 15-18, + app.zig import/call sites
:35/:142/:146 and tests :1124/:1237) · `import.isEmpty` (+6 tests, + the
storage_integration_test.zig call sites :763/:795/:894, + the clients_repo.zig
doc comments :6/:129) · `config_schema.content_tables` (+its test; `dump()`
re-pointed to the new `table_names` list) · `app.seedFromFile` (+its app.zig
tests) · `CheckArgs.config_explicit` · the `checkImpl` source heuristic ·
`applyToDb`'s wipe loop, including the `saved_clients` lift/merge/restore
scaffolding (import.zig:268-361 — superseded by ruling 3's
promotion-in-place) · `toSettings`'s `web.password_hash` emission (+its
key-list test row; the model.zig:665-670 "skipped in both directions" test is
rewritten for the encode/decode split) · `http_util.respondPlain`'s
silent-downgrade path (`respondError` rebuilt on the request arena).
Renamed, not deleted (ruling 6): `Options.force``Options.allow_delete`
(`--force``--allow-delete`), `error.DatabaseNotEmpty`
`error.DestructiveImport` (cli.zig:533 arm retargeted, faults.zig exclusion
and tests follow the name).
## Sessions
R1 lands first and R2 rewires onto it and carries every deletion — they are
ordered, not parallel (the old plan had R1 deleting `bootstrap.zig` out from
under R2-owned app.zig call sites). R1 is **additive engine plus one
coordinated model change**, not purely additive: the `model.Web` optional
fields force same-session edits at every site that reads them, or the tree
stops building. R3 and R4 run parallel to both; their interfaces
(`reconcile.Summary`, `WebState.authority` + `reconciled_at`,
`RouteInfo.policy`) are fixed here.
### Session R1: reconcile engine + the coordinated model change
Owns `src/config/reconcile.zig` (engine + `Summary` as specified), the new
repo verbs in `src/storage/repositories/`, the `needsRefresh` clock clamp in
`src/filter/manager.zig`, registration in `src/tests.zig`. Rulings 3, 4, 5.
Owns `src/config/model.zig` whole: the optional `Web.password`/`password_hash`
fields, the `isEncodeSkipped`/`isDecodeSkipped` split, `decodeValue`'s
`.optional` arm, and the bridge tests. And the sites the
optional fields break, which no session previously owned: `validate.zig`
(:395 both-set port, the new empty-password rejection, test :1966-1968),
`auth.zig` (`authEnabled` orelse), `export.zig` (canonical `password = null`,
comment and header sample), `src/web/handlers/settings.zig` (`newPassword`'s
`orelse` chain at :184-186, and `FieldType` collapsing `?T` so `Partial`'s
`?FieldType(...)` at :114 does not generate a double optional — landing this
in R1 keeps R3's `web/` work unblocked). R1 also makes the one-line
`orelse ""` edit at app.zig:475 so the tree builds; app.zig otherwise stays
R2's, and R2 absorbs that line into its startup rewire.
### Session R2: CLI + startup + deletions
Owns `src/cli.zig`, `src/app.zig`, `src/config/faults.zig`,
`src/config/import.zig` (thin-wrapper rewrite, diff-gate, `--allow-delete`),
the shared loader-fault mapping helper (ruling 2, used by `run` and `check`),
and the entire Deletions list plus the renames. Rulings 1, 2, 6, 8. Threads authority and
`reconciled_at` into `WebState` (arena-owned path) but does not touch the
router. Owns the cli.zig hint-line renderer arm.
### Session R3: web enforcement + UI
Owns `src/web/router.zig` (dispatch policy step, `test_table` gains the
policy column), `src/web/routes.zig` (all 56 entries state `policy`
explicitly — no default), `src/web/http_util.zig` (`respondError` arena
rewrite, `respondPlain` downgrade deletion), `src/web/openapi.yaml`, the
client-DELETE observed/declared branch in the clients handler, `web/`
frontend (banner, disabled controls, settings envelope with nullable
`reconciled_at`). Ruling 7. Consumes `WebState.authority`/`reconciled_at` as
fixed above.
### Session R4: deployment + docs
Owns `deploy/**`, `docs/**`, `README.md`, `INSTALL`, compose file + comment,
the systemd unit's two new lines. Rulings 9, 10.
### Orchestrator
This spec, R1→R2 sequencing, cross-session integration, the drift-guard
regenerations that span sessions (contract samples, openapi route counts,
api.md/cli.md rows), and the `-Dlive` acceptance run.
## Tests
- **Reconcile unit tests** (R1, `:memory:` + migrate): idempotence via the
rewritten `dump()` byte-stability across all tables after applying the same
file twice — including a file carrying a plaintext password **and a
non-canonical but equivalent file** (`FD00::1`-style) — plus the zero-writes
assertion (all-zero `Summary`, `total_changes` unmoved) on the second pass;
per-table preservation via `sources_repo.SourceRow` after seeding stats with
`updateSourceStats`; URL change ⇒ new id; source removal; observed-client
survival, **promote-on-declare (asserting `first_seen`/`last_seen` survive
the promotion and the row id is stable)**, and **reassign-to-default when
their group is removed or renamed** (the un-cascaded FK case);
`safe_search` edit on an existing group converges; rules `created_at`
stability including duplicate tuples; password verify-keeps-hash,
mismatch-rehashes, absent-keeps-stored, explicit-empty-disables; rollback
on mid-tx failure; default-group id 1 pin; `needsRefresh`
future-`last_updated` clamp.
- **Model/bridge tests** (R1, model.zig test neighbourhood at :659):
`decodeValue` on an optional field (absent ⇒
null, present ⇒ value); `web.password_hash` decodes from settings but is
not encoded (the encode/decode split); `validate` rejects present-and-empty
`password` with the `password_hash = ""` remedy in the diagnostic; export ⇒
import round trip with the canonical `password = null` form.
- **Import gate tests** (R2): a file whose diff deletes rows, without
`--allow-delete` ⇒ rollback, exit 2, per-table delete counts in the
message; with the flag ⇒ applied; additive/edit-only import needs no flag.
- **Loader-fault mapping** (R2, on the shared helper directly): each
path-class open error ⇒ `ManagedConfigUnreadable`; a non-path member of the
set propagates unmapped.
- **Restart-no-redownload** in filter_integration_test.zig: reconcile, then a
`Manager` restart reuses `<id>.list`/`<id>.wild` with no refetch (fixtures
already assert reuse by id) — this is also the behavioral enforcement of
ruling 3's ordering invariant. Plus **one `-Dlive` run of the real download
path** — hermetic suites have hidden a process-killing bug on the real
network path before (MEMORY), so the acceptance includes the real path once.
- **Router**: policy classification unit tests via `test_table`/`matchPath`,
no sockets; the contract table in web_integration_test.zig gains a `policy`
column and the existing 1:1 coverage assertion widens so classification
cannot drift; one socketed test per class in file mode (config write → 403,
runtime action → 2xx, read → 200) plus observed-vs-declared client DELETE,
via `EnvOptions` authority; `respondError` with a message longer than the
old 512-byte buffer stays `application/json`. Route count stays 56 — no new
routes.
- **CLI**: `parseArgs` tests: `run` without `--config` selects db authority;
`run --config=<path>` selects file authority and the path lands in the run
args; `--allow-delete` parsing; `usage_text` test; exit-2 for a missing
managed file and for bare `check` with no DB (hint line present) — via the
`Captured` runner (in-process; remember the buffered-writer caveat — flush
through a real `File.Writer` where the test asserts delivery).
- **Drift guards knowingly tripped and regenerated**: openapi.yaml (settings
envelope, 403 responses), golden contract samples (`-Dcontract-samples-out`),
api.md rows, cli.md sections, docs_drift_test, `toSettings` key-list test.
## Acceptance (design complete when implemented)
- [x] `nxdns run --config=<file>` on a DB with fetched blocklists (any boot
after adoption): restart performs zero downloads **and zero DB writes**;
source ids, checksums, `last_updated`, and compiled
`<id>.list`/`<id>.wild` files are identical before and after. Proven
once with `-Dlive` against a real source. *(Closed by
filter_integration test 10e: a hermetic HTTP fixture counts accepted
connections across a full Manager restart — 1 download total; inode,
mtime and source ids identical. The `-Dlive` gate passed 1559/1559
with 0 skips, which includes this test against the fixture and the
live-network suites against real upstreams.)*
- [x] Reconciling an unchanged exported config twice yields a byte-identical
`dump()` and an all-zero summary — including with a plaintext
`password` in the file, and with non-canonical addresses.
- [x] Removing (and renaming) a group that observed clients were assigned to
converges: clients land in the default group, no FK error, counts
reported. Declaring an observed client's IP promotes the row in place:
`first_seen`/`last_seen` and row id survive, counted as `updated`.
- [x] A file with neither `password` nor `password_hash` leaves the stored
hash — and auth — intact; `password_hash = ""` disables auth and the
startup summary says so; a present-but-empty `password` is refused at
validate with a diagnostic naming `password_hash = ""` as the disable
path.
- [x] `run --config=<file>` with a missing file exits 2 with the path in
the message; with an invalid file exits 2 with diagnostics; never serves
from the DB. An open failure outside the path class (fd exhaustion, I/O
error) exits 1, not 2. `check --config=<file>` agrees with `run`
on every parse/validate/path fault via the shared helper (the scoped
claim of ruling 2).
- [ ] A config file present at `/etc/nxdns/config.zon` with no `--config`
flag changes nothing: bare `run` serves the DB and never reads the
file. *(Design claim, verified structurally: no code path opens
`/etc/nxdns` — the deletion gate below proves the seed-by-presence
path is gone, and tests cover bare `run` with a config file present
in a lab directory. Not executed against the literal path
`/etc/nxdns/config.zon` on a host that has one; this machine does
not run nxdns from /etc.)*
- [x] `nxdns import` whose diff would delete rows fails exit 2 without
`--allow-delete`, printing per-table delete counts, and rolls back;
with the flag it applies; an additive import needs no flag.
- [ ] Fresh empty DB in db mode exits 2 (`NoUsableUpstreams`) with the hint
line; bare `check` with no `config.db` exits 2 with the
same hint; neither restart-loops under the shipped unit
(`RestartPreventExitStatus=2 64`).
*(Exit codes and hint lines are test-covered and closed. The
no-restart-loop half is a design claim: the unit line parses under
`systemd-analyze verify`, but no root systemd host was available to
observe systemd actually holding the unit down. Close it on the Pi 5
deployment.)*
- [x] The shipped compose file boots a fresh container (empty volume, mounted
config.zon) into file mode successfully; the db-mode import recovery
one-liner is documented and works. *(Run against a locally built
image: first boot reconciled `upstreams +1 ~0 -0; settings +45` with
auth enabled; second boot logged no changes.)*
- [x] In file mode: every `config_write` route answers 403 with the
single-field error envelope — `application/json` even when the managed
path is long; every `runtime_action` and `read` route behaves as in db
mode; DELETE of an observed client succeeds, of a declared client
answers 403; unauthenticated requests to protected routes still answer
401, not 403.
- [x] `GET /api/settings` reports `authority` with `reconciled_at` (null in
db mode); the UI shows the read-only banner and disables mutation
controls in file mode.
- [x] `rg -n 'import\.isEmpty|content_tables|config/bootstrap|seedFromFile|config_explicit' src/`
returns nothing (historical specs exempt; pattern chosen so
fetcher.zig's `host.isEmpty()` and validate.zig's "bootstrap problem"
prose cannot false-positive).
- [x] Adopt-file-mode walkthrough (stop → `export``check --config` → add
the flag → start) run end to end on a UI-configured instance: the first
reconcile summary is all-zero and writes nothing, as does every
unchanged boot after it; leave-file-mode (drop the flag, restart)
serves identically; `check` against the *running* instance's database
refuses with the uncheckpointed-WAL message, as documented (corrected
from `export`, which opens read/write and succeeds live — see ruling 9's
sync note).
- [x] All existing gates pass; tripped drift guards are regenerated, not
suppressed. *(Final numbers: `zig build test` 1429/1559 pass, 130
skipped, 0 failed; `-Dintegration` 1555/1559, 4 skipped, 0 failed;
`-Dintegration -Dlive` 1559/1559, 0 skipped, 0 failed. The live gate
also caught and fixed storage S7 case 22, whose expectation had been
stale since milestone 13 because nothing ran `-Dlive` in between.)*
## Anti-requirements
- No file watcher, no inotify, no SIGHUP reload — restart is the reload.
- No UI write-back to the file, no partial/merge authority, no per-table
hybrid modes, no multi-file config.
- No persisted authority state, no config hashing, no content-based change
detection — authority lives in the invocation; last-load time is
per-process state in the settings envelope, honest about what it can and
cannot answer (ruling 7).
- No deferred deletion of a replaced source's compiled artifacts — the URL-edit
gap is an accepted trade (ruling 3), bounded by the scheduler retry.
- No etag/conditional GET for blocklist fetches.
- No `code` field or richer error envelope — the status is the machine
contract.
- No rule-identity schema change (no synthetic rule key column).
- No preservation of rule `created_at` across pattern edits — an edited rule
is a new rule.
- No CLI-side blocking or detection of `import` against a file-mode box's
DB — the diff-gate guards deletion; the next boot converges and its
summary reports what it corrected.
- No fallback from file mode to db mode under any failure.
## Resolved defaults (were open questions; the user may overrule before R3/R4)
1. **UI naming and regrouping of observed (`hand_edited=0`) clients in file
mode: rejected as declarative drift** (client PUT stays `config_write`,
ruling 7). The household user adds the client to the file instead. The
alternative — carving naming/grouping out as a runtime action —
reintroduces two-way merge for one table, which the anti-requirements
refuse. Affects R3.
2. **`export` output is byte-identical in both modes** — no file-mode
annotation. An annotation would make export → file → adopt produce a
different file than the one checked, for a label the operator already has
in the unit file. Affects R4's round-trip docs.
3. **File mode ships as a documented systemd drop-in**, not a second
commented `ExecStart` in the packaged unit. The packaged unit stays
flagless and correct by itself; a commented alternative line in a unit
file is a doc pretending to be config. Affects R4.
## Cross-validation findings rejected
Round 1: none — all twelve findings (F1-F12) were confirmed against the repo
and are folded into the rulings above. The F1 remedy has since been
superseded: the premise revision deleted the persisted marker entirely
(ruling 8) instead of keeping it write-on-difference.
Round 2 (on the premise revision) returned three important findings and one
minor. Folded: the docker recovery one-liner used a `--config` flag `import`
does not have (fixed in ruling 9, file is positional); `import` is now
stated everywhere as a stop-first operation and the file-authority contract
as "converged at every boot" (ruling 6); identity-column edits are named as
delete-plus-insert needing `--allow-delete` (ruling 6). Declined: renaming
`run --config` to `--managed-config` — reasons recorded in ruling 1.
## Red-team findings rejected
None rejected outright — every finding checked out against the repo. Two
proposed remedies were declined while their findings were accepted:
- **ops 10's mitigation** (keep the old compiled artifact until the successor
URL's first successful fetch): declined — a cross-artifact lifecycle for a
rare, operator-initiated event on a household box; the trade is now stated
in ruling 3 and the anti-requirements instead.
- **ops 14's db-mode warning** ("a config file exists but authority is
database"): declined — db mode is given no file path, so warning would mean
probing a well-known location, which is precisely the ambient inference
ruling 1 bans; the accepted half (drift visibility) is served by the
settings envelope's `authority` block and per-process `reconciled_at`.
+342
View File
@@ -0,0 +1,342 @@
# Milestone 21: ABP exceptions from lists, regex rules for operators
Goal: honor `@@||domain^` exception lines in downloaded blocklists as allow
entries scoped below operator rules and above list blocks (tier 1), and add a
`regex` rule kind for operator rules backed by a homegrown linear-time engine
(tier 2). Nothing else from the ABP syntax enters scope.
Design written 2026-08-09 against HEAD `ffc3ca6`; every anchor re-verified
2026-08-11 against `a8e0fe4` after milestone 20 landed. The milestone amends
PLAN §2.2, which currently rules regex out permanently; the amendment is part
of session S3, not a side effect.
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- Pure core stays pure: `src/filter/` files that are fuzz-module roots import
only `std` (`src/filter/parsers.zig:1-14`). The new `src/filter/regex.zig`
obeys the same constraint.
- Every new `src/**.zig` file must be listed in `src/tests.zig`
(`build.zig:494-539` fatals otherwise).
- Frozen DDL is frozen: schema changes are new migration steps
(`src/storage/config_schema.zig:1-6`, `src/storage/migrations.zig:21-22`).
- After any API shape change, regenerate the contract samples
(`web/src/lib/contractSamples.gen.ts`; procedure in AGENTS.md).
## Rulings (binding)
### 1. Exception lines are `@@||name^` and nothing else, plus one modifier
`src/filter/parser_abp.zig:22` currently maps every `@@` line to
`.unsupported`. After this milestone, a line is an exception when it is
`@@||name^` or `@@||name` (same trailing-`^` and `rule_tokens` treatment as the
block anchor at parser_abp.zig:26-34), optionally suffixed with the literal
`$important` — that suffix is the common form in AdGuard-authored lists and
changes nothing about the meaning here, because list exceptions already sit
below every operator rule. Any other `@@` form (`@@name` without the anchor,
any other `$` modifier, a path, a scheme) stays `.unsupported`. The
parser-header policy paragraph (parser_abp.zig:5-6) is rewritten to state the
new rule and its precedence justification: a list exception can cancel only
list blocks, never an operator decision, so no downloaded list can open an
allow hole the operator did not open.
### 2. Precedence: list exceptions sit between operator rules and list blocks
`matcher.Snapshot.evaluate` (`src/filter/matcher.zig:286-338`) gains one level
between the operator wildcard-block walk (level 4) and the blocklist domain
probe (level 5): for each attached source, an exception match — full name or
parent walk, apex covered — returns `blocked = false`, reason
`.blocklist_exception`, `matched` = the matching entry, `source` = the source
index. The doc comment at matcher.zig:269-285 and PLAN §3.10 (PLAN.md:108-117)
are both updated with the new level. Regex rules (ruling 6) slot in as levels
after the wildcard rules and before list exceptions, allow before block, so
the full order is: exact allow, exact block, wildcard allow, wildcard block,
regex allow, regex block, list exception, list domain, list wildcard.
"Tie-break at same specificity: allow wins" is preserved.
### 3. The compiled-source format grows a third body, checksum-compatibly
`compiler.compile` (`src/filter/compiler.zig:50-56`) takes a third writer
(`allow_w`) and `Counts` (compiler.zig:23-35) gains `exceptions: u32 = 0`.
Exception candidates go through the existing `addCandidate` path into a third
`Entries` and emit as a sorted, deduplicated `.allow` body. The shared SHA-256
covers the bodies in order list, wild, allow — because SHA-256 of
`list ++ wild ++ ""` equals the current SHA-256 of `list ++ wild`, every
already-published checksum stays valid, and `Manager.loadSource`
(`src/filter/manager.zig:543-598`) treats a missing `<id>.allow` file as an
empty body. No refetch is forced by upgrading. `bodyChecksum`
(manager.zig:1572) follows the same order; `source_file_suffixes`
(manager.zig:1589) gains `.allow.tmp` and `.allow` with longest-suffix-first
order preserved; the on-disk header (manager.zig:221-241) gains
`# exceptions {d}` after the `# wildcards` line and the pinning test at
manager.zig:1914-1946 is extended, not weakened.
### 4. Exception counts persist and surface
Migration step 3 (`ddl_v3`, appended at `src/storage/migrations.zig:23-26`):
`ALTER TABLE blocklist_sources ADD COLUMN exception_count INTEGER NOT NULL
DEFAULT 0;`. `sources_repo.SourceRow` and `updateSourceStats`
(`src/storage/repositories/sources_repo.zig:80-93,159-169`) carry it, and the
checksum doc line at sources_repo.zig:100 is updated alongside
`bodyChecksum`'s per ruling 3;
`SourceStatus` rehydration (`manager.zig:1458-1502`) restores it alongside the
existing three counts; `StatusView` (`src/web/handlers/blocklists.zig:52-78`)
gains `exceptions: u32`; the blocklists UI shows it where `skipped_regex`
already shows (`web/src/features/blocklists/SourceStatusSection.tsx`,
`web/src/lib/types.ts:163,192`).
### 5. The regex engine is a Pike VM, linear-time by construction, `std` only
New file `src/filter/regex.zig`. Syntax: literal bytes, `.`, character
classes `[...]` with ranges and leading-`^` negation, escapes
`\. \\ \- \d \w`, repetition `* + ? {n} {n,m}`, alternation `|`,
non-capturing grouping `(...)`, anchors `^` and `$`. No backreferences, no
lookaround, no captures. Matching is unanchored unless anchors are written
(POSIX-grep convention, matching Pi-hole user expectations). Input is the
normalized lowercase name, ≤ `types.max_name_len` bytes. Hard limits, each a
distinct error: pattern ≤ 256 bytes (`PatternTooLong`), compiled program
≤ 1024 instructions (`PatternTooComplex`). Public API:
```zig
pub const Error = error{ OutOfMemory, BadPattern, PatternTooLong, PatternTooComplex };
pub const Program = struct { ... , pub fn deinit(self: *Program, gpa: Allocator) void };
pub fn compile(gpa: Allocator, pattern: []const u8) Error!Program;
pub fn matches(prog: *const Program, input: []const u8) bool;
```
`matches` is a Pike VM: two thread lists, each program counter admitted at
most once per input position, worst case O(program × input) with zero
allocation at match time (thread lists sized from the program at compile
time). The engine is a fuzz-module root like parsers.zig and imports only
`std`.
### 6. `regex` is a third rule kind, validated at the edge, memoized by the cache
Migration step 4 (`ddl_v4`): the 12-step rebuild of `rules` with
`CHECK(kind IN ('exact','wildcard','regex'))` — the frozen v1 DDL
(`src/storage/config_schema.zig:65-72`) cannot be edited. The rebuilt table
keeps the name `rules`: `config_schema.table_names`
(config_schema.zig:112-119) and the invariant tests at
config_schema.zig:120-127 and migrations.zig:356 assert the schema's table
set, and a rename would fail both. `model.RuleKind`
(`src/config/model.zig:259-275`) gains `.regex`; the exhaustive switches in
`config/validate.zig:1099-1130` (compile the pattern, report
`"... is not a valid regex pattern"` through the existing error path at
validate.zig:891-902) and `src/filter/rules.zig:62-68` extend. `RuleSet`
(`rules.zig:28-36`) grows `regex_allow` and `regex_block` slices holding
compiled `Program`s plus their pattern texts (for `Decision.matched`);
`bucketOf` (rules.zig:133-143) becomes a six-bucket layout;
`max_regex_per_group: usize = 256` with `TooManyRegexRules` mirroring
`max_wildcards_per_group` (rules.zig:26). A pattern that fails to compile is
`error.BadPattern` at snapshot build, never skipped (rules.zig:41-49 doc
holds). Reason tags `rule_allow_regex` and `rule_block_regex` join
`matcher.Reason` (matcher.zig:24-32); `/api/lookup` and the query log pick
them up automatically via `@tagName` (`src/web/handlers/lookup.zig:89`;
`max_reason_len = 32` in `src/storage/logger.zig` fits both at 16 chars).
Regex evaluation runs only after every hash and wildcard level missed, and
answers are memoized by the existing DNS cache like every other decision, so
the per-query cost lands on cache misses only.
### 7. The web contract names the third kind everywhere it names the first two
`src/web/handlers/rules.zig`: `toInput` accepts `"regex"`; the 400 string at
rules.zig:42 becomes `"kind must be 'exact', 'wildcard' or 'regex'"`.
`src/web/openapi.yaml:1948,1962,1976`: all three `enum: [exact, wildcard]` become
`[exact, wildcard, regex]`. `web/src/lib/types.ts:195`:
`RuleKind = "exact" | "wildcard" | "regex"`; the rules page kind selector
gains the option. Contract samples regenerated. `nxdns export` / `import`
round-trip the new kind with no extra work once `RuleKind.toDb/fromDb` extend
— the enum round-trip test at model.zig:782 is extended to prove it.
### 8. PLAN amendments land with the code, in S3
PLAN.md:36 (§2.2) is rewritten: operator regex rules are in scope, backed by
the linear-time engine of ruling 5; regex lines in downloaded lists stay
counted and skipped; `$` modifiers (except the `$important` suffix of ruling
1), partial-segment wildcards, and browser-syntax honoring stay permanently
out. PLAN.md:26 (§2.1 filtering sentence), PLAN.md:106 (§3.9), PLAN.md:108-117
(§3.10 precedence) and PLAN.md:700 (decision B) are updated to match rulings
2 and 6. In-code echoes of the old §2.2 move with it:
`src/filter/wildcard.zig:6-7,20-23`, `src/filter/parsers.zig:25`,
`src/filter/parser_abp.zig:5-6`, `src/filter/compiler.zig:129-131`.
### 9. Milestone 20's authority modes bound where rules are written
m20 classifies POST/PUT/DELETE `/api/rules` as `.config_write`
(`src/web/routes.zig:94-97`), and under `.managed_file` authority the router
answers 403 (`src/web/router.zig:173-178`). Everything in this milestone
works in both modes, but the write path differs: in `.database` mode regex
rules arrive through the API; in `.managed_file` mode they arrive through the
config file and `src/config/reconcile.zig` (`reconcileRules`,
reconcile.zig:566-598), whose enum comparison carries `.regex` with no code
change. S3 owns a reconcile test proving a file-declared regex rule converges
into the table. Every S3 API acceptance check runs a server in `.database`
authority (no `--config`). The `SELECT *` dump helpers
(`src/config/import.zig:167-186`, `src/config/reconcile.zig:981`) will emit
the new `exception_count` column after `ddl_v3`; golden dump assertions in
those suites are updated in S1, which owns that migration. Reconcile's
runtime-column protection (reconcile.zig:11-15,435) covers `exception_count`
with no change — the column survives reconciles untouched.
### 10. Fuzz invariants move, never lapse
`tests/fuzz/blocklist_fuzz.zig` header invariant "covers_apex only on
`.wildcard`" (its stated form at :4-28) becomes "only on `.wildcard` or
`.exception`". `tests/fuzz/compiler_fuzz.zig:43` reads `Format` from
`compile`'s parameter list by index — the new `allow_w` parameter appends
after `wild_w`, leaving index 2 valid; the session touching `compile` runs the
fuzz suite and fixes that line if the assumption fails. A new
`tests/fuzz/regex_fuzz.zig` target asserts: `compile` on arbitrary bytes
never crashes and either errors or produces a program within the ruling-5
limits; `matches` terminates and its VM step count never exceeds
program length × (input length + 1); compile-then-match is deterministic.
## Sessions
Three sessions. S1 and S2 run in parallel — they share no files. S3 starts
after both land.
### Session S1: list exceptions end to end (tier 1)
Owns: `src/filter/parsers.zig`, `src/filter/parser_abp.zig`,
`src/filter/compiler.zig`, `src/filter/manager.zig`, `src/filter/matcher.zig`,
`src/filter/domain_set.zig` (only if a helper is needed; expected untouched),
`src/filter/filter_integration_test.zig`, `src/storage/migrations.zig`
(step 3 only), `src/storage/repositories/sources_repo.zig`,
`src/web/handlers/blocklists.zig`, `src/web/handlers/lookup.zig` (doc
sentence only), `src/web/openapi.yaml` (StatusView shape only),
`web/src/features/blocklists/*`, `web/src/lib/types.ts` (source-stat fields
only), `web/src/lib/contractSamples.gen.ts`,
`tests/fuzz/blocklist_fuzz.zig`, `tests/fuzz/compiler_fuzz.zig`, and the
dump-golden assertions in `src/config/import.zig` and
`src/config/reconcile.zig` test suites only (ruling 9's `exception_count`
fallout from `ddl_v3`; S1 touches no reconcile logic).
- S1.1 `Kind.exception` in parsers.zig; parser_abp emits it per ruling 1;
parser_hosts and parser_domains never emit it (no change beyond the enum).
- S1.2 compiler third body per ruling 3; `Counts.exceptions`.
- S1.3 manager: suffixes, header line, `bodyChecksum`, `loadSource`
missing-file-is-empty, `Snapshot.Compiled.allow_body`,
`prepareRefresh`/`publishRefresh`/`applyLoadOutcomes` carry the count.
`rejectedWithoutEntries` (manager.zig:1563-1570) treats a compile with only
exceptions as loadable, not rejected.
- S1.4 matcher: `SourceSets.exceptions`, `Reason.blocklist_exception`,
the new evaluate level per ruling 2, `memoryBytes` includes the new sets.
- S1.5 storage + API + UI per ruling 4.
- S1.6 tests: parser cases (`@@||x^`, `@@||x`, `@@||x^$important`,
`@@||x^$third-party` → unsupported, `@@x` → unsupported); compiler
three-body + checksum-compat cases (empty allow body reproduces the old
digest byte for byte); manager header pin extended; matcher precedence
cases: operator block beats list exception, list exception beats list
domain and list wildcard, exception parent walk covers apex and
subdomains; an integration case through
`filter_integration_test.zig` with a real ABP fixture carrying `@@` lines.
Acceptance (S1):
- [ ] `zig build test` passes; fuzz targets build and run.
- [ ] A fixture list with `||ads.example^` and `@@||good.ads.example^`
compiled and loaded blocks `ads.example` and `x.ads.example`, does not
block `good.ads.example` or `y.good.ads.example`, and
`/api/lookup` reports `blocklist_exception` with the source id for the
latter two.
- [ ] A pre-milestone data directory (no `.allow` files, old checksums)
loads with zero checksum mismatches.
- [ ] `POST /api/blocklists/update` response rows carry `exceptions`.
### Session S2: the regex engine (tier 2, engine only)
Owns: `src/filter/regex.zig` (new), `tests/fuzz/regex_fuzz.zig` (new),
`src/tests.zig` (one added line), `build.zig` (fuzz-suite wiring for the new
target only).
- S2.1 the engine per ruling 5: parser → AST → NFA program → Pike VM.
- S2.2 unit tests in-file: every syntax form; anchored and unanchored
matching; negated classes; `{n,m}` bounds; each error case; the
pathological backtracker-killers (`(a+)+b` against `aaaaaaaaaaaaaaaaaaaaX`,
nested alternation) complete within the step bound.
- S2.3 the fuzz target per ruling 10.
Acceptance (S2):
- [ ] `zig build test` passes with the new file in `src/tests.zig`.
- [ ] The step-bound property holds under the fuzz corpus.
- [ ] `regex.zig` imports nothing but `std`.
### Session S3: the regex rule kind, wired through (needs S1 + S2)
Owns: `src/storage/migrations.zig` (step 4), `src/storage/config_schema.zig`
(comment only if needed), `src/config/model.zig`, `src/config/validate.zig`,
`src/filter/rules.zig`, `src/filter/matcher.zig`,
`src/storage/repositories/rules_repo.zig`, `src/web/handlers/rules.zig`,
`src/web/handlers/mutations.zig` (only if `checkRule` needs the kind),
`src/web/openapi.yaml`, `web/src/features/rules/*`, `web/src/lib/types.ts`,
`web/src/lib/contractSamples.gen.ts`, `src/config/reconcile.zig` (test per
ruling 9), `PLAN.md`, `src/filter/wildcard.zig` (comments),
`src/filter/parsers.zig` (comment), `src/filter/parser_abp.zig` (comment),
`src/filter/compiler.zig` (comment), `tools/bench.zig`.
- S3.1 migration step 4 per ruling 6; repo and model layers.
- S3.2 validate at both edges (config import, API) per rulings 6 and 7.
- S3.3 `RuleSet` six buckets; matcher levels per ruling 2; reasons.
- S3.4 web + UI + openapi + samples per ruling 7.
- S3.5 PLAN and comment amendments per ruling 8.
- S3.6 bench: the filter suite in `tools/bench.zig` gains a variant with 32
regex rules loaded; the existing p95 < 1 ms assertion covers it.
- S3.7 tests: rule CRUD with kind `regex` through the API including the 400
for a bad pattern at insert time; precedence cases regex-allow over
regex-block, wildcard over regex, regex over list entries; export/import
round trip; a migration test upgrading a v3 database.
Acceptance (S3):
- [ ] `zig build test` and `cd web && npm test` pass.
- [ ] `POST /api/rules` with `{"kind":"regex","pattern":"^ad[0-9]+-"}`
returns 201; with `"pattern":"("` returns 400 naming the pattern.
- [ ] A regex block rule blocks a matching name; `/api/lookup` reports
`rule_block_regex` and `matched` carries the pattern text.
- [ ] `zig build bench -Doptimize=ReleaseFast -- filter` passes its targets
with the regex variant present.
- [ ] PLAN §2.2 no longer forbids operator regex; all listed echoes updated.
### Orchestrator
Verify S1 and S2 acceptance before starting S3. After S3: run the full gate
set (`zig build test`, `test-aarch64` if qemu present, `npm test`,
`npm run assert-bundled`), then a live smoke against a scratch server: load
one real ABP list with `@@` lines, add one regex rule, verify both over dig
and `/api/lookup`. Record deviations in `## Recorded (implementation)`.
## Module layout
New files:
- `src/filter/regex.zig` — the linear-time engine (ruling 5).
- `tests/fuzz/regex_fuzz.zig` — its fuzz target (ruling 10).
Deleted surface: none.
## Acceptance (milestone complete)
- [ ] All session acceptance boxes above.
- [ ] Schema at version 4; a v2 database migrates cleanly with data intact.
- [ ] A pre-milestone blocklist data directory loads without refetch.
- [ ] The six-level operator precedence plus three list levels behave per
ruling 2, proven by matcher tests that enumerate adjacent-level pairs.
- [ ] No `src/filter/` fuzz-root file imports anything but `std`.
- [ ] Contract samples, openapi.yaml and `web/src/lib/types.ts` agree with
the server (the drift guards pass).
## Anti-requirements
- No `$` modifier support beyond tolerating `$important` on exception lines.
`$dnstype`, `$dnsrewrite`, `$client`, `$denyallow` and every browser
modifier stay unsupported and counted.
- No regex from downloaded lists. `.regex` lines stay counted and skipped;
`skipped_regex` keeps its meaning. The engine exists for operator rules
only.
- No partial-segment wildcards (`ads*.example.com`) — writing one as a rule
stays rejected; the regex kind covers the need.
- No backreferences, lookaround, captures, named groups or Unicode classes in
the engine, ever. A pattern needing them is rejected, not approximated.
- No PCRE2, RE2 or any external regex dependency.
- No exception-rule UI editor: exceptions come from lists; operators write
allow rules.
- No re-download forced by the upgrade; checksum compatibility (ruling 3) is
a requirement, not an optimization.
+108
View File
@@ -0,0 +1,108 @@
# Milestone 22: TypeScript 7
Goal: move the web toolchain from TypeScript 6.0.3 to TypeScript 7.0.x (the
native compiler, GA 2026-07-08). One devDependency bump; every web gate and
the release pipeline stay green.
Design written 2026-08-12 against HEAD `91a0aa9`. TypeScript 7.0.2 is
`typescript@latest` on npm at spec time; the milestone pins whatever 7.0.x is
current when the session runs.
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- `web/src/lib/contractSamples.gen.ts` is generated; it is type-checked by
`tsc -b` but never hand-edited. If TS7 rejects it, the fix goes in the
generator (`AGENTS.md` regeneration procedure), not the file.
- No license work: `typescript` is a devDependency, absent from both the
runtime closure and the bundled set in `licenses/dependency-identity.txt`.
The Zig drift guard (`src/licenses_drift_test.zig`) must still pass —
it recomputes from `web/package-lock.json` and dev-only changes are
invisible to it.
## Rulings (binding)
### 1. The bump is one line plus the lockfile
`web/package.json:45` `"typescript": "6.0.3"` becomes the current 7.0.x,
exact pin (repo convention: no ranges, milestone-14 ruling 12 pins by exact
version everywhere). `npm install` updates `web/package-lock.json`. Nothing
else in `dependencies` or `devDependencies` moves in this milestone.
### 2. The tsconfigs are already TS7-clean; verify, do not churn
Checked against TS7's removed options (recorded here so the session does not
re-derive it): `tsconfig.app.json` and `tsconfig.node.json` use
`moduleResolution: "bundler"`, `module: "esnext"`, targets `es2022`/`es2023`,
`paths` without `baseUrl`, `verbatimModuleSyntax`, `isolatedModules`,
`noEmit`. None of TS7's removals (`target: es5`, `downlevelIteration`,
`moduleResolution: node10`/`classic`, `module: amd/umd/system/none`,
`baseUrl`-as-alias) are present. `tsconfig.json` is a two-entry project
reference; TS7 supports `tsc -b` and project references. The session changes
a tsconfig only if `tsc -b` errors demand it, and records any such change in
`## Recorded (implementation)`.
### 3. Known TS7 edge: `skipLibCheck` no longer hides parse-level .d.ts errors
Both tsconfigs set `skipLibCheck: true`. Under TS7 that still skips type
checking of `.d.ts` files but not parse-level errors in them. If a dependency
`.d.ts` fails to parse, the fix is a dependency patch bump or an upstream
issue reference recorded in the spec — never a copied-and-edited local
`.d.ts`.
### 4. Nothing else in the toolchain consumes the TS compiler
Verified at spec time: `oxlint` (own parser), `vite`/`vitest`
(esbuild transpile, no type checking), `prettier`, and
`@vitejs/plugin-react` run no `tsc` and load no `typescript` API. The only
consumer is the `typecheck` script (`web/package.json:12`, `tsc -b`), run in
CI at `.gitea/workflows/gates.yml:113-114`. TS7 has no stable programmatic
API until 7.1; nothing in this repo needs one.
## Sessions
One session.
### Session S1: the bump
Owns: `web/package.json`, `web/package-lock.json`, and — only if ruling 2
forces it — `web/tsconfig.app.json`, `web/tsconfig.node.json`,
`web/tsconfig.json`.
- S1.1 bump per ruling 1; `npm install`.
- S1.2 run, in order: `npm run format:check`, `npm run lint`,
`npm run typecheck`, `npm test`, `npm run build`, `npm run assert-bundled`
(all from `web/`).
- S1.3 `zig build test` (the licenses drift guard and the contract-samples
guard run inside it).
Acceptance (S1):
- [ ] `web/node_modules/.bin/tsc --version` reports 7.0.x.
- [ ] `npm run typecheck` exits 0 with no tsconfig change, or the change is
recorded with the TS7 error text that forced it.
- [ ] All six web gate commands exit 0.
- [ ] `zig build test` exits 0.
- [ ] `git diff` touches only the files S1 owns.
## Module layout
New files: none. Deleted surface: none.
## Acceptance (milestone complete)
- [ ] All S1 boxes.
- [ ] `licenses/dependency-identity.txt` is byte-identical to HEAD.
## Anti-requirements
- No other dependency bumps ride along — not oxlint, not vite, not vitest,
not `@types/*`.
- No tsconfig flag additions or "modernization" beyond what a TS7 error
forces.
- No typescript-eslint, ts-jest, or any tool needing the TS programmatic API
(unavailable until 7.1, and unwanted regardless — oxlint is the linter).
- No `tsgo` binary references anywhere: the binary is `tsc`, scripts stay as
they are.
- No edits to `web/src/**` — a source change to satisfy the new compiler is a
finding to record and fix, not silently absorb, unless `tsc -b` fails
without it; then it is recorded in `## Recorded (implementation)`.
+574
View File
@@ -0,0 +1,574 @@
# Milestone 23: StyleX and React Aria replace Tailwind
Goal: every styled element in `web/src` renders through StyleX
(`@stylexjs/unplugin` at build time); Dialog, AlertDialog, Tabs and Select
come from `react-aria-components`; Tailwind is removed. TanStack Router and
TanStack Query stay (decided 2026-08-12 after review; the router swap is out
of scope permanently, not deferred).
Design written 2026-08-12 against HEAD `91a0aa9`. Needs milestone 22 landed
(the StyleX and RAC `.d.ts` surfaces are checked by the TS7 `tsc -b`).
Inventory this spec rests on: 522 `className=` occurrences across 31
`web/src` files; 17 shared class constants in `web/src/ui/classes.ts`;
`web/src/styles.css` is the single line `@import "tailwindcss"` (so dark mode
is Tailwind 4's default `prefers-color-scheme` media strategy — no class
toggle exists); the Tailwind plugin sits at `web/vite.config.ts:3,8`;
`tailwindcss` and `@tailwindcss/vite` are devDependencies
(`web/package.json:34,44`) and appear in neither the runtime closure nor the
bundled set of `licenses/dependency-identity.txt`.
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- `web/src/lib/contractSamples.gen.ts` and `web/src/lib/types.ts` are API
contract surfaces; this milestone is styling-only and touches neither.
- Every session leaves all gates green: `npm run format:check`, `npm run
lint`, `npm run typecheck`, `npm test`, `npm run build`,
`npm run assert-bundled` (from `web/`), and `zig build test`. The release
gate (`zig build dist` + `verify-dist`, `.gitea/workflows/gates.yml`) keeps
its byte budgets; they are not tight — `web/dist` is 440,924 bytes raw
against the 15 MiB binary budget — but `verify-dist` remains the arbiter.
- `assert-bundled` fails whenever the bundled npm package set changes. That
failure is the license workflow trigger, not an obstacle: review each new
package into `licenses/inventory.zon`, then record the new set in
`licenses/dependency-identity.txt` (procedure in that file's header).
Never update the recorded list without the inventory review.
## Rulings (binding)
### 1. Build integration is `@stylexjs/unplugin`, proven before any page moves
`stylex.vite()` from `@stylexjs/unplugin` goes into `web/vite.config.ts`
alongside the existing plugins (Tailwind stays until S3). Configuration per
the official Vite guide (stylexjs.com/docs/learn/installation/vite):
`useCSSLayers: true`, `runtimeInjection: false`, `dev` keyed off the Vite
mode. The generated CSS injects into an existing CSS asset, so
`web/src/styles.css` (imported by `main.tsx:8`) remains the CSS entry for the
whole milestone; the dev/HMR virtual-module wiring from that guide is part of
S1. The unmaintained community `vite-plugin-stylex` is forbidden. S1's
acceptance proves dev server, `vitest run` and `vite build` all handle a
StyleX component before S2 or S3 convert anything — this is a gate, not an
assumption.
### 2. Tokens live in `web/src/ui/tokens.stylex.ts`
`stylex.defineVars` with role names, not raw shades: surface, surface-raised,
border, border-strong, text, text-muted (the zinc ramp today), primary and
primary-text (blue-600/white), danger, danger-surface, danger-border (the red
ramp), focus (blue-600). Each var carries its dark value under
`@media (prefers-color-scheme: dark)` inside `defineVars` — same media
strategy the app has now. No theme toggle, no `data-theme` attribute.
### 3. `web/src/ui/styles.ts` replaces `ui/classes.ts`
The 17 exports of `web/src/ui/classes.ts` (focusRing, insetFocusRing, input,
smallInput, button, smallButton, largeButton, primaryButton,
largePrimaryButton, rowButton, linkButton, dangerLinkButton, retryButton, th,
td, tableWrap, formCard) become `stylex.create` style objects in
`web/src/ui/styles.ts`, composed at call sites via
`{...stylex.props(styles.button, extra)}`. The milestone-9 accessibility
floor restated by milestone-18 ruling 10 carries over verbatim: every
interactive element renders the focus ring (`:focus-visible` outline, 2px,
offset 2, focus token; the inset variant for controls flush against panel
edges). `ui/classes.ts` is deleted in S3 when its last importer converts.
### 4. React Aria scope is exactly four components — end state, not a phase
From `react-aria-components`: Dialog + Modal (replacing the hand-rolled
`role="dialog"` overlay in `web/src/features/clients/ClientEditDialog.tsx:27-28`),
AlertDialog composition (ruling 5), Tabs (replacing the button-pair tab
switcher in `web/src/features/local/LocalDnsPage.tsx`), Select (replacing the
7 files carrying native `<select>`: LookupPage, QueryLogPage, RecordsTab,
RulesPage, SettingsPage, PrefixesEditor, ClientEditDialog). Everything else —
buttons, text inputs, tables, links, banners — stays native HTML styled with
StyleX. A native styled `<button>` is the finished design, not an interim
one. S2 wraps the four as shared components: `web/src/ui/Dialog.tsx`,
`web/src/ui/ConfirmDialog.tsx`, `web/src/ui/Tabs.tsx`,
`web/src/ui/Select.tsx`, each styled with StyleX through RAC's render-prop
state booleans (`stylex.props(isSelected && styles.selected)`) — never
data-attribute selectors, which StyleX cannot express.
### 5. `window.confirm` dies with the conversion
All call sites become `ConfirmDialog` (RAC AlertDialog: `role="alertdialog"`,
focus trapped, danger-styled confirm button):
`web/src/features/blocklists/BlocklistsPage.tsx:61`,
`web/src/features/upstreams/UpstreamsPage.tsx:43`,
`web/src/features/rules/RulesPage.tsx:34`, and
`web/src/ui/useCrudForm.ts:49` (which carries the confirm for ZonesTab and
RecordsTab). `useCrudForm`'s `CrudFormSpec.confirmDelete` keeps its signature;
the hook returns the dialog state instead of calling `window.confirm`. The
tests that stub confirm today
(`web/src/features/upstreams/UpstreamsPage.test.tsx:140,144,188`,
`web/src/ui/useCrudForm.test.tsx:102`) are rewritten to drive the real
dialog: open, assert `role="alertdialog"` and the entity name in the message,
confirm and cancel paths both asserted. BlocklistsPage and RulesPage gain the
same coverage for their delete paths.
### 6. Styling mechanics
`stylex.create`/`stylex.props` only. Conditions are boolean-guarded style
objects; variants are separate named styles; responsive behavior (the
`md:` sidebar grid in `web/src/shell/AppShell.tsx:90-98`, the mobile drawer)
becomes `@media` conditions inside style values. No dynamic class-name
strings, no `clsx`-style helpers, no `className` literals left anywhere in
`web/src` after S3 — `className`/`style` attributes appear only as the spread
of `stylex.props(...)` (RAC render-prop `className` functions included).
TanStack `Link` `activeProps`/`inactiveProps` (`AppShell.tsx:35-42`) pass
`stylex.props(...).className` outputs.
### 7. Visual contract: drift accepted, structure pinned
No pixel parity requirement and no visual-regression harness. Pinned instead,
checkable by reading the converted file against the original: same layout
structure (grid/flex relationships, table columns, heading levels), token
roles per ruling 2 (zinc surfaces, blue primary actions, red danger), the
focus floor of ruling 3 on every interactive element, dark mode via media
query, `overflow-x-auto` table wrappers preserved.
### 8. License inventory, named per step
- S1 adds `@stylexjs/stylex` (runtime dependency; `stylex.props` ships in the
bundle) to the runtime closure and the bundled set. `@stylexjs/unplugin` is
a devDependency — runtime closure and bundled set untouched by it.
- S2 adds `react-aria-components` and its transitive closure
(`@react-aria/*`, `@react-stately/*`, `@react-types/*`,
`@internationalized/*`, `@swc/helpers` — the exact bundled subset is
whatever `npm run assert-bundled` reports after S2's conversions; every
reported name gets a reviewed `licenses/inventory.zon` entry before the
recorded list moves). This is the milestone's largest review surface.
- S3 removes nothing from the license files: Tailwind was dev-only and never
recorded. The `@tanstack/*` entries stay untouched.
### 9. PLAN.md wording moves in S3
`PLAN.md:138` "Vite + React + TypeScript + Tailwind; TanStack Router +
TanStack Query" → "Vite + React + TypeScript + StyleX + React Aria; TanStack
Router + TanStack Query". `PLAN.md:235` directory comment updates the same
way. Line 562 names loaders and Query only — untouched. No other PLAN
amendment.
### 10. Conversion order is ascending `className` count, one commit per file
So a regression bisects to one page. Counts at spec time:
| file | count |
|---|---|
| lib/InlineError.tsx | 2 |
| features/settings/ReadOnlyConfigBanner.tsx | 2 |
| features/dashboard/HealthBanners.tsx | 2 |
| features/local/LocalDnsPage.tsx | 3 |
| features/settings/RestartBanner.tsx | 3 |
| features/dashboard/StatCards.tsx | 5 |
| routes.tsx | 6 |
| auth/LoginPage.tsx | 9 |
| features/dashboard/DashboardPage.tsx | 9 |
| features/groups/GroupSourcesEditor.tsx | 9 |
| features/pause/PauseWidget.tsx | 9 |
| features/blocklists/BlocklistForm.tsx | 11 |
| features/clients/ClientEditDialog.tsx | 11 |
| features/dashboard/DiskCard.tsx | 13 |
| features/upstreams/UpstreamForm.tsx | 14 |
| features/clients/PrefixesEditor.tsx | 15 |
| shell/AppShell.tsx | 17 |
| features/live/LiveLogPage.tsx | 18 |
| features/dashboard/TimeseriesChart.tsx | 21 |
| features/dashboard/UpstreamHealthTable.tsx | 21 |
| features/upstreams/UpstreamsPage.tsx | 21 |
| features/groups/GroupsPage.tsx | 22 |
| features/lookup/LookupPage.tsx | 25 |
| features/local/ZonesTab.tsx | 26 |
| features/blocklists/SourceStatusSection.tsx | 27 |
| features/clients/ClientsPage.tsx | 28 |
| features/rules/RulesPage.tsx | 31 |
| features/blocklists/BlocklistsPage.tsx | 31 |
| features/settings/SettingsPage.tsx | 31 |
| features/local/RecordsTab.tsx | 34 |
| features/queries/QueryLogPage.tsx | 46 |
S2 takes its owned cluster out of this order (converted together because the
RAC wrappers land there); S3 walks the remainder smallest-first.
### 11. Tests that must change, known in advance
RAC changes DOM and focus behavior; these edits are expected work, not
regressions to paper over:
- Dialog: `ClientsPage.test.tsx` (ClientEditDialog now portals into an RAC
Modal; queries move to `within(screen.getByRole("dialog"))`).
- Tabs: `LocalDnsPage.test.tsx` (tab switcher becomes `role="tablist"`/
`role="tab"`; `getByRole("button", { name: ... })` queries move to
`getByRole("tab", ...)`).
- AlertDialog: `UpstreamsPage.test.tsx`, `useCrudForm.test.tsx` per ruling 5.
- Select: RAC Select renders a button + listbox, not `<select>`, so
`getByLabelText(...)` + `fireEvent.change` patterns break in the tests for
the 7 Select files (LookupPage, QueryLogPage, LocalDnsPage/RecordsTab,
RulesPage, SettingsPage, ClientsPage). They move to
`userEvent`-style open-then-select interactions.
All other tests query by role/text and are expected to survive; a test that
asserted a Tailwind class is rewritten against role or accessible name.
## Sessions
Three sessions, strictly sequential: S1 → S2 → S3. (S2 and S3 both write
`web/package.json` and the license files; the RAC wrappers S3 consumes are
built in S2.)
### Session S1: StyleX foundation, proven end to end
Owns: `web/package.json`, `web/package-lock.json`, `web/vite.config.ts`,
`web/src/styles.css`, `web/src/ui/tokens.stylex.ts` (new),
`web/src/ui/styles.ts` (new), `web/src/ui/styles.test.tsx` (new),
`licenses/inventory.zon`, `licenses/dependency-identity.txt`.
- S1.1 add `@stylexjs/stylex` (dependency) and `@stylexjs/unplugin`
(devDependency), exact pins; wire `stylex.vite()` per ruling 1. Tailwind
plugin stays.
- S1.2 tokens per ruling 2; shared styles per ruling 3. `ui/classes.ts` is
not deleted yet — both vocabularies coexist until S3.
- S1.3 `styles.test.tsx`: render a probe component using
`stylex.props(styles.button)` under vitest; assert a non-empty
`className` lands on the element (proves the transform runs in the test
pipeline, since `runtimeInjection` is off).
- S1.4 license entries per ruling 8.
Acceptance (S1):
- [ ] `npm run dev` serves a probe page where a StyleX rule visibly applies
and edits hot-reload (checked by hand, result recorded).
- [ ] `npm test` passes including `styles.test.tsx`.
- [ ] `npm run build` emits StyleX CSS into the `web/dist` assets; the built
`index.html` page renders with the probe styled.
- [ ] `npm run assert-bundled` passes with `@stylexjs/stylex` in the recorded
set; `zig build test` passes (drift guard agrees).
- [ ] All remaining gates green.
### Session S2: React Aria primitives and their cluster
Owns: `web/package.json`, `web/package-lock.json`, `web/src/ui/Dialog.tsx`
(new), `web/src/ui/ConfirmDialog.tsx` (new), `web/src/ui/Tabs.tsx` (new),
`web/src/ui/Select.tsx` (new), `web/src/ui/useCrudForm.ts`,
`web/src/ui/useCrudForm.test.tsx`, `web/src/features/clients/*`,
`web/src/features/local/*`, `web/src/features/upstreams/*`,
`web/src/features/blocklists/BlocklistsPage.tsx` and its test,
`web/src/features/rules/RulesPage.tsx` and its test,
`licenses/inventory.zon`, `licenses/dependency-identity.txt`.
- S2.1 add `react-aria-components`, exact pin; build the four wrappers per
ruling 4, styled with tokens and `ui/styles.ts`.
- S2.2 convert the owned cluster fully to StyleX while adopting the wrappers:
ClientEditDialog + PrefixesEditor + ClientsPage (Dialog, Select);
LocalDnsPage + ZonesTab + RecordsTab (Tabs, Select, ConfirmDialog via
useCrudForm); UpstreamsPage + UpstreamForm, BlocklistsPage, RulesPage
(ConfirmDialog, Select). No `className` literal survives in an owned file.
- S2.3 `window.confirm` removal and test rewrites per rulings 5 and 11.
- S2.4 license review per ruling 8 — every package `assert-bundled` names.
Acceptance (S2):
- [ ] `grep -rn 'window\.confirm(' web/src` → zero matches. The bare name
still appears in three comments in `ui/` that record what replaced it,
which is why the check is for a call and not for the name.
- [ ] Delete flows on upstreams, blocklists, rules, zones and records drive a
`role="alertdialog"` element; confirm and cancel both covered by tests.
- [ ] Tabs on `/local-dns` expose `role="tablist"`; keyboard arrow keys move
between tabs (asserted in `LocalDnsPage.test.tsx`).
- [ ] No `className=` literal in S2-owned files
(`grep -c 'className="' <file>` = 0; `stylex.props` spreads excepted).
- [ ] `assert-bundled` passes with the RAC closure recorded;
`zig build test` passes.
- [ ] All remaining gates green.
### Session S3: the sweep and the removal
Owns: every `web/src` file not owned by S1/S2 that carries `className=`
(the ruling-10 remainder: InlineError, ReadOnlyConfigBanner, HealthBanners,
RestartBanner, StatCards, routes.tsx, LoginPage, DashboardPage,
GroupSourcesEditor, GroupsPage, PauseWidget, BlocklistForm,
SourceStatusSection, DiskCard, AppShell, LiveLogPage, TimeseriesChart,
UpstreamHealthTable, LookupPage, QueryLogPage, SettingsPage) plus their
tests, `web/src/ui/classes.ts` (delete), `web/package.json`,
`web/package-lock.json`, `web/vite.config.ts`, `web/src/styles.css`,
`PLAN.md`.
- S3.1 convert the remainder smallest-first per ruling 10, one commit per
file, gates green at every commit.
- S3.2 delete `ui/classes.ts` when its import count reaches zero.
- S3.3 remove Tailwind: drop `tailwindcss` and `@tailwindcss/vite` from
`package.json`, the plugin from `vite.config.ts:3,8`, and the
`@import "tailwindcss"` line from `styles.css` (the file itself stays as
the StyleX CSS entry per ruling 1).
- S3.4 PLAN.md per ruling 9.
Acceptance (S3):
- [ ] `grep -rn 'className="' web/src` returns zero matches. No Tailwind
dependency in `web/package.json`, no plugin in `web/vite.config.ts`, no
`@import "tailwindcss"` in `web/src/styles.css`. The word still appears
in seven comments in `web/src` that name the ramps the tokens were taken
from and the reset the preflight used to supply; those are the record of
where the values came from, so the check is not for the name.
- [ ] `ui/classes.ts` is gone; nothing imports it.
- [ ] Dark scheme still follows `prefers-color-scheme` (vitest: token var
resolves differently under a mocked dark media query, or a recorded
manual check in both schemes).
- [ ] `PLAN.md:138` and `:235` name StyleX + React Aria; `git diff PLAN.md`
touches nothing else.
- [ ] All gates green, including `zig build dist` + `zig build verify-dist`
with `-Dweb-dist=web/dist` (run once at milestone end).
### Orchestrator
Verify S1 acceptance (the ruling-1 gate) before S2 starts; S2 before S3.
After S3: full gate set, then a live smoke — `zig build` a server with the
fresh `web/dist`, click through every page in light and dark scheme, exercise
one delete confirm and the local-dns tabs. Record deviations in
`## Recorded (implementation)`.
## Module layout
New files:
- `web/src/ui/tokens.stylex.ts` — the design tokens (ruling 2).
- `web/src/ui/styles.ts` — the shared style vocabulary (ruling 3).
- `web/src/ui/styles.test.tsx` — the transform-pipeline probe (S1.3).
- `web/src/ui/Dialog.tsx`, `web/src/ui/ConfirmDialog.tsx`,
`web/src/ui/Tabs.tsx`, `web/src/ui/Select.tsx` — the RAC wrappers
(ruling 4).
Deleted surface: `web/src/ui/classes.ts`; the `window.confirm` calls; the
Tailwind dependency pair and its vite plugin line and CSS import.
## Acceptance (milestone complete)
- [ ] All session boxes above.
- [ ] Zero Tailwind: no dependency, no plugin, no `@import`, no utility
`className` literal in `web/src`.
- [ ] RAC appears in exactly four wrapper files plus their consumers; no
other `react-aria-components` import (`grep -rln 'react-aria-components'
web/src` ⊆ the four wrappers).
- [ ] Focus ring visible via keyboard on every interactive element of every
page (spot-checked live, recorded).
- [ ] `licenses/dependency-identity.txt`, `licenses/inventory.zon` and the
bundled reality agree (`assert-bundled` and `zig build test` both
green).
- [ ] `web/src/lib/types.ts` and `web/src/lib/contractSamples.gen.ts` are
byte-identical to HEAD.
## Recorded (implementation)
Written during S1 and the S2 hand-off. Each entry is a place the spec was wrong
or a decision the spec did not have.
### S1 could not put StyleX in the bundle, and its acceptance says otherwise
S1 owns no page file, so no production module imports StyleX and rollup emits
none of it. Two S1 boxes — "`npm run build` emits StyleX CSS into the `web/dist`
assets" and "`assert-bundled` passes with `@stylexjs/stylex` in the recorded
set" — are therefore unmeetable inside S1 as scoped, and the recorded bundled
set is unchanged at the S1 commit. Both were proven instead against a temporary
probe component, then deferred to S2, which converts real pages and does change
the bundled set. Ruling 8's "S1 adds `@stylexjs/stylex` ... to the runtime
closure and the bundled set" is half right: the closure moves in S1, the bundled
set in S2. The inventory entry for StyleX is anticipatory at the S1 commit,
which is deliberate — see the styleq entry for why it cannot wait.
### The probe results, recorded rather than left as a claim
Against the temporary probe: the built CSS carries `@layer priority1..N` and the
token variables, resolving to `#fafafa` light and `#09090b` dark with
lightningcss `lab()` fallbacks. The dev server needs no wiring of its own — the
plugin injects `<link rel="stylesheet" href="/virtual:stylex.css">` into
`index.html`, so the `DevStyleXInject` component in StyleX's Vite guide is for
frameworks that bypass Vite's HTML transform and is not used here. Editing a
token regenerates the virtual stylesheet and the dev server issues a page
reload.
### S1 wrote outside its owned files, and outside an anti-requirement
The licence guard forced it: `licenses/stylex-mit.txt`, `licenses/styleq-mit.txt`
and their registration in `licenses/licenses.zig`, plus the `npm_not_shipped`
array in `src/licenses_drift_test.zig`. The last contradicts the "no edits ...
under `src/`" anti-requirement below. The anti-requirement is amended, not
ignored: `src/licenses_drift_test.zig` is writable for its licence-fact arrays
and, from S2, for the per-package licence change described next. The rest of
`src/` stays closed.
### Apache-2.0 inbound: a decision, not a deduction
`react-aria-components` 1.20.0 brings eight Apache-2.0 packages
(react-aria-components, react-aria, react-stately, `@react-types/shared`, three
`@internationalized/*`, `@swc/helpers`) and `tslib` under 0BSD into an EUPL-1.2
project. The user accepted Apache-2.0 inbound on 2026-08-12 and authorised the
guard change it needs. This is recorded as their decision because nothing in the
licence settles it: EUPL-1.2's appendix does not list Apache-2.0 — it names GPL,
AGPL, OSL, EPL, CeCILL, MPL, LGPL, CC BY-SA, EUPL and LiLiQ — and that appendix
governs Article 5 outbound relicensing, not inbound consumption. Any claim that
the appendix permits this is false and must not be written into the inventory.
`src/licenses_drift_test.zig:122` hardcodes a single expected licence and
becomes per-package in S2; the guard stays strict, an unexpected licence still
fails.
### Ruling 8's picture of the React Aria closure is wrong
There are no `@react-aria/*` or `@react-stately/*` scoped packages. RAC 1.20.0
ships consolidated `react-aria` and `react-stately`. The runtime closure it adds
is the eleven packages named above plus `aria-hidden`, `clsx` and `client-only`
under MIT — not the "dozens" the ruling anticipated.
### Ruling 11's claim about the tab tests is wrong
`LocalDnsPage.tsx` already renders `role="tablist"` and `role="tab"` by hand, so
no test query moves from `getByRole("button")` to `getByRole("tab")`. What RAC
adds there is keyboard arrow-key navigation, which the hand-rolled switcher
lacks, and that is what the S2 acceptance box asserts.
### `formCard` is a layout change, not a port
`space-y-3` set sibling margins on a block container; the StyleX version is a
flex column with `gap`. StyleX cannot express the `> * + *` selector `space-y`
compiles to, so `gap` is the only mechanism available. Both call sites
(`RecordsTab`, `ZonesTab`) are plain vertical form stacks, where the difference
in child sizing and margin behaviour does not show. Recorded against ruling 7,
which pins layout structure.
### S2 needed two build-config changes the spec did not anticipate
Both are in files S1 and S3 own, both were forced, and neither is optional.
`web/vitest.setup.ts` (new) plus `setupFiles` in `web/vite.config.ts`: jsdom
implements no `CSS` interface at all, and React Aria's collection code calls
`CSS.escape` unguarded when it looks an item up by key
(`react-aria/dist/private/selection/utils.mjs:22`). Every RAC Select and Tabs
test threw `Cannot read properties of undefined (reading 'escape')` before this
landed. The setup file implements CSSOM's *serialize an identifier* algorithm
rather than approximating it, because a wrong escape would break the key lookups
silently instead of loudly.
The `@` alias passed to `stylex.vite()` in `web/vite.config.ts`: StyleX resolves
the `.stylex.ts` theme file itself and cannot see Vite's `resolve.alias`, so
`import { colors } from "@/ui/tokens.stylex"` in a feature file failed the build
with "Could not resolve the path to the imported file". Relative imports were the
alternative; passing the alias keeps the repo's `@/` convention instead.
### RAC Select changes the accessible name of a labelled control
`useSelect` labels the trigger button with `aria-labelledby="<value> <label>"`,
so a select labelled "Type" showing "A" has the accessible name "A Type", and
`getByLabelText("Type")` no longer finds it. Converted tests query
`getByRole("button", { name: /Type$/ })` and read the options by opening the
listbox. RAC also renders a hidden native `<select>` for form submission; it sits
inside an `aria-hidden` container, so role and label queries skip it.
### The RAC anti-requirement forbids parts of a component it requires
`Select.tsx` imports RAC `Button`, `Popover`, `Label`, `ListBox`, `ListBoxItem`
and `SelectValue`. The anti-requirement below names `Button` and `Popover`
among the components RAC must not bring. Those two are not adopted components
here — RAC composes a Select out of exactly these parts, and there is no RAC
Select without them. The anti-requirement is amended: it forbids adopting a RAC
component as an app-wide primitive, and the internal parts of the four adopted
components are permitted where no file outside their wrapper imports them.
That last clause is the check that keeps the scope honest, and it holds:
`react-aria-components` is imported only by the four `ui/` wrappers. A RAC
`Button` in a feature file would still be a breach.
### The focus-ring floor was briefly amended on a false premise
S2 left the Select's options with their outline removed, showing keyboard focus
as a background inversion alone. This section previously amended ruling 3 to
excuse that, arguing RAC drives option focus through an `isFocused` render prop
so a `:focus-visible` rule could never match. That argument is wrong.
`useSelectableItem` gives options a roving `tabIndex` and focuses the option's
own DOM node (`react-aria/dist/private/selection/useSelectableItem.mjs:64`),
which `ListBoxItem` spreads onto its element — so `:focus-visible` matches and
the ring was simply missing. The amendment is withdrawn and ruling 3 stands
unchanged. `Select.tsx` now composes the shared inset ring onto every option:
inset because an option flush against a scrolling popover clips an outset
outline, and recoloured to `primaryText` on the highlighted row because the
focus token is the same blue that row paints behind it. The inversion stays as
the listbox convention; it is no longer the only indicator.
### Two notes for S3
`textMuted` resolves to zinc-400 in dark mode. Several current `text-zinc-500`
call sites carry no dark override and stay zinc-500, so the token cannot replace
those without a shade shift — decide per call site rather than sweeping.
Vitest began reporting "Tests closed successfully but something prevents Vite
server from exiting" with the StyleX plugin in the pipeline. The suite passes
and exits 0. It should not be allowed to become the accepted baseline.
### Both S3 notes are now settled
**The vitest warning is fixed, and it was not our code.** Bisecting a trivial
one-assertion test against a bare config showed `react()` exits clean and
`stylex.vite()` does not. `@stylexjs/unplugin@0.19.0`'s
`lib/es/vite.mjs:59` starts a 150ms `setInterval` in `configureServer` to poll
its CSS store for HMR, and clears it from the `close` event of
`server.httpServer`. A probe plugin confirmed vitest resolves `httpServer` to
`null`, because it runs the dev server in middleware mode — so the timer is
never cleared and holds the event loop open until vitest kills the worker on a
ten-second timeout. `web/vite.config.ts` now strips that one hook when
`process.env.VITEST` is set; the transform hook, which the tests do need, is
untouched, and `ui/styles.test.tsx` still proves it runs. `npm test` reports 31
files and 183 tests passed, with no warning, and exits 0.
**`textMuted` was swept; here is the per-call-site judgement.** Measuring the
two shifts against their grounds settles it in opposite directions:
- 39 call sites were a flat `text-zinc-500` with no dark override. On the dark
ground that measures **4.12:1**, which fails WCAG AA. The token's zinc-400
dark value measures **7.56:1**. The sweep repaired a real accessibility
defect there, so it stands, and the token comment now records why.
- 4 call sites were `text-zinc-600 dark:text-zinc-400` — already adaptive. The
sweep moved their light value to zinc-500, from **7.40:1** to **4.62:1**.
That clears AA by a hair on small text: the query-log table header, the
dashboard period button, the inactive nav item and the chart legend. It buys
nothing, so a second token `textSecondary` (zinc-600 light, zinc-400 dark)
restores the original pair at exactly those four.
Contrast was computed from the tokens' own oklch values through linear sRGB to
WCAG relative luminance, not read off a chart.
### The reset's font stack is not Tailwind's, deliberately
Tailwind is uninstalled, so its default stack cannot be reproduced from the
tree, and ruling 7 accepts the metric drift. One part is not drift: `Select.tsx`
renders U+25BE for its chevron, and Segoe UI does not carry that glyph, so on
Windows the stack has to reach a font that does. `"Segoe UI Symbol"` and
`"Noto Sans Symbols 2"` are appended for that glyph. No emoji families are
listed, because the app renders no emoji.
### Two review findings closed by decision, not by a change
Both were put to Mokhtar Mial on 2026-08-12 and both are closed. Neither is to
be re-opened by a later review reading the history and inferring an oversight.
`0f58966` carries a `README.md` edit that was already in the working tree and
is not part of the `srOnly` work its message describes. The content is intact
and only the attribution is wrong; rewriting published history to correct that
was declined.
Ruling 10 asks for one commit per file in ascending `className` count. S3 did
not hold to the order strictly. Bisectability does not depend on it and the
commits were already published, so the order stands as it is.
## Anti-requirements
- No router or query changes: TanStack Router and TanStack Query stay,
untouched. That decision is closed.
- No RAC beyond Dialog, AlertDialog, Tabs, Select — no RAC Button, TextField,
Table, Menu, DatePicker adopted as an app primitive. Native elements styled
with StyleX are the end state. The internal parts of the four adopted
components (Button, Popover, Label, ListBox, ListBoxItem, SelectValue inside
Select) are permitted, and only inside their own wrapper file — see the
recorded amendment above.
- No runtime style injection (`runtimeInjection: false` stays), no CSS-in-JS
at runtime, no styled-components-like patterns.
- No dynamic class-name assembly, no `clsx`/`classnames` dependency, no
Tailwind-compatibility shim.
- No theme toggle, no `data-theme`, no stored preference — media-query dark
mode only, as today.
- No redesign: layout structure is pinned by ruling 7. Drift in shades and
spacing is accepted; new page structures are not.
- No visual-regression harness, no screenshot tooling.
- No edits to `web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts`,
`web/src/lib/api.ts`, or anything under `src/` (the Zig tree).
- No `vite-plugin-stylex` (unmaintained community plugin).
+443 -70
View File
@@ -32,7 +32,6 @@ const tls = std.crypto.tls;
const api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig");
const bootstrap = @import("config/bootstrap.zig");
const cert_store = @import("server/cert_store.zig");
const cli = @import("cli.zig");
const clients = @import("server/clients.zig");
@@ -49,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 loader = @import("config/loader.zig");
const local_records = @import("local/records.zig");
const local_tables = @import("server/local_tables.zig");
const logger_mod = @import("storage/logger.zig");
@@ -60,6 +60,7 @@ const pause = @import("server/pause.zig");
const pool_mod = @import("upstream/pool.zig");
const query_sink = @import("server/query_sink.zig");
const rate_limiter = @import("server/rate_limiter.zig");
const reconcile = @import("config/reconcile.zig");
const retention_mod = @import("storage/retention.zig");
const safe_url = @import("safe_url.zig");
const shutdown = @import("server/shutdown.zig");
@@ -95,6 +96,12 @@ pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
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;
};
@@ -113,70 +120,201 @@ fn failureExitCode(err: anyerror) u8 {
return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime;
}
/// First run only: the file seeds an empty database and is ignored forever
/// after. Its diagnostics are the operator's one chance to see what the file
/// said, so they are printed the way `check` and `import` print them.
/// File authority (ruling 2): read the file the operator named, validate it, and
/// converge the database onto it — on this start and on every start after it.
/// Returns the wall clock the reconcile ran at, which becomes the settings
/// envelope's `reconciled_at`.
///
/// Printed on the way out either way. A seed file can be accepted and still
/// carry warnings — a blocklist source in no group is downloaded and compiled
/// into nothing — and a warning that only appears when the start fails is a
/// warning nobody ever reads: the start it describes is the one that worked.
/// `check` reported it and `run` did not, which left the same file graded two
/// ways.
/// A missing, unreadable or invalid file fails the start. There is no fallback
/// to the database under any failure: a fallback turns a deploy typo into a
/// silently stale configuration, which is the failure mode the whole mode exists
/// to prevent.
///
/// The runner's error writer, not `std.log`: this runs before
/// `logging.install`, and one rendering of a diagnostic across `run`, `check`
/// and `import` is the point of `Diagnostics.writeAll`.
/// Diagnostics are printed on the way out either way. A file can be accepted and
/// still carry warnings — a blocklist source in no group is downloaded and
/// compiled into nothing — and a warning that only appears when the start fails
/// is a warning nobody ever reads: the start it describes is the one that
/// worked.
///
/// Flushed here rather than left to `run`'s exit flush. That writer is buffered
/// (`main` gives it 4 KiB) and `serve` does not return for as long as the
/// service runs, so a line left in the buffer reaches the operator when the
/// The runner's writers, not `std.log`: this runs before `logging.install`, and
/// one rendering of a diagnostic across `run`, `check` and `import` is the point
/// of `Diagnostics.writeAll`.
///
/// Flushed here rather than left to `run`'s exit flush. Both writers are
/// buffered (`main` gives them 4 KiB) and `serve` does not return for as long as
/// the service runs, so a line left in the buffer reaches the operator when the
/// process stops — days after the start it describes. A failure path flushes
/// anyway because it returns immediately; the successful start is the one that
/// needs this.
fn seedFromFile(
fn reconcileFromFile(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
) bootstrap.Error!bootstrap.Outcome {
) !i64 {
return reconcileFromFileAt(
r,
config_db,
dir,
config_path,
std.Io.Clock.real.now(r.io).toSeconds(),
);
}
/// `pass_now` is what the engine stamps into the runtime columns of the rows it
/// inserts (`first_seen`, `last_seen`, `created_at`), and it is deliberately not
/// the value this returns.
///
/// The two clocks answer different questions, and conflating them was a real
/// defect: `reconciled_at` means "this process loaded the file at T" and is
/// compared against the file's mtime to detect a restart-pending state
/// (ruling 7). A stamp taken *before* the read makes a file written during the
/// read look newer than the process that loaded it — a false "restart pending"
/// in the UI for a file that is fully applied. So this returns a clock read
/// taken immediately after the commit, and `pass_now` never leaves the engine.
///
/// Split from `reconcileFromFile` so the two are separable in a test: pin
/// `pass_now` and the returned stamp must still be the real clock.
fn reconcileFromFileAt(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
pass_now: i64,
) !i64 {
var arena_state: std.heap.ArenaAllocator = .init(r.gpa);
defer arena_state.deinit();
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
const result = bootstrap.bootstrap(r.io, r.gpa, config_db, dir, config_path, &diags);
const result = applyManagedFile(r, config_db, dir, config_path, arena_state.allocator(), &diags, pass_now);
// Neither discard is an oversight, and the two answer different questions.
//
// On a rejected seed file, `result` is returned untouched: the operator gets
// the reason the start failed, never a writer error standing in front of it.
// A broken stderr is not why the configuration was refused.
//
// On a seed that worked, a failure here does not stop the start. The trade
// is one lost warning line against a household with no name resolution, and
// `run` before this point is the only stretch of this program where an
// output failure could take DNS down at all — ruling 4 already says nothing
// after it is fatal. Nor could the failure be reported: this writer *is* the
// error channel, and `logging.install` has not run yet, so `std.log` resolves
// to the same stderr a diagnostic about it would have to travel down.
//
// It is not lost from the process either. A failed drain consumes nothing,
// so whatever the buffer held it still holds — that half is observed, in
// "a broken error writer does not stop a first start that succeeded" below,
// which reads the retained warning back out of the same writer.
//
// What happens to those bytes afterwards is derived, not watched, and is
// labelled so deliberately. `Io.Writer.defaultFlush` drains while `end != 0`
// and `run`'s exit flush maps a failure to exit 1, so a stderr still broken
// at shutdown should carry the condition out in the exit code, and one that
// recovered should deliver the line late. No test drives `run` that far.
// Two limits come with the derivation: an empty buffer flushes clean and
// reports nothing at all, and a failure that recovers ends at exit 0 with a
// line the operator reads days after the start it describes.
// Both discards are deliberate, and they answer different questions. On a
// rejected file the operator gets the reason the start failed, never a
// writer error standing in front of it — a broken stderr is not why the
// configuration was refused. On a file that applied, a failure here does not
// stop the start: the trade is one lost warning line against a household
// with no name resolution, and this writer *is* the error channel, so the
// failure has nowhere to be reported anyway.
diags.writeAll(r.err) catch {};
r.err.flush() catch {};
return result;
}
/// Returns the moment the transaction committed, which is what the settings
/// envelope reports as `reconciled_at`.
fn applyManagedFile(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
arena: Allocator,
diags: *validate.Diagnostics,
now: i64,
) !i64 {
const cfg = try loader.load(r.io, arena, dir, config_path, diags);
try validate.validate(cfg, diags);
// The keys this pass wrote, never their values (ruling 8). Duplicated into
// `gpa` by the engine, so this frame frees them.
var changed: std.ArrayList([]const u8) = .empty;
defer {
for (changed.items) |key| r.gpa.free(key);
changed.deinit(r.gpa);
}
var pass = reconcile.begin(r.io, r.gpa, config_db, cfg, now, .{
.changed_settings = &changed,
}) catch |err| {
// `begin` has already rolled its own transaction back. What the operator
// needs is the cause: a full SD card must read as "disk", not as a bare
// exit 1, so the SQLite condition is named.
reportReconcileFailure(r, config_db, config_path, err);
return err;
};
errdefer pass.rollback();
pass.commit() catch |err| {
reportReconcileFailure(r, config_db, config_path, err);
return err;
};
// Read here and nowhere earlier: the file is loaded once this line runs, and
// not one statement before it.
const reconciled_at = std.Io.Clock.real.now(r.io).toSeconds();
printSummary(r, config_path, pass.summary, changed.items) catch {};
return reconciled_at;
}
/// SQLite conditions an operator acts on differently. `@errorName` alone would
/// say `Full`, which is not a word anyone can search for; the primary result
/// code's own name is.
fn sqliteCodeName(err: anyerror) ?[]const u8 {
return switch (err) {
error.Full => "SQLITE_FULL",
error.Busy => "SQLITE_BUSY",
error.IoErr => "SQLITE_IOERR",
error.ReadOnly => "SQLITE_READONLY",
error.Corrupt => "SQLITE_CORRUPT",
error.Constraint => "SQLITE_CONSTRAINT",
else => null,
};
}
fn reportReconcileFailure(r: cli.Runner, config_db: *db.Db, config_path: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
const detail = config_db.lastError(&buf);
const code = sqliteCodeName(err) orelse @errorName(err);
r.err.print("reconciling '{s}' failed: {s}: {s}\n", .{ config_path, code, detail }) catch {};
r.err.flush() catch {};
}
/// What that restart changed, without opening sqlite (ruling 8): per-table
/// counts, the settings keys that moved — never their values — and an
/// authentication change, which is never a silent line item in a count.
fn printSummary(
r: cli.Runner,
config_path: []const u8,
summary: reconcile.Summary,
changed_settings: []const []const u8,
) !void {
try r.out.print("reconciled '{s}':", .{config_path});
if (summary.isNoOp()) {
try r.out.writeAll(" no changes\n");
} else {
inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| {
if (field.type == reconcile.TableCounts) {
const counts = @field(summary, field.name);
if (counts.total() != 0) {
try r.out.print(" {s} +{d} ~{d} -{d};", .{
field.name,
counts.inserted,
counts.updated,
counts.deleted,
});
}
}
}
try r.out.writeAll("\n");
if (changed_settings.len != 0) {
try r.out.writeAll("settings keys changed:");
for (changed_settings) |key| try r.out.print(" {s}", .{key});
try r.out.writeAll("\n");
}
switch (summary.auth_transition) {
.none => {},
.enabled => try r.out.writeAll("web authentication is now enabled\n"),
.disabled => try r.out.writeAll("web authentication is now disabled\n"),
.rotated => try r.out.writeAll("the web password changed\n"),
}
}
try r.out.flush();
}
fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
const io = r.io;
const gpa = r.gpa;
@@ -193,7 +331,15 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer config_db.close();
_ = try migrations.migrate(&config_db);
_ = try seedFromFile(r, &config_db, std.Io.Dir.cwd(), paths.config);
// Ruling 1: the presence of `--config` is the whole authority decision. With
// it, the file is the sole declarative source and the database is converged
// onto it here, before anything reads the database. Without it the database
// is authority and this step does not exist — a file on disk that no flag
// names changes nothing.
const reconciled_at: ?i64 = if (args.config) |config_path|
try reconcileFromFile(r, &config_db, std.Io.Dir.cwd(), config_path)
else
null;
// Every string in `cfg` points into this arena, and the pool's endpoints,
// the handler's records and the monitor's paths all keep such strings. It
@@ -203,6 +349,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer arena_state.deinit();
const arena = arena_state.allocator();
// The web layer reads the managed path out of `WebState` for the whole life
// of the process, so it takes a copy from the arena that outlives it rather
// than borrowing `argv`.
const authority: web_server.Authority = if (args.config) |config_path|
.{ .managed_file = try arena.dupe(u8, config_path) }
else
.database;
// The database stays the runtime substrate and the effective-config read
// path in both modes: in file mode the reconcile above has just made it
// agree with the file.
const cfg = try config_export.readConfig(&config_db, arena);
// From here on `std.log` goes wherever the operator asked. Before this call
@@ -472,7 +629,9 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
if (cfg.web.enabled) web_state = .{
.gpa = gpa,
.web = cfg.web,
.live_hash = .init(cfg.web.password_hash),
.authority = authority,
.reconciled_at = reconciled_at,
.live_hash = .init(cfg.web.password_hash orelse ""),
.handler = &h,
.pause = &paused,
.tracker = &tracker,
@@ -602,7 +761,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// this box exists for — keeps serving.
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
logStartup(io, &manager, upstreams.active().len, .{
logStartup(io, authority, &manager, upstreams.active().len, .{
.udp6 = if (udp6) |*s| s.boundAddress() else null,
.udp4 = if (udp4) |*s| s.boundAddress() else null,
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
@@ -1005,8 +1164,8 @@ test "run, check and import agree on a seed file with no default group" {
\\}
;
// `run`: `serve` seeds through `bootstrap`, which is a wrapper over this
// exact call, so this is the error `run` classifies.
// `run --config`: `serve` validates through this exact call before it
// reconciles, so this is the error `run` classifies.
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
@@ -1082,11 +1241,11 @@ test "a configuration whose blocklist source is in no group imports and checks c
try std.testing.expectEqual(@as(usize, 1), check_diags.warningCount());
}
test "a first start that seeds from a file prints the warnings the file earned" {
// D5, second half. The seed file is read once in the life of a database, so
// a warning it earns is printed on that start or never. `run` printed
// diagnostics only when the file was rejected, which made a successful first
// start the one place the finding could not surface.
test "a start in file mode prints the warnings the file earned" {
// D5, second half. `run` printed diagnostics only when the file was
// rejected, which made a successful start the one place a finding could not
// surface. Under file authority the file is read on every start, so this is
// the line an operator sees after every restart, not only the first.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -1119,11 +1278,10 @@ test "a first start that seeds from a file prints the warnings the file earned"
var err_writer = err_file.writer(io, &err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
// The file is valid, so the start succeeds and the database is seeded.
try std.testing.expectEqual(
bootstrap.Outcome.seeded,
try seedFromFile(r, &database, tmp.dir, "config.zon"),
);
// The file is valid, so the start succeeds and the database converges onto
// it. The returned stamp is what the settings envelope reports.
const reconciled_at = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
try std.testing.expect(reconciled_at > 0);
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Nothing flushes here on purpose. In production `serve` runs from this
@@ -1138,6 +1296,164 @@ test "a first start that seeds from a file prints the warnings the file earned"
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "FAIL"));
}
test "run with a missing managed file exits 2 with the path, and never serves from the database" {
// Ruling 2: file mode fails closed. The database below is a perfectly good
// one — migrated, and the run would have reached the listeners on it in db
// mode — so a fallback would show up here as exit 0.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var data_buf: [160]u8 = undefined;
const data_dir = try std.fmt.bufPrint(&data_buf, ".zig-cache/tmp/{s}/data", .{tmp.sub_path});
var missing_buf: [160]u8 = undefined;
const missing = try std.fmt.bufPrint(&missing_buf, ".zig-cache/tmp/{s}/nope.zon", .{tmp.sub_path});
// Real buffered `File.Writer`s, not `Writer.fixed`: this asserts the
// operator received the lines, and a fixed writer's flush is a no-op that
// counts a buffered line as delivered.
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
defer out_file.close(io);
var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
defer err_file.close(io);
var out_buf: [4096]u8 = undefined;
var err_buf: [4096]u8 = undefined;
var out_writer = out_file.writer(io, &out_buf);
var err_writer = err_file.writer(io, &err_buf);
const r: cli.Runner = .{
.io = io,
.gpa = gpa,
.out = &out_writer.interface,
.err = &err_writer.interface,
};
try std.testing.expectEqual(cli.exit_check, run(r, .{
.paths = .{ .data_dir = data_dir },
.config = missing,
}));
const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
defer gpa.free(printed);
// The path is in the message: an error name alone tells the operator nothing
// about which file the deploy got wrong.
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, missing));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no such file"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "nxdns check"));
// The db-mode remediation hint belongs to db mode: in file mode the operator
// has a file, and the diagnostic above says what is wrong with it.
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "make a file the source of truth"));
}
test "reconciled_at is stamped after the commit, not from the clock the pass wrote with" {
// Ruling 7: `reconciled_at` means "this process loaded the file at T", and
// the UI compares it against the file's mtime to say whether a restart is
// pending. A stamp taken before the read makes a file written while the read
// ran look newer than the process that loaded it — a restart-pending banner
// over a configuration that is fully applied.
//
// The pass clock is pinned to 1970 here, which the engine really does use:
// the inserted client below carries it. If the two were one value, the
// returned stamp would be 1970 too.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\ .clients = .{ .{ .ip = "192.168.1.5", .name = "tablet" } },
\\}
});
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
var out_buf: [4096]u8 = undefined;
var err_buf: [1024]u8 = undefined;
var out: Writer = .fixed(&out_buf);
var err: Writer = .fixed(&err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err };
const pass_now: i64 = 42;
const before = std.Io.Clock.real.now(io).toSeconds();
const reconciled_at = try reconcileFromFileAt(r, &database, tmp.dir, "config.zon", pass_now);
// The pinned clock reached the engine, so the two values really are separate
// inputs rather than the same read twice.
try std.testing.expectEqual(pass_now, try database.queryInt(
"SELECT first_seen FROM clients WHERE ip = '192.168.1.5'",
));
try std.testing.expect(reconciled_at != pass_now);
try std.testing.expect(reconciled_at >= before);
}
test "the startup summary reports what the reconcile changed, then that nothing changed" {
// Ruling 8: the answer to "what did that restart change" without opening
// sqlite. Also ruling 5 from the operator's side — the second start of an
// unchanged file says so.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" },
\\}
});
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
defer out_file.close(io);
var out_buf: [4096]u8 = undefined;
var out_writer = out_file.writer(io, &out_buf);
var err_buf: [1024]u8 = undefined;
var err: Writer = .fixed(&err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out_writer.interface, .err = &err };
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
{
// Read back through the file: `serve` does not return for as long as the
// service runs, so a summary still in the buffer is a summary nobody
// reads.
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
defer gpa.free(printed);
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "reconciled 'config.zon':"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "upstreams +1 ~0 -0"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "settings keys changed:"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "web.password_hash"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "authentication is now enabled"));
// The keys, never the values: the hash the file set must not be echoed.
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "$argon2id$"));
}
try tmp.dir.writeFile(io, .{ .sub_path = "stdout.txt", .data = "" });
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
{
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
defer gpa.free(printed);
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no changes"));
}
}
/// A broken stderr, in the shape `main` builds: a buffered `File.Writer`, with
/// its drain switched to the mode that fails. `Writer.fixed` cannot stand in —
/// its flush is `noopFlush`, so it has no failure to report and its `written()`
@@ -1151,8 +1467,8 @@ fn brokenErrWriter(io: std.Io, file: std.Io.File, buffer: []u8) std.Io.File.Writ
return w;
}
test "a broken error writer does not replace the reason a seed file was rejected" {
// The operator has to see why seeding failed, and a broken stderr is not
test "a broken error writer does not replace the reason a managed file was rejected" {
// The operator has to see why the start failed, and a broken stderr is not
// that reason.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
@@ -1187,7 +1503,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
try std.testing.expectError(
error.MissingDefaultGroup,
seedFromFile(r, &database, tmp.dir, "config.zon"),
reconcileFromFile(r, &database, tmp.dir, "config.zon"),
);
// Empty, so the writer did fail — without this the assertion above would
@@ -1197,7 +1513,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
try std.testing.expectEqual(@as(usize, 0), printed.len);
}
test "a broken error writer does not stop a first start that succeeded" {
test "a broken error writer does not stop a start whose file applied" {
// The call this file makes: a DNS server for a household does not refuse to
// resolve because stderr is broken. What it must not do is drop the warning
// on the floor, so the second half checks the buffer still holds it.
@@ -1233,10 +1549,7 @@ test "a broken error writer does not stop a first start that succeeded" {
var err_writer = brokenErrWriter(io, err_file, &err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
try std.testing.expectEqual(
bootstrap.Outcome.seeded,
try seedFromFile(r, &database, tmp.dir, "config.zon"),
);
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Nothing reached the file, so the flush really did fail.
@@ -1289,7 +1602,20 @@ fn isWildcard(addr: net.IpAddress) bool {
/// One line, at info, naming what an operator needs to see in `journalctl`
/// right after a restart: where it listens, how many upstreams it has, and
/// whether filtering is live.
fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void {
///
/// Preceded by the authority (ruling 8), because every other question about a
/// restart — why a UI edit vanished, why a file edit did not apply — starts with
/// which of the two governs this process, and the journal is where an operator
/// looks for it.
fn logStartup(
io: std.Io,
authority: web_server.Authority,
manager: *manager_mod.Manager,
upstream_count: usize,
bound: Listeners,
) void {
log.info("authority: {f}", .{AuthorityText{ .authority = authority }});
var buf: [256]u8 = undefined;
var w: Writer = .fixed(&buf);
appendBind(&w, "udp", bound.udp6);
@@ -1316,6 +1642,25 @@ fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize,
}
}
/// Which source governs this process, in the words the journal carries.
///
/// A formatter rather than a rendering into a buffer of this file's own: a
/// managed path is bounded only by `Dir.max_path_bytes`, and nested bind mounts
/// make long ones ordinary, so a fixed buffer here would silently drop exactly
/// the half of the line an operator came for. Writing straight to the log sink's
/// writer leaves the one documented, counted truncation in `platform/logging.zig`
/// as the only limit.
const AuthorityText = struct {
authority: web_server.Authority,
pub fn format(self: AuthorityText, w: *Writer) Writer.Error!void {
switch (self.authority) {
.database => try w.writeAll("database"),
.managed_file => |path| try w.print("file ({s})", .{path}),
}
}
};
/// Silent on overflow: a truncated startup line is not worth a failure path,
/// and 256 bytes hold four addresses.
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
@@ -1323,6 +1668,34 @@ fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
w.print(" {s} {f}", .{ which, value }) catch {};
}
test "the startup line names which source governs this process, path and all" {
// Ruling 8. Every other question about a restart starts here, so the answer
// is in the journal rather than derived from the unit file by whoever is
// reading at 2am.
const gpa = std.testing.allocator;
var short: Writer.Allocating = .init(gpa);
defer short.deinit();
try short.writer.print("{f}", .{AuthorityText{ .authority = .database }});
try std.testing.expectEqualStrings("database", short.written());
var named: Writer.Allocating = .init(gpa);
defer named.deinit();
try named.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = "/etc/nxdns/config.zon" } }});
try std.testing.expectEqualStrings("file (/etc/nxdns/config.zon)", named.written());
// A path past any buffer this file could reasonably have picked. Nested bind
// mounts produce paths like this, and the path is the half of the line the
// operator came for — dropping it to keep the line short is the wrong trade.
const long_path = "/mnt/" ++ ("deeply-nested-mount/" ** 20) ++ "config.zon";
try std.testing.expect(long_path.len > 256);
var long: Writer.Allocating = .init(gpa);
defer long.deinit();
try long.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = long_path } }});
try std.testing.expect(std.mem.containsAtLeast(u8, long.written(), 1, long_path));
}
const test_address = @import("platform/address.zig");
test "one maintenance pass drops the api limiter's stale buckets" {
+165 -106
View File
@@ -22,6 +22,7 @@ const app = @import("app.zig");
const config_export = @import("config/export.zig");
const faults = @import("config/faults.zig");
const import = @import("config/import.zig");
const loader = @import("config/loader.zig");
const model = @import("config/model.zig");
const validate = @import("config/validate.zig");
const cert_store = @import("server/cert_store.zig");
@@ -50,19 +51,31 @@ pub const querylog_db_name = "querylog.db";
pub const Paths = struct {
/// PLAN §3.13.
data_dir: []const u8 = "/var/lib/nxdns",
config: []const u8 = "/etc/nxdns/config.zon",
};
/// `config_explicit` records whether `--config` was given, because `check` has
/// to tell "the operator named a file" from "the default path happens to
/// exist".
pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false };
/// Milestone-20 ruling 1: `--config` has no default path, and its presence is
/// the whole of the authority decision. Null means the database is authority —
/// for `run` that is today's appliance behaviour, for `check` it is the database
/// that gets graded. A file sitting at a well-known path that no flag names
/// changes nothing.
pub const CheckArgs = struct { paths: Paths = .{}, config: ?[]const u8 = null };
pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };
/// `allow_delete` is `--allow-delete`: it permits an import whose diff removes
/// declarative rows (ruling 6).
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, allow_delete: bool = false };
/// `config` names the managed configuration file and, by being present at all,
/// makes that file the sole declarative source of truth: it is read, validated
/// and reconciled into the database on every start.
///
/// `web_dev` is milestone-8 ruling 24's `--web-dev <dir>`: serve the web
/// interface from that directory instead of the embedded assets.
pub const RunArgs = struct { paths: Paths = .{}, web_dev: ?[]const u8 = null };
pub const RunArgs = struct {
paths: Paths = .{},
config: ?[]const u8 = null,
web_dev: ?[]const u8 = null,
};
pub const Command = union(enum) {
run: RunArgs,
@@ -176,7 +189,7 @@ fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs {
if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "config")) {
args.paths.config = try flagValue(flag, argv, &i);
args.config = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "web-dev")) {
args.web_dev = try flagValue(flag, argv, &i);
} else return error.UnknownFlag;
@@ -192,8 +205,7 @@ fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
if (eql(flag.name, "data-dir")) {
args.paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "config")) {
args.paths.config = try flagValue(flag, argv, &i);
args.config_explicit = true;
args.config = try flagValue(flag, argv, &i);
} else return error.UnknownFlag;
}
return args;
@@ -215,7 +227,7 @@ fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs {
fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
var paths: Paths = .{};
var force = false;
var allow_delete = false;
var file: ?[]const u8 = null;
var i: usize = 0;
@@ -227,18 +239,18 @@ fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
};
if (eql(flag.name, "data-dir")) {
paths.data_dir = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "force")) {
// A boolean flag takes no value, so `--force=1` is not a spelling of
// any flag this program has.
} else if (eql(flag.name, "allow-delete")) {
// A boolean flag takes no value, so `--allow-delete=1` is not a
// spelling of any flag this program has.
if (flag.attached != null) return error.UnknownFlag;
force = true;
allow_delete = true;
} else return error.UnknownFlag;
}
return .{
.paths = paths,
.file = file orelse return error.MissingArgument,
.force = force,
.allow_delete = allow_delete,
};
}
@@ -379,14 +391,30 @@ const usage_text =
\\
\\options:
\\ --data-dir DIR data directory (default /var/lib/nxdns)
\\ --config FILE configuration file (default /etc/nxdns/config.zon)
\\ --config FILE run: make FILE the sole source of configuration and
\\ reconcile the database onto it at every start;
\\ check: grade FILE instead of the database
\\ --out FILE write the export to FILE instead of stdout
\\ --force let import replace a database that already has content
\\ --allow-delete let import apply a file whose diff deletes rows
\\ --web-dev DIR run only: serve the web interface from DIR instead of
\\ the embedded assets
\\
;
/// The one remediation line for a database that holds no usable configuration.
/// Both routes to that state print it: `run` refusing an unconfigured database
/// (`NoUsableUpstreams`) and `check` finding no `config.db` at all.
///
/// It lives here, beside the exit-code mapping, because a Zig error carries no
/// text and `config/validate.zig` must stay blind to which source it is grading
/// — the same file validates a database reading and a managed file.
pub const db_source_hint =
"load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`\n";
pub fn writeDbSourceHint(w: *Writer) void {
w.writeAll(db_source_hint) catch {};
}
/// Returns nothing, so a writer failure here has nowhere to go. Every caller
/// flushes afterwards and reports that failure instead.
pub fn usage(w: *Writer) void {
@@ -493,7 +521,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
&database,
std.Io.Dir.cwd(),
args.file,
.{ .force = args.force },
.{ .allow_delete = args.allow_delete },
diags,
);
@@ -514,9 +542,10 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
/// That file is the only list — this function keeps none of its own, which is
/// what stops `run`, `check` and `import` drifting apart again (D1).
///
/// `error.DatabaseNotEmpty` is the one exception, and it is deliberate: it
/// reports the state of the database rather than the content of a file, so it
/// is not a configuration fault, and `import` alone decides it is exit 2.
/// `error.DestructiveImport` is the one exception, and it is deliberate: it
/// reports what the diff would do to the database rather than the content of a
/// file, so it is not a configuration fault, and `import` alone decides it is
/// exit 2.
///
/// `error.OutOfMemory` is matched first, before anything else is consulted.
/// Both recording paths — `validate` and import's per-line rendering of a ZON
@@ -530,7 +559,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
fn failureExitCode(e: anyerror, failures: usize) u8 {
if (e == error.OutOfMemory) return exit_runtime;
if (failures != 0) return exit_check;
if (e == error.DatabaseNotEmpty) return exit_check;
if (e == error.DestructiveImport) return exit_check;
return if (faults.isConfigFault(e)) exit_check else exit_runtime;
}
@@ -563,28 +592,31 @@ fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
// Which source was used is printed in every branch, so the answer is never
// ambiguous about what it checked.
if (args.config_explicit) {
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
return checkFile(r, arena, args.paths.config, probe);
//
// Ruling 1: the invocation decides, and nothing else. The heuristic that
// used to live here — probe for `config.db`, fall back to probing
// `/etc/nxdns/config.zon`, grade whichever exists — made the answer a
// function of what happened to be on disk, which is the ambient inference
// that made seed-once bootstrap a source of documentation lies. A file no
// flag names is not graded.
if (args.config) |path| {
try r.out.print("checking configuration file {s}\n", .{path});
return checkFile(r, arena, path, probe);
}
const config_db_path = try std.fs.path.joinZ(arena, &.{ args.paths.data_dir, config_db_name });
if (try pathExists(r.io, config_db_path)) {
try r.out.print("checking database {s}\n", .{config_db_path});
return checkDatabase(r, arena, config_db_path, probe);
if (!try pathExists(r.io, config_db_path)) {
// The deleted heuristic's "nothing to check" branch, replaced rather
// than dropped: an operator running `check` on a box that has never
// been configured gets the same exit code and one line saying what to
// do about it.
try r.out.print("no config database at {s}\n", .{config_db_path});
try r.out.writeAll(db_source_hint);
return exit_check;
}
if (try pathExists(r.io, args.paths.config)) {
try r.out.print("checking configuration file {s}\n", .{args.paths.config});
return checkFile(r, arena, args.paths.config, probe);
}
try r.out.print("nothing to check: no {s} in {s} and no {s}\n", .{
config_db_name,
args.paths.data_dir,
args.paths.config,
});
return exit_check;
try r.out.print("checking database {s}\n", .{config_db_path});
return checkDatabase(r, arena, config_db_path, probe);
}
/// `check` reads `config.db` and writes nothing to it (F-c): no create, no
@@ -722,52 +754,31 @@ fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
return true;
}
/// Grades the file `--config` named, through `config/loader.zig` — the same
/// read and the same classification `nxdns run --config` uses. That shared
/// helper is what makes the scoped agreement of ruling 2 true: `check` reaches
/// exactly the read, size and parse faults `run` would reach, and validation
/// below is the same call on the same `Config`.
///
/// D4: a named file that is missing or unreadable is the same operator-fixable
/// condition as one that fails to parse, so it is reported as a finding rather
/// than escaping as a runtime failure.
fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
const source = std.Io.Dir.cwd().readFileAllocOptions(
r.io,
path,
arena,
.limited(import.max_config_bytes),
.of(u8),
0,
) catch |e| switch (e) {
error.StreamTooLong => {
try r.out.print("FAIL {s}: larger than {d} bytes\n", .{ path, import.max_config_bytes });
return exit_check;
},
// D4: a named file that is missing or unreadable is the same
// operator-fixable condition as one that fails to parse, so it is
// reported as a finding rather than escaping as a runtime failure. The
// implicit path already exits 2 when it finds nothing to check; naming
// the file must not change the code.
error.FileNotFound => {
try r.out.print("FAIL {s}: no such file\n", .{path});
return exit_check;
},
error.AccessDenied, error.PermissionDenied => {
try r.out.print("FAIL {s}: not readable\n", .{path});
return exit_check;
},
else => |other| return other,
};
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
// Arena-owned and never handed to `std.zon.parse.free`; see the rule and its
// `parse.zig:874` citation in `config/import.zig`.
var zon_diag: std.zon.parse.Diagnostics = .{};
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
const cfg = loader.load(r.io, arena, std.Io.Dir.cwd(), path, &diags) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
// The rendering carries the line and column, which is the whole value of
// running `check` against a file the operator just edited. It is
// multi-line, and `check` promises one line per problem, so it goes
// through the same `Diagnostics` channel `nxdns import` uses rather than
// into one `FAIL` record with newlines inside it.
error.ParseZon => {
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
try import.reportParseFailure(&diags, &zon_diag);
error.ManagedConfigUnreadable, error.ConfigTooLarge, error.ParseZon => {
// One line per problem, the promise the rest of `check` keeps: a
// multi-line ZON rendering is several problems, not one `FAIL`
// record with newlines inside it.
try diags.writeAll(r.out);
return exit_check;
},
// A box fault — fd exhaustion, an I/O error — is not a verdict on the
// configuration and keeps its own name at exit 1.
else => |other| return other,
};
return checkConfig(r, cfg, probe);
@@ -1013,17 +1024,22 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
const testing = std.testing;
test "parseArgs accepts run with no flags" {
test "run without --config selects database authority" {
// Ruling 1: authority is the invocation. No default path, so nothing on
// disk can make a bare `run` read a file.
const command = try parseArgs(&.{"run"});
try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir);
try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.paths.config);
try testing.expectEqual(@as(?[]const u8, null), command.run.config);
try testing.expectEqual(@as(?[]const u8, null), command.run.web_dev);
}
test "parseArgs accepts run with --data-dir and --config" {
test "run --config selects file authority and the path lands in the run args" {
const attached = try parseArgs(&.{ "run", "--config=/etc/nxdns/config.zon" });
try testing.expectEqualStrings("/etc/nxdns/config.zon", attached.run.config.?);
const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" });
try testing.expectEqualStrings("/srv/nx", command.run.paths.data_dir);
try testing.expectEqualStrings("/tmp/c.zon", command.run.paths.config);
try testing.expectEqualStrings("/tmp/c.zon", command.run.config.?);
}
test "parseArgs accepts run with --web-dev in both spellings" {
@@ -1049,13 +1065,12 @@ test "parseArgs accepts --data-dir with and without an equals sign" {
try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir);
}
test "parseArgs records whether check was given an explicit --config" {
test "bare check grades the database and check --config grades the file" {
const implicit = try parseArgs(&.{"check"});
try testing.expect(!implicit.check.config_explicit);
try testing.expectEqual(@as(?[]const u8, null), implicit.check.config);
const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" });
try testing.expect(explicit.check.config_explicit);
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config);
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.config.?);
}
test "parseArgs accepts export with --out" {
@@ -1066,17 +1081,21 @@ test "parseArgs accepts export with --out" {
try testing.expectEqual(@as(?[]const u8, null), bare.export_.out);
}
test "parseArgs accepts import with a file, --force and --data-dir" {
const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" });
test "parseArgs accepts import with a file, --allow-delete and --data-dir" {
const command = try parseArgs(&.{ "import", "c.zon", "--allow-delete", "--data-dir=/srv/nx" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(command.import_.force);
try testing.expect(command.import_.allow_delete);
try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir);
}
test "parseArgs accepts import with the file after the flags" {
const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(!command.import_.force);
try testing.expect(!command.import_.allow_delete);
}
test "the renamed import flag replaces --force rather than joining it" {
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force" }));
}
test "parseArgs accepts version" {
@@ -1091,7 +1110,7 @@ test "parseArgs accepts help, --help and -h" {
test "parseArgs rejects import without a file" {
try testing.expectError(error.MissingArgument, parseArgs(&.{"import"}));
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" }));
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--allow-delete" }));
}
test "parseArgs rejects --out without a value" {
@@ -1101,7 +1120,7 @@ test "parseArgs rejects --out without a value" {
test "parseArgs rejects an unknown flag" {
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" }));
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force=1" }));
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--allow-delete=1" }));
}
test "parseArgs rejects an unknown command" {
@@ -1134,6 +1153,17 @@ test "usage_text lists every command in command_names" {
}
}
test "usage_text names the flags this milestone renamed and describes --config" {
// The flag an operator reaches for is the one the help text names. `--force`
// is gone rather than aliased (greenfield rules), and `--config` no longer
// advertises a default path, because there is none: its presence is the
// whole authority decision.
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, " --allow-delete "));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "--force"));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "default /etc/nxdns/config.zon"));
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, "sole source of configuration"));
}
test "usage writes non-empty text" {
var out: Writer.Allocating = .init(testing.allocator);
defer out.deinit();
@@ -1246,7 +1276,7 @@ test "runUsageError names the fault and prints the usage text" {
}
test "failureExitCode separates a fixable configuration from a runtime failure" {
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0));
try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1));
try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0));
@@ -1302,9 +1332,13 @@ test "failureExitCode keeps no list of its own and classifies through config/fau
}
// The one config-shaped exit 2 `cli` still decides for itself: it reports
// the state of the database, not the content of a file.
try testing.expect(!faults.isConfigFault(error.DatabaseNotEmpty));
try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0));
// what the diff would do to the database, not the content of a file.
try testing.expect(!faults.isConfigFault(error.DestructiveImport));
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
// The managed file goes the other way: `config/loader.zig` converts the
// path class, so the classification — not this function — carries it.
try testing.expectEqual(exit_check, failureExitCode(error.ManagedConfigUnreadable, 0));
}
const fixtures = @import("test_fixtures");
@@ -1457,10 +1491,7 @@ test "check --config naming a missing file is a reported failure at exit 2" {
defer captured.deinit();
const r = captured.runner();
const code = runCheck(r, .{
.paths = .{ .config = env.missing_path },
.config_explicit = true,
}, false);
const code = runCheck(r, .{ .config = env.missing_path }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
@@ -1469,6 +1500,37 @@ test "check --config naming a missing file is a reported failure at exit 2" {
try testing.expectEqualStrings("", captured.err.written());
}
test "bare check with no config database exits 2 and says how to make one" {
// The deleted heuristic's "nothing to check" branch, replaced. The valid
// file sitting in the same directory is the other half of ruling 1: bare
// `check` grades the database, and a file no flag named is not consulted —
// if it were, this run would print "OK: no problems found" instead.
var env: CheckEnv = undefined;
try env.init();
defer env.deinit();
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
const r = captured.runner();
try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
});
const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "no config database at "));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, config_db_name));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK"));
try testing.expectEqualStrings("", captured.err.written());
}
test "check renders a multi-line ZON failure as one FAIL line per message" {
// The rendering used to go inline into a single `FAIL` record, which put
// newlines mid-line and broke the one-line-per-problem promise the rest of
@@ -1486,10 +1548,7 @@ test "check renders a multi-line ZON failure as one FAIL line per message" {
var path_buf: [160]u8 = undefined;
const config_path = try env.path(&path_buf, "config.zon");
const code = runCheck(r, .{
.paths = .{ .config = config_path },
.config_explicit = true,
}, false);
const code = runCheck(r, .{ .config = config_path }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
-141
View File
@@ -1,141 +0,0 @@
//! First-start seeding (PLAN §3.5).
//!
//! A policy wrapper over `import.importFile`, and nothing more. There is exactly
//! one code path from a config file into the database, so bootstrap and
//! `nxdns import` cannot drift apart.
//!
//! The policy is three lines long:
//!
//! - no file → normal steady state, keep the database as it is;
//! - database already configured → the file is ignored, as PLAN §3.5 requires.
//! Configured means an operator put something there. A database that has only
//! answered queries is not configured, however many client rows the DNS path
//! materialised into it, and `import.isEmpty` is where that line is drawn;
//! - otherwise → import it, and a file that is unreadable, unparseable or
//! invalid is an error. The operator wrote that file and meant it; starting
//! with silent defaults instead is the exact failure mode PLAN §1.3 exists to
//! prevent.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../storage/db.zig");
const import = @import("import.zig");
const validate = @import("validate.zig");
const log = std.log.scoped(.config_bootstrap);
pub const Outcome = enum { seeded, db_already_configured, no_config_file };
pub const Error = import.Error || std.Io.Dir.AccessError;
/// Called by `nxdns run` before serving.
pub fn bootstrap(
io: std.Io,
gpa: Allocator,
database: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
diags: *validate.Diagnostics,
) Error!Outcome {
dir.access(io, config_path, .{}) catch |e| switch (e) {
error.FileNotFound => {
log.info("no configuration file at '{s}'; using the database as it is", .{config_path});
return .no_config_file;
},
else => |other| return other,
};
// Deliberately before the read: on every start after the first, the file is
// not even opened.
if (!try import.isEmpty(database)) {
log.info("configuration file ignored; the database is already configured", .{});
return .db_already_configured;
}
try import.importFile(io, gpa, database, dir, config_path, .{ .force = false }, diags);
log.info("seeded the database from '{s}'", .{config_path});
return .seeded;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
//
// All three outcomes are exercised end to end in
// `src/storage/storage_integration_test.zig` (S7) against a real data directory.
// What the two cases below add is the one distinction that decides which outcome
// an operator gets, and it is too important to leave behind a `-Dintegration`
// flag: whether the database has been *configured*, not whether it has been
// *used*.
const testing = std.testing;
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const migrations = @import("../storage/migrations.zig");
const seed_source =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
;
/// Unparseable on purpose: a call that succeeds proves the file was never read.
const broken_source = ".{ .groups = ";
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
test "a server that has answered queries still seeds from its configuration file" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = seed_source });
var database = try openMigrated();
defer database.close();
// The unattended first boot: the server came up on defaults, answered
// traffic, and the operator dropped a config file in afterwards.
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
try testing.expectEqual(Outcome.seeded, outcome);
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Seeding did not cost the operator the device list they had been watching.
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}
test "a client the operator has customised keeps the configuration file out" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = broken_source });
var database = try openMigrated();
defer database.close();
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.5'");
try clients_repo.updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
try testing.expectEqual(Outcome.db_already_configured, outcome);
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
// The name and the flag the operator set are still theirs.
try testing.expectEqual(@as(i64, 1), try database.queryInt(
"SELECT count(*) FROM clients WHERE name = 'tv' AND hand_edited = 1",
));
}
+57 -10
View File
@@ -34,7 +34,7 @@ pub const Error = ReadError || Writer.Error ||
const header =
\\// nxdns configuration
\\// generated by `nxdns export` the database is the source of truth
\\// generated by `nxdns export` from the running configuration
\\
;
@@ -64,11 +64,13 @@ pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items;
cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items;
// `web.password` is operator input and is never stored; the exported file
// always carries an empty one. This is exactly what makes the round trip
// stable: re-importing takes the "password is empty" branch and stores the
// same hash.
cfg.web.password = "";
// `web.password` is operator input and is never stored, so the exported
// file always states it as absent. Absent rather than `""`: a present empty
// password is refused by `validate` (ruling 4), so exporting one would make
// every export fail its own rules. It is also what makes the round trip
// stable — re-applying the file takes the "password_hash written verbatim"
// branch and stores the same hash.
cfg.web.password = null;
return cfg;
}
@@ -250,7 +252,7 @@ test "readConfig, writeConfig, import and readConfig again produce an equal conf
try testing.expectEqual(a.dns.port, b.dns.port);
try testing.expectEqual(a.logging.level, b.logging.level);
try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash);
try testing.expectEqualStrings(a.web.password_hash.?, b.web.password_hash.?);
try testing.expectEqual(a.groups.len, b.groups.len);
try testing.expectEqual(a.upstreams.len, b.upstreams.len);
for (a.upstreams, b.upstreams) |left, right| {
@@ -303,12 +305,57 @@ test "an exported password_hash survives a re-import unchanged" {
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
};
try import.applyToDb(io, gpa, &database, cfg, 42, .{});
var diags: validate.Diagnostics = .init(gpa);
defer diags.deinit();
try import.apply(io, gpa, &database, cfg, 42, .{}, &diags);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const exported = try readConfig(&database, arena_state.allocator());
try testing.expectEqualStrings("", exported.web.password);
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$"));
try testing.expectEqual(@as(?[]const u8, null), exported.web.password);
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash.?, "$argon2id$"));
}
test "the exported password form is the one validate accepts" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
var apply_diags: validate.Diagnostics = .init(gpa);
defer apply_diags.deinit();
try import.apply(io, gpa, &database, .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
}, 42, .{}, &apply_diags);
var out: Writer.Allocating = .init(gpa);
defer out.deinit();
try writeToWriter(gpa, &database, &out.writer);
// The literal form matters: an export carrying `password = ""` beside a
// stored hash would trip `EmptyWebPassword` on the way back in, so export
// would produce a file its own validator refuses.
try testing.expect(std.mem.indexOf(u8, out.written(), ".password = null,") != null);
const source = try gpa.dupeZ(u8, out.written());
defer gpa.free(source);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const reparsed = try std.zon.parse.fromSliceAlloc(
model.Config,
arena_state.allocator(),
source,
null,
.{},
);
var diags: validate.Diagnostics = .init(gpa);
defer diags.deinit();
try validate.validate(reparsed, &diags);
try testing.expectEqual(@as(?[]const u8, null), reparsed.web.password);
try testing.expect(std.mem.startsWith(u8, reparsed.web.password_hash.?, "$argon2id$"));
}
+27 -10
View File
@@ -13,17 +13,26 @@ const validate = @import("validate.zig");
/// `ValidateError` enters as a whole set rather than variant by variant, so a
/// variant added to the validator cannot silently fall through to exit 1. The
/// four extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the seed-file size limit (`ConfigTooLarge`), the
/// composition root's upstream build (`NoUsableUpstreams`) and its certificate
/// load (`BadCertificate`).
/// five extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed
/// file the operator named and this process cannot open
/// (`ManagedConfigUnreadable`, milestone-20 ruling 2), the composition root's
/// upstream build (`NoUsableUpstreams`) and its certificate load
/// (`BadCertificate`).
///
/// Not here on purpose: `error.DatabaseNotEmpty`, which reports the state of
/// the database rather than the content of a file, and is the one config-shaped
/// exit 2 `cli` decides for itself.
/// `ManagedConfigUnreadable` is the only place a missing or unreadable path is a
/// configuration fault, and it is deliberately not `FileNotFound` itself: the
/// operator named that path on the command line, so it is theirs to fix, while a
/// missing file anywhere else stays a runtime failure. `config/loader.zig` owns
/// the conversion and the closed set of open errors that qualify.
///
/// Not here on purpose: `error.DestructiveImport`, which reports what an import
/// would do to the database rather than the content of a file, and is the one
/// config-shaped exit 2 `cli` decides for itself.
const ConfigFault = validate.ValidateError || error{
ParseZon,
ConfigTooLarge,
ManagedConfigUnreadable,
NoUsableUpstreams,
BadCertificate,
};
@@ -94,6 +103,14 @@ test "the faults raised outside the validator are configuration faults" {
try testing.expect(isConfigFault(error.BadCertificate));
}
test "a managed file the operator named and this process cannot open is exit 2" {
// Ruling 2. The general rule below still holds — a bare `FileNotFound` is a
// runtime failure — and this is the one converted form, produced only by
// `config/loader.zig` for a path `--config` named.
try testing.expect(isConfigFault(error.ManagedConfigUnreadable));
try testing.expect(!isConfigFault(error.FileNotFound));
}
test "the seed-file errors that used to exit 1 from run are configuration faults" {
// D1 verbatim: these three reached `run` from a rejected seed file and were
// classified as runtime failures.
@@ -107,9 +124,9 @@ test "a runtime failure is not a configuration fault" {
try testing.expect(!isConfigFault(error.AccessDenied));
try testing.expect(!isConfigFault(error.FileNotFound));
try testing.expect(!isConfigFault(error.AddressInUse));
// A state conflict, not a bad file: `import` refuses to overwrite a
// configured database and decides that exit code itself.
try testing.expect(!isConfigFault(error.DatabaseNotEmpty));
// A verdict on the diff, not on the file: `import` refuses a run that would
// delete rows and decides that exit code itself.
try testing.expect(!isConfigFault(error.DestructiveImport));
// Only ever a warning, so it never reaches an exit code by this route.
try testing.expect(!isConfigFault(error.SourceInNoGroup));
}
+268 -675
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
//! The one way a configuration file becomes a `model.Config`.
//!
//! `nxdns run --config <file>` and `nxdns check --config <file>` must grade the
//! same file the same way, so the read, the error classification and the parse
//! live here rather than once per subcommand. `config/faults.zig` exists for the
//! same reason one layer up: two copies of a rule are two rules.
//!
//! **The classification.** `faults.isConfigFault` deliberately excludes
//! `FileNotFound` and `AccessDenied` in general — a missing file is usually a
//! broken box, not a wrong configuration. The managed file is the one place
//! where the opposite holds: the operator named that path, so a path that does
//! not resolve is a configuration fault (exit 2, `nxdns check` is the next
//! step). Only the path class converts:
//!
//! `FileNotFound`, `AccessDenied`, `PermissionDenied`, `NotDir`, `IsDir`,
//! `SymLinkLoop`, `NameTooLong`, `BadPathName` → `ManagedConfigUnreadable`
//!
//! Everything else `readFileAllocOptions` can return — `SystemResources`, the
//! two fd-quota errors, I/O failures, `OutOfMemory` — propagates unmapped and
//! exits 1. Those are box faults a retry can clear, and the shipped unit carries
//! `RestartPreventExitStatus=2 64`: mapping a transient failure to exit 2 would
//! stop the service permanently on a fault that would have cleared itself.
//!
//! The mapping is a named error set switched exhaustively with
//! `else => |other| return other`, so an error a Zig upgrade adds to
//! `ReadFileAllocError` defaults to exit 1 rather than silently to exit 2.
const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("model.zig");
const validate = @import("validate.zig");
/// The ceiling on a configuration file. A file above it is a configuration
/// fault, not a resource failure: nothing an operator writes by hand comes near
/// 4 MiB, so this is a typo or a wrong path rather than a real config.
pub const max_config_bytes = 4 * 1024 * 1024;
/// Every error `readFileAllocOptions` can hand back, plus the parse.
pub const ReadError = std.Io.Dir.ReadFileAllocError;
/// The open failures that mean the operator's path is wrong rather than the box
/// being broken. Spelled out as a set rather than as switch prongs so that the
/// list is one thing a reader can find and a test can enumerate.
pub const PathFault = error{
FileNotFound,
AccessDenied,
PermissionDenied,
NotDir,
IsDir,
SymLinkLoop,
NameTooLong,
BadPathName,
};
pub const Error = ReadError || error{ ManagedConfigUnreadable, ConfigTooLarge, ParseZon };
/// The classification itself, pure and testable on its own: a path-class
/// failure becomes `ManagedConfigUnreadable`, the size limit becomes
/// `ConfigTooLarge`, and every other member travels unchanged.
pub fn mapReadError(e: ReadError) Error {
return switch (e) {
error.StreamTooLong => error.ConfigTooLarge,
error.FileNotFound,
error.AccessDenied,
error.PermissionDenied,
error.NotDir,
error.IsDir,
error.SymLinkLoop,
error.NameTooLong,
error.BadPathName,
=> error.ManagedConfigUnreadable,
else => |other| other,
};
}
/// The file, NUL-terminated because `std.zon.parse` needs a sentinel and
/// `readFileAlloc` cannot supply one. Errors travel exactly as the filesystem
/// returned them: `nxdns import` reads an operator-supplied argument, not a
/// managed file, and its exit codes are its own.
pub fn readSource(
io: std.Io,
gpa: Allocator,
dir: std.Io.Dir,
path: []const u8,
) ReadError![:0]u8 {
return dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0);
}
/// `readSource` under the managed-file classification, with the reason recorded
/// as a diagnostic. A Zig error carries no text, so the path an operator has to
/// go and fix reaches them through `Diagnostics` — the same channel every other
/// configuration problem travels down, and the reason `check` and `run` print
/// these in one shape.
pub fn readManaged(
io: std.Io,
gpa: Allocator,
dir: std.Io.Dir,
path: []const u8,
diags: *validate.Diagnostics,
) Error![:0]u8 {
return readSource(io, gpa, dir, path) catch |e| {
switch (e) {
error.StreamTooLong => try diags.add(
error.ConfigTooLarge,
"{s}",
.{path},
"larger than {d} bytes",
.{max_config_bytes},
),
error.FileNotFound => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"no such file",
.{},
),
error.AccessDenied, error.PermissionDenied => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"not readable",
.{},
),
error.IsDir => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"is a directory, not a configuration file",
.{},
),
error.NotDir, error.SymLinkLoop, error.NameTooLong, error.BadPathName => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"cannot be opened ({s})",
.{@errorName(e)},
),
// A box fault. It exits 1 with its own name and records nothing: a
// diagnostic would file it under "the configuration is wrong".
else => {},
}
return mapReadError(e);
};
}
/// The line and column of a ZON syntax error are the only thing the operator can
/// act on, so they travel the same channel as every other config problem: the
/// caller's `Diagnostics`, which `run`, `check` and `import` all render. The
/// global log is not that channel — an operator reading command output would see
/// a bare `ParseZon` and nothing else.
///
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
/// problem, plus a "note:" line each, so each rendered line becomes one
/// `Problem` and the list keeps the parser's order. Rendering them inline would
/// put newlines inside a single `FAIL` record.
pub fn reportParseFailure(
diags: *validate.Diagnostics,
zon_diag: *const std.zon.parse.Diagnostics,
) error{OutOfMemory}!void {
const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag});
defer diags.gpa.free(rendered);
var lines = std.mem.splitScalar(u8, rendered, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
try diags.add(error.ParseZon, "config", .{}, "{s}", .{line});
}
}
/// The parse, with its failure rendered. The result is arena-owned and
/// `std.zon.parse.free` is NEVER called on it: `Parser.parseStruct` fills an
/// absent field by copying the struct's default straight through
/// (parse.zig:874), so a defaulted `[]const u8` — and this model has many
/// non-empty string defaults — points into the binary's read-only data.
/// `parse.free` keeps no record of which fields were parsed and which were
/// defaulted, so it would `@memset` and free rodata. Freeing the arena is the
/// only correct release.
pub fn parse(
arena: Allocator,
source: [:0]const u8,
diags: *validate.Diagnostics,
) error{ ParseZon, OutOfMemory }!model.Config {
var zon_diag: std.zon.parse.Diagnostics = .{};
return std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.ParseZon => {
try reportParseFailure(diags, &zon_diag);
return error.ParseZon;
},
};
}
/// Read and parse the managed file, everything a caller needs before
/// `validate.validate`. Validation is deliberately left to the caller: `check`
/// runs it beside its certificate and upstream probes, `run` runs it alone, and
/// both call the same validator on the same `Config`, which is what makes the
/// two agree.
///
/// `arena` owns both the source text and the returned configuration.
pub fn load(
io: std.Io,
arena: Allocator,
dir: std.Io.Dir,
path: []const u8,
diags: *validate.Diagnostics,
) Error!model.Config {
const source = try readManaged(io, arena, dir, path, diags);
return parse(arena, source, diags);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "every path-class open failure is a managed-config fault" {
inline for (@typeInfo(PathFault).error_set.?) |member| {
const e = @field(ReadError, member.name);
try testing.expectEqual(error.ManagedConfigUnreadable, mapReadError(e));
}
}
test "a box fault outside the path class propagates unmapped" {
// Each of these exits 1: a retry can clear them, and the shipped unit's
// `RestartPreventExitStatus=2 64` would make exit 2 permanent.
try testing.expectEqual(error.SystemResources, mapReadError(error.SystemResources));
try testing.expectEqual(error.ProcessFdQuotaExceeded, mapReadError(error.ProcessFdQuotaExceeded));
try testing.expectEqual(error.SystemFdQuotaExceeded, mapReadError(error.SystemFdQuotaExceeded));
try testing.expectEqual(error.OutOfMemory, mapReadError(error.OutOfMemory));
try testing.expectEqual(error.InputOutput, mapReadError(error.InputOutput));
}
test "the size limit is its own fault, not an unreadable path" {
try testing.expectEqual(error.ConfigTooLarge, mapReadError(error.StreamTooLong));
}
test "the path class is exactly the eight members the ruling names" {
// A member added to `PathFault` without a decision recorded in the spec
// fails here rather than quietly moving an exit code from 1 to 2.
const expected = [_][]const u8{
"FileNotFound", "AccessDenied", "PermissionDenied", "NotDir",
"IsDir", "SymLinkLoop", "NameTooLong", "BadPathName",
};
const members = @typeInfo(PathFault).error_set.?;
try testing.expectEqual(expected.len, members.len);
inline for (members) |member| {
var found = false;
for (expected) |name| {
if (std.mem.eql(u8, name, member.name)) found = true;
}
try testing.expect(found);
}
}
test "a missing managed file records the path and the reason" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try testing.expectError(
error.ManagedConfigUnreadable,
load(testing.io, arena_state.allocator(), tmp.dir, "nope.zon", &diags),
);
try testing.expectEqual(@as(usize, 1), diags.failureCount());
try testing.expectEqualStrings("nope.zon", diags.problems.items[0].path);
try testing.expectEqualStrings("no such file", diags.problems.items[0].message);
}
test "a directory named as the managed file is a configuration fault, not a crash" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(testing.io, "sub");
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try testing.expectError(
error.ManagedConfigUnreadable,
load(testing.io, arena_state.allocator(), tmp.dir, "sub", &diags),
);
try testing.expectEqual(@as(usize, 1), diags.failureCount());
}
test "load parses a valid file and renders a syntax error line by line" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "good.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
});
try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad.zon", .data = ".{ .groups = " });
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var good_diags: validate.Diagnostics = .init(testing.allocator);
defer good_diags.deinit();
const cfg = try load(testing.io, arena, tmp.dir, "good.zon", &good_diags);
try testing.expectEqual(@as(usize, 0), good_diags.problems.items.len);
try testing.expectEqual(@as(usize, 1), cfg.upstreams.len);
var bad_diags: validate.Diagnostics = .init(testing.allocator);
defer bad_diags.deinit();
try testing.expectError(
error.ParseZon,
load(testing.io, arena, tmp.dir, "bad.zon", &bad_diags),
);
try testing.expect(bad_diags.failureCount() >= 1);
try testing.expectEqualStrings("config", bad_diags.problems.items[0].path);
}
+76 -11
View File
@@ -87,10 +87,16 @@ pub const Web = struct {
enabled: bool = true,
bind: []const u8 = "0.0.0.0",
port: u16 = 8080,
/// Operator input only. Never a settings row, always exported as "".
password: []const u8 = "",
/// argon2id PHC string; "" disables authentication.
password_hash: []const u8 = "",
/// Operator input only. Never a settings row, never exported.
///
/// Optional because absence and emptiness are different declarations: null
/// means "the file says nothing about the password, keep the stored hash",
/// while a present value is an instruction to set one.
password: ?[]const u8 = null,
/// argon2id PHC string. Null means "the file says nothing, keep what is
/// stored"; an explicit `""` is the documented way to disable
/// authentication.
password_hash: ?[]const u8 = null,
session_ttl_hours: u16 = 24,
api_rate_limit_per_min: u32 = 300,
/// Requests from the box itself skip the API rate limit. On by default: a
@@ -387,9 +393,23 @@ fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
/// `web.password` is operator input, never a settings row: it is hashed into
/// `web.password_hash` at import time and discarded (S2.5).
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool {
/// The skip policy splits by direction, because encode and decode need
/// different sets.
///
/// `web.password` is operator input and is skipped both ways: it is hashed into
/// `web.password_hash` and discarded (S2.5).
///
/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns
/// that settings row directly — ruling 4 of milestone 20 makes absence mean
/// "keep the stored hash", which a general encode pass cannot express. Skipping
/// it on decode as well would leave `cfg.web.password_hash` null on every read
/// path, turn `auth.authEnabled` false, and silently open the admin UI.
fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
if (!std.mem.eql(u8, section, "web")) return false;
return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash");
}
fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
@@ -416,6 +436,10 @@ fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
.pointer => text,
// A stored key is a present value, so an optional field decodes to a
// non-null one; the null stays reserved for the absent key, which never
// reaches this function at all.
.optional => |info| try decodeValue(info.child, text),
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
@@ -441,7 +465,7 @@ pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair))
if (comptime isScalarSection(section_field.type)) {
const section = @field(cfg, section_field.name);
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
const value = try encodeValue(field.type, @field(section, field.name), gpa);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
@@ -465,7 +489,7 @@ pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usi
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isDecodeSkipped(section_field.name, field.name)) {
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
@field(@field(cfg, section_field.name), field.name) =
try decodeValue(field.type, pair.value);
@@ -531,7 +555,6 @@ const expected_keys = [_][]const u8{
"web.api_rate_limit_per_min",
"web.bind",
"web.enabled",
"web.password_hash",
"web.port",
"web.session_ttl_hours",
"web.sse_max_connections_per_ip",
@@ -642,7 +665,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
const a = @field(@field(original, section_field.name), field.name);
const b = @field(@field(restored, section_field.name), field.name);
if (comptime @typeInfo(field.type) == .pointer) {
@@ -671,6 +694,48 @@ test "an unknown settings key is counted and not an error" {
try testing.expectEqual(@as(usize, 2), unknown);
}
test "web.password_hash decodes from the settings table but is never encoded" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
// Encode: the reconciler owns that row, so no pass over the model emits it.
const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs);
for (pairs.items) |pair| {
try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
}
// Decode: every read path still sees the stored hash, or `authEnabled`
// would read false on a box that has a password set.
var cfg: Config = .{};
var unknown: usize = 0;
const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }};
try fromSettings(&stored, &cfg, &unknown);
try testing.expectEqual(@as(usize, 0), unknown);
try testing.expectEqualStrings(hash, cfg.web.password_hash.?);
}
test "an optional settings field is null when absent and non-null when present" {
var absent: Config = .{};
var unknown: usize = 0;
const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
try fromSettings(&other, &absent, &unknown);
try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash);
// An explicit empty string is a present value, not an absent key: it is how
// a config file disables authentication.
var empty: Config = .{};
const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }};
try fromSettings(&disabled, &empty, &unknown);
try testing.expect(empty.web.password_hash != null);
try testing.expectEqualStrings("", empty.web.password_hash.?);
}
test "a malformed settings value is BadSettingValue" {
var cfg: Config = .{};
var unknown: usize = 0;
File diff suppressed because it is too large Load Diff
+56 -2
View File
@@ -101,6 +101,7 @@ pub const ValidateError = error{
MissingKeyPath,
MissingLogPath,
PasswordAndHashBothSet,
EmptyWebPassword,
};
/// What `validate` returns: a verdict on the configuration, or the allocation
@@ -114,7 +115,19 @@ pub const Error = ValidateError || Allocator.Error;
/// same channel so a syntax error's line/column reaches the operator's output,
/// plus the warnings — which are never returned by `validate` and so are not
/// `ValidateError` members.
pub const ProblemError = ValidateError || error{ ParseZon, SourceInNoGroup };
/// Wider than `ValidateError`: the diagnostic channel also carries the problems
/// found before the validator ever sees a `Config` — the ZON parse, the managed
/// file that would not open (`config/loader.zig`), the file above the size limit
/// — and the one found after it, an import whose diff would delete rows. The
/// validator itself records only `ValidateError` members, which is what makes
/// `validate`'s `@errorCast` of its own findings checked-safe.
pub const ProblemError = ValidateError || error{
ParseZon,
SourceInNoGroup,
ManagedConfigUnreadable,
ConfigTooLarge,
DestructiveImport,
};
/// `.fail` rejects the configuration and is what an exit code is computed from.
/// `.warn` reports something legal that is almost certainly not what the
@@ -392,7 +405,10 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
try checkBind(diags, cfg.web.bind, "web.bind", .any);
try checkPort(diags, cfg.web.port, "web.port");
if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) {
// Both fields are optional, and absence is the third state: a file that
// states neither keeps the stored hash. So the test is on presence, not on
// length.
if (cfg.web.password != null and cfg.web.password_hash != null) {
try diags.add(
error.PasswordAndHashBothSet,
"web.password",
@@ -401,6 +417,22 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
.{},
);
}
// A present-but-empty password would hash the empty string into a non-empty
// PHC — authentication on — while every login with an empty password is
// refused: authentication on and unreachable. The remedy is named, because
// the operator who wrote this meant one of two other things.
if (cfg.web.password) |password| {
if (password.len == 0) {
try diags.add(
error.EmptyWebPassword,
"web.password",
.{},
"password is set to the empty string; omit the field to keep the stored password, " ++
"or set password_hash = \"\" to disable authentication",
.{},
);
}
}
// A session TTL is a TTL; `BadTtl` is its bucket.
if (cfg.web.session_ttl_hours < 1) {
try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 1", .{});
@@ -1968,6 +2000,28 @@ test "error.PasswordAndHashBothSet" {
try expectProblem(cfg, error.PasswordAndHashBothSet, "web.password");
}
test "error.EmptyWebPassword names password_hash as the way to disable auth" {
var cfg = baseConfig();
cfg.web.password = "";
try expectProblem(cfg, error.EmptyWebPassword, "web.password");
// The remedy has to be in the text: the operator who wrote `password = ""`
// meant either "keep the current one" or "turn authentication off", and the
// diagnostic is the only place that distinction is spelled out.
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(error.EmptyWebPassword, validate(cfg, &diags));
try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "password_hash = \"\"") != null);
// Absence is not emptiness: a file that states no password is legal and
// means "keep the stored hash".
var absent = baseConfig();
absent.web.password = null;
var quiet: Diagnostics = .init(testing.allocator);
defer quiet.deinit();
try validate(absent, &quiet);
}
test "a config with five distinct problems yields five diagnostics and the first error" {
var cfg = baseConfig();
cfg.dns.port = 0; // BadPort, first in check order
+113
View File
@@ -22,6 +22,7 @@ const build_options = @import("build_options");
const net = std.Io.net;
const model = @import("../config/model.zig");
const reconcile = @import("../config/reconcile.zig");
const db = @import("../storage/db.zig");
const migrations = @import("../storage/migrations.zig");
const context = @import("../storage/repositories/context.zig");
@@ -400,6 +401,10 @@ const HttpFixture = struct {
server: net.Server,
body: []const u8,
route: std.atomic.Value(u8),
/// Connections accepted, whatever came over them. A test that claims a pass
/// downloaded nothing reads this rather than the route counters: a refetch
/// that failed on the wire is still a refetch, and this counts it.
accepted: std.atomic.Value(u32),
/// How many parts the `chunked` route has flushed. The test reads it to
/// prove the reply really left this server in pieces, because a `Writer`
/// reports a buffered part as written and would otherwise hide a fixture
@@ -422,6 +427,7 @@ const HttpFixture = struct {
.server = try local.listen(io, .{ .reuse_address = true }),
.body = body,
.route = .init(@intFromEnum(Route.body)),
.accepted = .init(0),
.flushed_parts = .init(0),
.stall_reached = .unset,
.stall_release = .unset,
@@ -449,6 +455,7 @@ const HttpFixture = struct {
while (true) {
var stream = self.server.accept(io) catch return;
defer stream.close(io);
_ = self.accepted.fetchAdd(1, .monotonic);
var read_buf: [8192]u8 = undefined;
var write_buf: [8192]u8 = undefined;
@@ -1372,6 +1379,112 @@ test "10d: a source deleted mid-refresh does not take the refresh's temporary fi
}
}
// ---------------------------------------------------------------------------
// 10e: the restart invariant, across the config engine and the filter layer
// ---------------------------------------------------------------------------
test "10e: a reconcile then a restart reuses the compiled files and downloads nothing" {
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);
// 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(@as(u32, 1), fixture.accepted.load(.monotonic));
var dir = try env.blocklistDir();
defer dir.close(io);
var list_buf: [64]u8 = undefined;
var wild_buf: [64]u8 = undefined;
const list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id});
const wild_name = try std.fmt.bufPrint(&wild_buf, "{d}.wild", .{id});
const list_before = try dir.statFile(io, list_name, .{});
const wild_before = try dir.statFile(io, wild_name, .{});
// File mode, declaring exactly what the database already holds. The engine
// has to recognise the source by its url and leave the row where it is:
// the compiled files are named after that id, and the manager looks for
// them under the same number.
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.blocklist_sources = &.{.{ .url = url, .name = source_name }},
.group_sources = &.{.{ .group = "default", .source_url = url }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
};
var pass = try reconcile.begin(
io,
gpa,
&env.database,
cfg,
std.Io.Clock.real.now(io).toSeconds(),
.{},
);
errdefer pass.rollback();
// Committed before the manager comes up, which is the ordering the invariant
// rests on: a manager that read the table mid-transaction could see either
// half of a source it is about to look for on disk.
try pass.commit();
// The restart. The old manager is gone and a new one comes up over the same
// directory and the same database with nothing carried across in memory.
// `runScheduler` is the boot sequence the server runs — the orphan sweep,
// then the startup pass — and a disabled update makes it return rather than
// wait out an interval.
env.mgr.deinit(io);
env.mgr = try manager.Manager.init(
gpa,
&env.database,
.{ .dir = env.tmp.dir },
&env.f,
.{ .enabled = false },
budget,
);
try env.mgr.runScheduler(io);
// Nothing was downloaded. The server is still listening, so this is a
// decision the pass made rather than a connection it could not have opened.
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
// The same two files: not recompiled, and not swept as orphans and written
// back.
const list_after = try dir.statFile(io, list_name, .{});
const wild_after = try dir.statFile(io, wild_name, .{});
try testing.expectEqual(list_before.inode, list_after.inode);
try testing.expectEqual(list_before.mtime, list_after.mtime);
try testing.expectEqual(wild_before.inode, wild_after.inode);
try testing.expectEqual(wild_before.mtime, wild_after.mtime);
// The row kept the id those files are named after, and the snapshot the
// restart published is the one compiled from them.
var rows = try listRows(&env.database);
defer rows.deinit();
try testing.expectEqual(id, (try rows.byUrl(url)).id);
try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
// Asserted last, after the behaviour it explains: the engine wrote no row
// at all, which is why the id above survived and why the restart above had
// files to find.
try testing.expectEqual(@as(u32, 0), pass.summary.sources.total());
const decision, _ = try env.evaluate("ads.example.com");
try testing.expect(decision.blocked);
try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason);
}
// ---------------------------------------------------------------------------
// 1112: local records, from the database to the wire
// ---------------------------------------------------------------------------
+42
View File
@@ -1199,6 +1199,14 @@ pub const Manager = struct {
if (state != .ok) return true;
const last = row.last_updated orelse return true;
// The Pi has no RTC, so a fetch stamped while the clock ran ahead of
// real time (a pre-NTP boot, a restored image) leaves a `last_updated`
// in the future. Plain interval arithmetic would then suspend every
// refresh until real time caught up with the poison stamp, and the
// reconcile engine preserves runtime columns faithfully, so nothing
// else would ever clear it. A stamp from the future is not evidence of
// a recent fetch.
if (last > now) return true;
return now - last >= model.updateIntervalSeconds(self.update);
}
@@ -1674,6 +1682,40 @@ test "acquire before any reload returns null and holds no lock" {
manager.lock.unlock(io);
}
test "needsRefresh treats a last_updated in the future as due" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var f: fetcher.Fetcher = undefined;
var manager = try testManager(&database, &f);
defer manager.deinit(io);
// `.ok` is the only state that consults the clock at all; every other one
// is already due, so the arithmetic below would be unreachable without it.
var statuses = [_]SourceStatus{.{ .id = 1, .state = .ok }};
manager.statuses = &statuses;
defer manager.statuses = &.{};
const row = testRow(1, true);
const stamp = row.last_updated.?;
const interval = model.updateIntervalSeconds(manager.update);
// The ordinary cases still hold: fresh is not due, stale is.
try testing.expect(!manager.needsRefresh(io, row, stamp + 1));
try testing.expect(manager.needsRefresh(io, row, stamp + interval));
// The Pi has no RTC. A fetch stamped while the clock ran ahead of real
// time leaves `now - last` negative, which reads as "fetched moments ago"
// and suspends every refresh until real time catches the poison stamp —
// for a whole day here, and for as long as the clock was wrong in general.
try testing.expect(manager.needsRefresh(io, row, stamp - 1));
try testing.expect(manager.needsRefresh(io, row, stamp - 86_400));
}
test "the disk gate skips a scheduled refresh only while writes are critical" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+106 -20
View File
@@ -47,7 +47,7 @@ const licenses = @import("licenses_files");
/// rolldown for the CommonJS interop helpers it prepends to any chunk pulling in
/// a CommonJS module. Every other devDependency left `web/dist` untouched.
/// Anything added here must also be inventoried.
const npm_generators = [_][]const u8{ "rolldown", "tailwindcss", "vite" };
const npm_generators = [_][]const u8{ "rolldown", "vite" };
/// Packages of the recorded runtime closure that put no byte in `web/dist`, so
/// the inventory names them in a note rather than carrying their licence. The
@@ -57,7 +57,23 @@ const npm_generators = [_][]const u8{ "rolldown", "tailwindcss", "vite" };
/// This list is a claim about the build, and the build is what settles it: a
/// name here that turns up in the recorded `web/dist` bundle fails, so a
/// package that starts shipping cannot stay on this list quietly.
const npm_not_shipped = [_][]const u8{ "cookie-es", "isbot", "seroval", "seroval-plugins" };
const npm_not_shipped = [_][]const u8{
"cookie-es",
"isbot",
"seroval",
"seroval-plugins",
"css-mediaquery",
"invariant",
"js-tokens",
"loose-envify",
"@internationalized/date",
"@internationalized/number",
"@react-types/shared",
"@swc/helpers",
"aria-hidden",
"client-only",
"tslib",
};
/// Components the shipped artifacts contain that no dependency file mentions,
/// so no automatic check would ever notice their disappearance from the
@@ -71,7 +87,6 @@ const required_components = [_][]const u8{
"p256-m",
"React",
"TanStack",
"Tailwind",
"Vite",
"Rolldown",
"Mozilla CA certificate bundle",
@@ -103,15 +118,61 @@ const ZigDependencyVersion = struct {
},
};
/// The licence every *shipped* npm package carries today, and the licence of
/// every npm text under `licenses/`. The identity file records a licence token
/// per package; nothing used to read it, so a package that relicensed passed as
/// long as its version had not moved. A token other than this one needs its own
/// licence text and a decision about EUPL-1.2 compatibility, which is a human
/// review, not a paste. The `npm_not_shipped` packages are exempt — they
/// redistribute nothing, and one of them is Unlicense.
/// The licence each npm package of the recorded sets carries today. The identity
/// file records a licence token per package; nothing used to read it, so a
/// package that relicensed passed as long as its version had not moved.
///
/// MIT is the default because almost every package here is MIT, so only the
/// exceptions are written down. A package whose recorded token stops matching
/// its expectation fails — including the packages below, which is the point of
/// naming them rather than waving them through: a new licence on any of them is
/// a new decision, not a fact to absorb quietly.
///
/// Adding a name here is a human review, not a paste. A licence this project has
/// not taken before needs its own text under `licenses/`, an inventory entry
/// that says who accepted it and when, and whatever that licence's attribution
/// terms require. Apache-2.0 arrived that way: Mokhtar Mial accepted it inbound
/// on 2026-08-12, and `licenses/react-aria-apache-2.0.txt` carries the text
/// Section 4 asks for.
///
/// Packages that put no byte in `web/dist` are here too. They redistribute
/// nothing today, so their licence carries no obligation today — but "not
/// shipped" is a claim about the build that a future build can falsify, and
/// recording the licence now means the answer is already reviewed when it does.
const npm_expected_licence = "MIT";
const npm_licence_exceptions = [_]NpmLicence{
// Shipped: React Aria and the one @internationalized package it pulls into
// the bundle.
.{ .name = "react-aria-components", .licence = "Apache-2.0" },
.{ .name = "react-aria", .licence = "Apache-2.0" },
.{ .name = "react-stately", .licence = "Apache-2.0" },
.{ .name = "@internationalized/string", .licence = "Apache-2.0" },
// Not shipped: the rest of the React Aria closure.
.{ .name = "@internationalized/date", .licence = "Apache-2.0" },
.{ .name = "@internationalized/number", .licence = "Apache-2.0" },
.{ .name = "@react-types/shared", .licence = "Apache-2.0" },
.{ .name = "@swc/helpers", .licence = "Apache-2.0" },
.{ .name = "tslib", .licence = "0BSD" },
// Not shipped: reached only through @tanstack/router-core's server paths
// and @stylexjs/stylex's compiler.
.{ .name = "isbot", .licence = "Unlicense" },
.{ .name = "css-mediaquery", .licence = "BSD" },
};
const NpmLicence = struct {
name: []const u8,
licence: []const u8,
};
/// The reviewed licence for one package: its exception, or MIT.
fn expectedLicence(name: []const u8) []const u8 {
for (npm_licence_exceptions) |entry| {
if (std.mem.eql(u8, entry.name, name)) return entry.licence;
}
return npm_expected_licence;
}
/// The two licence texts that must be reproduced in full, pinned by content.
/// The marker-string probes below prove the right *document* is present; they
/// cannot tell a complete Apache-2.0 from one with its middle sections deleted,
@@ -481,22 +542,21 @@ test "the CA bundle entry names the Alpine release the Dockerfile pins" {
}
}
test "every npm package in the recorded closure ships under the licence the inventory assumes" {
test "every npm package in the recorded closure carries the licence the inventory expects" {
for ([_][]const u8{ npm_section, generator_section }) |header| {
const recorded = try recordedSection(header);
var lines = std.mem.tokenizeScalar(u8, recorded, '\n');
while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse return error.MalformedIdentityLine;
// A package that puts no byte in web/dist redistributes nothing, so
// its licence carries no obligation. isbot is Unlicense and is one
// of these.
if (isNotShipped(pkg.name)) continue;
if (!std.mem.eql(u8, pkg.licence, npm_expected_licence)) {
const expected = expectedLicence(pkg.name);
if (!std.mem.eql(u8, pkg.licence, expected)) {
std.debug.print(
"npm package '{s}' is recorded under '{s}', not {s}. Every npm licence text under" ++
" licenses/ is an {s} text, so this package needs one of its own and a" ++
" decision about EUPL-1.2 compatibility.\n",
.{ pkg.name, pkg.licence, npm_expected_licence, npm_expected_licence },
"npm package '{s}' is recorded under '{s}', but the reviewed expectation is" ++
" '{s}'. Work out what the new licence means for the shipped artifacts, give" ++
" it a text under licenses/ and an inventory entry naming who accepted it if" ++
" this project has not taken it before, then record the new expectation in" ++
" npm_licence_exceptions.\n",
.{ pkg.name, pkg.licence, expected },
);
return error.NpmLicenceChanged;
}
@@ -504,6 +564,32 @@ test "every npm package in the recorded closure ships under the licence the inve
}
}
test "every npm licence exception names a package the identity file records" {
const closure = try recordedSection(npm_section);
const generators = try recordedSection(generator_section);
for (npm_licence_exceptions) |entry| {
// A stale exception is how a package quietly regains the MIT default
// after it leaves the tree and comes back under a different licence.
if (recordsPackage(closure, entry.name) or recordsPackage(generators, entry.name)) continue;
std.debug.print(
"npm_licence_exceptions names '{s}', which licenses/dependency-identity.txt no longer" ++
" records. Drop the exception when the package leaves the closure.\n",
.{entry.name},
);
return error.StaleLicenceException;
}
}
/// Whether a recorded `<package> <version> <licence>` section names a package.
fn recordsPackage(section: []const u8, name: []const u8) bool {
var lines = std.mem.tokenizeScalar(u8, section, '\n');
while (lines.next()) |line| {
const pkg = parseRecordedPackage(line) orelse continue;
if (std.mem.eql(u8, pkg.name, name)) return true;
}
return false;
}
test "the licence texts that must be reproduced in full are unmodified" {
for (pinned_texts) |pinned| {
const body = textBody(pinned.file) orelse {
+5 -3
View File
@@ -965,10 +965,12 @@ test "S7 case 11: the app boots, serves a query and exits zero on shutdown" {
shutdown.reset();
defer shutdown.reset();
var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{ .paths = .{
.data_dir = root,
// `--config` present: the file is authority and the database is converged
// onto it before the listeners bind (milestone-20 ruling 1).
var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{
.paths = .{ .data_dir = root },
.config = config_path,
} } });
} });
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
const client = try client_address.bind(test_io, .{ .mode = .dgram });
+24 -21
View File
@@ -1,5 +1,5 @@
//! The `config.db` schema, verbatim from PLAN §11.2, plus the two table orders
//! every other storage session needs.
//! The `config.db` schema, verbatim from PLAN §11.2, plus the table lists every
//! other storage session needs.
//!
//! The DDL text is data, not code: `migrations.zig` carries it as step 1 and
//! never edits it in place. A schema change is a *new* step with new DDL, so
@@ -89,8 +89,10 @@ pub const ddl_v1: [:0]const u8 =
\\CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
;
/// Child-before-parent. Used by import's wipe step; correct under
/// `foreign_keys = ON`.
/// Child-before-parent, and correct under `foreign_keys = ON`. The reconcile
/// engine deletes in this order so that every declarative child of a dying
/// parent is removed — and counted — before the parent goes, which keeps the FK
/// cascades a safety net rather than the accountant.
///
/// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign
/// keys, so their position is free; `groups` and `blocklist_sources` must come
@@ -102,18 +104,28 @@ pub const delete_order = [_][]const u8{
"blocklist_sources", "groups",
};
/// Every table whose emptiness defines "the database has never been configured"
/// (S5.2). `groups` is absent because migration step 1 seeds `(1, 'default')`,
/// so an empty database still holds one group row; `schema_version` is absent
/// for the same reason.
pub const content_tables = [_][]const u8{
"clients", "client_prefixes", "upstreams", "blocklist_sources",
"group_sources", "rules", "local_records", "forward_zones",
"settings",
/// Every table that holds configuration, in a fixed order. It includes
/// `groups`, because a byte-stability dump has to be able to see a reconcile
/// that renumbered a group.
///
/// `schema_version` is absent: it is the migration's, not the operator's.
pub const table_names = [_][]const u8{
"groups", "clients", "client_prefixes", "upstreams",
"blocklist_sources", "group_sources", "rules", "local_records",
"forward_zones", "settings",
};
const testing = std.testing;
test "table_names names exactly the tables delete_order does" {
try testing.expectEqual(delete_order.len, table_names.len);
for (delete_order) |name| {
try testing.expect(indexOf(&table_names, name) != null);
}
try testing.expect(indexOf(&table_names, "groups") != null);
try testing.expect(indexOf(&table_names, "schema_version") == null);
}
test "delete_order lists every referrer before the table it references" {
// The two parents in the schema. Every child that references them must be
// deleted first, or `foreign_keys = ON` turns import's wipe into a
@@ -130,15 +142,6 @@ test "delete_order lists every referrer before the table it references" {
}
}
test "content_tables is delete_order without groups" {
try testing.expectEqual(delete_order.len - 1, content_tables.len);
for (content_tables) |name| {
try testing.expect(indexOf(&delete_order, name) != null);
}
try testing.expect(indexOf(&content_tables, "groups") == null);
try testing.expect(indexOf(&content_tables, "schema_version") == null);
}
fn indexOf(haystack: []const []const u8, needle: []const u8) ?usize {
for (haystack, 0..) |item, i| {
if (std.mem.eql(u8, item, needle)) return i;
+10
View File
@@ -57,6 +57,7 @@ pub const c = struct {
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
pub extern fn sqlite3_total_changes(db: *Sqlite3) c_int;
};
/// Result codes, from the vendored `sqlite3.h` (3.53.4).
@@ -429,6 +430,15 @@ pub const Db = struct {
pub fn changes(self: *Db) i64 {
return c.sqlite3_changes(self.handle);
}
/// Every row this connection has inserted, updated or deleted since it was
/// opened. Monotonic, so a caller proves "this call wrote nothing" by
/// reading it either side and comparing — which is stronger than comparing
/// content, because an UPDATE that rewrites identical values still moves
/// this counter.
pub fn totalChanges(self: *Db) i64 {
return c.sqlite3_total_changes(self.handle);
}
};
fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 {
+2 -2
View File
@@ -353,7 +353,7 @@ test "readVersion reads a file database through an immutable open, writing nothi
try testing.expectEqual(@as(u32, 1), try readVersion(&database));
}
test "delete_order and content_tables name exactly the tables the schema creates" {
test "delete_order and table_names name exactly the tables the schema creates" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
@@ -361,7 +361,7 @@ test "delete_order and content_tables name exactly the tables the schema creates
for (config_schema.delete_order) |name| {
try testing.expect(try tableExists(&database, name));
}
for (config_schema.content_tables) |name| {
for (config_schema.table_names) |name| {
try testing.expect(try tableExists(&database, name));
}
// delete_order covers every table except `schema_version`.
+69 -7
View File
@@ -3,13 +3,14 @@
//! `listClients` returns only `hand_edited = 1` rows. A client the server
//! materialised from live traffic is runtime state, not configuration, and must
//! not appear in an export. `hand_edited` is the only marker of operator intent
//! in this table, so it also decides what `import.isEmpty` counts: a database
//! carrying nothing but materialised rows has never been configured, and a seed
//! file must still be able to fill it. `countClients` counts **all** rows and is
//! a test helper — it deliberately does not answer that question.
//! in this table, so it also decides what the reconcile engine may delete: a
//! declared row the file drops is removed, an observed row is kept whatever the
//! file says, and declaring an observed address promotes that row in place.
//! `countClients` counts **all** rows and is a test helper.
//!
//! The import path is list / insert / deleteAll / count, plus the two runtime
//! calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s tracker owns.
//! The configuration path is list / insert / update / delete / count, plus the
//! two runtime calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s
//! tracker owns.
//! The REST surface is the third section: it speaks row ids and shows
//! every client, materialised ones included.
@@ -126,7 +127,7 @@ pub fn deleteAllClients(database: *db.Db) db.Error!void {
}
/// Counts every row, including the materialised ones `listClients` filters out.
/// Used by tests; `import.isEmpty` counts operator intent instead.
/// Used by tests.
pub fn countClients(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM clients");
}
@@ -407,6 +408,67 @@ pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput)
try tx.commit();
}
// ---------------------------------------------------------------------------
// reconcile surface (milestone 20)
// ---------------------------------------------------------------------------
//
// `replaceClientPrefixes` above is the REST list resource: one atomic swap of
// the whole table, in a transaction of its own. The reconcile engine cannot use
// it — it runs inside a transaction already, and rewriting every row would
// forfeit the row ids and the zero-writes property the engine exists for — so
// it edits and removes prefixes one at a time instead.
/// Writes the two columns a prefix row carries besides its identity.
///
/// `error.NotFound`: no prefix holds `id`. `error.Constraint`:
/// `client_prefixes.prefix` is UNIQUE, or `group_id` names no group.
pub fn updateClientPrefix(database: *db.Db, id: i64, item: ClientPrefixInput) db.Error!void {
var stmt = try database.prepare(
"UPDATE client_prefixes SET prefix = ?2, group_id = ?3, priority = ?4 WHERE id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.prefix);
try stmt.bindInt(3, item.group_id);
try stmt.bindInt(4, item.priority);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no prefix holds `id`. Nothing references
/// `client_prefixes`, so a delete cannot violate a constraint.
pub fn deleteClientPrefix(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM client_prefixes WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
/// Moves the observed clients of one group to another, and reports how many
/// rows moved.
///
/// `clients.group_id` references `groups(id)` with no `ON DELETE` action
/// (config_schema.zig:26), so a group that any client still sits in cannot be
/// deleted. When a configuration stops declaring a group, its *declared*
/// clients go with it, but the devices the DNS path materialised into it did
/// not come from the configuration and must not be deleted for a decision that
/// was never about them. They move to the default group, which is also the
/// semantics the operator asked for: they un-declared the group, not the
/// devices.
///
/// `hand_edited = 1` rows are untouched — those are configuration, and the
/// reconcile engine has already accounted for them.
pub fn reassignObservedClients(database: *db.Db, from_group_id: i64, to_group_id: i64) db.Error!u32 {
var stmt = try database.prepare(
"UPDATE clients SET group_id = ?2 WHERE hand_edited = 0 AND group_id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, from_group_id);
try stmt.bindInt(2, to_group_id);
try stmt.exec();
const moved = database.changes();
return @intCast(@min(moved, std.math.maxInt(u32)));
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
+35
View File
@@ -254,6 +254,41 @@ pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64)
try tx.commit();
}
// ---------------------------------------------------------------------------
// reconcile surface (milestone 20)
// ---------------------------------------------------------------------------
//
// `group_sources` has no row id — the pair *is* the identity — so the reconcile
// engine matches on the pair and needs the ids the name-keyed list above
// resolves away.
pub const GroupSourcePair = struct { group_id: i64, source_id: i64 };
/// Every assignment as the pair of ids it is. Nothing to free.
pub fn listGroupSourcePairs(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupSourcePair) {
return crud.listRows(
GroupSourcePair,
database,
gpa,
"SELECT group_id, source_id FROM group_sources ORDER BY group_id, source_id",
readGroupSourcePair,
);
}
fn readGroupSourcePair(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupSourcePair {
_ = gpa;
return .{ .group_id = stmt.columnInt(0), .source_id = stmt.columnInt(1) };
}
/// Removes one assignment. `error.NotFound`: no row holds the pair.
pub fn deleteGroupSourcePair(database: *db.Db, pair: GroupSourcePair) db.Error!void {
var stmt = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1 AND source_id = ?2");
defer stmt.deinit();
try stmt.bindInt(1, pair.group_id);
try stmt.bindInt(2, pair.source_id);
return crud.execStrict(database, &stmt);
}
fn groupExists(database: *db.Db, id: i64) db.Error!bool {
var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1");
defer stmt.deinit();
@@ -88,6 +88,18 @@ pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error
try stmt.exec();
}
/// Removes one key. Silent about a key that is not stored: the reconcile
/// engine's sweep computes the set of keys to drop from a list it has already
/// read, so "no such row" is not a caller error the way it is for a by-id
/// mutation, and `execStrict` would turn a harmless race into a failed
/// transaction.
pub fn deleteSetting(database: *db.Db, key: []const u8) db.Error!void {
var stmt = try database.prepare("DELETE FROM settings WHERE key = ?1");
defer stmt.deinit();
try stmt.bindText(1, key);
return stmt.exec();
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
+20 -135
View File
@@ -22,7 +22,6 @@ const build_options = @import("build_options");
const Writer = std.Io.Writer;
const cli = @import("../cli.zig");
const bootstrap = @import("../config/bootstrap.zig");
const config_export = @import("../config/export.zig");
const import = @import("../config/import.zig");
const model = @import("../config/model.zig");
@@ -127,7 +126,7 @@ fn openMigrated(f: *const Fixture, name: []const u8) !Data {
return .{ .dir = dir, .database = database };
}
fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !void {
fn importInto(f: *const Fixture, data: *Data, file: []const u8, allow_delete: bool) !void {
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
return import.importFile(
@@ -136,7 +135,7 @@ fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !vo
&data.database,
f.tmp.dir,
file,
.{ .force = force },
.{ .allow_delete = allow_delete },
&diags,
);
}
@@ -655,7 +654,7 @@ test "S7 case 10: a config.db stamped one version ahead is refused and left alon
}
// ---------------------------------------------------------------------------
// case 11-19: export, import and bootstrap on real files
// case 11-19: export and import on real files
// ---------------------------------------------------------------------------
test "S7 case 11: an exported file is mode 0600 and starts with the header comment" {
@@ -704,7 +703,7 @@ test "S7 case 12: export, import and export again are byte-identical files" {
try testing.expectEqualStrings(a, b);
}
test "S7 case 13: import refuses a configured database unless --force is given" {
test "S7 case 13: import refuses a diff that deletes rows unless --allow-delete is given" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
@@ -716,7 +715,10 @@ test "S7 case 13: import refuses a configured database unless --force is given"
defer data.deinit();
try importInto(&f, &data, "first.zon", false);
try testing.expectError(error.DatabaseNotEmpty, importInto(&f, &data, "second.zon", false));
// The two files name different upstream urls, and a url is the upstream's
// identity: applying the second deletes the first's row, which is what the
// gate exists to stop.
try testing.expectError(error.DestructiveImport, importInto(&f, &data, "second.zon", false));
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt(
@@ -753,14 +755,15 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
&data.database,
f.tmp.dir,
"config.zon",
.{ .force = false },
.{ .allow_delete = false },
&diags,
)) |_| {
return error.TestUnexpectedResult;
} else |_| {}
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
try testing.expect(try import.isEmpty(&data.database));
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM upstreams"));
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM settings"));
// Nothing beyond the database and its sidecars was created.
var dir = try f.tmp.dir.openDir(io, "data", .{ .iterate = true });
@@ -771,129 +774,6 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
}
}
test "S7 case 15: bootstrap with no configuration file leaves the database empty" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
var data = try openMigrated(&f, "data");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
);
try testing.expectEqual(bootstrap.Outcome.no_config_file, outcome);
try testing.expect(try import.isEmpty(&data.database));
}
test "S7 case 16: bootstrap seeds an empty database from the configuration file" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", rich_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
);
try testing.expectEqual(bootstrap.Outcome.seeded, outcome);
try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM groups"));
try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM upstreams"));
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"),
);
try testing.expectEqual(
@as(i64, 5353),
try data.database.queryInt("SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'dns.port'"),
);
}
test "S7 case 17: bootstrap on a configured database never reads the file" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("seed.zon", minimal_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
try importInto(&f, &data, "seed.zon", false);
// Unparseable on purpose: the call can only succeed if the file is never
// opened.
try f.write("config.zon", broken_zon);
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
);
try testing.expectEqual(bootstrap.Outcome.db_already_configured, outcome);
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt(
"SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'",
),
);
}
test "S7 case 18: bootstrap with an invalid configuration file fails and writes nothing" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", two_problem_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
if (bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
)) |outcome| {
std.debug.print("bootstrap unexpectedly returned .{s}\n", .{@tagName(outcome)});
return error.TestUnexpectedResult;
} else |_| {}
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
try testing.expect(try import.isEmpty(&data.database));
}
test "S7 case 19: writeToFile replaces an existing file and restores mode 0600" {
if (!build_options.integration) return error.SkipZigTest;
@@ -988,11 +868,13 @@ test "S7 case 21: runCheck passes a seeded database and reports two stored probl
try importInto(&f, &data, "config.zon", false);
}
{
// `applyToDb` rather than an import: the validator would refuse this
// `apply` rather than an import: the validator would refuse this
// configuration, and the case needs the problems to reach the database.
var data = try openMigrated(&f, "bad");
defer data.deinit();
try import.applyToDb(io, testing.allocator, &data.database, two_problem_model, 42, .{});
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
try import.apply(io, testing.allocator, &data.database, two_problem_model, 42, .{}, &diags);
}
{
@@ -1045,13 +927,16 @@ test "S7 case 22: runCheck probes a real upstream and prints an OK line" {
const code = cli.runCheck(
captured.runner(),
.{ .paths = .{ .config = config_path }, .config_explicit = true },
.{ .config = config_path },
true,
);
try testing.expectEqual(cli.exit_ok, code);
// Milestone 13 changed the probe line to the redacted `OK upstreams[i]`
// form; this expectation went stale unnoticed because nothing ran -Dlive
// between then and milestone 20.
try testing.expect(std.mem.count(
u8,
captured.out.written(),
"OK https://cloudflare-dns.com/dns-query\n",
"OK upstreams[0] https://cloudflare-dns.com\n",
) == 1);
}
+2 -1
View File
@@ -49,7 +49,8 @@ comptime {
_ = @import("storage/repositories/settings_repo.zig");
_ = @import("config/export.zig");
_ = @import("config/import.zig");
_ = @import("config/bootstrap.zig");
_ = @import("config/loader.zig");
_ = @import("config/reconcile.zig");
_ = @import("cli.zig");
_ = @import("storage/storage_integration_test.zig");
_ = @import("filter/parsers.zig");
+6 -2
View File
@@ -58,9 +58,12 @@ pub const cookie_attributes = "HttpOnly; SameSite=Lax; Path=/";
pub const max_password_len = 256;
/// Authentication is on exactly when a hash exists (ruling 17). An empty hash
/// is the documented "no password set" state, not a misconfiguration.
/// is the documented "no password set" state, not a misconfiguration; a null
/// one means the settings table holds no hash row at all, which is the same
/// answer.
pub fn authEnabled(web: model.Web) bool {
return web.password_hash.len != 0;
const hash = web.password_hash orelse return false;
return hash.len != 0;
}
pub const Outcome = enum {
@@ -453,6 +456,7 @@ fn tokenOf(n: u8) [token_bytes]u8 {
test "authEnabled follows the presence of a hash" {
try testing.expect(!authEnabled(.{}));
try testing.expect(!authEnabled(.{ .password_hash = null }));
try testing.expect(!authEnabled(.{ .password_hash = "" }));
try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }));
}
+110 -1
View File
@@ -24,6 +24,7 @@ const Allocator = std.mem.Allocator;
const address = @import("../../platform/address.zig");
const clients_repo = @import("../../storage/repositories/clients_repo.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
@@ -155,7 +156,76 @@ const resource = mutations.Resource(.{
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
/// What file authority found when it went to delete a row.
pub const ObservedDelete = enum { deleted, declared, absent };
/// Reads `hand_edited` and acts on it inside one `BEGIN IMMEDIATE`, because the
/// two halves are a single decision. Split across two statements, a concurrent
/// `nxdns import` — which takes the same write lock for its own reconcile — can
/// promote the row between the read and the DELETE, and file authority would
/// delete a client the file had just declared. Holding the write lock across
/// both makes the promotion wait, and it then sees the row already gone or
/// still there, never half of each.
///
/// A read-only outcome commits an empty transaction, which costs nothing and
/// keeps the one exit path.
fn deleteIfObserved(database: *db.Db, arena: Allocator, id: i64) db.Error!ObservedDelete {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
const row = try clients_repo.getClient(database, arena, id);
const verdict: ObservedDelete = if (row) |found|
(if (found.hand_edited) .declared else .deleted)
else
.absent;
if (verdict == .deleted) try clients_repo.deleteClient(database, id);
try tx.commit();
return verdict;
}
/// DELETE is a `runtime_action` in the route table (milestone-20 ruling 7), so
/// file authority lets it through: an observed row is runtime state the file
/// never declared, and without a way to remove it a mis-identified or departed
/// device would be immortal — the file can promote an IP, never forget one.
/// A row the file *declares* is configuration, and deleting it would contradict
/// the file, so it answers the same 403 the router answers elsewhere. This is
/// the one policy decision that needs a row read, which is why it is here and
/// not a table column.
///
/// A row that is not there is a 404, exactly as in database mode: file
/// authority must not turn a missing row into a policy verdict.
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const path = switch (state.authority) {
.database => return resource.remove(state, io, request),
.managed_file => |managed| managed,
};
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, delete_what);
state.config_lock.lockUncancelable(io);
const outcome = deleteIfObserved(database, request.arena, request.id.?);
state.config_lock.unlock(io);
switch (outcome catch |err| return mutations.respondFailure(
request,
mutations.dbFailure(err, group_conflict),
delete_what,
)) {
.absent => return mutations.respondFailure(request, .not_found, ""),
.declared => return http_util.respondManagedByFile(request, path),
.deleted => {},
}
if (mutations.reload(state, io)) |failure| {
return mutations.respondFailure(request, failure, delete_what);
}
return http_util.respondEmpty(request, .no_content);
}
const delete_what = "deleting a client";
/// The prefixes are one list resource with no `/{id}` route: the whole set is
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
@@ -280,6 +350,45 @@ test "deleting a client removes the row and announces the change" {
try testing.expectEqual(@as(usize, 1), bench.reloads);
}
test "file authority deletes an observed client and refuses a declared one" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seedClient(&bench);
try bench.exec(
\\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen)
\\VALUES (2, '192.168.1.11', 1, 1, 100, 200);
);
// The declared row is configuration; it survives, and nothing is written.
try testing.expectEqual(ObservedDelete.declared, try deleteIfObserved(&bench.database, bench.arena(), 2));
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 2"));
try testing.expectEqual(ObservedDelete.deleted, try deleteIfObserved(&bench.database, bench.arena(), 1));
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
try testing.expectEqual(ObservedDelete.absent, try deleteIfObserved(&bench.database, bench.arena(), 999));
}
test "the observed check and the delete are one transaction" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seedClient(&bench);
// SQLite refuses a `BEGIN IMMEDIATE` inside an open transaction, so a held
// transaction is what proves this takes the write lock rather than reading
// and deleting through two unsynchronised statements — the window a
// concurrent `nxdns import` would promote the row in. Without the
// transaction both statements run and the row is gone.
var tx = try db.Tx.begin(&bench.database);
try testing.expectError(error.Unexpected, deleteIfObserved(&bench.database, bench.arena(), 1));
tx.rollback();
// The row is untouched: the refusal happened before any statement ran.
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
}
test "the prefix list is replaced whole" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
+65 -14
View File
@@ -125,9 +125,15 @@ fn Partial(comptime Section: type, comptime section_name: []const u8) type {
/// Enums arrive as the words the database stores, so they are parsed from text
/// rather than by tag name (`logging.level` is `error`, whose tag cannot be).
///
/// An optional model field collapses to its child, because `Partial` wraps
/// every field in one optional of its own and that optional already carries the
/// only meaning a PUT has for absence — "leave it". A double optional would be
/// two ways to say the same thing, and `std.json` cannot parse the outer one.
fn FieldType(comptime T: type) type {
return switch (@typeInfo(T)) {
.@"enum" => []const u8,
.optional => |info| FieldType(info.child),
else => T,
};
}
@@ -321,16 +327,17 @@ pub fn applyPut(
// The password never becomes a row: the hash made above is what the merged
// configuration — and therefore the settings table — carries.
const previous_hash = cfg.web.password_hash;
const previous_hash = cfg.web.password_hash orelse "";
if (password != null) cfg.web.password_hash = new_hash;
cfg.web.password = "";
cfg.web.password = null;
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
// The gpa copy the live holder will own, made before the write so a
// committed transaction can never be followed by a failed revocation.
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
const merged_hash = cfg.web.password_hash orelse "";
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
writeSettings(arena, database, cfg) catch |err| {
if (replacement) |hash| state.gpa.free(hash);
@@ -365,6 +372,12 @@ fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error
for (pairs.items) |pair| {
try settings_repo.putSetting(database, pair.key, pair.value);
}
// `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the
// reconcile engine owns that row, because only it can tell "the file said
// nothing" from "the file said empty"). A PUT has no such ambiguity — the
// merged configuration is the whole truth — so this handler writes the row
// itself rather than losing the password change.
try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse "");
try tx.commit();
}
@@ -455,6 +468,36 @@ pub const hash_stall_control = if (builtin.is_test) struct {
// routes
// ---------------------------------------------------------------------------
/// Which source governs this process's configuration, and when it last read
/// it (milestone-20 ruling 7). This is how the UI learns that configuration is
/// read-only — declaratively, rather than by probing a route for a 403.
///
/// It rides `GET /api/settings` because that route needs a session: the
/// managed path is a filesystem path and must never reach the open
/// `/api/version` or `/api/health`.
///
/// `reconciled_at` means exactly "this process loaded the file at T". A file
/// whose mtime is newer has not been loaded by the running process. It cannot
/// answer "is the file what the server uses" — a stepped clock or a preserved
/// mtime defeats the comparison in either direction, and the database can move
/// under `nxdns import` without either timestamp moving.
const AuthorityView = struct {
mode: []const u8,
path: ?[]const u8,
reconciled_at: ?i64,
};
fn authorityView(state: *const server.WebState) AuthorityView {
return switch (state.authority) {
.database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at },
.managed_file => |path| .{
.mode = "managed_file",
.path = path,
.reconciled_at = state.reconciled_at,
},
};
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
@@ -470,7 +513,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
const cfg = loaded catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
return respondSettings(request, .ok, cfg);
return respondSettings(request, state, .ok, cfg);
}
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
@@ -479,14 +522,20 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
return switch (try applyPut(state, io, request.arena, parsed.value)) {
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
.config => |cfg| respondSettings(request, .ok, cfg),
.config => |cfg| respondSettings(request, state, .ok, cfg),
};
}
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
fn respondSettings(
request: *Request,
state: *const server.WebState,
status: std.http.Status,
cfg: model.Config,
) HandlerError!void {
return http_util.respondJson(request, status, .{
.settings = view(cfg),
.restart_required = restart_required_keys,
.authority = authorityView(state),
}, &.{});
}
@@ -498,8 +547,10 @@ const testing = std.testing;
const auth_handlers = @import("auth.zig");
test "the restart-required table lists every settings key and no secret" {
// `model.toSettings` is the other half of the same fact: the keys the
// database stores, minus the hash the API never serializes.
// `model.toSettings` is the other half of the same fact. The two lists are
// now equal rather than off by one: `toSettings` stopped emitting
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
// it, so both exclude the hash and the plaintext.
var pairs: std.ArrayList(model.SettingPair) = .empty;
defer {
model.freeSettings(testing.allocator, pairs.items);
@@ -507,7 +558,7 @@ test "the restart-required table lists every settings key and no secret" {
}
try model.toSettings(.{}, testing.allocator, &pairs);
try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len);
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
for (restart_required_keys) |key| {
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, key, "web.password"));
@@ -641,7 +692,7 @@ test "a new password is stored as a hash and ends every session" {
patch.web = .{ .password = "correct horse battery staple" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$"));
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$"));
try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));
// The plain password is nowhere in the table, and the hash is.
@@ -650,10 +701,10 @@ test "a new password is stored as a hash and ends every session" {
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
);
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$"));
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$"));
try testing.expectEqual(
auth.Outcome.ok,
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"),
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"),
);
}
@@ -713,7 +764,7 @@ test "an empty password is not a password change" {
patch.web = .{ .password = "" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expectEqualStrings("", outcome.config.web.password_hash);
try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
}
test "reading the settings with no database is unavailable" {
+25 -10
View File
@@ -307,23 +307,38 @@ pub fn parseBody(comptime T: type, request: *Request) (BodyError || error{BadJso
/// Ruling 8's envelope. `message` is operator-facing text, never a raw internal
/// error string for a 500 (PLAN §19: details go to the log, not the wire).
///
/// Built on the request arena, like `respondJson` below. It used to build into
/// a 512-byte stack buffer and fall back to `text/plain` when the message
/// overflowed it, which made the documented JSON envelope a function of message
/// length — a long managed-file path (milestone-20 ruling 7) was enough to
/// demote it. The envelope is `application/json` at every length now.
pub fn respondError(
request: *Request,
status: http.Status,
message: []const u8,
) HandlerError!void {
var buf: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var stringify: std.json.Stringify = .{ .writer = &writer };
stringify.beginObject() catch return respondPlain(request, status, message);
stringify.objectField("error") catch return respondPlain(request, status, message);
stringify.write(message) catch return respondPlain(request, status, message);
stringify.endObject() catch return respondPlain(request, status, message);
return respondBytes(request, status, writer.buffered(), content_type_json, &.{});
var allocating: std.Io.Writer.Allocating = .init(request.arena);
defer allocating.deinit();
var stringify: std.json.Stringify = .{ .writer = &allocating.writer };
stringify.beginObject() catch return error.OutOfMemory;
stringify.objectField("error") catch return error.OutOfMemory;
stringify.write(message) catch return error.OutOfMemory;
stringify.endObject() catch return error.OutOfMemory;
return respondBytes(request, status, allocating.written(), content_type_json, &.{});
}
fn respondPlain(request: *Request, status: http.Status, message: []const u8) HandlerError!void {
return respondBytes(request, status, message, content_type_text, &.{});
/// Milestone-20 ruling 7's rejection: a configuration write under file
/// authority. One function, because the router rejects most of them and the
/// clients handler rejects the one that needs a row read — two wordings would
/// be two contracts.
pub fn respondManagedByFile(request: *Request, path: []const u8) HandlerError!void {
const message = try std.fmt.allocPrint(
request.arena,
"configuration is managed by {s}; edit the file and restart",
.{path},
);
return respondError(request, .forbidden, message);
}
/// Serialises `value` and responds. The document is built in the request arena
+99 -1
View File
@@ -29,6 +29,15 @@ info:
- Mutations to groups, blocklists, rules, local records, forward zones,
clients and client prefixes take effect live. Upstreams and
`/api/settings` are restart-required.
- nxdns runs under one of two configuration authorities. Started with
`--config=<file>`, that file is the sole declarative source, and every
operation that writes configuration answers 403 with the same error
envelope, naming the file. Operations that change runtime state —
`/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the
login and the logout — stay live, as does `DELETE /api/clients/{id}`
for a client the file does not declare. `GET /api/settings` reports
the live authority, so a client reads the mode rather than
discovering it from a rejection.
servers:
- url: /
@@ -391,6 +400,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -444,6 +455,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -466,6 +479,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -519,6 +534,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -575,6 +592,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -655,6 +674,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -674,6 +695,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -728,6 +751,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -780,6 +805,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -799,6 +826,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -853,6 +882,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -905,6 +936,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -924,6 +957,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -978,6 +1013,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1030,6 +1067,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1049,6 +1088,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1133,6 +1174,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1222,6 +1265,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1277,6 +1322,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1330,6 +1377,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1350,6 +1399,8 @@ paths:
description: Deleted; takes effect on restart.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1454,6 +1505,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"413":
$ref: "#/components/responses/BodyTooLarge"
"429":
@@ -1526,6 +1579,16 @@ components:
application/json:
schema:
$ref: "#/components/schemas/Error"
ManagedByFile:
description: |
nxdns is running under file authority and this operation writes
configuration. The message names the file. Authentication is checked
first, so an unauthenticated request to a protected route still
answers 401 rather than disclosing that the route exists.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
NotFound:
description: No row has this id.
content:
@@ -2193,7 +2256,7 @@ components:
SettingsEnvelope:
type: object
required: [settings, restart_required]
required: [settings, restart_required, authority]
properties:
settings:
$ref: "#/components/schemas/Settings"
@@ -2203,6 +2266,41 @@ components:
description: |
Every `section.field` key that needs a restart to take effect —
currently all of them.
authority:
$ref: "#/components/schemas/Authority"
Authority:
type: object
description: |
Which source governs this process's configuration. This is how a
client learns that configuration is read-only; it never has to probe
a write route for a 403. The block rides this authenticated endpoint
because `path` is a filesystem path, and never appears on the open
`/api/version` or `/api/health`.
required: [mode, path, reconciled_at]
properties:
mode:
type: string
enum: [database, managed_file]
description: |
`database` when nxdns runs without `--config`; `managed_file`
when it runs with it, in which case every configuration write
answers 403.
path:
type: string
nullable: true
description: The managed file, or null in `database` mode.
reconciled_at:
type: integer
nullable: true
description: |
When this process loaded the managed file, in epoch seconds, and
null in `database` mode. It means exactly that: a file whose
mtime is newer has not been loaded by the running process. It
cannot answer whether the file matches what the server serves —
a stepped clock or a preserved mtime defeats the comparison
either way, and `nxdns import` can move the database without
moving either timestamp.
SettingsPatch:
type: object
+78
View File
@@ -48,6 +48,84 @@ test "every served route appears textually in the document" {
}
}
// Drift guard for milestone-20 ruling 7: a route classified `config_write` can
// answer 403 under file authority, so its operation must say so — and a route
// that cannot must not claim it. Textual, like the coverage test above: the
// document has no parser here, and the two facts it compares are one line each.
test "every config write documents the file-authority 403, and nothing else does" {
for (router.routes) |route| {
const operation = try operationBlock(route.pattern, route.method);
const documented = std.mem.containsAtLeast(u8, operation, 1, "\n \"403\":\n");
if (documented != (route.policy == .config_write)) {
std.debug.print(
"{t} {s} is {t} but {s} a 403\n",
.{ route.method, route.pattern, route.policy, if (documented) "documents" else "does not document" },
);
return error.TestUnexpectedResult;
}
}
}
/// The body of one operation: everything under `pattern`'s `method` key.
///
/// The path block is bounded *before* the method is looked for. Searching the
/// rest of the document instead would let a later path's `delete:` answer for a
/// path that has none, and the guard above would pass on an operation nobody
/// documented.
fn operationBlock(pattern: []const u8, method: std.http.Method) ![]const u8 {
var key_buf: [128]u8 = undefined;
const path_key = try std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{pattern});
const path_at = std.mem.indexOf(u8, yaml, path_key) orelse return error.PathNotDocumented;
const path_body = blockUnder(yaml[path_at + path_key.len ..], 2);
var method_buf: [16]u8 = undefined;
const method_key = try std.fmt.bufPrint(&method_buf, " {s}:\n", .{@tagName(method)});
_ = std.ascii.lowerString(&method_buf, method_key);
const key = method_buf[0..method_key.len];
// Anchored at a line start: a `get:` nested deeper inside a description
// contains the four-space key as a substring.
var offset: usize = 0;
while (offset < path_body.len) {
if (std.mem.startsWith(u8, path_body[offset..], key)) {
return blockUnder(path_body[offset + key.len ..], 4);
}
offset = (std.mem.indexOfScalarPos(u8, path_body, offset, '\n') orelse path_body.len) + 1;
}
return error.MethodNotDocumented;
}
/// The run of lines at the start of `body` indented deeper than `indent` — what
/// belongs to the key that just ended. `body` starts at a line boundary. Blank
/// lines belong to whatever surrounds them and never close a block.
fn blockUnder(body: []const u8, indent: usize) []const u8 {
var offset: usize = 0;
while (offset < body.len) {
const line_end = std.mem.indexOfScalarPos(u8, body, offset, '\n') orelse body.len;
if (line_end != offset) {
const depth = for (body[offset..line_end], 0..) |c, i| {
if (c != ' ') break i;
} else line_end - offset;
if (depth <= indent) return body[0..offset];
}
offset = line_end + 1;
}
return body;
}
test "an operation block stops at its own path and its own method" {
// `/api/groups` has no DELETE. An unbounded search answers with the one
// under `/api/groups/{id}`, and the 403 guard then grades the wrong
// operation — silently passing for a route nobody documented.
try testing.expectError(error.MethodNotDocumented, operationBlock("/api/groups", .DELETE));
// A block it does have never reaches into its neighbour under the same
// path either.
const list_groups = try operationBlock("/api/groups", .GET);
try testing.expect(std.mem.containsAtLeast(u8, list_groups, 1, "List groups"));
try testing.expect(!std.mem.containsAtLeast(u8, list_groups, 1, "Create a group"));
}
test "the document names the contract's fixed points" {
for ([_][]const u8{
"openapi: 3.0.3",
+55 -7
View File
@@ -37,12 +37,20 @@ pub const Auth = enum { open, session };
/// monitoring endpoints so a Prometheus scrape can never be throttled.
pub const RateLimit = enum { counted, exempt };
/// What a route does to the configuration, and therefore whether file
/// authority may allow it (milestone-20 ruling 7). `config_write` changes the
/// declarative state the managed file owns; `runtime_action` changes runtime
/// state the file never declares; `read` changes nothing.
pub const Policy = enum { read, config_write, runtime_action };
pub const RouteInfo = struct {
method: http.Method,
/// Segments separated by `/`, with at most one `{id}` capture, which must
/// be a positive integer row id.
pattern: []const u8,
auth: Auth,
/// No default: a new route states its class or does not compile.
policy: Policy,
handler: HandlerFn,
rate_limit: RateLimit = .counted,
};
@@ -158,6 +166,17 @@ pub fn dispatch(
return http_util.respondError(request, .unauthorized, "authentication required");
}
// Milestone-20 ruling 7, and it runs *after* the auth check on purpose:
// rejecting before authenticating would tell an anonymous caller which
// routes exist. An unauthenticated request to a protected route answers
// 401 in both authority modes.
if (found.route.policy == .config_write) {
switch (state.authority) {
.database => {},
.managed_file => |path| return http_util.respondManagedByFile(request, path),
}
}
return found.route.handler(state, io, request);
}
@@ -196,13 +215,14 @@ fn noopHandler(
}
const test_table = [_]RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = noopHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = noopHandler },
};
fn matchPath(method: http.Method, path: []const u8) Match {
@@ -263,6 +283,34 @@ test "the allow header lists every method the path accepts" {
try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf));
}
test "matching carries the class the table declares, per route and not per prefix" {
const cases = [_]struct { method: http.Method, path: []const u8, policy: Policy }{
.{ .method = .GET, .path = "/api/groups", .policy = .read },
.{ .method = .GET, .path = "/api/groups/7", .policy = .read },
.{ .method = .POST, .path = "/api/groups", .policy = .config_write },
.{ .method = .PUT, .path = "/api/groups/7", .policy = .config_write },
.{ .method = .DELETE, .path = "/api/groups/7", .policy = .config_write },
.{ .method = .PUT, .path = "/api/groups/7/sources", .policy = .config_write },
// Same prefix, different class: the column is per route.
.{ .method = .POST, .path = "/api/pause", .policy = .runtime_action },
};
for (cases) |case| {
try testing.expectEqual(case.policy, matchPath(case.method, case.path).found.route.policy);
}
}
test "the shipped route table classifies /api/blocklists by route, not by prefix" {
var refresh: ?Policy = null;
var create: ?Policy = null;
for (routes) |route| {
if (route.method != .POST) continue;
if (std.mem.eql(u8, route.pattern, "/api/blocklists/update")) refresh = route.policy;
if (std.mem.eql(u8, route.pattern, "/api/blocklists")) create = route.policy;
}
try testing.expectEqual(Policy.runtime_action, refresh.?);
try testing.expectEqual(Policy.config_write, create.?);
}
test "the shipped route table is the one the router matches against" {
try testing.expectEqual(routes_table.table.ptr, routes.ptr);
try testing.expectEqual(routes_table.table.len, routes.len);
+132 -56
View File
@@ -18,6 +18,17 @@
//! bucket. The static assets are ruling 18's remaining exemption; they are
//! not routes — the router sends unmatched non-`/api` paths to
//! `WebState.fallback` before any policy check.
//!
//! `policy` is the third such column, and milestone-20 ruling 7's contract:
//! under file authority the file is the sole declarative source, so a
//! `config_write` answers 403 and a `runtime_action` stays live. It has no
//! default value on purpose — a route added without a stated class must not
//! inherit one. Classification is per route, not per prefix:
//! `POST /api/blocklists/update` is a refresh, a `runtime_action`, while its
//! CRUD siblings write configuration. `DELETE /api/clients/{id}` is a
//! `runtime_action` here because deleting an *observed* row discards runtime
//! state the file never declared; the declared case needs a row read and the
//! clients handler answers it.
const router = @import("router.zig");
@@ -43,85 +54,85 @@ const version = @import("handlers/version.zig");
pub const table: []const router.RouteInfo = &.{
// Monitoring and contract (ruling 18's open set, ruling 19's exemptions).
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .handler = metrics.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = health.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .handler = version.handle },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .handler = openapi.handle },
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .handler = metrics.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = health.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .handler = version.handle },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .handler = openapi.handle },
// Authentication.
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .handler = auth.login },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .handler = auth.logout },
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .handler = auth.login },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .handler = auth.logout },
// Query log, stats, live stream, lookup.
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .handler = queries.list },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .handler = live.stream, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .handler = stats.totals },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .handler = stats.timeseries },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .handler = lookup.handle },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .handler = upstream_health.handle },
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle },
// Groups.
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = groups.list },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = groups.create },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.get },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.update },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.remove },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.getSources },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.putSources },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = groups.list },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = groups.create },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = groups.get },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.update },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.remove },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .handler = groups.getSources },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = groups.putSources },
// Blocklist sources. `/api/blocklists/update` is a literal segment; it
// cannot collide with `{id}`, which only matches a positive integer.
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.list },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.create },
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .handler = blocklists.refresh },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.get },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.update },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.remove },
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .handler = blocklists.list },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .handler = blocklists.create },
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .handler = blocklists.refresh },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .handler = blocklists.get },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.update },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.remove },
// Rules.
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .handler = rules.list },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .handler = rules.create },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.get },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.update },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.remove },
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .handler = rules.list },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .handler = rules.create },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .handler = rules.get },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.update },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.remove },
// Local records.
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .handler = local.listRecords },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .handler = local.createRecord },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.getRecord },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.updateRecord },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.removeRecord },
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .handler = local.listRecords },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .handler = local.createRecord },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .handler = local.getRecord },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.updateRecord },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.removeRecord },
// Forward zones.
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .handler = local.listZones },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .handler = local.createZone },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.getZone },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.updateZone },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.removeZone },
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .handler = local.listZones },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .handler = local.createZone },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .handler = local.getZone },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.updateZone },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.removeZone },
// Clients (no POST — rows come from DNS activity or import, ruling 9).
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .handler = clients.list },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.get },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.update },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.remove },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.listPrefixes },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.putPrefixes },
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .handler = clients.list },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .handler = clients.get },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .handler = clients.update },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .handler = clients.remove },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .handler = clients.listPrefixes },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .handler = clients.putPrefixes },
// Upstreams (restart-required resource).
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.list },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.create },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.get },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.update },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.remove },
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .handler = upstreams.list },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .handler = upstreams.create },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .handler = upstreams.get },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.update },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.remove },
// Pause and settings.
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .handler = pause.get },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put },
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .handler = pause.get },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = pause.post },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put },
// Certificates (milestone-10 ruling 8).
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .handler = certs.post },
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .handler = certs.post },
};
// ---------------------------------------------------------------------------
@@ -187,6 +198,71 @@ test "the limiter exemptions are the monitoring endpoints and the live stream" {
try testing.expectEqual(exempt.len, found);
}
test "the config writes are exactly the declarative mutations" {
const writes = [_][]const u8{
"POST /api/groups",
"PUT /api/groups/{id}",
"DELETE /api/groups/{id}",
"PUT /api/groups/{id}/sources",
"POST /api/blocklists",
"PUT /api/blocklists/{id}",
"DELETE /api/blocklists/{id}",
"POST /api/rules",
"PUT /api/rules/{id}",
"DELETE /api/rules/{id}",
"POST /api/local-records",
"PUT /api/local-records/{id}",
"DELETE /api/local-records/{id}",
"POST /api/forward-zones",
"PUT /api/forward-zones/{id}",
"DELETE /api/forward-zones/{id}",
"PUT /api/clients/{id}",
"PUT /api/client-prefixes",
"POST /api/upstreams",
"PUT /api/upstreams/{id}",
"DELETE /api/upstreams/{id}",
"PUT /api/settings",
};
try expectClass(.config_write, &writes);
}
test "the runtime actions are exactly ruling 7's list" {
const actions = [_][]const u8{
"POST /api/auth/login",
"POST /api/auth/logout",
"POST /api/blocklists/update",
"DELETE /api/clients/{id}",
"POST /api/pause",
"POST /api/certs/reload",
};
try expectClass(.runtime_action, &actions);
}
test "every read is a GET and every GET is a read" {
for (table) |route| {
try testing.expectEqual(route.method == .GET, route.policy == .read);
}
}
/// Asserts that the routes classified `policy` are exactly `expected`, each
/// written `METHOD /pattern`.
fn expectClass(policy: router.Policy, expected: []const []const u8) !void {
var buf: [64]u8 = undefined;
var found: usize = 0;
for (table) |route| {
if (route.policy != policy) continue;
found += 1;
const label = try std.fmt.bufPrint(&buf, "{t} {s}", .{ route.method, route.pattern });
var listed = false;
for (expected) |name| listed = listed or std.mem.eql(u8, label, name);
if (!listed) {
std.debug.print("{s} is {t}, and the list does not say so\n", .{ label, policy });
return error.TestUnexpectedResult;
}
}
try testing.expectEqual(expected.len, found);
}
test "item routes capture one id and collection routes capture none" {
for (table) |route| {
const captures = std.mem.count(u8, route.pattern, "{id}");
+21
View File
@@ -102,10 +102,31 @@ pub const ReloadFn = *const fn (state: *WebState, io: std.Io) anyerror!void;
/// false` means several of them are never opened at all (ruling 6). A handler
/// that finds the collaborator it needs missing answers 503, the same way it
/// answers a missing snapshot.
/// Which of the two sources governs this process's configuration (milestone-20
/// ruling 1). Per-process state, never persisted: authority lives in the
/// invocation, and the database carries no record of who wrote it.
///
/// The `managed_file` path is owned by `serve`'s arena, which outlives every
/// `WebState`, so nothing here copies it.
pub const Authority = union(enum) {
database,
managed_file: []const u8,
};
pub const WebState = struct {
gpa: Allocator,
web: model.Web = .{},
/// Defaults to `.database`: a `WebState` nobody told about a managed file
/// governs nothing declaratively, which is the safe reading — the mutation
/// routes stay live rather than a half-wired server refusing every write.
authority: Authority = .database,
/// When this process loaded the managed file, in epoch seconds. Null in
/// database mode, which never reconciles. It answers exactly "this process
/// loaded the file at T" and nothing more: a file whose mtime is newer has
/// not been loaded by the running process.
reconciled_at: ?i64 = null,
handler: ?*dns_handler.Handler = null,
pause: ?*pause_mod.Pause = null,
tracker: ?*clients.Tracker = null,
+5 -5
View File
@@ -90,11 +90,11 @@ fn bodyThenPathHandler(
}
const test_routes = [_]router.RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = okHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = okHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = echoLengthHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = bodyThenPathHandler },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .handler = echoDomainHandler },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = okHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = okHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = echoLengthHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = bodyThenPathHandler },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .policy = .read, .handler = echoDomainHandler },
};
fn denyAll(state: *server.WebState, io: std.Io, request: *const http_util.Request) bool {
+313 -56
View File
@@ -264,6 +264,10 @@ const EnvOptions = struct {
sse_max_per_ip: u16 = 3,
trusted_proxies: []const u8 = "",
fallback: ?router.HandlerFn = null,
/// Milestone-20 ruling 7. `.database` is what every pre-existing test
/// wants; the file-authority tests below name a path.
authority: server.Authority = .database,
reconciled_at: ?i64 = null,
};
/// Heap-allocated because `state` and the listener hold pointers into it.
@@ -372,6 +376,8 @@ const Env = struct {
.sse_max_connections_per_ip = options.sse_max_per_ip,
.trusted_proxies = options.trusted_proxies,
},
.authority = options.authority,
.reconciled_at = options.reconciled_at,
.live_hash = .init(options.password_hash),
.pause = &self.pauser,
.manager = &self.mgr,
@@ -573,6 +579,7 @@ const SettingsView = struct {
blocklist_update: struct { enabled: bool, interval_hours: u16 },
},
restart_required: []const []const u8,
authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 },
};
const Contract = struct {
@@ -580,6 +587,9 @@ const Contract = struct {
/// Must equal a `routes.zig` pattern; the coverage test enforces it.
pattern: []const u8,
auth: router.Auth,
/// Milestone-20 ruling 7's class, restated here so the coverage test can
/// hold the served table to it. No default, like the route table.
policy: router.Policy,
rate_limit: router.RateLimit = .counted,
/// The concrete request target the walk sends.
target: []const u8,
@@ -597,96 +607,96 @@ const Contract = struct {
/// create that made the row, and deletes come last for their resource.
const contract = [_]Contract{
// Monitoring and contract.
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
// Authentication (auth is disabled in the walk's environment; the on/off
// matrix has its own test).
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
// Refresh-all before any source row exists: nothing to fetch, 202 anyway.
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
// Query log, stats, live stream, upstream health.
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
// Groups. The migrated schema seeds `default` as id 1; the POST creates
// id 2, which the delete at the end of the walk removes.
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },
// Blocklist sources. The POST runs after the refresh above, so the created
// row's url is never fetched.
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .status = 204, .kind = .none },
// Rules. The lookup below wants the blocking rule still in place, so the
// rule's delete follows it.
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .status = 204, .kind = .none },
// Local records.
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .status = 204, .kind = .none },
// Forward zones.
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .status = 204, .kind = .none },
// Clients (row id 1 is seeded — clients have no POST, ruling 9).
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .target = "/api/clients/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },
// Upstreams. Row id 1 is seeded; the POST creates id 2, whose delete
// cannot collide with the last-enabled-upstream guard.
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/2", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/2", .status = 204, .kind = .none },
// Pause and settings. The pause POST leaves filtering running; the
// settings PUT is a real change, echoed by the same response shape.
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },
// Certificates. The walk's environment wires no cert store, so both
// endpoints report disabled — and the reload still answers 200 (m10
// ruling 8: the outcome is the payload).
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
// The walk's last delete returns the groups table to its seeded shape.
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 204, .kind = .none },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .status = 204, .kind = .none },
};
// Drift guard: the contract table covers the served route table exactly —
@@ -707,6 +717,7 @@ test "the contract table covers every served route with the served policy" {
covered[index] = true;
try testing.expectEqual(route.auth, entry.auth);
try testing.expectEqual(route.rate_limit, entry.rate_limit);
try testing.expectEqual(route.policy, entry.policy);
found = true;
break;
}
@@ -933,6 +944,252 @@ test "W10 auth off: an empty hash leaves every route open" {
try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// file authority (milestone-20 ruling 7)
// ---------------------------------------------------------------------------
const managed_path = "/etc/nxdns/config.zon";
const managed_body = "{\"error\":\"configuration is managed by " ++ managed_path ++
"; edit the file and restart\"}";
/// Long enough that the envelope could not be built in the 512-byte stack
/// buffer `respondError` used before this milestone. Nested bind mounts really
/// do produce paths like this, and the old code answered them in `text/plain`.
const long_managed_path = "/mnt/" ++ ("deeply-nested-bind-mount/" ** 24) ++ "config.zon";
fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
var body_buf: [8192]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// A read is untouched.
try conn.request("GET", "/api/groups", null, null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
// Every class of configuration write answers the one envelope.
const writes = [_]struct { method: []const u8, target: []const u8, body: ?[]const u8 }{
.{ .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}" },
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}" },
.{ .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"x\",\"group_id\":1}" },
.{ .method = "DELETE", .target = "/api/upstreams/1", .body = null },
};
for (writes) |write| {
try conn.request(write.method, write.target, null, write.body);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 403), response.status);
try testing.expectEqualStrings(managed_body, response.body);
try testing.expectEqualStrings("application/json", response.header("content-type").?);
}
// Rejected before the handler, not after it: the group was never created.
try conn.request("GET", "/api/groups", null, null);
response = try conn.receive(&body_buf);
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "kids"));
// Runtime actions stay live.
try conn.request("POST", "/api/pause", null, "{\"paused\":false}");
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try conn.request("POST", "/api/blocklists/update", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 202), response.status);
try conn.request("POST", "/api/certs/reload", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
}
test "W10 milestone 20: file authority rejects configuration writes and spares the rest" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
defer env.destroy();
try bounded(env.io(), default_budget, fileModeClasses, .{ env.io(), env });
}
fn fileModeClientDelete(io: std.Io, env: *Env) anyerror!void {
var body_buf: [4096]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// The declared row contradicts the file, so it stays.
try conn.request("DELETE", "/api/clients/2", null, null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 403), response.status);
try testing.expectEqualStrings(managed_body, response.body);
// The observed row is runtime state the file never declared; without this
// a departed device would be immortal, since the file can only promote an
// address, never forget one.
try conn.request("DELETE", "/api/clients/1", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 204), response.status);
// An id no client holds is still a 404, not a policy verdict.
try conn.request("DELETE", "/api/clients/999", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 404), response.status);
}
test "W10 milestone 20: file authority deletes an observed client and refuses a declared one" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
defer env.destroy();
// Row 1 is seeded observed (`hand_edited = 0`); row 2 is what the file
// declares.
try env.config_db.exec(
\\INSERT INTO clients (id, ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (2, '192.168.1.51', 'nas', 1, 1, 1700000000, 1700000000)
);
try bounded(env.io(), default_budget, fileModeClientDelete, .{ env.io(), env });
}
fn longPathEnvelope(io: std.Io, env: *Env) anyerror!void {
var body_buf: [8192]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 403), response.status);
try testing.expect(response.body.len > 512);
try testing.expectEqualStrings("application/json", response.header("content-type").?);
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, long_managed_path));
// Still the documented envelope, not a truncation and not plain text.
const parsed = try std.json.parseFromSlice(
struct { @"error": []const u8 },
env.gpa,
response.body,
.{},
);
defer parsed.deinit();
}
test "W10 milestone 20: an error longer than the old 512-byte buffer stays application/json" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = long_managed_path } });
defer env.destroy();
try bounded(env.io(), default_budget, longPathEnvelope, .{ env.io(), env });
}
fn fileModeUnauthenticated(io: std.Io, env: *Env) anyerror!void {
var body_buf: [4096]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// Policy runs after authentication: a caller with no session learns that
// it needs one, never that the route exists and is managed by a file whose
// path the envelope would otherwise disclose.
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 401), response.status);
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
}
test "W10 milestone 20: an unauthenticated configuration write is 401, never 403" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var hash_buf: [256]u8 = undefined;
const hash = try hashTestPassword(gpa, &hash_buf);
var env = try Env.create(gpa, .{
.password_hash = hash,
.authority = .{ .managed_file = managed_path },
});
defer env.destroy();
try bounded(env.io(), default_budget, fileModeUnauthenticated, .{ env.io(), env });
}
fn authorityEnvelope(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16384]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("GET", "/api/settings", null, null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
defer parsed.deinit();
try testing.expectEqualStrings("managed_file", parsed.value.authority.mode);
try testing.expectEqualStrings(managed_path, parsed.value.authority.path.?);
try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.authority.reconciled_at);
// The path is a filesystem path and must not reach the open routes.
for ([_][]const u8{ "/api/version", "/api/health" }) |target| {
try conn.request("GET", target, null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "authority"));
}
}
test "W10 milestone 20: the settings envelope reports the authority and the open routes do not" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{
.authority = .{ .managed_file = managed_path },
.reconciled_at = 1_700_000_042,
});
defer env.destroy();
try bounded(env.io(), default_budget, authorityEnvelope, .{ env.io(), env });
}
fn databaseAuthorityEnvelope(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16384]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("GET", "/api/settings", null, null);
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
defer parsed.deinit();
try testing.expectEqualStrings("database", parsed.value.authority.mode);
try testing.expectEqual(@as(?[]const u8, null), parsed.value.authority.path);
try testing.expectEqual(@as(?i64, null), parsed.value.authority.reconciled_at);
// And nothing is rejected.
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
const created = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 201), created.status);
}
test "W10 milestone 20: database authority reports null and writes normally" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, databaseAuthorityEnvelope, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// oversized cookie headers (ruling 7 of milestone 16)
// ---------------------------------------------------------------------------
+1
View File
@@ -1,3 +1,4 @@
dist/
dist-placeholder/
package-lock.json
dist-sourcemap/
+1111 -328
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -8,7 +8,7 @@
},
"scripts": {
"dev": "vite",
"build": "vite build && node scripts/stamp-dist.mjs",
"build": "vite build && node scripts/assert-css-layers.mjs && node scripts/stamp-dist.mjs",
"typecheck": "tsc -b",
"lint": "oxlint src vite.config.ts",
"format": "prettier --write .",
@@ -25,13 +25,15 @@
"trailingComma": "all"
},
"dependencies": {
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
"react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@stylexjs/unplugin": "0.19.0",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/node": "26.1.1",
@@ -41,8 +43,7 @@
"jsdom": "29.1.1",
"oxlint": "1.75.0",
"prettier": "3.9.6",
"tailwindcss": "4.3.3",
"typescript": "6.0.3",
"typescript": "7.0.2",
"vite": "8.1.5",
"vitest": "4.1.10"
}
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
// Every rule in the built stylesheet must sit inside a cascade layer
// (milestone 23). Unlayered author CSS outranks every layer whatever its
// selector says, so a single unlayered rule silently beats the StyleX atomic
// rules it was written to sit under. That failure renders wrong and passes
// every other gate: no test asserts computed style, and the bundler is happy.
// It also checks the layer ORDER, which is the invariant that actually matters:
// a later layer beats an earlier one, so `reset` has to be declared first.
//
// What it does not catch, so nobody reads more into a pass than is there: an
// unlayered rule that sets only custom properties is allowed, because StyleX
// emits its token `:root` block exactly that way and this cannot tell that
// block from an override of it; a declaration value containing `@layer` or a
// brace inside a string blinds the stripper; and with several stylesheets it
// judges each alone, not their load order in the document.
//
// This check runs from web/ as part of `npm run build`.
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
const sheets = readdirSync(distDir).filter((name) => name.endsWith(".css"));
if (sheets.length === 0) {
console.error("assert-css-layers: no stylesheet in dist/assets — did the build emit one?");
process.exit(1);
}
// At-rules that describe a resource or a name rather than styling an element.
// They carry no cascade priority against a layer, so being outside one is
// correct, and StyleX emits `@property` for its custom properties.
const unlayerable = String.raw`@(?:layer|property|keyframes|font-face|counter-style|charset|import)`;
/** Strip comments, then every balanced block and statement the rule above allows. */
function outsideLayers(css) {
let rest = css.replace(/\/\*[\s\S]*?\*\//g, "");
for (;;) {
const at = rest.search(new RegExp(`${unlayerable}[^{;]*\\{`));
if (at === -1) break;
let depth = 0;
let end = rest.indexOf("{", at);
for (let i = end; i < rest.length; i += 1) {
if (rest[i] === "{") depth += 1;
else if (rest[i] === "}") {
depth -= 1;
if (depth === 0) {
end = i;
break;
}
}
}
rest = rest.slice(0, at) + rest.slice(end + 1);
}
return rest.replace(new RegExp(`${unlayerable}[^;{}]*;`, "g"), "");
}
/**
* A rule that only sets custom properties styles nothing on its own StyleX
* emits its token `:root` block that way, ahead of its layers, and a variable
* is consumed through `var()` rather than competing with a layered rule.
*/
function stylesSomething(body) {
return body
.split(";")
.map((declaration) => declaration.trim())
.some((declaration) => declaration.length > 0 && !declaration.startsWith("--"));
}
/**
* Layer names in the order their position is fixed, which is where each name is
* first mentioned a later block under an already-named layer does not move it.
*/
function layerOrder(css) {
const seen = [];
for (const [, names] of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@layer\s+([^{;]+)[{;]/g)) {
for (const name of names.split(",")) {
const trimmed = name.trim();
if (trimmed.length > 0 && !seen.includes(trimmed)) seen.push(trimmed);
}
}
return seen;
}
let failed = false;
for (const sheet of sheets) {
const css = readFileSync(join(distDir, sheet), "utf8");
// Order is the whole point: a later layer wins, so the reset has to be first.
const order = layerOrder(css);
if (order.length > 0 && order[0] !== "reset") {
console.error(
`assert-css-layers: ${sheet} declares layers in the order ${order.join(", ")}` +
`'reset' must come first or it outranks the StyleX rules written against it.`,
);
failed = true;
}
const leftover = outsideLayers(css);
for (const [, selector, body] of leftover.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!stylesSomething(body)) continue;
console.error(
`assert-css-layers: ${sheet} styles elements outside every @layer:\n` +
` ${selector.trim().slice(0, 80)} { ${body.trim().slice(0, 60)} … }`,
);
failed = true;
break;
}
}
if (failed) {
console.error("Wrap it in a layer declared before StyleX's, as web/src/styles.css does.");
process.exit(1);
}
console.log(
`every rule in ${sheets.length === 1 ? "the stylesheet" : `${sheets.length} stylesheets`} sits inside a cascade layer`,
);
+59 -10
View File
@@ -1,8 +1,57 @@
import { useEffect, useState, type FormEvent } from "react";
import { useRouter, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
/** Login renders outside AppShell, so it paints the page ground itself. */
page: {
display: "flex",
minHeight: "100dvh",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.surface,
color: colors.text,
padding: "1rem",
},
card: {
width: "100%",
maxWidth: "24rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
probing: {
marginTop: "1rem",
color: colors.textMuted,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1.5rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
submit: {
width: "100%",
},
error: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
});
export function safeRedirect(raw: string | undefined): string {
if (raw === undefined) return "/";
@@ -76,15 +125,15 @@ export default function LoginPage() {
}
return (
<main className="flex min-h-dvh items-center justify-center bg-zinc-50 p-4 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<section className="w-full max-w-sm">
<h1 className="text-2xl font-semibold">nxdns</h1>
<main {...stylex.props(styles.page)}>
<section {...stylex.props(styles.card)}>
<h1 {...stylex.props(styles.heading)}>nxdns</h1>
{authRequired !== true ? (
<p className="mt-4 text-zinc-500">Checking whether a password is required</p>
<p {...stylex.props(styles.probing)}>Checking whether a password is required</p>
) : (
<form onSubmit={onSubmit} className="mt-6 space-y-4">
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<div>
<label htmlFor="password" className="block text-sm font-medium">
<label htmlFor="password" {...stylex.props(styles.fieldLabel)}>
Password
</label>
<input
@@ -95,20 +144,20 @@ export default function LoginPage() {
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<button
type="submit"
disabled={busy || lockedOut}
className={`w-full ${largePrimaryButtonClass}`}
{...stylex.props(shared.largePrimaryButton, styles.submit, shared.focusRing)}
>
{busy ? "Logging in…" : "Log in"}
</button>
</form>
)}
{error !== null && (
<p role="alert" className="mt-4 text-sm text-red-600 dark:text-red-400">
<p role="alert" {...stylex.props(styles.error)}>
{errorMessage(error, remaining)}
</p>
)}
@@ -10,7 +10,9 @@ test("swallowMutationError drops an ApiError and rethrows anything else", () =>
test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => {
const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
const { rerender } = render(<BlocklistForm busy={false} error={null} onSubmit={rejecting} onCancel={undefined} />);
const { rerender } = render(
<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={rejecting} onCancel={undefined} />,
);
const url = screen.getByLabelText("URL") as HTMLInputElement;
const name = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } });
@@ -22,7 +24,7 @@ test("a rejected submit leaves the typed values in place; a resolved one clears
expect(name.value).toBe("Example");
const resolving = vi.fn(() => Promise.resolve());
rerender(<BlocklistForm busy={false} error={null} onSubmit={resolving} onCancel={undefined} />);
rerender(<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={resolving} onCancel={undefined} />);
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
await waitFor(() => expect(url.value).toBe(""));
expect(name.value).toBe("");
+63 -13
View File
@@ -1,8 +1,47 @@
import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const styles = stylex.create({
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
checkboxLabel: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
cancel: {
fontWeight: 500,
},
});
/**
* Drops the rejection the page already renders inline below the form. Anything
@@ -17,12 +56,14 @@ export function swallowMutationError(error: unknown): void {
interface BlocklistFormProps {
initial?: Blocklist;
busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null;
onSubmit: (input: BlocklistInput) => Promise<void>;
onCancel?: () => void;
}
export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) {
export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [name, setName] = useState(initial?.name ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -43,10 +84,10 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
}
return (
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3">
<h2 className="text-lg font-medium">{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
<div>
<label htmlFor="blocklist-url" className="block text-sm font-medium">
<label htmlFor="blocklist-url" {...stylex.props(styles.fieldLabel)}>
URL
</label>
<input
@@ -55,11 +96,11 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
required
value={url}
onChange={(event) => setUrl(event.target.value)}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor="blocklist-name" className="block text-sm font-medium">
<label htmlFor="blocklist-name" {...stylex.props(styles.fieldLabel)}>
Name
</label>
<input
@@ -68,24 +109,33 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
required
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<label className="flex items-center gap-2 text-sm font-medium">
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
Enabled
</label>
<div className="flex items-center gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{initial === undefined ? "Add source" : "Save changes"}
</button>
{onCancel !== undefined && (
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
<button
type="button"
onClick={onCancel}
{...stylex.props(shared.button, styles.cancel, shared.focusRing)}
>
Cancel
</button>
)}
@@ -41,14 +41,20 @@ const RESPONSES: Record<string, unknown> = {
};
let resolveUpdate: ((response: Response) => void) | null;
let deleted: string[];
beforeEach(() => {
clearRefreshStatus();
resolveUpdate = null;
deleted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/blocklists/update" && init?.method === "POST") {
return new Promise<Response>((resolve) => {
resolveUpdate = resolve;
@@ -227,3 +233,27 @@ test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useRealTimers();
}
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleted).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that source", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
});
+128 -51
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
@@ -13,20 +14,75 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection";
import {
dangerLinkButtonClass,
focusRing,
linkButtonClass,
primaryButtonClass,
tableWrapClass,
tdClass,
thClass,
} from "@/ui/classes";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
header: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
done: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.border,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.text,
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
export default function BlocklistsPage() {
const queryClient = useQueryClient();
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const [editing, setEditing] = useState<Blocklist | null>(null);
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
const create = useMutation(blocklistCreateMutation(queryClient));
const save = useMutation(blocklistUpdateMutation(queryClient));
@@ -36,6 +92,9 @@ export default function BlocklistsPage() {
const sources = useRefreshStatus();
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
// The refresh below re-fetches the sources the config already declares, so
// it stays live in file mode; every other control here writes config.
const readOnly = useReadOnlyConfig();
async function submitForm(input: BlocklistInput) {
if (editing === null) {
@@ -53,10 +112,10 @@ export default function BlocklistsPage() {
});
}
function deleteBlocklist(b: Blocklist) {
if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) {
remove.mutate(b.id);
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
const formError = editing === null ? create.error : save.error;
@@ -64,89 +123,93 @@ export default function BlocklistsPage() {
return (
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Blocklists</h1>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
<button
type="button"
onClick={() => updateNow.mutate()}
disabled={updateNow.isPending}
className={primaryButtonClass}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{updateNow.isPending ? "Updating…" : "Update now"}
</button>
</div>
{updateNow.isSuccess && !updateNow.isPending && (
<p className="mt-2 text-sm text-green-700 dark:text-green-400" role="status">
<p {...stylex.props(styles.done)} role="status">
Update completed; source status refreshed below.
</p>
)}
<InlineError error={updateNow.error} />
{blocklists.length === 0 ? (
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th className={thClass}>Name</th>
<th className={thClass}>URL</th>
<th className={thClass}>Enabled</th>
<th className={thClass}>Domains</th>
<th className={thClass}>Wildcards</th>
<th className={thClass}>Skipped regex</th>
<th className={thClass}>Last updated</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>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)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td className={tdClass}>
<span className="font-medium">{b.name}</span>
{b.is_suggested && (
<span className="ml-2 rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
Suggested
</span>
)}
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
</td>
<td className={tdClass}>
<span className="block max-w-72 truncate" title={b.url}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.url)} title={b.url}>
{b.url}
</span>
</td>
<td className={tdClass}>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
aria-label={`${b.name} enabled`}
checked={b.enabled}
disabled={toggle.isPending}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
</td>
<td className={`${tdClass} tabular-nums`}>{b.domain_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.wildcard_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.skipped_regex_count}</td>
<td className={tdClass}>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
<td className={tdClass}>
<div className="flex gap-3">
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => setEditing(b)}
className={linkButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => deleteBlocklist(b)}
disabled={remove.isPending}
className={dangerLinkButtonClass}
onClick={() => setPendingDelete(b)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
@@ -164,12 +227,26 @@ export default function BlocklistsPage() {
key={editing?.id ?? "add"}
initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError}
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
/>
<SourceStatusSection sources={sources} namesById={namesById} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete blocklist"
message={
pendingDelete === null
? ""
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
@@ -1,6 +1,8 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { SourceStatus } from "@/lib/types";
import { tdClass, thClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
function formatAttempt(unixSeconds: number): string {
return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
@@ -11,61 +13,108 @@ interface SourceStatusSectionProps {
namesById: ReadonlyMap<number, string>;
}
const styles = stylex.create({
section: {
marginTop: "2rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
note: {
marginTop: "0.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "0.5rem",
overflowX: "auto",
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
url: {
marginTop: "0.125rem",
display: "block",
maxWidth: "16rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
/** Green has no token: a loaded source is the only success state in the app. */
loaded: {
color: {
default: "oklch(52.7% 0.154 150.069)",
"@media (prefers-color-scheme: dark)": "oklch(79.2% 0.209 151.711)",
},
},
failed: {
color: colors.danger,
},
absent: {
color: colors.textMuted,
},
});
export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
return (
<section className="mt-8">
<h2 className="text-lg font-medium">Source status</h2>
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>Source status</h2>
{sources === null ? (
<p className="mt-2 text-zinc-500">
<p {...stylex.props(styles.note)}>
No status snapshot yet run Update now to fetch status for every enabled source.
</p>
) : sources.length === 0 ? (
<p className="mt-2 text-zinc-500">The last update ran against no enabled sources.</p>
<p {...stylex.props(styles.note)}>The last update ran against no enabled sources.</p>
) : (
<div className="mt-2 overflow-x-auto">
<table className="w-full min-w-max border-collapse text-sm">
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th className={thClass}>Source</th>
<th className={thClass}>State</th>
<th className={thClass}>Last attempt</th>
<th className={thClass}>Last success</th>
<th className={thClass}>Domains</th>
<th className={thClass}>Wildcards</th>
<th className={thClass}>Skipped regex</th>
<th className={thClass}>Last error</th>
<th {...stylex.props(shared.th)}>Source</th>
<th {...stylex.props(shared.th)}>State</th>
<th {...stylex.props(shared.th)}>Last attempt</th>
<th {...stylex.props(shared.th)}>Last success</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Last error</th>
</tr>
</thead>
<tbody>
{sources.map((source) => (
<tr key={source.id}>
<td className={tdClass}>
<span className="font-medium">{namesById.get(source.id) ?? source.url}</span>
<span className="mt-0.5 block max-w-64 truncate text-xs text-zinc-500">
{source.url}
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>
{namesById.get(source.id) ?? source.url}
</span>
<span {...stylex.props(styles.url)}>{source.url}</span>
</td>
<td className={tdClass}>
<span
className={
source.loaded
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}
>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(source.loaded ? styles.loaded : styles.failed)}>
{source.state}
</span>
</td>
<td className={tdClass}>{formatAttempt(source.last_attempt)}</td>
<td className={tdClass}>{formatAttempt(source.last_success)}</td>
<td className={`${tdClass} tabular-nums`}>{source.domains}</td>
<td className={`${tdClass} tabular-nums`}>{source.wildcards}</td>
<td className={`${tdClass} tabular-nums`}>{source.skipped_regex}</td>
<td className={tdClass}>
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_attempt)}</td>
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_success)}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.domains}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td>
<td {...stylex.props(shared.td)}>
{source.last_error === "" ? (
<span className="text-zinc-400"></span>
<span {...stylex.props(styles.absent)}></span>
) : (
<span className="text-red-600 dark:text-red-400">{source.last_error}</span>
<span {...stylex.props(styles.failed)}>{source.last_error}</span>
)}
</td>
</tr>
+78 -55
View File
@@ -1,9 +1,13 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
import Dialog from "@/ui/Dialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
client: Client;
@@ -11,68 +15,87 @@ interface Props {
onClose: () => void;
}
const dialogInputClass = `mt-1 w-full ${smallInputClass}`;
const styles = stylex.create({
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
dialogInput: {
marginTop: "0.25rem",
width: "100%",
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
},
});
export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientUpdateMutation(queryClient));
const [name, setName] = useState(client.name);
const [groupId, setGroupId] = useState(client.group_id);
const readOnly = useReadOnlyConfig();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div
role="dialog"
aria-modal="true"
aria-label={`Edit client ${client.ip}`}
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
<form
{...stylex.props(styles.form)}
onSubmit={(event) => {
event.preventDefault();
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
);
}}
>
<h2 className="text-lg font-semibold">Edit {client.ip}</h2>
<form
className="mt-4 space-y-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
);
}}
>
<label className="block text-sm font-medium">
Name
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className={dialogInputClass}
autoFocus
/>
</label>
<label className="block text-sm font-medium">
Group
<select
value={String(groupId)}
onChange={(event) => setGroupId(Number(event.target.value))}
className={dialogInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
</label>
<InlineError error={mutation.error} />
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className={buttonClass}>
Cancel
</button>
<button type="submit" disabled={mutation.isPending} className={primaryButtonClass}>
Save
</button>
</div>
</form>
</div>
</div>
<label {...stylex.props(styles.fieldLabel)}>
Name
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
{...stylex.props(shared.smallInput, styles.dialogInput, shared.focusRing)}
/>
</label>
<Select
label="Group"
variant="compactField"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.actions)}>
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save
</button>
</div>
</form>
</Dialog>
);
}
+20 -5
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -106,10 +106,25 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
expect(dialog).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("1");
// The dialog portals out of the table, so every field query is scoped to it.
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
// The RAC Select names its trigger with the value and then the label.
expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default");
});
test("the group picker offers every group and reports the choice", async () => {
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]);
fireEvent.click(screen.getByRole("option", { name: "kids" }));
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
});
test("prefix editor starts clean and dirties on add", async () => {
+140 -33
View File
@@ -1,14 +1,99 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
import { formatTime } from "@/lib/format";
import type { Client } from "@/lib/types";
import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError";
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const cellClass = "px-3 py-2";
/**
* Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts
* the file and is the one client DELETE the server answers 403 (ruling 7).
*/
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "48rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
right: {
textAlign: "right",
},
dash: {
color: colors.textMuted,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.primary,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
color: colors.primaryText,
},
confirmGroup: {
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
actionGroup: {
display: "inline-flex",
gap: "0.5rem",
},
note: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
/**
* The accessible name of the actions column, kept out of the visual table
* without leaving the accessibility tree.
*/
});
export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery());
@@ -18,49 +103,50 @@ export default function ClientsPage() {
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const readOnly = useReadOnlyConfig();
return (
<section>
<h1 className="text-2xl font-semibold">Clients</h1>
<h1 {...stylex.props(styles.heading)}>Clients</h1>
{clients.length === 0 ? (
<p className="mt-4 text-zinc-500">
<p {...stylex.props(styles.empty)}>
No clients yet. Rows appear automatically as devices on the network make DNS queries there is
nothing to create by hand.
</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-[48rem] text-left text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700">
<th className={cellClass}>IP</th>
<th className={cellClass}>Name</th>
<th className={cellClass}>Group</th>
<th className={cellClass}>First seen</th>
<th className={cellClass}>Last seen</th>
<th className={cellClass}>
<span className="sr-only">Actions</span>
<tr {...stylex.props(styles.headRow)}>
<th {...stylex.props(styles.cell)}>IP</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)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{clients.map((client) => (
<tr key={client.id} className="border-b border-zinc-100 dark:border-zinc-800">
<td className={`${cellClass} font-mono`}>{client.ip}</td>
<td className={cellClass}>
{client.name === "" ? <span className="text-zinc-400"></span> : client.name}
{client.hand_edited && (
<span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
edited
</span>
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
<td {...stylex.props(styles.cell)}>
{client.name === "" ? (
<span {...stylex.props(styles.dash)}></span>
) : (
client.name
)}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
</td>
<td className={cellClass}>{client.group}</td>
<td className={cellClass}>{formatTime(client.first_seen)}</td>
<td className={cellClass}>{formatTime(client.last_seen)}</td>
<td className={`${cellClass} text-right`}>
<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.right)}>
{confirmingId === client.id ? (
<span className="inline-flex flex-wrap items-center justify-end gap-2">
<span className="text-xs text-zinc-500">
<span {...stylex.props(styles.confirmGroup)}>
<span {...stylex.props(styles.note)}>
Deleted clients re-materialize on their next DNS query.
</span>
<button
@@ -69,31 +155,52 @@ export default function ClientsPage() {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
{...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
>
Confirm delete
</button>
<button
type="button"
onClick={() => setConfirmingId(null)}
className={smallButtonClass}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Cancel
</button>
</span>
) : (
<span className="inline-flex gap-2">
<span {...stylex.props(styles.actionGroup)}>
<button
type="button"
onClick={() => setEditing(client)}
className={smallButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.smallButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => setConfirmingId(client.id)}
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
disabled={readOnly && client.hand_edited}
title={
readOnly && client.hand_edited
? DECLARED_CLIENT_NOTE
: undefined
}
{...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
+95 -27
View File
@@ -1,17 +1,87 @@
import { useReducer, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
prefixes: ClientPrefix[];
groups: Group[];
}
const styles = stylex.create({
section: {
marginTop: "2.5rem",
},
heading: {
fontSize: "1.25rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
rows: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "1rem",
listStyleType: "none",
padding: 0,
},
row: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
prefixInput: {
width: "13rem",
},
priorityInput: {
width: "5rem",
},
removeButton: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: "transparent",
paddingInline: "0.5rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
validation: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
actions: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
},
});
export default function PrefixesEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
@@ -19,6 +89,8 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const [validation, setValidation] = useState<string | null>(null);
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig();
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
const save = () => {
const problem = firstProblem(state.rows);
@@ -30,18 +102,18 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
};
return (
<section className="mt-10">
<h2 className="text-xl font-semibold">Client prefixes</h2>
<p className="mt-1 text-sm text-zinc-500">
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
<p {...stylex.props(styles.intro)}>
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
match wins.
</p>
{state.rows.length === 0 ? (
<p className="mt-4 text-sm text-zinc-500">No prefixes configured.</p>
<p {...stylex.props(styles.empty)}>No prefixes configured.</p>
) : (
<ul className="mt-4 space-y-2">
<ul {...stylex.props(styles.rows)}>
{state.rows.map((row, index) => (
<li key={index} className="flex flex-wrap items-center gap-2">
<li key={index} {...stylex.props(styles.row)}>
<input
type="text"
aria-label={`Prefix ${index + 1}`}
@@ -50,22 +122,17 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
}
className={`${smallInputClass} w-52`}
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/>
<select
<Select
aria-label={`Group for prefix ${index + 1}`}
variant="inline"
value={String(row.group_id)}
onChange={(event) =>
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } })
onChange={(value) =>
dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
}
className={smallInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
options={groupOptions}
/>
<input
type="text"
inputMode="numeric"
@@ -75,12 +142,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
}
className={`${smallInputClass} w-20`}
{...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
/>
<button
type="button"
onClick={() => dispatch({ type: "remove", index })}
className={`rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 ${focusRing} dark:border-zinc-700 dark:text-red-400`}
{...stylex.props(styles.removeButton, shared.focusRing)}
>
Remove
</button>
@@ -89,24 +156,25 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
</ul>
)}
{validation !== null && (
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-400">
<p role="alert" {...stylex.props(styles.validation)}>
{validation}
</p>
)}
<InlineError error={mutation.error} />
<div className="mt-4 flex gap-2">
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
className={buttonClass}
{...stylex.props(shared.button, shared.focusRing)}
>
Add prefix
</button>
<button
type="button"
onClick={save}
disabled={!dirty || mutation.isPending}
className={primaryButtonClass}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save prefixes
</button>
@@ -117,7 +185,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
className={buttonClass}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard changes
</button>
+94 -14
View File
@@ -1,9 +1,11 @@
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { focusRing } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import DiskCard from "./DiskCard";
import HealthBanners from "./HealthBanners";
import StatCards from "./StatCards";
@@ -12,20 +14,98 @@ import UpstreamHealthTable from "./UpstreamHealthTable";
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
const styles = stylex.create({
page: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
titleRow: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
periodGroup: {
display: "flex",
gap: "0.25rem",
},
period: {
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
paddingBlock: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
periodIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
},
/** Dynamic: the caller sizes the placeholder to the widget it stands in for. */
skeletonHeight: (height: number) => ({ height }),
skeleton: {
borderRadius: "0.25rem",
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
},
},
/** The chart takes two thirds beside the disk card from `lg`, one column below. */
panelGrid: {
display: "grid",
gap: "1rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 1024px)": "2fr 1fr",
},
},
panel: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
panelHeading: {
marginBottom: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
});
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return (
<div role="group" aria-label="Period" className="flex gap-1">
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
{PERIODS.map((option) => (
<button
key={option}
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
className={`rounded px-2.5 py-1 text-sm ${focusRing} ${
option === period
? "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
}`}
{...stylex.props(
styles.period,
option === period ? styles.periodSelected : styles.periodIdle,
shared.focusRing,
)}
>
{option}
</button>
@@ -35,7 +115,7 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
}
function Skeleton({ height }: { height: number }) {
return <div aria-hidden="true" className="animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" style={{ height }} />;
return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
}
export default function DashboardPage() {
@@ -46,9 +126,9 @@ export default function DashboardPage() {
const upstreamHealth = useQuery(upstreamHealthQuery());
return (
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<section {...stylex.props(styles.page)}>
<div {...stylex.props(styles.titleRow)}>
<h1 {...stylex.props(styles.heading)}>Dashboard</h1>
<PeriodPicker period={period} onChange={setPeriod} />
</div>
@@ -62,9 +142,9 @@ export default function DashboardPage() {
<StatCards stats={stats.data} />
)}
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
<h2 className="mb-3 text-sm font-semibold">Queries over time</h2>
<div {...stylex.props(styles.panelGrid)}>
<section {...stylex.props(styles.panel)}>
<h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
{timeseries.isError ? (
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
) : timeseries.data === undefined ? (
+82 -20
View File
@@ -1,33 +1,95 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const STATE_CLASSES: Record<Health["disk"]["state"], string> = {
ok: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
warn: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
critical: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
};
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
heading: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
badge: {
borderRadius: "0.25rem",
paddingInline: "0.5rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/**
* The badge fills are their own three-step scale, not the `danger`/`warn`
* banner tokens: they read as a tinted chip against a raised card, where a
* banner fill would be too heavy.
*/
ok: {
backgroundColor: { default: "oklch(95% 0.052 163.051)", [DARK]: "oklch(26.2% 0.051 172.552)" },
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
},
warn: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
critical: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
row: {
display: "flex",
justifyContent: "space-between",
},
term: {
color: colors.textMuted,
},
});
function stateStyle(state: Health["disk"]["state"]) {
if (state === "critical") return styles.critical;
return state === "warn" ? styles.warn : styles.ok;
}
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
return (
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
<h2 className="flex items-center justify-between text-sm font-semibold">
<section {...stylex.props(styles.card)}>
<h2 {...stylex.props(styles.heading)}>
Disk
<span className={`rounded px-2 py-0.5 text-xs font-medium ${STATE_CLASSES[disk.state]}`}>
{disk.state}
</span>
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
</h2>
<dl className="mt-3 space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-zinc-500">Free</dt>
<dd className="tabular-nums">{formatBytes(disk.free_bytes)}</dd>
<dl {...stylex.props(styles.list)}>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Free</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-zinc-500">Database</dt>
<dd className="tabular-nums">{formatBytes(disk.db_bytes)}</dd>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Database</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-zinc-500">Logs</dt>
<dd className="tabular-nums">{formatBytes(disk.log_bytes)}</dd>
<div {...stylex.props(styles.row)}>
<dt {...stylex.props(styles.term)}>Logs</dt>
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
</div>
</dl>
</section>
+31 -6
View File
@@ -1,13 +1,38 @@
import * as stylex from "@stylexjs/stylex";
import { formatBytes } from "@/lib/format";
import type { Health } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
stack: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
banner: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
warn: {
borderColor: colors.warnBorder,
backgroundColor: colors.warnSurface,
color: colors.warnText,
},
critical: {
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
color: colors.dangerText,
},
});
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
const classes =
tone === "critical"
? "border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200"
: "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200";
return (
<p role="alert" className={`rounded border px-4 py-2 text-sm ${classes}`}>
<p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
{children}
</p>
);
@@ -39,5 +64,5 @@ export default function HealthBanners({ health }: { health: Health }) {
);
}
if (banners.length === 0) return null;
return <div className="space-y-2">{banners}</div>;
return <div {...stylex.props(styles.stack)}>{banners}</div>;
}
+46 -5
View File
@@ -1,8 +1,49 @@
import * as stylex from "@stylexjs/stylex";
import { formatMicros } from "@/lib/format";
import type { StatsTotals } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const numberFormat = new Intl.NumberFormat();
const styles = stylex.create({
/** Two columns on a phone, three from `md`, five from `xl`, as before. */
grid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(2, minmax(0, 1fr))",
"@media (min-width: 768px)": "repeat(3, minmax(0, 1fr))",
"@media (min-width: 1280px)": "repeat(5, minmax(0, 1fr))",
},
},
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
label: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
value: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
detail: {
marginLeft: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
function percentOf(part: number, total: number): string | null {
if (total === 0) return null;
return `${((part / total) * 100).toFixed(1)}%`;
@@ -10,11 +51,11 @@ function percentOf(part: number, total: number): string | null {
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
return (
<div className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
<dt className="text-sm text-zinc-500">{label}</dt>
<div {...stylex.props(styles.card)}>
<dt {...stylex.props(styles.label)}>{label}</dt>
<dd>
<span className="text-2xl font-semibold tabular-nums">{value}</span>
{detail != null && <span className="ml-2 text-sm text-zinc-500 tabular-nums">{detail}</span>}
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
</dd>
</div>
);
@@ -22,7 +63,7 @@ function Card({ label, value, detail }: { label: string; value: string; detail?:
export default function StatCards({ stats }: { stats: StatsTotals }) {
return (
<dl className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
<dl {...stylex.props(styles.grid)}>
<Card label="Queries" value={numberFormat.format(stats.queries)} />
<Card
label="Blocked"
+131 -30
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { StatsTimeseries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
// Series colors validated for CVD separation and 3:1 surface contrast in both
@@ -14,6 +17,108 @@ const SERIES = [
const CHART_HEIGHT = 240;
const FALLBACK_WIDTH = 640;
const styles = stylex.create({
empty: {
display: "flex",
alignItems: "center",
justifyContent: "center",
height: CHART_HEIGHT,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "dashed",
borderColor: colors.borderStrong,
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
chartRoot: {
position: "relative",
},
tooltip: {
pointerEvents: "none",
position: "absolute",
top: "0.5rem",
zIndex: 10,
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
/** Dynamic: the tooltip flips to whichever side of the bar has room. */
tooltipLeft: (left: number) => ({ left, right: null }),
tooltipRight: (right: number) => ({ left: null, right }),
tooltipTitle: {
fontWeight: 500,
},
tooltipList: {
display: "flex",
flexDirection: "column",
gap: "0.125rem",
marginTop: "0.25rem",
},
tooltipRow: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
},
tooltipTerm: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
color: colors.textMuted,
},
swatch: {
display: "inline-block",
borderRadius: "0.125rem",
},
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
swatchColor: (color: string) => ({ backgroundColor: color }),
swatchSmall: {
width: "0.5rem",
height: "0.5rem",
},
swatchLarge: {
width: "0.625rem",
height: "0.625rem",
},
gridLine: {
stroke: colors.border,
},
axisLine: {
stroke: colors.borderStrong,
},
axisLabel: {
fill: colors.textMuted,
fontSize: "10px",
},
/** The hairline separating touching segments is the page ground, not a colour. */
segment: {
stroke: colors.surface,
},
legend: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
columnGap: "1rem",
rowGap: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textSecondary,
},
legendItem: {
display: "flex",
alignItems: "center",
gap: "0.375rem",
},
});
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
@@ -46,28 +151,29 @@ function barSummary(bar: BarLayout): string {
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
const centerX = bar.slot.x + bar.slot.width / 2;
const leftHalf = centerX < chartWidth / 2;
const side = leftHalf
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
: styles.tooltipRight(chartWidth - centerX + 8);
return (
<div
className="pointer-events-none absolute top-2 z-10 rounded border border-zinc-200 bg-white px-3 py-2 text-xs shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
style={leftHalf ? { left: Math.min(centerX + 8, chartWidth - 160) } : { right: chartWidth - centerX + 8 }}
>
<div className="font-medium">{formatTime(bar.bucket.ts)}</div>
<dl className="mt-1 space-y-0.5">
<div className="flex justify-between gap-4">
<dt className="text-zinc-500">Queries</dt>
<dd className="tabular-nums">{bar.bucket.queries}</dd>
<div {...stylex.props(styles.tooltip, side)}>
<div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
<dl {...stylex.props(styles.tooltipList)}>
<div {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
<dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
</div>
{SERIES.map((series) => (
<div key={series.key} className="flex items-center justify-between gap-4">
<dt className="flex items-center gap-1.5 text-zinc-500">
<div key={series.key} {...stylex.props(styles.tooltipRow)}>
<dt {...stylex.props(styles.tooltipTerm)}>
<span
aria-hidden="true"
className="inline-block size-2 rounded-xs"
style={{ backgroundColor: series.color }}
{...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
/>
{series.label}
</dt>
<dd className="tabular-nums">{series.key === "other" ? bar.other : bar.bucket[series.key]}</dd>
<dd {...stylex.props(shared.tabularNums)}>
{series.key === "other" ? bar.other : bar.bucket[series.key]}
</dd>
</div>
))}
</dl>
@@ -82,11 +188,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
return (
<div
ref={containerRef}
className="flex items-center justify-center rounded border border-dashed border-zinc-300 text-sm text-zinc-500 dark:border-zinc-700"
style={{ height: CHART_HEIGHT }}
>
<div ref={containerRef} {...stylex.props(styles.empty)}>
No queries in this period.
</div>
);
@@ -97,7 +199,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
return (
<div ref={containerRef} className="relative">
<div ref={containerRef} {...stylex.props(styles.chartRoot)}>
<svg
role="img"
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
@@ -113,14 +215,14 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width}
y1={tick.y}
y2={tick.y}
className="stroke-zinc-200 dark:stroke-zinc-800"
{...stylex.props(styles.gridLine)}
/>
<text
x={layout.plot.x - 6}
y={tick.y}
textAnchor="end"
dominantBaseline="middle"
className="fill-zinc-500 text-[10px] tabular-nums"
{...stylex.props(styles.axisLabel, shared.tabularNums)}
>
{compact.format(tick.value)}
</text>
@@ -131,7 +233,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x2={layout.plot.x + layout.plot.width}
y1={baseline}
y2={baseline}
className="stroke-zinc-300 dark:stroke-zinc-700"
{...stylex.props(styles.axisLine)}
/>
{layout.xTicks.map((tick) => (
<text
@@ -139,7 +241,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
x={tick.x}
y={baseline + 14}
textAnchor="middle"
className="fill-zinc-500 text-[10px]"
{...stylex.props(styles.axisLabel)}
>
{formatTick(tick.ts, data.bucket_seconds)}
</text>
@@ -157,8 +259,8 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
width={rect.width}
height={rect.height}
fill={series.color}
className="stroke-zinc-50 dark:stroke-zinc-950"
strokeWidth={rect.width > 3 ? 1 : 0}
{...stylex.props(styles.segment)}
/>
);
})}
@@ -179,19 +281,18 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
))}
</svg>
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400">
<ul {...stylex.props(styles.legend)}>
{SERIES.map((series) => (
<li key={series.key} className="flex items-center gap-1.5">
<li key={series.key} {...stylex.props(styles.legendItem)}>
<span
aria-hidden="true"
className="inline-block size-2.5 rounded-xs"
style={{ backgroundColor: series.color }}
{...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
/>
{series.label}
</li>
))}
</ul>
<table className="sr-only">
<table {...stylex.props(shared.srOnly)}>
<caption>Queries per time bucket</caption>
<thead>
<tr>
@@ -1,64 +1,150 @@
import * as stylex from "@stylexjs/stylex";
import type { UpstreamHealth } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
card: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
},
heading: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
paddingInline: "1rem",
paddingTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 600,
},
count: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 400,
color: colors.textMuted,
},
empty: {
paddingInline: "1rem",
paddingBlock: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
tableWrap: {
overflowX: "auto",
},
table: {
marginTop: "0.5rem",
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
textAlign: "left",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
th: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontWeight: 500,
},
thRight: {
textAlign: "right",
},
/** No hairline under the last row: the card border already closes the table. */
row: {
borderBottomWidth: { default: 1, ":last-child": 0 },
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
},
cellRight: {
textAlign: "right",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
},
muted: {
color: colors.textMuted,
},
bad: {
color: colors.danger,
},
});
function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
const bad = value === badValue;
return <span className={bad ? "text-red-700 dark:text-red-400" : ""}>{value ? "yes" : "no"}</span>;
return <span {...stylex.props(bad && styles.bad)}>{value ? "yes" : "no"}</span>;
}
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
return (
<section className="rounded border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<h2 className="flex items-baseline justify-between px-4 pt-3 text-sm font-semibold">
<section {...stylex.props(styles.card)}>
<h2 {...stylex.props(styles.heading)}>
Upstreams
<span className="text-xs font-normal text-zinc-500 tabular-nums">
<span {...stylex.props(styles.count, shared.tabularNums)}>
{health.available}/{health.total} available
</span>
</h2>
{health.upstreams.length === 0 ? (
<p className="px-4 py-3 text-sm text-zinc-500">No upstreams configured.</p>
<p {...stylex.props(styles.empty)}>No upstreams configured.</p>
) : (
<div className="overflow-x-auto">
<table className="mt-2 w-full text-sm">
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr className="border-b border-zinc-200 text-left text-xs text-zinc-500 dark:border-zinc-800">
<th scope="col" className="px-4 py-2 font-medium">
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.th)}>
URL
</th>
<th scope="col" className="px-4 py-2 font-medium">
<th scope="col" {...stylex.props(styles.th)}>
Enabled
</th>
<th scope="col" className="px-4 py-2 font-medium">
<th scope="col" {...stylex.props(styles.th)}>
Available
</th>
<th scope="col" className="px-4 py-2 text-right font-medium">
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Failures
</th>
<th scope="col" className="px-4 py-2 text-right font-medium">
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Success rate
</th>
<th scope="col" className="px-4 py-2 font-medium">
<th scope="col" {...stylex.props(styles.th)}>
Last error
</th>
</tr>
</thead>
<tbody>
{health.upstreams.map((upstream) => (
<tr
key={upstream.url}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800/50"
>
<td className="px-4 py-2 font-mono text-xs">{upstream.url}</td>
<td className="px-4 py-2">
<tr key={upstream.url} {...stylex.props(styles.row)}>
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>{upstream.url}</td>
<td {...stylex.props(styles.cell)}>
<YesNo value={upstream.enabled} badValue={false} />
</td>
<td className="px-4 py-2">
<td {...stylex.props(styles.cell)}>
<YesNo value={upstream.available} badValue={false} />
</td>
<td className="px-4 py-2 text-right tabular-nums">{upstream.total_failures}</td>
<td className="px-4 py-2 text-right tabular-nums">
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{upstream.total_failures}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{(upstream.success_rate * 100).toFixed(1)}%
</td>
<td className="px-4 py-2 text-xs text-zinc-500">{upstream.last_error || "—"}</td>
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
{upstream.last_error || "—"}
</td>
</tr>
))}
</tbody>
+49 -15
View File
@@ -1,25 +1,58 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
groupId: number;
blocklists: Blocklist[];
}
const styles = stylex.create({
note: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
root: {
marginTop: "0.75rem",
},
list: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
buttonRow: {
marginTop: "0.75rem",
display: "flex",
gap: "0.5rem",
},
});
export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const queryClient = useQueryClient();
const sources = useQuery(groupSourcesQuery(groupId));
const mutation = useMutation(groupSourcesPutMutation(queryClient));
const [selected, setSelected] = useState<number[] | null>(null);
const readOnly = useReadOnlyConfig();
if (sources.isPending) {
return (
<p role="status" className="mt-3 text-sm text-zinc-500">
<p role="status" {...stylex.props(styles.note)}>
Loading sources
</p>
);
@@ -27,27 +60,23 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
if (sources.isError) return <InlineError error={sources.error} />;
if (blocklists.length === 0) {
return (
<p className="mt-3 text-sm text-zinc-500">
No blocklist sources exist yet add them on the Blocklists page.
</p>
);
return <p {...stylex.props(styles.note)}>No blocklist sources exist yet add them on the Blocklists page.</p>;
}
const current = selected ?? sources.data;
const dirty = !sameSet(current, sources.data);
return (
<div className="mt-3">
<ul className="space-y-1">
<div {...stylex.props(styles.root)}>
<ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<label className="inline-flex items-center gap-2 text-sm">
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
{blocklist.name}
</label>
@@ -55,19 +84,24 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
))}
</ul>
<InlineError error={mutation.error} />
<div className="mt-3 flex gap-2">
<div {...stylex.props(styles.buttonRow)}>
<button
type="button"
disabled={!dirty || mutation.isPending}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
}
className={primaryButtonClass}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save sources
</button>
{dirty && (
<button type="button" onClick={() => setSelected(null)} className={buttonClass}>
<button
type="button"
onClick={() => setSelected(null)}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard
</button>
)}
+132 -29
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
blocklistsQuery,
groupCreateMutation,
@@ -10,12 +11,85 @@ import {
import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const groupButtonClass = `${smallButtonClass} disabled:opacity-50`;
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
createForm: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
fieldLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
list: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1.5rem",
},
row: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
padding: "1rem",
},
rowControls: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
renameForm: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
name: {
fontWeight: 500,
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
actions: {
marginLeft: "auto",
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
groupButton: {
opacity: { default: 1, ":disabled": 0.5 },
},
destructive: {
color: colors.danger,
},
lockNote: {
marginTop: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
export default function GroupsPage() {
const { data: groups } = useSuspenseQuery(groupsQuery());
@@ -23,12 +97,13 @@ export default function GroupsPage() {
const queryClient = useQueryClient();
const createMutation = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState("");
const readOnly = useReadOnlyConfig();
return (
<section>
<h1 className="text-2xl font-semibold">Groups</h1>
<h1 {...stylex.props(styles.heading)}>Groups</h1>
<form
className="mt-4 flex flex-wrap items-center gap-2"
{...stylex.props(styles.createForm)}
onSubmit={(event) => {
event.preventDefault();
const name = newName.trim();
@@ -36,7 +111,7 @@ export default function GroupsPage() {
createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
}}
>
<label className="text-sm font-medium" htmlFor="new-group-name">
<label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
New group
</label>
<input
@@ -44,14 +119,20 @@ export default function GroupsPage() {
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
className={smallInputClass}
disabled={readOnly}
{...stylex.props(shared.smallInput, shared.focusRing)}
/>
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
<button
type="submit"
disabled={createMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Create
</button>
</form>
<InlineError error={createMutation.error} />
<ul className="mt-6 space-y-4">
<ul {...stylex.props(styles.list)}>
{groups.map((group) => (
<GroupRow key={group.id} group={group} blocklists={blocklists} />
))}
@@ -69,13 +150,15 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
const [confirming, setConfirming] = useState(false);
const [expanded, setExpanded] = useState(false);
const isDefault = group.id === DEFAULT_GROUP_ID;
const readOnly = useReadOnlyConfig();
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
return (
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700">
<div className="flex flex-wrap items-center gap-3">
<li {...stylex.props(styles.row)}>
<div {...stylex.props(styles.rowControls)}>
{renaming ? (
<form
className="flex items-center gap-2"
{...stylex.props(styles.renameForm)}
onSubmit={(event) => {
event.preventDefault();
const trimmed = name.trim();
@@ -91,10 +174,15 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
aria-label={`New name for ${group.name}`}
value={name}
onChange={(event) => setName(event.target.value)}
className={smallInputClass}
{...stylex.props(shared.smallInput, shared.focusRing)}
autoFocus
/>
<button type="submit" disabled={updateMutation.isPending} className={groupButtonClass}>
<button
type="submit"
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Save
</button>
<button
@@ -103,20 +191,21 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name);
setRenaming(false);
}}
className={groupButtonClass}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Cancel
</button>
</form>
) : (
<span className="font-medium">{group.name}</span>
<span {...stylex.props(styles.name)}>{group.name}</span>
)}
<label className="inline-flex items-center gap-2 text-sm">
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={group.safe_search}
disabled={updateMutation.isPending}
className={focusRing}
disabled={updateMutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.focusRing)}
onChange={(event) =>
updateMutation.mutate({
id: group.id,
@@ -126,25 +215,25 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
/>
Safe search
</label>
<span className="ml-auto inline-flex flex-wrap items-center gap-2">
<span {...stylex.props(styles.actions)}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)}
className={groupButtonClass}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Sources
</button>
{!renaming && (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => {
setName(group.name);
setRenaming(true);
}}
className={groupButtonClass}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Rename
</button>
@@ -157,28 +246,42 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setConfirming(false);
deleteMutation.mutate(group.id);
}}
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
{...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
>
Confirm delete
</button>
<button type="button" onClick={() => setConfirming(false)} className={groupButtonClass}>
<button
type="button"
onClick={() => setConfirming(false)}
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
>
Cancel
</button>
</>
) : (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
disabled={isDefault || readOnly}
title={lockNote}
onClick={() => setConfirming(true)}
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
{...stylex.props(
shared.smallButton,
styles.groupButton,
styles.destructive,
shared.focusRing,
)}
>
Delete
</button>
)}
</span>
</div>
{isDefault && <p className="mt-2 text-xs text-zinc-500">{DEFAULT_GROUP_NOTE}</p>}
{isDefault && <p {...stylex.props(styles.lockNote)}>{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={updateMutation.error ?? deleteMutation.error} />
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
</li>
+7 -1
View File
@@ -50,8 +50,14 @@ test("streams rows, flags blocked ones, and freezes the display", () => {
expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
expect(screen.getByText("AAAA")).toBeTruthy();
// StyleX compiles to opaque class names, so the check is structural: a blocked
// row carries every class a plain row does, plus the ones the flag adds.
const blockedRow = screen.getByText("ads.example").closest("tr");
expect(blockedRow?.className).toContain("bg-red-50");
const plainRow = screen.getByText("ok.example").closest("tr");
const blockedClasses = new Set(blockedRow?.className.split(" "));
const plainClasses = plainRow?.className.split(" ") ?? [];
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
const freeze = screen.getByRole("button", { name: "Freeze" });
fireEvent.click(freeze);
+175 -33
View File
@@ -1,20 +1,164 @@
import * as stylex from "@stylexjs/stylex";
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
import { RING_CAPACITY } from "./ringBuffer";
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
import { buttonClass, retryButtonClass, tableWrapClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const toolbarButtonClass = `${buttonClass} font-medium`;
const DARK = "@media (prefers-color-scheme: dark)";
const styles = stylex.create({
toolbar: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
toolbarButton: {
fontWeight: 500,
},
pill: {
borderRadius: "9999px",
paddingInline: "0.625rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/** Four stream states need four tints; only two of them map onto a token role. */
pillConnecting: {
backgroundColor: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
color: { default: "oklch(37% 0.013 285.805)", [DARK]: "oklch(87.1% 0.006 286.286)" },
},
pillOpen: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(39.3% 0.095 152.535)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(92.5% 0.084 155.995)" },
},
pillRetrying: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(92.4% 0.12 95.746)" },
},
pillCapped: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
},
note: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** Informational, neither a warning nor a failure, so the blue ramp stands alone. */
resumed: {
marginTop: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" },
backgroundColor: { default: "oklch(97% 0.014 254.604)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(88.2% 0.059 254.128)" },
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
dismiss: {
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
fontWeight: 500,
textDecorationLine: "underline",
},
failureNote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
cappedBox: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
padding: "1rem",
},
cappedHeading: {
fontWeight: 600,
color: colors.dangerText,
},
cappedDetail: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
empty: {
marginTop: "1.5rem",
color: colors.textMuted,
},
tableWrap: {
marginTop: "1rem",
overflowX: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
table: {
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
row: {
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
rowBlocked: {
backgroundColor: {
default: "oklch(97.1% 0.013 17.38)",
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
},
},
footnote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
const PILL_LABELS: Record<StreamStatus, string> = {
connecting: "Connecting…",
open: "Live",
retrying: "Reconnecting…",
capped: "Disconnected",
};
function pillStyle(status: StreamStatus) {
if (status === "open") return styles.pillOpen;
if (status === "retrying") return styles.pillRetrying;
if (status === "capped") return styles.pillCapped;
return styles.pillConnecting;
}
function StatusPill({ status }: { status: StreamStatus }) {
const styles: Record<StreamStatus, [string, string]> = {
connecting: ["Connecting…", "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"],
open: ["Live", "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"],
retrying: ["Reconnecting…", "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200"],
capped: ["Disconnected", "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"],
};
const [label, tone] = styles[status];
const label = PILL_LABELS[status];
return (
<span role="status" aria-label={label} className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${tone}`}>
<span role="status" aria-label={label} {...stylex.props(styles.pill, pillStyle(status))}>
{label}
</span>
);
@@ -27,58 +171,56 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
return (
<section>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold">Live</h1>
<div {...stylex.props(styles.toolbar)}>
<h1 {...stylex.props(styles.heading)}>Live</h1>
<StatusPill status={live.status} />
<button
type="button"
onClick={live.toggleFreeze}
aria-pressed={live.frozen}
className={toolbarButtonClass}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
{live.frozen ? "Resume" : "Freeze"}
</button>
</div>
{live.frozen && (
<p className="mt-2 text-sm text-zinc-500" role="status">
<p {...stylex.props(styles.note)} role="status">
Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
kept).
</p>
)}
{live.missed !== null && (
<div
role="status"
className="mt-3 flex items-center gap-3 rounded border border-blue-300 bg-blue-50 px-3 py-2 text-sm text-blue-800 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-200"
>
<div role="status" {...stylex.props(styles.resumed)}>
<span>
Stream resumed {" "}
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
</span>
<button type="button" onClick={live.dismissMissed} className="font-medium underline">
<button
type="button"
onClick={live.dismissMissed}
{...stylex.props(styles.dismiss, shared.focusRing)}
>
Dismiss
</button>
</div>
)}
{live.resyncFailed && (
<p role="alert" className="mt-3 text-sm text-red-700 dark:text-red-300">
<p role="alert" {...stylex.props(styles.failureNote)}>
Stream resumed, but re-syncing the gap failed some queries may be missing here.
</p>
)}
{live.status === "capped" && (
<div
role="alert"
className="mt-4 rounded border border-red-300 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950"
>
<h2 className="font-semibold text-red-800 dark:text-red-200">Live stream unavailable</h2>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
<div role="alert" {...stylex.props(styles.cappedBox)}>
<h2 {...stylex.props(styles.cappedHeading)}>Live stream unavailable</h2>
<p {...stylex.props(styles.cappedDetail)}>
The connection failed repeatedly possibly too many live viewers (the server caps streams per
address), or the server is unreachable.
</p>
<button type="button" onClick={live.retry} className={retryButtonClass}>
<button type="button" onClick={live.retry} {...stylex.props(shared.retryButton, shared.focusRing)}>
Retry
</button>
</div>
@@ -86,25 +228,25 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
{live.rows.length === 0 ? (
live.status !== "capped" && (
<p className="mt-6 text-zinc-500">
<p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p>
)
) : (
<>
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
<table className="w-full text-sm">
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<QueryTableHead />
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
<tbody>
{live.rows.map((row) => (
<tr key={row.key} className={row.blocked ? "bg-red-50 dark:bg-red-950/40" : ""}>
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
<QueryCells row={row} />
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-3 text-sm text-zinc-500">
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
{RING_CAPACITY} kept).
</p>
+87 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -24,6 +24,12 @@ function createFetchMock() {
records = [...records, created];
return json(created, 201);
}
if (url.startsWith("/api/local-records/") && method === "DELETE") {
const id = Number(url.slice("/api/local-records/".length));
records = records.filter((record) => record.id !== id);
return new Response(null, { status: 204 });
}
if (url.startsWith("/api/forward-zones/") && method === "DELETE") return new Response(null, { status: 204 });
if (url === "/api/forward-zones" && method === "GET") {
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
}
@@ -65,13 +71,34 @@ test("renders the records table and switches to the forward zones tab", async ()
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
});
test("the arrow keys move between tabs", async () => {
renderPage();
await screen.findByText("nas.lan.home");
const tablist = screen.getByRole("tablist", { name: "Local DNS" });
const records = screen.getByRole("tab", { name: "Records" });
expect(records.getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(tablist, { key: "ArrowRight" });
const zones = screen.getByRole("tab", { name: "Forward zones" });
expect(zones.getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("false");
await screen.findByText("lan.home");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true");
await screen.findByText("nas.lan.home");
});
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
fireEvent.change(screen.getByLabelText("Type"), { target: { value: "AAAA" } });
// The record type is a RAC Select now: open the listbox, then pick.
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
@@ -83,3 +110,61 @@ test("creates a record: POST body per LocalRecordInput, list refreshes", async (
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
});
test("cancelling the record delete dialog sends no request", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
expect(screen.getByText("nas.lan.home")).toBeTruthy();
});
test("confirming the record delete dialog issues the DELETE", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/local-records/1",
),
).toBe(true),
);
await waitFor(() => expect(screen.queryByText("nas.lan.home")).toBeNull());
});
test("the forward zone delete dialog names the zone and confirms", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/forward-zones/7",
),
).toBe(true),
);
});
+17 -69
View File
@@ -1,79 +1,27 @@
import { useState } from "react";
import * as stylex from "@stylexjs/stylex";
import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab";
import { focusRing } from "@/ui/classes";
import Tabs from "@/ui/Tabs";
type Tab = "records" | "zones";
function TabButton({
id,
controls,
selected,
onClick,
children,
}: {
id: string;
controls: string;
selected: boolean;
onClick: () => void;
children: string;
}) {
return (
<button
type="button"
role="tab"
id={id}
aria-controls={controls}
aria-selected={selected}
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 font-medium ${focusRing} ${
selected
? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
{children}
</button>
);
}
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
});
export default function LocalDnsPage() {
const [tab, setTab] = useState<Tab>("records");
return (
<section>
<h1 className="text-2xl font-semibold">Local DNS</h1>
<div
role="tablist"
aria-label="Local DNS"
className="mt-4 flex gap-2 border-b border-zinc-200 dark:border-zinc-800"
>
<TabButton
id="tab-records"
controls="panel-records"
selected={tab === "records"}
onClick={() => setTab("records")}
>
Records
</TabButton>
<TabButton
id="tab-zones"
controls="panel-zones"
selected={tab === "zones"}
onClick={() => setTab("zones")}
>
Forward zones
</TabButton>
</div>
{tab === "records" ? (
<div role="tabpanel" id="panel-records" aria-labelledby="tab-records">
<RecordsTab />
</div>
) : (
<div role="tabpanel" id="panel-zones" aria-labelledby="tab-zones">
<ZonesTab />
</div>
)}
<h1 {...stylex.props(styles.heading)}>Local DNS</h1>
<Tabs
label="Local DNS"
tabs={[
{ id: "records", label: "Records", content: <RecordsTab /> },
{ id: "zones", label: "Forward zones", content: <ZonesTab /> },
]}
/>
</section>
);
}
+173 -60
View File
@@ -1,5 +1,6 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
localRecordCreateMutation,
localRecordDeleteMutation,
@@ -8,27 +9,98 @@ import {
} from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
function RecordForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: LocalRecord;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: LocalRecordInput) => void;
onCancel: () => void;
@@ -47,10 +119,12 @@ function RecordForm({
}
return (
<form onSubmit={submit} className={formCardClass}>
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3>
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New record" : `Edit ${initial.name}`}
</h3>
<div>
<label htmlFor={`${id}-name`} className="block text-sm font-medium">
<label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
Name
</label>
<input
@@ -59,28 +133,19 @@ function RecordForm({
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="nas.lan.home"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-rtype`} className="block text-sm font-medium">
Type
</label>
<select
id={`${id}-rtype`}
<Select
label="Type"
value={rtype}
onChange={(event) => setRtype(event.target.value as LocalRecordType)}
className={inputClass}
>
{RTYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
onChange={(next) => setRtype(next as LocalRecordType)}
options={RTYPE_OPTIONS}
/>
</div>
<div>
<label htmlFor={`${id}-value`} className="block text-sm font-medium">
<label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
Value
</label>
<input
@@ -91,11 +156,11 @@ function RecordForm({
placeholder={
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-ttl`} className="block text-sm font-medium">
<label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
TTL (seconds)
</label>
<input
@@ -105,14 +170,19 @@ function RecordForm({
value={ttl}
onChange={(event) => setTtl(event.target.value)}
placeholder="300"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={largeButtonClass}>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel
</button>
</div>
@@ -123,64 +193,87 @@ function RecordForm({
export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data;
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
LocalRecord,
LocalRecordInput
>({
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<LocalRecord, LocalRecordInput>({
create: localRecordCreateMutation,
update: localRecordUpdateMutation,
remove: localRecordDeleteMutation,
confirmDelete: (record) => `Delete record "${record.name}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add record
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
<RecordForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
)}
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Name
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
Type
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
Value
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
TTL
</th>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{records.length === 0 && (
<tr>
<td colSpan={5} className="py-4 text-zinc-500">
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
No local records yet.
</td>
</tr>
)}
{records.map((record) => (
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}>
<RecordForm
initial={record}
busy={update.isPending}
readOnly={readOnly}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
@@ -188,23 +281,35 @@ export default function RecordsTab() {
</td>
) : (
<>
<td className="py-2 pr-4 font-mono">{record.name}</td>
<td className="py-2 pr-4">{record.rtype}</td>
<td className="py-2 pr-4 font-mono">{record.value}</td>
<td className="py-2 pr-4">{record.ttl}</td>
<td className="py-2 text-right whitespace-nowrap">
<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.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: record })}
className={rowButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(record)}
disabled={remove.isPending}
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
@@ -216,6 +321,14 @@ export default function RecordsTab() {
</tbody>
</table>
</div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete record"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}
+160 -40
View File
@@ -1,5 +1,6 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
forwardZoneCreateMutation,
forwardZoneDeleteMutation,
@@ -8,25 +9,94 @@ import {
} from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
});
function ZoneForm({
initial,
busy,
readOnly,
error,
onSubmit,
onCancel,
}: {
initial?: ForwardZone;
busy: boolean;
readOnly: boolean;
error: unknown;
onSubmit: (input: ForwardZoneInput) => void;
onCancel: () => void;
@@ -41,10 +111,12 @@ function ZoneForm({
}
return (
<form onSubmit={submit} className={formCardClass}>
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
</h3>
<div>
<label htmlFor={`${id}-zone`} className="block text-sm font-medium">
<label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
Zone
</label>
<input
@@ -53,11 +125,11 @@ function ZoneForm({
value={zone}
onChange={(event) => setZone(event.target.value)}
placeholder="lan.home"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-resolver`} className="block text-sm font-medium">
<label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
Resolver
</label>
<input
@@ -66,14 +138,19 @@ function ZoneForm({
value={resolver}
onChange={(event) => setResolver(event.target.value)}
placeholder="udp://192.168.1.1:53"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={largeButtonClass}>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel
</button>
</div>
@@ -84,60 +161,83 @@ function ZoneForm({
export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data;
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
ForwardZone,
ForwardZoneInput
>({
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<ForwardZone, ForwardZoneInput>({
create: forwardZoneCreateMutation,
update: forwardZoneUpdateMutation,
remove: forwardZoneDeleteMutation,
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
});
const readOnly = useReadOnlyConfig();
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>
Names under these zones go to their own resolver. Changes apply live.
</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add zone
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
<ZoneForm
busy={create.isPending}
readOnly={readOnly}
error={create.error}
onSubmit={onSubmit}
onCancel={closeForm}
/>
)}
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Zone
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
Resolver
</th>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{zones.length === 0 && (
<tr>
<td colSpan={3} className="py-4 text-zinc-500">
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
No forward zones yet.
</td>
</tr>
)}
{zones.map((zone) => (
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}>
<ZoneForm
initial={zone}
busy={update.isPending}
readOnly={readOnly}
error={update.error}
onSubmit={onSubmit}
onCancel={closeForm}
@@ -145,21 +245,33 @@ export default function ZonesTab() {
</td>
) : (
<>
<td className="py-2 pr-4 font-mono">{zone.zone}</td>
<td className="py-2 pr-4 font-mono">{zone.resolver}</td>
<td className="py-2 text-right whitespace-nowrap">
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: zone })}
className={rowButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(zone)}
disabled={remove.isPending}
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
@@ -171,6 +283,14 @@ export default function ZonesTab() {
</tbody>
</table>
</div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete forward zone"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}
+4 -2
View File
@@ -88,6 +88,8 @@ test("fetches nothing until submit, then renders the blocked verdict", async ()
test("defaults the group select to the default group (id 1)", async () => {
renderPage();
const select = (await screen.findByLabelText("Group")) as HTMLSelectElement;
expect(select.value).toBe("1");
// A RAC Select names its trigger with the current value and then the label, so
// the selected group's name is the only thing the trigger shows.
const trigger = await screen.findByRole("button", { name: /Group$/ });
expect(trigger.textContent).toContain("default");
});
+161 -44
View File
@@ -1,10 +1,15 @@
import { useState, type FormEvent, type ReactNode } from "react";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import { groupsQuery, lookupQuery } from "@/lib/queries";
import type { Group, LookupResult } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DARK = "@media (prefers-color-scheme: dark)";
interface Submitted {
domain: string;
@@ -13,10 +18,123 @@ interface Submitted {
interface Verdict {
label: string;
className: string;
tone: "local" | "blocked" | "forwarded" | "allowed";
description: string;
}
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
color: colors.textMuted,
},
form: {
marginTop: "1.5rem",
display: "flex",
maxWidth: "42rem",
flexWrap: "wrap",
alignItems: "flex-end",
gap: "0.75rem",
},
domainField: {
minWidth: "14rem",
flexGrow: 1,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
note: {
marginTop: "1.5rem",
color: colors.textMuted,
},
error: {
marginTop: "1.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
card: {
marginTop: "1.5rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
banner: {
borderStartStartRadius: "0.25rem",
borderStartEndRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
/** Four verdicts need four tints; only "blocked" maps onto a token role. */
local: {
backgroundColor: { default: "oklch(93.2% 0.032 255.585)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(80.9% 0.105 251.813)" },
},
blocked: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
forwarded: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
allowed: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(26.6% 0.065 152.934)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(87.1% 0.15 154.449)" },
},
verdictLabel: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
verdictDescription: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
details: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
detailRow: {
display: "flex",
gap: "1rem",
paddingBlock: "0.5rem",
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
detailTerm: {
width: "10rem",
flexShrink: 0,
color: colors.textMuted,
},
detailValue: {
minWidth: 0,
overflowWrap: "break-word",
},
sourceLink: {
color: colors.primaryOnSurface,
textDecorationLine: "underline",
},
});
function toneStyle(tone: Verdict["tone"]) {
if (tone === "local") return styles.local;
if (tone === "blocked") return styles.blocked;
return tone === "forwarded" ? styles.forwarded : styles.allowed;
}
/**
* Header priority follows the pipeline order the lookup handler documents
* (PLAN §6): local records answer first, then the block decision, then
@@ -26,27 +144,27 @@ export function verdictOf(result: LookupResult): Verdict {
if (result.local_records) {
return {
label: "Local answer",
className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300",
tone: "local",
description: "A local record answers this name directly.",
};
}
if (result.blocked) {
return {
label: "Blocked",
className: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
tone: "blocked",
description: "Queries for this name get a blocked response.",
};
}
if (result.forward_zone !== null) {
return {
label: "Forwarded",
className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
tone: "forwarded",
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
};
}
return {
label: "Allowed",
className: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300",
tone: "allowed",
description: "Queries resolve through the upstream pool.",
};
}
@@ -68,9 +186,9 @@ function errorMessage(error: unknown): string {
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex gap-4 py-2">
<dt className="w-40 shrink-0 text-zinc-500">{label}</dt>
<dd className="min-w-0 break-words">{children}</dd>
<div {...stylex.props(styles.detailRow)}>
<dt {...stylex.props(styles.detailTerm)}>{label}</dt>
<dd {...stylex.props(styles.detailValue)}>{children}</dd>
</div>
);
}
@@ -80,26 +198,30 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
return (
<div className="mt-6 rounded border border-zinc-200 dark:border-zinc-800">
<div className={`rounded-t px-4 py-3 ${verdict.className}`}>
<h2 className="text-lg font-semibold">{verdict.label}</h2>
<p className="text-sm">{verdict.description}</p>
<div {...stylex.props(styles.card)}>
<div {...stylex.props(styles.banner, toneStyle(verdict.tone))}>
<h2 {...stylex.props(styles.verdictLabel)}>{verdict.label}</h2>
<p {...stylex.props(styles.verdictDescription)}>{verdict.description}</p>
</div>
<dl className="divide-y divide-zinc-100 px-4 py-2 text-sm dark:divide-zinc-900">
<dl {...stylex.props(styles.details)}>
<DetailRow label="Domain">
<span className="font-mono">{result.domain}</span>
<span {...stylex.props(shared.mono)}>{result.domain}</span>
</DetailRow>
<DetailRow label="Group">{groupName}</DetailRow>
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
<DetailRow label="Forward zone">
{result.forward_zone !== null ? <span className="font-mono">{result.forward_zone}</span> : "—"}
{result.forward_zone !== null ? (
<span {...stylex.props(shared.mono)}>{result.forward_zone}</span>
) : (
"—"
)}
</DetailRow>
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
<DetailRow label="Reason">
<span className="font-mono">{result.reason}</span>
<span {...stylex.props(shared.mono)}>{result.reason}</span>
</DetailRow>
<DetailRow label="Matched pattern">
{result.matched !== "" ? <span className="font-mono">{result.matched}</span> : "—"}
{result.matched !== "" ? <span {...stylex.props(shared.mono)}>{result.matched}</span> : "—"}
</DetailRow>
<DetailRow label="Blocklist source">
{result.source_url !== null ? (
@@ -107,7 +229,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
href={result.source_url}
target="_blank"
rel="noreferrer"
className="text-blue-600 underline dark:text-blue-400"
{...stylex.props(styles.sourceLink, shared.focusRing)}
>
{result.source_url}
</a>
@@ -117,7 +239,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
</DetailRow>
<DetailRow label="Safe search rewrite">
{result.safe_search_rewrite !== null ? (
<span className="font-mono">{result.safe_search_rewrite}</span>
<span {...stylex.props(shared.mono)}>{result.safe_search_rewrite}</span>
) : (
"—"
)}
@@ -153,13 +275,13 @@ export default function LookupPage() {
return (
<section>
<h1 className="text-2xl font-semibold">Lookup</h1>
<p className="mt-2 text-zinc-500">
<h1 {...stylex.props(styles.heading)}>Lookup</h1>
<p {...stylex.props(styles.intro)}>
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
</p>
<form onSubmit={onSubmit} className="mt-6 flex max-w-2xl flex-wrap items-end gap-3">
<div className="min-w-56 grow">
<label htmlFor="lookup-domain" className="block text-sm font-medium">
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<div {...stylex.props(styles.domainField)}>
<label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
Domain
</label>
<input
@@ -168,33 +290,28 @@ export default function LookupPage() {
value={domain}
onChange={(event) => setDomain(event.target.value)}
placeholder="ads.example.com"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor="lookup-group" className="block text-sm font-medium">
Group
</label>
<select
id="lookup-group"
value={groupId}
onChange={(event) => setGroupId(Number(event.target.value))}
className={inputClass}
>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
<Select
label="Group"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
</div>
<button type="submit" className={largePrimaryButtonClass} disabled={lookup.isFetching}>
<button
type="submit"
disabled={lookup.isFetching}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Look up
</button>
</form>
{lookup.isFetching && <p className="mt-6 text-zinc-500">Looking up</p>}
{lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up</p>}
{!lookup.isFetching && lookup.isError && (
<p role="alert" className="mt-6 text-sm text-red-600 dark:text-red-400">
<p role="alert" {...stylex.props(styles.error)}>
{errorMessage(lookup.error)}
</p>
)}
+78 -15
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { buttonClass, insetFocusRing } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const DURATIONS = [
{ label: "60 seconds", seconds: 60 },
@@ -11,6 +13,72 @@ const DURATIONS = [
{ label: "Indefinitely", seconds: null },
] as const;
const styles = stylex.create({
/**
* Disabled text darkens in light scheme and lightens in dark, the opposite
* direction from `textMuted`, so the token cannot express it.
*/
trigger: {
color: {
default: null,
":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
},
},
pausedRow: {
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
},
pausedControls: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
/**
* Amber as standalone text on the app ground, not inside a warning banner, so
* the `warn*` tokens tuned against `warnSurface` do not apply here.
*/
pausedLabel: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: {
default: "oklch(55.5% 0.163 48.998)",
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
},
},
anchor: {
position: "relative",
},
menu: {
position: "absolute",
right: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menuItem: {
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
});
export function formatRemaining(totalSeconds: number): string {
const clamped = Math.max(0, totalSeconds);
const hours = Math.floor(clamped / 3600);
@@ -35,8 +103,6 @@ function useNowSeconds(active: boolean): number {
return now;
}
const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`;
export default function PauseWidget() {
const queryClient = useQueryClient();
const { data } = useQuery({
@@ -52,7 +118,7 @@ export default function PauseWidget() {
if (data === undefined) {
return (
<button type="button" disabled className={triggerButtonClass}>
<button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
Pause
</button>
);
@@ -60,16 +126,16 @@ export default function PauseWidget() {
if (data.paused) {
return (
<div className="flex flex-col items-end">
<div className="flex items-center gap-2">
<span className="text-sm text-amber-700 dark:text-amber-400">
<div {...stylex.props(styles.pausedRow)}>
<div {...stylex.props(styles.pausedControls)}>
<span {...stylex.props(styles.pausedLabel)}>
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
</span>
<button
type="button"
onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending}
className={triggerButtonClass}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Resume
</button>
@@ -81,7 +147,7 @@ export default function PauseWidget() {
return (
<div
className="relative"
{...stylex.props(styles.anchor)}
onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false);
}}
@@ -92,15 +158,12 @@ export default function PauseWidget() {
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
className={triggerButtonClass}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Pause
</button>
{menuOpen && (
<div
id="pause-menu"
className="absolute right-0 top-full z-10 mt-1 flex w-36 flex-col rounded border border-zinc-200 bg-white py-1 shadow dark:border-zinc-800 dark:bg-zinc-900"
>
<div id="pause-menu" {...stylex.props(styles.menu)}>
{DURATIONS.map(({ label, seconds }) => (
<button
key={label}
@@ -111,7 +174,7 @@ export default function PauseWidget() {
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
className={`px-3 py-1.5 text-left text-sm hover:bg-zinc-100 ${insetFocusRing} dark:hover:bg-zinc-800`}
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
>
{label}
</button>

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