19 Commits
Author SHA1 Message Date
mokhtar 377f00a35f changelog: 0.0.6 releases today
Release / guard (push) Successful in 22s
Gates / frontend (push) Successful in 1m30s
Gates / test (push) Successful in 1m50s
Gates / frontend (push) Successful in 1m9s
Gates / test-aarch64 (push) Successful in 6m55s
Gates / test (push) Successful in 1m34s
Gates / test-aarch64 (push) Successful in 6m9s
Gates / package (push) Successful in 3m49s
Gates / package (push) Successful in 32s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 21m28s
Gates / container (push) Successful in 9s
Release / gates (push) Successful in 20m7s
Release / publish (push) Successful in 4m26s
2026-08-17 18:22:54 +02:00
mokhtar e0a7cd8a6b milestone 26: upstream health answers for the selected period 2026-08-17 18:21:56 +02:00
mokhtar 3ed9a57822 changelog: 0.0.5 releases today
Gates / test (push) Successful in 1m44s
Gates / frontend (push) Successful in 1m2s
Gates / test-aarch64 (push) Successful in 5m55s
Gates / package (push) Successful in 28s
Gates / container (push) Successful in 16s
CI / gates (push) Successful in 20m25s
Release / guard (push) Successful in 18s
Gates / frontend (push) Successful in 1m5s
Gates / test-aarch64 (push) Successful in 6m33s
Gates / test (push) Successful in 1m31s
Gates / package (push) Successful in 3m41s
Gates / container (push) Successful in 9s
Release / gates (push) Successful in 19m27s
Release / publish (push) Successful in 5m43s
2026-08-16 23:55:19 +02:00
mokhtar 0601098ab0 admin: drop the learned tag from the query tables 2026-08-16 23:55:10 +02:00
mokhtar 49c7da2381 changelog: 0.0.4 releases today
Release / guard (push) Successful in 26s
Gates / frontend (push) Successful in 1m26s
Gates / test (push) Successful in 1m44s
Gates / frontend (push) Successful in 1m6s
Gates / test-aarch64 (push) Successful in 6m36s
Gates / test (push) Successful in 1m33s
Gates / test-aarch64 (push) Successful in 5m50s
Gates / package (push) Successful in 5m5s
Gates / package (push) Successful in 30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 21m58s
Gates / container (push) Successful in 10s
Release / gates (push) Successful in 20m42s
Release / publish (push) Successful in 4m41s
2026-08-16 23:00:41 +02:00
mokhtar 794ea6541f admin: live and query log pages show client names 2026-08-16 23:00:31 +02:00
mokhtar ba037c5958 website: plan for the nxdns.sh marketing and docs site
Gates / frontend (push) Successful in 1m8s
Gates / test (push) Successful in 2m5s
Gates / test-aarch64 (push) Successful in 7m1s
Gates / package (push) Successful in 4m3s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m28s
2026-08-16 00:17:58 +02:00
mokhtar 1e97c80f6b rename web/ to admin/, along with the web-named build and cli identifiers 2026-08-16 00:17:58 +02:00
mokhtar 5b3d1cd65c docs: unwrap hand-wrapped prose repo-wide
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s
2026-08-15 16:27:36 +02:00
mokhtar 50b8fd5c61 changelog: unwrap hand-wrapped lines, release bodies render newlines as breaks
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m31s
Gates / package (push) Successful in 3m36s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 11m21s
Gates / test-aarch64 (push) Successful in 5m54s
2026-08-15 13:06:44 +02:00
mokhtar efbe355070 changelog: 0.0.3 releases today
Gates / frontend (push) Successful in 1m3s
Gates / test (push) Successful in 1m39s
Gates / test-aarch64 (push) Successful in 6m28s
Gates / container (push) Successful in 18s
Release / guard (push) Successful in 1m30s
Gates / test-aarch64 (push) Successful in 5m45s
Gates / package (push) Successful in 24s
Release / gates (push) Successful in 7m54s
Gates / package (push) Successful in 5m34s
CI / gates (push) Successful in 14m5s
Gates / frontend (push) Successful in 58s
Gates / test (push) Successful in 1m29s
Gates / container (push) Successful in 10s
Release / publish (push) Successful in 9m12s
2026-08-15 12:24:05 +02:00
mokhtar fc60214b3e ci: the container gate and the version parse move into a compiled tool 2026-08-15 12:24:05 +02:00
mokhtar 3c794b645b milestone 25: client names learned over reverse dns
Gates / test (push) Successful in 2m58s
Gates / frontend (push) Successful in 3m57s
Gates / test-aarch64 (push) Successful in 8m20s
Gates / package (push) Successful in 7m27s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 19m9s
2026-08-15 11:18:44 +02:00
mokhtar c428bc2398 ci: allow dispatching the gate set, a replaced runner pod kills the job it holds
Gates / frontend (push) Successful in 1m18s
Gates / test (push) Successful in 2m27s
Gates / test-aarch64 (push) Successful in 5m39s
Gates / package (push) Successful in 3m34s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 11m57s
Release / guard (push) Successful in 1m30s
Gates / frontend (push) Successful in 1m6s
Gates / test (push) Successful in 1m28s
Gates / test-aarch64 (push) Successful in 5m41s
Gates / package (push) Successful in 28s
Gates / container (push) Successful in 16s
Release / gates (push) Successful in 7m55s
Release / publish (push) Successful in 10m37s
2026-08-14 02:34:06 +02:00
mokhtar c7c1e21267 changelog: 0.0.2 releases today
Gates / frontend (push) Successful in 1m9s
Gates / test (push) Failing after 12m32s
Gates / test-aarch64 (push) Successful in 9m14s
Gates / package (push) Successful in 7m27s
Gates / container (push) Successful in 18s
CI / gates (push) Failing after 22m41s
2026-08-14 02:10:45 +02:00
mokhtar 21c5ce1f36 filter: separate the compiled bodies in the checksum, a name moving between them was invisible 2026-08-14 02:09:25 +02:00
mokhtar 1bce81eea0 milestone 24: persist and surface the unsupported-line count 2026-08-13 20:44:27 +02:00
mokhtar 21571e448e schema: collapse config.db to a single baseline, ddl_v1 stays editable until v0.1 2026-08-13 19:14:54 +02:00
mokhtar 2ab7c1f1de milestone 21: abp list exceptions and a regex rule kind 2026-08-13 19:14:47 +02:00
223 changed files with 13708 additions and 11832 deletions
+6
View File
@@ -9,6 +9,12 @@ on:
branches: [master] branches: [master]
pull_request: pull_request:
branches: [master] branches: [master]
# A runner pod replaced mid-job kills the job it is running: the step's log
# stops dead, no epilogue is written, and Gitea marks the orphan failed.
# Nothing in the tree is wrong when that happens and nothing in the tree can
# fix it, so the gate set must be re-runnable without an empty commit. This
# adds a trigger, not a check — the job list below stays the gate set alone.
workflow_dispatch:
jobs: jobs:
gates: gates:
+97 -206
View File
@@ -48,8 +48,9 @@ env:
# version to exist in the tag and in build.zig.zon and nowhere else, and # version to exist in the tag and in build.zig.zon and nowhere else, and
# ruling 5 makes verify-dist fail when the version under build disagrees with # ruling 5 makes verify-dist fail when the version under build disagrees with
# build.zig.zon. An invented CI string such as "0.0.0-ci" therefore cannot # build.zig.zon. An invented CI string such as "0.0.0-ci" therefore cannot
# pass this file's own packaging gate. The package and container jobs read # pass this file's own packaging gate. The package job reads the version out
# the version out of build.zig.zon instead. # of build.zig.zon instead, and the container job takes it from that job's
# output.
jobs: jobs:
test: test:
@@ -118,39 +119,39 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: npm cache: npm
cache-dependency-path: web/package-lock.json cache-dependency-path: admin/package-lock.json
- name: Install dependencies - name: Install dependencies
working-directory: web working-directory: admin
run: npm ci run: npm ci
- name: Check formatting - name: Check formatting
working-directory: web working-directory: admin
run: npm run format:check run: npm run format:check
- name: Lint - name: Lint
working-directory: web working-directory: admin
run: npm run lint run: npm run lint
- name: Typecheck - name: Typecheck
working-directory: web working-directory: admin
run: npm run typecheck run: npm run typecheck
- name: Run tests - name: Run tests
working-directory: web working-directory: admin
run: npm test run: npm test
- name: Build - name: Build
working-directory: web working-directory: admin
run: npm run build run: npm run build
# The licence inventory has to cover every package whose bytes ship, and # The licence inventory has to cover every package whose bytes ship, and
# the lockfile does not answer that question: it lists what could be # the lockfile does not answer that question: it lists what could be
# reached, not what rollup kept. The bundle is what this reads. The logic # reached, not what rollup kept. The bundle is what this reads. The logic
# lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop # lives in admin/scripts/, unit-tested by `npm test`, so it runs on a laptop
# exactly as it runs here (milestone-14 deviation 24). # exactly as it runs here (milestone-14 deviation 24).
- name: Assert the packages bundled into web/dist are the recorded ones - name: Assert the packages bundled into admin/dist are the recorded ones
working-directory: web working-directory: admin
run: npm run assert-bundled run: npm run assert-bundled
# The package and container jobs consume this bundle instead of building # The package and container jobs consume this bundle instead of building
@@ -163,22 +164,27 @@ jobs:
# #
# A later move to v4 has to add `include-hidden-files: true` here. # A later move to v4 has to add `include-hidden-files: true` here.
# `npm run build` writes the freshness stamp to the hidden file # `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 # admin/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 # 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 # 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 # to v4 that also pointed the download back at admin/dist would fail with
# "web/dist is stale". # "admin/dist is stale".
- name: Upload the built web UI - name: Upload the built web UI
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20 uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with: with:
name: web-dist name: admin-dist
path: web/dist path: admin/dist
if-no-files-found: error if-no-files-found: error
package: package:
needs: [frontend] needs: [frontend]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
# The container job needs the version and installs no Zig, so it cannot read
# build.zig.zon the way this job does.
outputs:
version: ${{ steps.zon-version.outputs.version }}
steps: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -195,32 +201,37 @@ jobs:
- name: Create the fetch temp dir zig assumes - name: Create the fetch temp dir zig assumes
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp" run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
# `dist` refuses web/dist-placeholder (ruling 4), so a real bundle has to # `dist` refuses admin/dist-placeholder (ruling 4), so a real bundle has to
# exist before the packaging gate runs. It arrives from the frontend job, # exist before the packaging gate runs. It arrives from the frontend job,
# already formatted, linted, typechecked, tested and licence-checked. # already formatted, linted, typechecked, tested and licence-checked.
# #
# The target is deliberately not `web/dist`: build.zig runs the freshness # The target is deliberately not `admin/dist`: build.zig runs the freshness
# stamp check for that exact path and no other (milestone-15 ruling 5), # 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 # 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 # stamp hashes the admin/ sources, not the bundle, so against a checkout of
# the same commit that built the bundle it can only agree. An explicit # 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 # path is the case build.zig documents for a bundle built elsewhere, and
# taking it keeps node out of this job entirely. # taking it keeps node out of this job entirely.
- name: Download the web UI built by the frontend job - name: Download the web UI built by the frontend job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20 uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with: with:
name: web-dist name: admin-dist
path: web-dist-ci path: admin-dist-ci
# Ruling 2: build.zig.zon is the only place besides the tag that carries # 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 # the version, and ruling 5 makes verify-dist assert the two agree. The
# gate builds the version the repository declares. # gate builds the version the repository declares.
#
# This is the only job that reads it. The container job used to run its own
# `sed` over the same file; it now receives this step's output, so the two
# jobs cannot disagree about what the repository declares. The parse itself
# matches verify-dist's, through the zon grammar rather than a regex.
- name: Build the container gate tool
run: zig build container-check-tool
- name: Read the version from build.zig.zon - name: Read the version from build.zig.zon
run: | id: zon-version
set -euo pipefail run: ./zig-out/bin/container_check version
version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1)
test -n "$version"
echo "CI_VERSION=$version" >> "$GITHUB_ENV"
- name: Build the release artifacts - name: Build the release artifacts
run: | run: |
@@ -228,7 +239,7 @@ jobs:
zig build dist \ zig build dist \
-Dversion-string="$CI_VERSION" \ -Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \ -Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web-dist-ci \ -Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe -Doptimize=ReleaseSafe
# verify-dist owns every assert the CI shell used to make: ELF static # verify-dist owns every assert the CI shell used to make: ELF static
@@ -244,7 +255,7 @@ jobs:
zig build verify-dist \ zig build verify-dist \
-Dversion-string="$CI_VERSION" \ -Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \ -Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web-dist-ci \ -Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe -Doptimize=ReleaseSafe
# deploy/docker/Dockerfile copies both of these trees and nothing else # deploy/docker/Dockerfile copies both of these trees and nothing else
@@ -257,7 +268,13 @@ jobs:
# The zip round-trip drops the executable bit. That is survivable only # The zip round-trip drops the executable bit. That is survivable only
# because the Dockerfile chmods the binary itself and the contents # because the Dockerfile chmods the binary itself and the contents
# assertion compares sha256 of file contents, never modes. The archive # assertion compares sha256 of file contents, never modes. The archive
# modes are asserted by verify-dist, above, on the originals. # modes are asserted by verify-dist, above, on the originals. The gate tool
# rides along in the same artifact and the container job chmods it back.
#
# Adding zig-out/bin/container_check moves the artifact's common root from
# zig-out/dist up to zig-out, which is why the container job restores into
# zig-out rather than zig-out/dist. The Dockerfile's COPY paths still
# resolve; they are relative to the repository root either way.
- name: Upload the staged payload for the container job - name: Upload the staged payload for the container job
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20 uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with: with:
@@ -265,201 +282,69 @@ jobs:
path: | path: |
zig-out/dist/bin zig-out/dist/bin
zig-out/dist/stage zig-out/dist/stage
zig-out/bin/container_check
if-no-files-found: error if-no-files-found: error
container: container:
needs: [package] needs: [package]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
env:
# Ruling 2 and ruling 5: one parse of build.zig.zon per run, done in the
# package job. This job installs no Zig and cannot repeat it.
CI_VERSION: ${{ needs.package.outputs.version }}
steps: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
# Restored under zig-out/dist because that is where the Dockerfile's # Restored under zig-out because that is where the Dockerfile's COPY lines
# COPY lines look, with the repository root as the build context. This # look for dist/, with the repository root as the build context, and
# job compiles nothing and bundles nothing: the payload is the one the # because the payload also carries bin/container_check. This job compiles
# package job already built and verify-dist already checked, which is # nothing and bundles nothing: the payload is the one the package job
# also the point — an image built from a second, independent `dist` run # already built and verify-dist already checked, which is also the point —
# would prove nothing about the artifacts the release publishes. # 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 - name: Download the staged payload built by the package job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20 uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with: with:
name: dist-payload name: dist-payload
path: zig-out/dist path: zig-out
# Same single source of truth as the package job (rulings 2 and 5). This # The artifact zip carries no modes.
# job still needs the version for the stage directory name it hashes - name: Restore the gate tool's executable bit
# against the image and for the VERSION build arg. run: chmod +x zig-out/bin/container_check
- name: Read the version from build.zig.zon
run: |
set -euo pipefail
version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1)
test -n "$version"
echo "CI_VERSION=$version" >> "$GITHUB_ENV"
# Image tags, container names and published host ports are all # Everything this gate decides — this run's docker object names, the
# daemon-global. This workflow is called by both ci.yml and release.yml # ownership label, the image build, the contents assertion against the
# and the self-hosted runners share one docker daemon, so the fixed # staged payload, and the smoke test with its topology detection and its
# `nxdns:ci` and `nxdns-smoke` made two concurrent runs collide: the # absolute probe deadline — lives in tools/container_check.zig, along with
# second `docker create --name` fails outright, and worse, whichever run # the rationale comments that used to sit in this file. That is the same
# finishes first deletes the other's container mid-test. The names below # move release.yml made (milestone-14 deviation 24): logic in a `run:`
# carry the run identity plus four random bytes — the run id alone is not # block cannot be type-checked, run on a laptop, or covered by a test.
# enough, because two *repositories* on the same daemon can issue the - name: Run the container acceptance gate
# same one. The published port becomes ephemeral for the same reason. run: ./zig-out/bin/container_check gate
- name: Name this run's docker objects
run: |
set -euo pipefail
rand=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')
suffix="${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-$rand"
{
echo "CI_IMAGE=nxdns:ci-$suffix"
echo "SMOKE_NAME=nxdns-smoke-$suffix"
} >> "$GITHUB_ENV"
echo "image nxdns:ci-$suffix, container nxdns-smoke-$suffix"
# The build args carry the OCI label values (ruling 6); release.yml # The gate removes its own container and image on every exit path it
# passes the same three and then asserts the resulting # survives. This step is the exit path for a cancelled job and for a step
# org.opencontainers.image.version label. BuildKit is not optional here: # that died before the gate's own defers ran — on a long-lived self-hosted
# the builder stage is pinned to $BUILDPLATFORM, which the classic # daemon those accumulate one layer set per run. The name pair only exists
# builder does not define, so DOCKER_BUILDKIT=0 fails at the first FROM. # if the gate got as far as writing $GITHUB_ENV, so the label sweep covers
- name: Build the image # the rest of this run.
env:
DOCKER_BUILDKIT: "1"
run: |
set -euo pipefail
docker build -t "$CI_IMAGE" -f deploy/docker/Dockerfile \
--build-arg VERSION="$CI_VERSION" \
--build-arg REVISION="$GITHUB_SHA" \
--build-arg CREATED="1970-01-01T00:00:00Z" \
.
# Ruling 6: the binary in the image must be the binary in the tarball.
# Ruling 3: distributing the image is distribution, so /LICENSE and
# /THIRD-PARTY-NOTICES must be in it and must be the same files the
# tarball carries — that is an acceptance criterion and nothing checked
# it. Comparing against the staged payload rather than merely asserting
# the paths exist costs nothing and catches a stale or empty copy.
# #
# Native triple only: this job builds a single-architecture image. # It covers no more than that. `always()` does not run when the runner or
# release.yml covers both platforms against the pushed multi-arch index. # the pod itself dies, and the filter below names THIS attempt's label
- name: Assert the image contents match the packaged artifacts # value exactly — deliberately, since a concurrent run of another
run: | # repository must not be swept, but that also means a later attempt cannot
set -euo pipefail # collect an earlier one's leak. What the label buys for those cases is
stage="zig-out/dist/stage/nxdns-$CI_VERSION-x86_64-linux-musl" # discovery, not recovery: `docker ps -a --filter
test -d "$stage" # label=net.mial.nxdns.ci` and the matching `docker images` list every
# object this workflow has ever left behind, with the repository, run and
out=$(mktemp -d) # attempt that owns each one. Reclaiming them is a manual sweep today, and
cid=$(docker create "$CI_IMAGE") # the hook a janitor job would use later.
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true; rm -rf "$out"' EXIT
rc=0
for member in nxdns LICENSE THIRD-PARTY-NOTICES; do
docker cp "$cid:/$member" "$out/$member"
want=$(sha256sum "$stage/$member" | cut -d' ' -f1)
got=$(sha256sum "$out/$member" | cut -d' ' -f1)
if [ "$want" = "$got" ]; then
echo "/$member matches ($got)"
else
echo "/$member DIFFERS: image $got, packaged $want"
rc=1
fi
done
test "$rc" -eq 0
- name: Smoke test the container
run: |
set -euo pipefail
docker run --rm "$CI_IMAGE" version
mkdir -p etc-nxdns
cat > etc-nxdns/config.zon <<'EOF'
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}
EOF
# No bind mount: the runner talks to the daemon over a mounted
# socket, so a -v path would resolve on the docker host (where the
# workspace does not exist) and mount an empty directory over
# /etc/nxdns. docker cp streams the file through the socket instead.
#
# Networking: this job itself runs in a container on the runner's
# per-job network. A published port binds on the daemon's host, not
# here, and docker does not route between the default bridge and
# that network — a bridge-IP curl hangs to its connect timeout. So
# the smoke container joins the job's own network, where its name
# resolves and its port is reachable. On a host runner the inspect
# finds no container and the published-port path covers it.
#
# `-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 \
--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"
# 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=""
for _ in $(seq 1 30); do
if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then
echo "container exited during startup"
docker logs "$cid" || true
exit 1
fi
if curl -fsS --connect-timeout 2 "http://$SMOKE_NAME:8080/api/health" \
|| { [ -n "$hostport" ] && curl -fsS --connect-timeout 2 "http://127.0.0.1:$hostport/api/health"; } \
|| { [ -n "$ip" ] && curl -fsS --connect-timeout 2 "http://$ip:8080/api/health"; }; then
healthy=1
break
fi
sleep 1
done
if [ -z "$healthy" ]; then
echo "no /api/health response within 30 seconds"
docker logs "$cid" || true
exit 1
fi
docker stop -t 30 "$SMOKE_NAME"
exit_code=$(docker inspect -f '{{.State.ExitCode}}' "$SMOKE_NAME")
echo "exit code after SIGTERM: $exit_code"
docker logs "$SMOKE_NAME" || true
test "$exit_code" -eq 0
# The per-step traps only cover the step that set them. This is the exit
# path for a cancelled job, a step that died before its trap was
# installed, and the image itself, which no trap ever removed — on a
# long-lived self-hosted daemon those accumulate one layer set per run.
- name: Remove this run's docker objects - name: Remove this run's docker objects
if: always() if: always()
env:
CI_LABEL: net.mial.nxdns.ci=${{ github.repository }}/${{ github.run_id }}/${{ github.run_attempt }}
run: | run: |
set -uo pipefail set -uo pipefail
if [ -n "${SMOKE_NAME:-}" ]; then if [ -n "${SMOKE_NAME:-}" ]; then
@@ -468,4 +353,10 @@ jobs:
if [ -n "${CI_IMAGE:-}" ]; then if [ -n "${CI_IMAGE:-}" ]; then
docker image rm -f "$CI_IMAGE" >/dev/null 2>&1 || true docker image rm -f "$CI_IMAGE" >/dev/null 2>&1 || true
fi fi
for cid in $(docker ps -aq --filter "label=$CI_LABEL"); do
docker rm -f "$cid" >/dev/null 2>&1 || true
done
for iid in $(docker images -q --filter "label=$CI_LABEL"); do
docker image rm -f "$iid" >/dev/null 2>&1 || true
done
exit 0 exit 0
+4 -4
View File
@@ -269,10 +269,10 @@ jobs:
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: npm cache: npm
cache-dependency-path: web/package-lock.json cache-dependency-path: admin/package-lock.json
- name: Build the web UI - name: Build the web UI
working-directory: web working-directory: admin
run: | run: |
npm ci npm ci
npm run build npm run build
@@ -283,7 +283,7 @@ jobs:
zig build dist zig build dist
-Dversion-string="$VERSION" -Dversion-string="$VERSION"
-Dgit-commit="$TAG_COMMIT" -Dgit-commit="$TAG_COMMIT"
-Dweb-dist=web/dist -Dadmin-dist=admin/dist
-Doptimize=ReleaseSafe -Doptimize=ReleaseSafe
- name: Verify the release artifacts - name: Verify the release artifacts
@@ -291,7 +291,7 @@ jobs:
zig build verify-dist zig build verify-dist
-Dversion-string="$VERSION" -Dversion-string="$VERSION"
-Dgit-commit="$TAG_COMMIT" -Dgit-commit="$TAG_COMMIT"
-Dweb-dist=web/dist -Dadmin-dist=admin/dist
-Doptimize=ReleaseSafe -Doptimize=ReleaseSafe
# Step 9. Extracted and validated before anything is pushed anywhere, so # Step 9. Extracted and validated before anything is pushed anywhere, so
+4 -4
View File
@@ -1,7 +1,7 @@
.zig-cache/ .zig-cache/
zig-out/ zig-out/
zig-pkg/ zig-pkg/
web/node_modules/ admin/node_modules/
web/dist/ admin/dist/
web/dist-sourcemap/ admin/dist-sourcemap/
web-dist-ci/ admin-dist-ci/
+19 -56
View File
@@ -2,79 +2,42 @@
## Aim ## Aim
nxdns: a self-hosted DNS sinkhole for a household LAN, written in Zig 0.16.0. nxdns: a self-hosted DNS sinkhole for a household LAN, written in Zig 0.16.0. Portfolio-grade public repo. PLAN.md is the source of truth for scope and design; specs/ holds per-milestone contracts; specs/research/ holds verified stdlib facts.
Portfolio-grade public repo. PLAN.md is the source of truth for scope and design;
specs/ holds per-milestone contracts; specs/research/ holds verified stdlib facts.
## Values ## Values
We intentionally architect this code to be robust, maintainable, pragmatic — We intentionally architect this code to be robust, maintainable, pragmatic — good craftsmanship and good engineering. We explicitly avoid tech debt, code smells, bad architecture decisions, and brittle implementations.
good craftsmanship and good engineering. We explicitly avoid tech debt, code
smells, bad architecture decisions, and brittle implementations.
What that means in practice: What that means in practice:
- This is a greenfield project. Breaking changes are allowed. Never keep a bad - This is a greenfield project. Breaking changes are allowed. Never keep a bad interface for compatibility; fix it at the root.
interface for compatibility; fix it at the root. - No versioning of scope. A feature is in scope (build it completely) or out of scope (do not build it). No "v2 later", no stubs left behind.
- No versioning of scope. A feature is in scope (build it completely) or out of
scope (do not build it). No "v2 later", no stubs left behind.
- Fix root causes, not symptoms. Do not iterate on workarounds. - Fix root causes, not symptoms. Do not iterate on workarounds.
- Scope is small on purpose: household scale, two targets, few dependencies. - Scope is small on purpose: household scale, two targets, few dependencies. Do not add generality nobody asked for.
Do not add generality nobody asked for. - Dependencies are liabilities: stdlib first; vendored + pinned C deps (sqlite3, mbedTLS) only where the stdlib has nothing.
- Dependencies are liabilities: stdlib first; vendored + pinned C deps - Verify stdlib claims against ../zig at tag 0.16.0 — pre-0.16 knowledge is stale (std.Io migration). See specs/research/zig-0.16-api-notes.md.
(sqlite3, mbedTLS) only where the stdlib has nothing. - Pure core: dns/, filter/, local/, cache/ take bytes and return bytes — no Io, no sockets, no clocks hidden inside.
- Verify stdlib claims against ../zig at tag 0.16.0 — pre-0.16 knowledge is - Every failure mode must be visible: no silent drops, no unbounded logs, no swallowed errors. Counters + health surfaces over log spam.
stale (std.Io migration). See specs/research/zig-0.16-api-notes.md. - Tests are runnable acceptance criteria, not decoration. Required CI stays deterministic — no network-dependent tests in blocking jobs.
- Pure core: dns/, filter/, local/, cache/ take bytes and return bytes — no Io, - Comments state constraints the code cannot show. No narration, no commented-out code.
no sockets, no clocks hidden inside. - Git: GPG-signed commits (`git commit -S`), simple lowercase messages, no generated-by footers.
- Every failure mode must be visible: no silent drops, no unbounded logs, no
swallowed errors. Counters + health surfaces over log spam.
- Tests are runnable acceptance criteria, not decoration. Required CI stays
deterministic — no network-dependent tests in blocking jobs.
- Comments state constraints the code cannot show. No narration, no
commented-out code.
- Git: GPG-signed commits (`git commit -S`), simple lowercase messages, no
generated-by footers.
## Reading `zig build test` output ## Reading `zig build test` output
A fully passing `zig build test` still prints a line like `failed command: A fully passing `zig build test` still prints a line like `failed command: .../test --cache-dir=... --seed=... --listen=-`, and still exits 0. That line is a known upstream zig 0.16.0 labelling defect. It does not mean a test failed, and no test binary crashed.
.../test --cache-dir=... --seed=... --listen=-`, and still exits 0. That line
is a known upstream zig 0.16.0 labelling defect. It does not mean a test
failed, and no test binary crashed.
The build runner sets a step's `result_failed_command` on every spawn The build runner sets a step's `result_failed_command` on every spawn (`std/Build/Step/Run.zig:1540`) and never clears it on success. It then prints a step's diagnostics whenever the step wrote anything to stderr, explicitly "no matter the result" (`compiler/build_runner.zig:1381`), and that printer emits the `failed command: ` label unconditionally when the field is set (`compiler/build_runner.zig:1515`). Our suite writes to stderr on every run, because the tests that cover the warning paths log through the real sink. A minimal reproducer with no mbedTLS and no C — one passing test whose body is a `std.debug.print` — prints the same label and reports "3/3 steps succeeded; 1/1 tests passed"; deleting the print removes the label. No upstream issue matched a search, so the reference is the 0.16.0 source lines above.
(`std/Build/Step/Run.zig:1540`) and never clears it on success. It then prints
a step's diagnostics whenever the step wrote anything to stderr, explicitly "no
matter the result" (`compiler/build_runner.zig:1381`), and that printer emits
the `failed command: ` label unconditionally when the field is set
(`compiler/build_runner.zig:1515`). Our suite writes to stderr on every run,
because the tests that cover the warning paths log through the real sink. A
minimal reproducer with no mbedTLS and no C — one passing test whose body is a
`std.debug.print` — prints the same label and reports "3/3 steps succeeded;
1/1 tests passed"; deleting the print removes the label. No upstream issue
matched a search, so the reference is the 0.16.0 source lines above.
Any *other* failure text is real. Trust the summary line: `zig build test` Any *other* failure text is real. Trust the summary line: `zig build test` exiting non-zero, a `N failed` count, or a panic backtrace all mean a genuine failure. Do not filter, wrap, or suppress the runner's output to hide the label — that would hide real failures with it.
exiting non-zero, a `N failed` count, or a panic backtrace all mean a genuine
failure. Do not filter, wrap, or suppress the runner's output to hide the
label — that would hide real failures with it.
One trap: running a cached test binary by hand with `--listen=-` aborts with One trap: running a cached test binary by hand with `--listen=-` aborts with `internal test runner failure: EndOfStream`. That is not a teardown bug; the IPC runner is talking to a closed stdin because no build runner is on the other end. Run the binary with no arguments to get the plain stdio report.
`internal test runner failure: EndOfStream`. That is not a teardown bug; the
IPC runner is talking to a closed stdin because no build runner is on the other
end. Run the binary with no arguments to get the plain stdio report.
## Regenerating the contract samples ## Regenerating the contract samples
`web/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized `admin/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
API responses, byte-compared against the live server by a `-Dintegration`
test and type-checked by `tsc`. After a deliberate API contract change,
regenerate it with:
``` ```
zig build test -Dintegration -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts" zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
``` ```
then update `web/src/lib/types.ts` to match and commit both. Never edit the then update `admin/src/lib/types.ts` to match and commit both. Never edit the generated file by hand.
generated file by hand.
+83 -82
View File
@@ -1,54 +1,84 @@
# Changelog # Changelog
All notable changes to nxdns are recorded here. The format follows All notable changes to nxdns are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Sections are written by hand. Nothing here is generated from commit messages: Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
the point of the file is to say what changed for an operator, which a commit
subject rarely does.
## [Unreleased] ## [Unreleased]
## [0.0.6] - 2026-08-17
The period picker now scopes the whole dashboard. The upstream table was the last widget that ignored it, and fixing that meant recording upstream outcomes over time instead of counting them since boot. Read the query-log note below before you upgrade.
### Added ### Added
- **Declarative configuration for IaC.** `nxdns run --config=<file>` makes the - Four metrics for the new upstream-history recorder: `nxdns_upstream_history_flushes_total`, `nxdns_upstream_history_flush_failures_total`, `nxdns_upstream_history_rows_dropped_total` and the `nxdns_upstream_history_pending` gauge. While a flush to the database keeps failing, `GET /api/health` reports `degraded`; it recovers on the next flush that succeeds.
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 ### Changed
- **Breaking: `nxdns run --config <file>` changed meaning.** It used to seed - **Upstream health answers for the selected period.** The dashboard's upstream table used to print counters accumulated since process start beside a success rate taken over the last 32 exchanges, which is how "63 failures" and "100.0% success rate" ended up in the same row under a period picker that scoped nothing there. Every upstream outcome is now aggregated into its wall-clock minute and written to `querylog.db`, and `GET /api/upstream/health?period=…` serves the selected window: attempts, failures, success rate, and the last failure with its error name, all inside the period, from 31 days of history. A window with no attempts reports no success rate at all instead of a perfect one, and the table shows an em-dash. The in-memory health state that drives failover and backoff is unchanged, as are its `/metrics` series.
the database once and then ignore the file; it now makes the file the - **`GET /api/upstream/health` changed shape.** Gone from each upstream: `consecutive_failures`, `total_successes`, `total_failures`, the last-32 `success_rate`, `last_error` and `last_error_age_s`. Each upstream keeps `url`, `enabled` and `available` and gains a `period` object with the ranged numbers; the body gains `period`, `since`, `until` and a `complete` flag that says whether any outcome was known to be dropped inside the window. The removed counters are still exported by `/metrics` under their existing names. On the dashboard the "Right now" section is gone with them: the upstream table rejoined the ranged part of the page, and the disk card, the one live widget left, is titled "Storage now".
authority on every boot, which deletes any configuration the file does not - **The query log is recreated on upgrade.** Recording upstream history added two tables to the `querylog.db` schema, and its fingerprint check refuses a database that does not match the shipped definition. On first start this version renames the existing `querylog.db` aside as `querylog.db.corrupt-<unix seconds>` in the data directory and creates a fresh one, so query history and stats restart empty. The renamed file is left in place rather than deleted, so removing it is your call. `config.db` is untouched: no configuration is lost.
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 ## [0.0.5] - 2026-08-16
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). One rendering fix on the 0.0.4 feature, caught the day it shipped.
- **Breaking: 0.0.1 exports are refused by this version.** A 0.0.1
`nxdns export` writes both `.password = ""` and the stored ### Changed
`.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 - The query tables no longer repeat the *learned* tag on every row: in the live page and the query log a learned name is just muted, with the address still in the row's tooltip. The clients page keeps the tag, where it appears once per client and says something.
`nxdns import` alike. Fix an existing export by deleting its
`.password = ""` line (keep the `.password_hash` line). Take fresh backups ## [0.0.4] - 2026-08-16
with the new binary.
- **Breaking: the offline password-change recipe changed.** Setting The names learned in 0.0.3 now show up where queries do: the live page and the query log name each client instead of printing its address.
`.password = "new"` together with `.password_hash = ""` is now refused
(empty `password_hash` is an explicit "disable authentication", and the two ### Added
fields cannot both be present). To change the password in the file: set
`.password` and delete the `.password_hash` line entirely. - **Client names in the query tables.** The live page and the query log show each query's client by name, with the same precedence as the clients page: a hand-typed name wins, else the learned name (muted, tagged *learned*), else the bare address. When a name replaces the address, the address stays readable as the row's tooltip. Devices that appear mid-stream show their address first and pick up their name within half a minute.
## [0.0.3] - 2026-08-15
Devices name themselves: the clients table asks the router over reverse DNS instead of waiting for the operator to type every name. The CI container gate also moved from workflow shell into a compiled, tested tool, which fixed a latent temp-directory bug shared with the release tool.
### Added
- **Client names learned over reverse DNS.** A client row that carries no hand-typed name gets one from the network: each tracker flush pass takes up to 16 unnamed rows, builds each address's reverse name, matches it against the declared `forward_zones`, and on a match sends one PTR query to that zone's resolver, storing the answer as a *learned* name. This requires a conditional forward zone covering the LAN's reverse space — for example `168.192.in-addr.arpa` pointed at the router; without one, nothing is sent anywhere. A hand-typed name always wins, learned names never appear in `nxdns export` and are never set by `nxdns import`, and each row refreshes once a day (an hour after a failure), so a rename can show stale for up to 24 hours. The API's `Client` object gains a `learned_name` field and the clients page shows it.
### Changed
- The container CI gate — image build, image-contents assertion against the packaged artifacts, and the startup/shutdown smoke test — moved from workflow shell into `tools/container_check.zig`, compiled and unit-tested by `zig build test` and runnable on a laptop against a local docker daemon. The health probe now runs under a real 60-second deadline (the shell loop's "30 seconds" could stretch past three minutes), and the gate's docker objects carry an ownership label so anything a dead runner leaks is discoverable. The version in CI is parsed from `build.zig.zon` through the zon grammar, once, instead of by two copies of a `sed` regex.
### Fixed
- The release tool's temporary-directory claim was not exclusive: the "create" it relied on succeeds on a directory that already exists, so a stale or concurrent directory could be silently adopted, written into, and deleted on exit. Both the release tool and the new container gate now claim their directories exclusively and retry on collision.
## [0.0.2] - 2026-08-14
Configuration can now be a file that every boot converges to, filtering gains regex rules and honors blocklist exception lines, and two refresh bugs that silently kept stale state are fixed. Note the three breaking changes below if you script against `nxdns import` or run with `--config`.
### 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.
- **Regex rules.** Rules gain a third kind, `regex`, beside `exact` and `wildcard`, for per-group allow and block patterns such as `^ad[0-9]+-`. The engine is homegrown and linear-time by construction, so no pattern can make matching blow up; backreferences and lookaround do not exist, and a bad pattern is refused at insert time with the limit it hit. Matches appear in `/api/lookup` and the query log as `rule_allow_regex` / `rule_block_regex`. Regex still comes only from you: regex lines in downloaded lists stay counted and skipped.
- **Blocklist exception lines are honored.** An Adblock-Plus `@@||name^` line in a downloaded list now lifts that name — and its subdomains — out of what the attached lists block. Exceptions sit below every rule you wrote: a downloaded list can reopen only a hole another downloaded list dug, never override an operator decision. Each source reports how many it carried.
- **Browser-only lines are counted where you can see them.** Every source now reports how many of its lines nxdns skipped as syntax with no DNS meaning — cosmetic filters, `$`-modifier rules — beside the existing skipped-regex count. Both blocklist tables show the number and the UI explains the difference: a list whose skipped-unsupported count dwarfs its domain count is written for browser extensions, and its DNS or hosts variant will block more. Previously such a list compiled to almost nothing and looked clean.
### 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`. - `nxdns import --force` is renamed `--allow-delete`.
- A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence. - A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence. Use `nxdns import` once, or run in file mode with `--config`.
Use `nxdns import` once, or run in file mode with `--config`. - The admin UI's internals moved to TypeScript 7 and replaced Tailwind with StyleX and React Aria. The visible change is small: selects are real widgets with working keyboard focus; everything else renders as before.
- The `config.db` schema is a single baseline definition again; numbered migration steps start accumulating at v0.1.
### Fixed
- **A list switching a name between its exact and wildcard forms never took effect.** The compiled-list checksum hashed the exact and wildcard bodies as one unseparated byte stream, so a list carrying `a.example` and the same list carrying `*.a.example` produced the same digest, and the refresh kept the old compiled files. The checksum now separates the bodies. Every source recompiles once on its first refresh after the upgrade; no re-download of unchanged content is forced beyond the refresh's normal fetch.
- **A refresh could store stale skip counts.** When a refresh found the list content unchanged, it wrote the previously stored skip counters back to the database while showing the fresh ones in the UI, and the next restart reverted the numbers to the stale copy. All counters now persist from the fresh compile.
- An Adblock-Plus entry with embedded whitespace (`||good.example bad.example^`) compiled into an entry no query could ever match. Such lines are now counted as unsupported instead.
## [0.0.1] - 2026-08-09 ## [0.0.1] - 2026-08-09
@@ -56,47 +86,18 @@ First release. Everything below is new.
### Added ### Added
- **Forwarding DNS server.** UDP and TCP listeners with a wire-format parser and - **Forwarding DNS server.** UDP and TCP listeners with a wire-format parser and encoder written against RFC 1035 and EDNS(0), a bounded worker model, per-client rate limiting and a `pause` control that stops filtering without stopping resolution.
encoder written against RFC 1035 and EDNS(0), a bounded worker model, per-client - **Encrypted upstreams.** DNS-over-HTTPS and DNS-over-TLS clients over a pool that tracks per-upstream health and fails over, with SNI and certificate verification driven by a per-upstream TLS name.
rate limiting and a `pause` control that stops filtering without stopping - **DoH and DoT endpoints.** nxdns also answers as an encrypted resolver, with a certificate store that reloads on disk changes and through the API, so renewals do not need a restart.
resolution. - **Blocklist filtering.** Subscriptions in hosts, plain-domain and Adblock-Plus-style formats, compiled into a compact matcher; per-group allow and block rules with wildcards; safe-search enforcement.
- **Encrypted upstreams.** DNS-over-HTTPS and DNS-over-TLS clients over a pool - **Per-client policy groups.** Clients are identified by address and assigned to groups, so the filtering a device gets depends on which device it is.
that tracks per-upstream health and fails over, with SNI and certificate - **Local DNS.** Local A/AAAA/CNAME/PTR records and conditional forwarding of internal zones to another resolver.
verification driven by a per-upstream TLS name. - **Cache.** A bounded in-memory cache that respects upstream TTLs and expires entries rather than serving them stale.
- **DoH and DoT endpoints.** nxdns also answers as an encrypted resolver, with a - **Query log.** Queries land in SQLite under a retention policy in both rows and days, with disk-full self-protection that degrades instead of corrupting, and a live SSE stream of the same events.
certificate store that reloads on disk changes and through the API, so renewals - **Web UI and REST API.** A React single-page admin UI embedded in the binary, a REST API with a served OpenAPI document, session authentication, API rate limiting and Prometheus-style `/metrics`.
do not need a restart. - **Configuration.** A ZON configuration file seeds the database on first boot; after that the database is the truth, and `nxdns export` / `nxdns import` move configuration in and out. `nxdns check` validates a file without starting.
- **Blocklist filtering.** Subscriptions in hosts, plain-domain and
Adblock-Plus-style formats, compiled into a compact matcher; per-group allow
and block rules with wildcards; safe-search enforcement.
- **Per-client policy groups.** Clients are identified by address and assigned to
groups, so the filtering a device gets depends on which device it is.
- **Local DNS.** Local A/AAAA/CNAME/PTR records and conditional forwarding of
internal zones to another resolver.
- **Cache.** A bounded in-memory cache that respects upstream TTLs and expires
entries rather than serving them stale.
- **Query log.** Queries land in SQLite under a retention policy in both rows and
days, with disk-full self-protection that degrades instead of corrupting, and a
live SSE stream of the same events.
- **Web UI and REST API.** A React single-page admin UI embedded in the binary,
a REST API with a served OpenAPI document, session authentication, API rate
limiting and Prometheus-style `/metrics`.
- **Configuration.** A ZON configuration file seeds the database on first boot;
after that the database is the truth, and `nxdns export` / `nxdns import` move
configuration in and out. `nxdns check` validates a file without starting.
- **CLI.** `run`, `check`, `export`, `import`, `version` and `help`. - **CLI.** `run`, `check`, `export`, `import`, `version` and `help`.
- **Packaging.** A hardened systemd unit with a sysusers fragment, and a - **Packaging.** A hardened systemd unit with a sysusers fragment, and a `FROM scratch` container image holding the binary, a CA bundle and the licence files, assembled by a builder stage pinned to `alpine:3.22` by digest. Nothing from Alpine ships in the published image except that CA bundle.
`FROM scratch` container image holding the binary, a CA bundle and the licence - **Releases.** Tags publish five assets — static musl tarballs for `x86_64-linux-musl` and `aarch64-linux-musl`, `IMAGE-DIGEST.txt` naming the multi-architecture container image by digest, `SHA256SUMS.txt` over those three, and `SHA256SUMS.txt.asc`, a detached signature over the checksum file. `zig build dist` and `zig build verify-dist` produce and check the same artifacts on a laptop.
files, assembled by a builder stage pinned to `alpine:3.22` by digest. Nothing - **Licensing.** EUPL-1.2, with a `THIRD-PARTY-NOTICES` file in every tarball and image assembled from a reviewed inventory of what the artifacts contain.
from Alpine ships in the published image except that CA bundle. - **Documentation.** A Diátaxis split — tutorial, how-to, reference, explanation — with drift guards that fail the build when the reference pages fall behind the code.
- **Releases.** Tags publish five assets — static musl tarballs for
`x86_64-linux-musl` and `aarch64-linux-musl`, `IMAGE-DIGEST.txt` naming the
multi-architecture container image by digest, `SHA256SUMS.txt` over those
three, and `SHA256SUMS.txt.asc`, a detached signature over the checksum file.
`zig build dist` and `zig build verify-dist` produce and check the same
artifacts on a laptop.
- **Licensing.** EUPL-1.2, with a `THIRD-PARTY-NOTICES` file in every tarball and
image assembled from a reviewed inventory of what the artifacts contain.
- **Documentation.** A Diátaxis split — tutorial, how-to, reference, explanation —
with drift guards that fail the build when the reference pages fall behind the
code.
+11 -32
View File
@@ -11,12 +11,9 @@ This directory is an nxdns release for one architecture. It holds:
| `THIRD-PARTY-NOTICES` | Licences of everything compiled or bundled in | | `THIRD-PARTY-NOTICES` | Licences of everything compiled or bundled in |
| `INSTALL.md` | This file | | `INSTALL.md` | This file |
The binary is statically linked against musl and needs nothing installed on the The binary is statically linked against musl and needs nothing installed on the target host.
target host.
Verify the download before you trust it. `docs/how-to/verify-a-release.md` in Verify the download before you trust it. `docs/how-to/verify-a-release.md` in the repository covers where the public key comes from, what fingerprint to expect, and what the signature does and does not prove.
the repository covers where the public key comes from, what fingerprint to
expect, and what the signature does and does not prove.
## 1. Install the binary, the user and the unit ## 1. Install the binary, the user and the unit
@@ -34,12 +31,9 @@ systemctl daemon-reload
mkdir -p -m 0755 /etc/nxdns mkdir -p -m 0755 /etc/nxdns
``` ```
`nxdns.conf` ships under the name it is installed as, so there is no rename to `nxdns.conf` ships under the name it is installed as, so there is no rename to get wrong.
get wrong.
Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's 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`.
`StateDirectory` and `LogsDirectory` settings make systemd create them on first
start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
## 2. Write the configuration ## 2. Write the configuration
@@ -53,17 +47,14 @@ nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`:
} }
``` ```
That file holds a password in plain text. Root's umask is 022 on most That file holds a password in plain text. Root's umask is 022 on most distributions, so restrict it as soon as you have written it:
distributions, so restrict it as soon as you have written it:
```sh ```sh
chown root:nxdns /etc/nxdns/config.zon chown root:nxdns /etc/nxdns/config.zon
chmod 0640 /etc/nxdns/config.zon chmod 0640 /etc/nxdns/config.zon
``` ```
0640 with group `nxdns` rather than 0600: the service runs as `nxdns` and has to 0640 with group `nxdns` rather than 0600: the service runs as `nxdns`, and systemd leaves `/etc/nxdns` owned by root. Keep that group read bit for good. Under `run --config` the service reads this file on **every** start, not once, so tightening the mode after the first boot breaks the next restart. Under database authority it is `nxdns import` that reads the file, as whoever runs that command, and a bare `nxdns run` never reads it at all.
read this file on the first start, and systemd leaves `/etc/nxdns` owned by
root.
Check it before starting the service: Check it before starting the service:
@@ -71,9 +62,7 @@ Check it before starting the service:
nxdns check --config /etc/nxdns/config.zon nxdns check --config /etc/nxdns/config.zon
``` ```
A good file ends with `OK: no problems found`. Exit 2 means `check` found 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.
something to fix and printed every problem it found. The upstream probe sends a
real query, so this needs working DNS on the host.
Load it into the database: Load it into the database:
@@ -81,10 +70,7 @@ Load it into the database:
nxdns import /etc/nxdns/config.zon nxdns import /etc/nxdns/config.zon
``` ```
The packaged unit runs `nxdns run` with no `--config`, so from here the database 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:
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 ```sh
rm /etc/nxdns/config.zon rm /etc/nxdns/config.zon
@@ -92,10 +78,7 @@ rm /etc/nxdns/config.zon
A kept file 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 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`.
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 ## 3. Start it
@@ -104,9 +87,7 @@ systemctl enable --now nxdns
journalctl -u nxdns -f journalctl -u nxdns -f
``` ```
A healthy start logs a line naming every socket it bound. Port 53 is A healthy start logs a line naming every socket it bound. Port 53 is privileged, and the unit grants `CAP_NET_BIND_SERVICE` through `AmbientCapabilities`.
privileged, and the unit grants `CAP_NET_BIND_SERVICE` through
`AmbientCapabilities`.
## 4. Confirm it answers ## 4. Confirm it answers
@@ -116,9 +97,7 @@ From another machine on the LAN:
dig @<server-ip> example.com A +short dig @<server-ip> example.com A +short
``` ```
The admin interface is on port 8080 by default; log in with the password from The admin interface is on port 8080 by default; log in with the password from the configuration file. `http://<server-ip>:8080/api/health` reports upstream availability and disk state without a login.
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
availability and disk state without a login.
## More ## More
+90 -106
View File
@@ -1,7 +1,6 @@
# nxdns — Implementation Plan v3.0 (Zig 0.16.0 Stable) # nxdns — Implementation Plan v3.0 (Zig 0.16.0 Stable)
Source of truth for **nxdns**, a self-hosted DNS sinkhole written in Zig 0.16.0 stable. Source of truth for **nxdns**, a self-hosted DNS sinkhole written in Zig 0.16.0 stable. All stdlib claims in this document are verified against the `0.16.0` tag of the Zig repo (`../zig`).
All stdlib claims in this document are verified against the `0.16.0` tag of the Zig repo (`../zig`).
There is no v1/v2 versioning. Scope is binary: a feature is in scope (and gets built) or out of scope (and does not). "Done" = everything in scope implemented, tested, documented. There is no v1/v2 versioning. Scope is binary: a feature is in scope (and gets built) or out of scope (and does not). "Done" = everything in scope implemented, tested, documented.
@@ -23,17 +22,19 @@ Serves a household LAN (≈220 devices). Portfolio-grade public repo with ext
- DNS server for LAN clients: UDP/53, TCP/53, DoH server, DoT server. - DNS server for LAN clients: UDP/53, TCP/53, DoH server, DoT server.
- Upstream resolution: DoH (HTTP/1.1), DoT. - Upstream resolution: DoH (HTTP/1.1), DoT.
- Local DNS records (A/AAAA/CNAME) + conditional forwarding (zone → designated resolver, plain UDP/TCP allowed). - Local DNS records (A/AAAA/CNAME) + conditional forwarding (zone → designated resolver, plain UDP/TCP allowed).
- Domain filtering: blocklists (hosts/domains/ABP), custom rules (allow/block; exact, parent-walk, wildcard), CNAME uncloaking (depth 8), per-group safe-search rewrite. - Domain filtering: blocklists (hosts/domains/ABP, including `@@||name^` exception lines), custom rules (allow/block; exact, parent-walk, wildcard, regex), CNAME uncloaking (depth 8), per-group safe-search rewrite.
- DNS caching: positive + negative, in-memory only. - DNS caching: positive + negative, in-memory only.
- Client/group model: IPv4 + IPv6 parity, per-client group assignment, per-group source assignments. - Client/group model: IPv4 + IPv6 parity, per-client group assignment, per-group source assignments.
- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, dashboard + time buckets, live SSE stream. - Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, dashboard + time buckets, live SSE stream.
- Web app + REST API: LAN/Tailscale admin UI, optional password auth, OpenAPI schema + CI contract tests. - Web app + REST API: LAN/Tailscale admin UI, optional password auth, OpenAPI schema + CI contract tests.
- Observability: upstream health API + UI, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`. - Observability: upstream health API + UI, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`.
- Ops: DB-as-truth config, `nxdns export`/`import` (ZON), scheduled + manual blocklist updates, TLS cert watcher + reload, auto-migration on upgrade, systemd service + Dockerfile + compose. - Ops: config authority chosen by the invocation (database, or a file named by `--config`), `nxdns export`/`import` (ZON), scheduled + manual blocklist updates, TLS cert watcher + reload, auto-migration on upgrade, systemd service + Dockerfile + compose.
### 2.2 Out of Scope (permanent scope decisions, not deferrals) ### 2.2 Out of Scope (permanent scope decisions, not deferrals)
- Regex rules. Wildcards + parent-walk cover the real use cases; regex on the DNS hot path means ReDoS exposure plus an immature dependency or a homegrown engine. Regex lines in blocklists are counted, skipped, and the skip count is surfaced in the UI. - Regex from downloaded blocklists. A list is other people's code running on the household's DNS, and the engine exists for rules the operator wrote. Regex lines in blocklists stay counted and skipped, and the skip count stays surfaced in the UI. (Operator regex rules themselves are **in** scope as of milestone 21: the ReDoS objection that once ruled them out is answered by `filter/regex.zig`, a homegrown Pike VM whose running time is bounded by program length × name length by construction, with no dependency and no backtracking. It is reached only after every hash and wildcard level has missed, and only on a cache miss.)
- ABP syntax beyond domain anchors and `@@||name^` exceptions. `$dnstype`, `$dnsrewrite`, `$client`, `$denyallow` and every browser modifier stay unsupported and counted; the one tolerated modifier is a `$important` suffix on an exception line, which changes nothing about where that exception lands.
- Partial-segment wildcards (`ads*.example.com`) as a rule kind. The regex kind covers the need without a second globbing dialect.
- DHCP server. - DHCP server.
- DNSSEC validation (DO bit passthrough only). - DNSSEC validation (DO bit passthrough only).
- DoQ (QUIC), HTTP/2 upstream transport. - DoQ (QUIC), HTTP/2 upstream transport.
@@ -74,8 +75,8 @@ Verified: 0.16.0 ships `std.crypto.tls.Client` only. There is no server-side TLS
### 3.5 Config Format + Truth Model (Decision F) ### 3.5 Config Format + Truth Model (Decision F)
- **DB is truth. Config file format is ZON** (`std.zon` parse + stringify — typed parsing into config structs, exact round-trip, stdlib-maintained, comments supported). No TOML: a third-party parser plus a hand-written serializer is two failure surfaces in the correctness-critical bootstrap/round-trip path, bought for syntax familiarity. - **The invocation picks truth (see the next point). Config file format is ZON** (`std.zon` parse + stringify — typed parsing into config structs, exact round-trip, stdlib-maintained, comments supported). No TOML: a third-party parser plus a hand-written serializer is two failure surfaces in the correctness-critical round-trip path, bought for syntax familiarity.
- First start: if DB empty and `/etc/nxdns/config.zon` exists, validate → seed DB. Subsequent starts ignore the file. - The invocation picks the authority, and nothing else does (m20): `nxdns run` makes the database the configuration, `nxdns run --config FILE` makes the file the sole source and refuses the API routes that would edit configuration. There is no seeding and no first-start special case.
- `nxdns export [--out file.zon]` dumps DB state as canonical ZON. `nxdns import <file.zon>` validates + replaces DB contents (`--force` if the DB holds configuration; client rows materialised from traffic do not count, and survive the replacement. First-seen/last-seen are runtime state, not configuration: they follow the address, so an import never restamps a device the DB already knew). Export/import = backup + host migration, **not** upgrades (§3.7). - `nxdns export [--out file.zon]` dumps DB state as canonical ZON. `nxdns import <file.zon>` validates + replaces DB contents (`--force` if the DB holds configuration; client rows materialised from traffic do not count, and survive the replacement. First-seen/last-seen are runtime state, not configuration: they follow the address, so an import never restamps a device the DB already knew). Export/import = backup + host migration, **not** upgrades (§3.7).
- No file watcher, no auto-regeneration. - No file watcher, no auto-regeneration.
@@ -89,21 +90,21 @@ Two SQLite files with opposite write profiles, isolated from each other:
### 3.7 Upgrades: Auto-Migration (Decision J) ### 3.7 Upgrades: Auto-Migration (Decision J)
- `config.db`: numbered, sequential SQL migration steps compiled into the binary. At startup: read schema version row, apply newer steps inside a transaction, continue. Operator upgrade = install binary, restart. - `config.db`: numbered, sequential SQL migration steps compiled into the binary. At startup: read schema version row, apply newer steps inside a transaction, continue. Operator upgrade = install binary, restart. Before v0.1 the list holds one step — the baseline of §11.2, edited in place — because nxdns has no installs and a step exists only to reconcile a database somebody already has.
- `querylog.db`: **no migrations.** On schema mismatch: rename aside, recreate fresh. - `querylog.db`: **no migrations.** On schema mismatch: rename aside, recreate fresh.
### 3.8 Blocklist Storage (Decision A) ### 3.8 Blocklist Storage (Decision A)
Blocklist domains are **not** stored in SQLite — they are a cache of re-downloadable remote artifacts, not config or state: Blocklist domains are **not** stored in SQLite — they are a cache of re-downloadable remote artifacts, not config or state:
- Each source compiles to `/var/lib/nxdns/blocklists/<source_id>.list`: normalized, one domain per line, small header (source URL, fetch time, count, checksum). Wildcard/regex-flavored lines: wildcards go to `<source_id>.wild`; regex lines are counted + skipped (count in metadata → UI). - Each source compiles to `/var/lib/nxdns/blocklists/<source_id>.list`: normalized, one domain per line, small header (source URL, fetch time, counts, checksum). Wildcard/regex/exception-flavored lines: wildcards go to `<source_id>.wild`, ABP exceptions (`@@||name^`) to `<source_id>.allow`; regex lines and browser-syntax lines nxdns cannot translate into a DNS decision are counted + skipped, both counts in metadata → UI. The checksum covers the three bodies in that order, each followed by a separator byte so that moving a name between bodies — an upstream switching `a.example` to `*.a.example` — changes the digest and forces a republish.
- `config.db` keeps source **metadata only** (`blocklist_sources`). - `config.db` keeps source **metadata only** (`blocklist_sources`).
- Startup + post-update: parse files into the immutable in-memory matcher (RCU swap, §9.5). - Startup + post-update: parse files into the immutable in-memory matcher (RCU swap, §9.5).
- Corruption recovery is per-file: checksum mismatch → re-download one list. - Corruption recovery is per-file: checksum mismatch → re-download one list.
### 3.9 Rule Model (Decision B) ### 3.9 Rule Model (Decision B)
Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*` segment patterns, e.g. `*.doubleclick.net`, `ads.*.example.com`). Actions: `allow` | `block`. Kind is an explicit column — the model is extensible without breakage, but regex stays out of scope (§2.2). Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*` segment patterns, e.g. `*.doubleclick.net`, `ads.*.example.com`), `regex` (the linear-time engine of `filter/regex.zig`, matched unanchored against the whole normalized name). Actions: `allow` | `block`. Kind is an explicit column, so a fourth kind widens one `CHECK` and touches no other table. A regex pattern is stored exactly as written — it is not a name, so it is never lowercased or dot-stripped — and is compiled at both edges: `config/validate.zig` refuses a bad one with the limit it hit, and `filter/rules.zig` compiles it once per snapshot.
### 3.10 Filtering Precedence ### 3.10 Filtering Precedence
@@ -111,11 +112,18 @@ Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*`
2. Exact/parent **block** rules 2. Exact/parent **block** rules
3. Wildcard allow rules 3. Wildcard allow rules
4. Wildcard block rules 4. Wildcard block rules
5. Blocklist domains 5. Regex allow rules
6. Blocklist wildcards 6. Regex block rules
7. Blocklist exceptions (`@@||name^`)
8. Blocklist domains
9. Blocklist wildcards
Tie-break at same specificity: **allow wins**. Tie-break at same specificity: **allow wins**.
Each level is checked against the whole candidate chain before the next level is checked against any, which is what makes an allow rule on a parent beat a block rule on the child.
Two positions carry an argument rather than a preference. The regex levels come last among the operator rules because they are the only ones that are not a set lookup or a label walk: a regex runs only once every cheaper level has missed. Blocklist exceptions come below **every** operator level because a downloaded list may cancel what another list blocked and must never cancel what the operator decided — no list can open an allow hole the operator did not open.
### 3.11 Network Posture ### 3.11 Network Posture
- Default web bind: LAN/Tailscale-friendly (non-loopback allowed). - Default web bind: LAN/Tailscale-friendly (non-loopback allowed).
@@ -128,9 +136,9 @@ IPv4 + IPv6 full parity for: client identity, rate limiting, logging, group assi
### 3.13 Filesystem Layout (FHS) ### 3.13 Filesystem Layout (FHS)
- `/etc/nxdns/config.zon`bootstrap (first start only). - `/etc/nxdns/config.zon`the declarative source, read only when `run --config` names it. A file no flag names changes nothing.
- `/var/lib/nxdns/config.db`, `/var/lib/nxdns/querylog.db` - `/var/lib/nxdns/config.db`, `/var/lib/nxdns/querylog.db`
- `/var/lib/nxdns/blocklists/*.list|*.wild` (plus `*.raw.tmp|*.list.tmp|*.wild.tmp` during a refresh) - `/var/lib/nxdns/blocklists/*.list|*.wild|*.allow` (plus `*.raw.tmp|*.list.tmp|*.wild.tmp|*.allow.tmp` during a refresh)
- `/var/log/nxdns/nxdns.log` — only in file output mode; default is stderr → journald. - `/var/log/nxdns/nxdns.log` — only in file output mode; default is stderr → journald.
### 3.14 Frontend Stack (Decision I) ### 3.14 Frontend Stack (Decision I)
@@ -173,7 +181,7 @@ Client DNS Query
Response to client Response to client
``` ```
Cross-cutting: `ConfigManager` (bootstrap/import/export/settings), `BlocklistManager` (fetch/compile/swap), `Storage` (two SQLite DBs), `Cache`, `UpstreamHealth`, `DiskMonitor`, `Auth`, `Web API` (REST + SSE + metrics). Cross-cutting: `ConfigManager` (load/reconcile/import/export/settings), `BlocklistManager` (fetch/compile/swap), `Storage` (two SQLite DBs), `Cache`, `UpstreamHealth`, `DiskMonitor`, `Auth`, `Web API` (REST + SSE + metrics).
--- ---
@@ -203,7 +211,8 @@ src/
filter/ # pure filter/ # pure
matcher.zig rules.zig wildcard.zig matcher.zig rules.zig wildcard.zig
parser_hosts.zig parser_domains.zig parser_abp.zig parser_hosts.zig parser_domains.zig parser_abp.zig
fetcher.zig compiler.zig # list download -> compiled .list/.wild files fetcher.zig compiler.zig # list download -> compiled .list/.wild/.allow files
regex.zig # linear-time Pike VM for operator regex rules
safesearch.zig safesearch.zig
local/ # pure local/ # pure
@@ -223,7 +232,8 @@ src/
logger.zig retention.zig disk_monitor.zig logger.zig retention.zig disk_monitor.zig
config/ config/
model.zig bootstrap.zig import.zig export.zig validate.zig # all ZON via std.zon model.zig loader.zig reconcile.zig import.zig export.zig # all ZON via std.zon
validate.zig faults.zig
web/ web/
server.zig router.zig auth.zig sse.zig static.zig metrics.zig openapi.zig server.zig router.zig auth.zig sse.zig static.zig metrics.zig openapi.zig
@@ -232,7 +242,7 @@ src/
rules.zig local.zig lookup.zig pause.zig settings.zig rules.zig local.zig lookup.zig pause.zig settings.zig
upstream_health.zig certs.zig health.zig version.zig upstream_health.zig certs.zig health.zig version.zig
web/ # Vite + React + TS + StyleX + React Aria + TanStack admin/ # Vite + React + TS + StyleX + React Aria + TanStack
vendor/ # sqlite3 amalgamation, mbedtls (pinned) vendor/ # sqlite3 amalgamation, mbedtls (pinned)
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis) docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
tests/ # dns/ integration/ fuzz/ tests/ # dns/ integration/ fuzz/
@@ -272,32 +282,27 @@ Walk chain to depth 8; any target hitting block logic → synthesize blocked res
### 7.1 Evaluation ### 7.1 Evaluation
For `{domain, group_id}` (the qtype travels with the query for logging and response synthesis, not For `{domain, group_id}` (the qtype travels with the query for logging and response synthesis, not for matching):
for matching):
1. Normalize: lowercase, trim trailing dot. 1. Normalize: lowercase, trim trailing dot.
2. Build candidate chain (full, parent1, parent2, …). 2. Build candidate chain (full, parent1, parent2, …).
3. Explicit rules per §3.10 precedence, evaluated against every candidate in the chain. 3. Explicit rules per §3.10 precedence, allow before block at each level: exact rules against every candidate in the chain, then wildcard patterns and then regex patterns against the whole name (both kinds express their own reach, so neither walks the chain).
4. Group's blocklist domains (hash set over compiled lists), matched against the query name only. 4. Group's blocklist exceptions (`@@` entries), against every candidate in the chain. They cancel a block a list made and never one a rule made.
5. Group's blocklist wildcards, matched against every proper parent of the query name. 5. Group's blocklist domains (hash set over compiled lists), matched against the query name only.
6. No match → allow. 6. Group's blocklist wildcards, matched against every proper parent of the query name.
7. No match → allow.
Blocklist entries do not parent-walk; only rules do (§3.9). ABP `||x.y^` emits both a domain entry Blocklist *domain* entries do not parent-walk: they are matched against the query name alone. Wildcard entries match every proper parent, and exception entries walk the candidate chain the way rules do (§3.9), so `@@||good.ads.example^` also lifts `y.good.ads.example`. ABP `||x.y^` emits both a domain entry `x.y` and a wildcard entry `x.y`, which together give domain-and-subdomains semantics.
`x.y` and a wildcard entry `x.y`, which together give domain-and-subdomains semantics.
### 7.2 Group Assignment ### 7.2 Group Assignment
- Per source IP: (1) exact match in `clients`, (2) longest-prefix match in `client_prefixes` (ties: longer prefix, then priority), (3) `default` group. - Per source IP: (1) exact match in `clients`, (2) longest-prefix match in `client_prefixes` (ties: longer prefix, then priority), (3) `default` group.
- Auto-materialization: first query from an unseen IP inserts a `clients` row (`hand_edited=0`, `first_seen=now`) for UI visibility and stable group assignment. The query log does **not** FK to it (§3.6). - Auto-materialization: first query from an unseen IP inserts a `clients` row (`hand_edited=0`, `first_seen=now`) for UI visibility and stable group assignment. The query log does **not** FK to it (§3.6).
- Materialized clients name themselves: the tracker's flush pass sends one PTR query per unnamed row through the declared forward zones (§6.5), and the answer is runtime state in `learned_name`, never configuration.
- Retention drops `hand_edited=0` clients with no queries in `retention_days`. - Retention drops `hand_edited=0` clients with no queries in `retention_days`.
### 7.3 Reload ### 7.3 Reload
New immutable matcher built from DB + compiled list files → swap under an `std.Io.RwLock` with a New immutable matcher built from DB + compiled list files → swap under an `std.Io.RwLock` with a generation counter. Readers take the shared lock for the microseconds of one evaluate; the writer takes the exclusive lock only for the swap, and the source status table is installed in the same critical section, so a failed reload publishes neither. (Deliberate deviation from "readers lock-free": freeing the old snapshot without a lock needs epoch-based reclamation, unjustifiable at household scale — see specs/milestone-5.md S8.3.)
generation counter. Readers take the shared lock for the microseconds of one evaluate; the writer
takes the exclusive lock only for the swap, and the source status table is installed in the same
critical section, so a failed reload publishes neither. (Deliberate deviation from "readers
lock-free": freeing the old snapshot without a lock needs epoch-based reclamation, unjustifiable at
household scale — see specs/milestone-5.md S8.3.)
### 7.4 Safe-Search ### 7.4 Safe-Search
@@ -320,7 +325,7 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ
- Schemes: `https://…` → DoH, `tls://host:853` → DoT. - Schemes: `https://…` → DoH, `tls://host:853` → DoT.
- Ordered by priority; sequential attempt; per-upstream failure counters; exponential backoff with jitter; success resets. - Ordered by priority; sequential attempt; per-upstream failure counters; exponential backoff with jitter; success resets.
- `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. Exposed via `GET /api/upstream/health`, dashboard, `/metrics`, and `nxdns check`. - `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. This is routing state: it drives failover and backoff, and is exposed through `/metrics` and `nxdns check`. `GET /api/upstream/health?period=…` exposes none of it except the live `enabled`/`available` pair; its counts, success rate and last failure are ranged aggregates read from the per-minute upstream history in `querylog.db`, so the dashboard's period scopes them like every other number on the page.
- DoH client: `std.http.Client` with `content-type/accept: application/dns-message`; strict status + payload checks. - DoH client: `std.http.Client` with `content-type/accept: application/dns-message`; strict status + payload checks.
- `platform/tls_client.zig` enforces per-connection read/write deadlines, classifies TLS errors explicitly, retries with backoff. Integration tests cover timeout/hang scenarios so compiler upgrades can't silently regress them. - `platform/tls_client.zig` enforces per-connection read/write deadlines, classifies TLS errors explicitly, retries with backoff. Integration tests cover timeout/hang scenarios so compiler upgrades can't silently regress them.
- Connect, read, and total-budget timeouts each configurable. - Connect, read, and total-budget timeouts each configurable.
@@ -341,7 +346,11 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ
`journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout` set. `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout` set.
### 11.2 config.db Schema ### 11.2 config.db Schema (v1 baseline)
The DDL below is the live schema, kept byte-identical to `src/storage/config_schema.zig`. `src/storage/migrations.zig` carries it as its one and only step, so a database is at **version 1** or it does not exist.
Until nxdns reaches v0.1 this baseline is **editable**: a schema change edits this section and `config_schema.zig` together and adds no migration step. nxdns has no installs, so there is no database for a step to reconcile. At v0.1 the baseline freezes and every later change becomes an append-only step.
```sql ```sql
CREATE TABLE schema_version (version INTEGER NOT NULL); CREATE TABLE schema_version (version INTEGER NOT NULL);
@@ -357,6 +366,8 @@ CREATE TABLE clients (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
ip TEXT NOT NULL UNIQUE, -- canonical text form (v4 dotted / v6 RFC 5952) ip TEXT NOT NULL UNIQUE, -- canonical text form (v4 dotted / v6 RFC 5952)
name TEXT, name TEXT,
learned_name TEXT,
name_attempt_after INTEGER NOT NULL DEFAULT 0,
group_id INTEGER NOT NULL REFERENCES groups(id), group_id INTEGER NOT NULL REFERENCES groups(id),
hand_edited INTEGER NOT NULL DEFAULT 0, hand_edited INTEGER NOT NULL DEFAULT 0,
first_seen INTEGER NOT NULL, first_seen INTEGER NOT NULL,
@@ -374,7 +385,8 @@ CREATE TABLE upstreams (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE, url TEXT NOT NULL UNIQUE,
priority INTEGER NOT NULL DEFAULT 100, priority INTEGER NOT NULL DEFAULT 100,
enabled INTEGER NOT NULL DEFAULT 1 enabled INTEGER NOT NULL DEFAULT 1,
tls_name TEXT NOT NULL DEFAULT '' -- DoT verification name; empty verifies against the url host
); );
CREATE TABLE blocklist_sources ( CREATE TABLE blocklist_sources (
@@ -386,7 +398,9 @@ CREATE TABLE blocklist_sources (
last_updated INTEGER, last_updated INTEGER,
domain_count INTEGER NOT NULL DEFAULT 0, domain_count INTEGER NOT NULL DEFAULT 0,
wildcard_count INTEGER NOT NULL DEFAULT 0, wildcard_count INTEGER NOT NULL DEFAULT 0,
exception_count INTEGER NOT NULL DEFAULT 0,
skipped_regex_count INTEGER NOT NULL DEFAULT 0, skipped_regex_count INTEGER NOT NULL DEFAULT 0,
skipped_unsupported_count INTEGER NOT NULL DEFAULT 0,
checksum TEXT checksum TEXT
); );
@@ -400,7 +414,7 @@ CREATE TABLE rules (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
pattern TEXT NOT NULL, pattern TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard')), kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard','regex')),
action TEXT NOT NULL CHECK(action IN ('allow','block')), action TEXT NOT NULL CHECK(action IN ('allow','block')),
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
@@ -446,6 +460,24 @@ CREATE TABLE query_log (
CREATE INDEX idx_query_log_ts ON query_log(timestamp); CREATE INDEX idx_query_log_ts ON query_log(timestamp);
CREATE INDEX idx_query_log_client ON query_log(client_ip); CREATE INDEX idx_query_log_client ON query_log(client_ip);
CREATE INDEX idx_query_log_domain ON query_log(domain_id); CREATE INDEX idx_query_log_domain ON query_log(domain_id);
CREATE TABLE upstream_targets (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE -- the historical identity: config.db ids cannot cross database files
);
CREATE TABLE upstream_minute (
upstream_id INTEGER NOT NULL REFERENCES upstream_targets(id),
minute_ts INTEGER NOT NULL,
successes INTEGER NOT NULL,
failures INTEGER NOT NULL,
last_failure_ts INTEGER,
last_error TEXT,
PRIMARY KEY (upstream_id, minute_ts),
CHECK (successes >= 0),
CHECK (failures >= 0)
) WITHOUT ROWID;
CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts);
``` ```
### 11.4 Query Logger ### 11.4 Query Logger
@@ -471,52 +503,17 @@ Periodic delete of rows older than `retention_days`; scheduled checkpoint/VACUUM
## 12. Configuration ## 12. Configuration
### 12.1 Bootstrap ZON Shape ### 12.1 Config ZON Shape
The canonical shape is not duplicated here. It lives in [docs/reference/configuration.md](docs/reference/configuration.md), which is handwritten against `config/model.zig` and only partly guarded (the drift test covers settings-key rows, not the whole shape, so a new collection can go undocumented while the guard stays green), and `nxdns export` emits it. A copy in this document is how §12.1 came to describe an `.upstream.servers` field that never existed and to omit the required `.groups` and `.upstreams` — a sample nobody could load. The skeleton, for orientation only:
```zon ```zon
.{ .{
.upstream = .{ .groups = .{ .{ .name = "default" } },
.servers = .{ "https://cloudflare-dns.com/dns-query", "tls://dns.google:853" }, .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
.read_timeout_ms = 3000, .rules = .{
.{ .group = "default", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
}, },
.dns = .{
.bind_ipv4 = "0.0.0.0",
.bind_ipv6 = "::",
.port = 53,
.rate_limit = 1000,
.rate_window_seconds = 60,
},
.blocking = .{ .response = .zero, .ttl = 5 }, // .zero | .nxdomain
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
.web = .{
.enabled = true,
.bind = "0.0.0.0",
.port = 8080,
.password = "", // empty => auth disabled
.session_ttl_hours = 24,
.api_rate_limit_per_min = 300,
.sse_max_connections_per_ip = 3,
},
.doh_server = .{ .enabled = false, .bind = "0.0.0.0", .port = 443,
.cert_path = "/etc/nxdns/cert.pem", .key_path = "/etc/nxdns/key.pem" },
.dot_server = .{ .enabled = false, .bind = "0.0.0.0", .port = 853,
.cert_path = "/etc/nxdns/cert.pem", .key_path = "/etc/nxdns/key.pem" },
.edns = .{ .ecs_mode = .strip }, // .strip | .forward
.local_records = .{ .{ .name = "nas.lan", .rtype = .A, .value = "192.168.1.10" } },
.forward_zones = .{ .{ .zone = "lan.home", .resolver = "udp://192.168.1.1:53" } },
.logging = .{
.level = .info,
.retention_days = 30,
.query_log_buffer_max = 10000,
.hide_domains = false,
.hide_client_ips = false,
.output = .stderr, // .stderr | .syslog | .file
.file_path = "/var/log/nxdns/nxdns.log",
.max_size_mb = 50,
.max_files = 5,
},
.disk = .{ .min_free_mb = 200, .warn_free_mb = 500 },
.blocklist_update = .{ .enabled = true, .interval_hours = 24 },
} }
``` ```
@@ -543,7 +540,7 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables.
- `GET /api/lookup?domain=…&group_id=…` - `GET /api/lookup?domain=…&group_id=…`
- `GET/POST /api/pause` - `GET/POST /api/pause`
- `GET/PUT /api/settings` - `GET/PUT /api/settings`
- `GET /api/upstream/health` - `GET /api/upstream/health?period=…`
- `POST /api/certs/reload` - `POST /api/certs/reload`
- `GET /api/health` — overall + disk + upstream + queries_dropped rollup - `GET /api/health` — overall + disk + upstream + queries_dropped rollup
- `GET /metrics` — Prometheus text exposition: query counters (total/blocked/cached), per-upstream health, cache stats, queries_dropped, disk gauges - `GET /metrics` — Prometheus text exposition: query counters (total/blocked/cached), per-upstream health, cache stats, queries_dropped, disk gauges
@@ -576,54 +573,43 @@ Requirements: responsive desktop/mobile; route loaders for initial fetch; TanSta
## 16. Implementation Order ## 16. Implementation Order
### Phase 0 — Build Baseline ### Phase 0 — Build Baseline
Scaffold tree; `build.zig` with 0.16 assertion, musl targets, vendored sqlite3 + mbedtls compiling; version plumbing; Gitea Actions workflows (test, integration, fuzz smoke, OpenAPI lint, frontend build). Scaffold tree; `build.zig` with 0.16 assertion, musl targets, vendored sqlite3 + mbedtls compiling; version plumbing; Gitea Actions workflows (test, integration, fuzz smoke, OpenAPI lint, frontend build). Exit: cross-compiled hello-world linking both C deps on both targets; CI green.
Exit: cross-compiled hello-world linking both C deps on both targets; CI green.
### Phase 1 — Platform Layer ### Phase 1 — Platform Layer
`platform/address.zig` (v4/v6 parity + canonical keys); `platform/tls_client.zig` (deadlines, error classes); `platform/tls_server.zig` (mbedTLS handshake → `std.Io.Reader`/`Writer`). `platform/address.zig` (v4/v6 parity + canonical keys); `platform/tls_client.zig` (deadlines, error classes); `platform/tls_server.zig` (mbedTLS handshake → `std.Io.Reader`/`Writer`). Exit: UDP echo over `std.Io`; TLS client handshake against a real host; mbedTLS server terminating a loopback TLS connection.
Exit: UDP echo over `std.Io`; TLS client handshake against a real host; mbedTLS server terminating a loopback TLS connection.
### Phase 2 — DNS Core ### Phase 2 — DNS Core
Types/header/name/question/record/packet; parser + encoder tests + fuzz target; EDNS + DO passthrough. Types/header/name/question/record/packet; parser + encoder tests + fuzz target; EDNS + DO passthrough. Exit: unit + fuzz smoke pass.
Exit: unit + fuzz smoke pass.
### Phase 3 — Resolver Transport ### Phase 3 — Resolver Transport
UDP server, TCP server, DoH + DoT upstream clients, pool + failover/backoff + health. UDP server, TCP server, DoH + DoT upstream clients, pool + failover/backoff + health. Exit: A/AAAA forwarding over UDP + TCP; health populated.
Exit: A/AAAA forwarding over UDP + TCP; health populated.
### Phase 4 — Storage + Config ### Phase 4 — Storage + Config
SQLite wrapper; config.db schema + migration runner; querylog.db schema + recreate-on-mismatch; repositories; ZON bootstrap + import/export; `nxdns check`. SQLite wrapper; config.db schema + migration runner; querylog.db schema + recreate-on-mismatch; repositories; ZON loading + import/export; `nxdns check`. Exit: export → import round-trips byte-stable. (The ZON bootstrap this phase shipped was replaced in m20 by the two authority modes above.)
Exit: first start seeds DB from ZON; export → import round-trips byte-stable.
### Phase 5 — Filtering + Local DNS ### Phase 5 — Filtering + Local DNS
Rule matcher (exact/parent/wildcard); blocklist parsers → compiled file format; fetcher + scheduled update; RCU swap; per-group safe-search; local records; forward zones. Rule matcher (exact/parent/wildcard; `regex` added in m21); blocklist parsers → compiled file format; fetcher + scheduled update; RCU swap; per-group safe-search; local records; forward zones. Exit: precedence table validated by tests; local zone answers + conditional forwards work.
Exit: precedence table validated by tests; local zone answers + conditional forwards work.
### Phase 6 — Cache + Rate Limit + Logging + Disk Monitor ### Phase 6 — Cache + Rate Limit + Logging + Disk Monitor
TTL cache + reconstruction; v4/v6 rate limiter; async query logger + backpressure + retention; DiskMonitor + rotation + error-log dedup. TTL cache + reconstruction; v4/v6 rate limiter; async query logger + backpressure + retention; DiskMonitor + rotation + error-log dedup. Exit: disk thresholds trigger degradation + drop counters in integration test.
Exit: disk thresholds trigger degradation + drop counters in integration test.
### Phase 7 — Handler Integration ### Phase 7 — Handler Integration
Full pipeline composition; CNAME uncloaking; pause/resume. Full pipeline composition; CNAME uncloaking; pause/resume. Exit: end-to-end DNS flow with blocking, local records, cache, failover.
Exit: end-to-end DNS flow with blocking, local records, cache, failover.
### Phase 8 — Web / API / SSE / Auth / Metrics ### Phase 8 — Web / API / SSE / Auth / Metrics
HTTP server + router; handlers; SSE; optional auth; API rate limiting; `/metrics`; OpenAPI served + contract tests; embedded frontend + dev-mode disk serving. HTTP server + router; handlers; SSE; optional auth; API rate limiting; `/metrics`; OpenAPI served + contract tests; embedded frontend + dev-mode disk serving. Exit: frontend fully drives config and operations; contract tests green.
Exit: frontend fully drives config and operations; contract tests green.
### Phase 9 — Local DoH/DoT Endpoints ### Phase 9 — Local DoH/DoT Endpoints
DoH server + DoT server on `platform/tls_server.zig`; cert watcher + reload. DoH server + DoT server on `platform/tls_server.zig`; cert watcher + reload. Exit: LAN client resolves via DoH and DoT against local certs.
Exit: LAN client resolves via DoH and DoT against local certs.
### Phase 10 — Packaging + Ops + Docs ### Phase 10 — Packaging + Ops + Docs
systemd unit (`AmbientCapabilities=CAP_NET_BIND_SERVICE`, hardened, writable `/var/lib/nxdns` + optional `/var/log/nxdns`); Dockerfile + compose (53/udp+tcp, 8080; mounts `/etc/nxdns`, `/var/lib/nxdns`); operator/architecture/config-reference/API docs. systemd unit (`AmbientCapabilities=CAP_NET_BIND_SERVICE`, hardened, writable `/var/lib/nxdns` + optional `/var/log/nxdns`); Dockerfile + compose (53/udp+tcp, 8080; mounts `/etc/nxdns`, `/var/lib/nxdns`); operator/architecture/config-reference/API docs. Exit: documented deployment works end-to-end on the Pi 5.
Exit: documented deployment works end-to-end on the Pi 5.
--- ---
## 17. Testing Strategy ## 17. Testing Strategy
- **Unit**: DNS encode/decode; rule precedence + wildcard matcher; cache put/get/TTL rewrite; ZON bootstrap + export round-trip; rate limiter; migration runner (fresh + stepwise upgrade). - **Unit**: DNS encode/decode; rule precedence + wildcard matcher; cache put/get/TTL rewrite; ZON loading + export round-trip; rate limiter; migration runner (fresh + stepwise upgrade).
- **Fuzz**: DNS parser malformed-packet fuzzing; blocklist parser fuzzing. - **Fuzz**: DNS parser malformed-packet fuzzing; blocklist parser fuzzing.
- **Integration**: UDP/TCP query path; blocked path; allow-over-block; wildcard precedence; CNAME uncloaking block; local records + forward zones; upstream failover/backoff/health; disk-full degradation; querylog.db corruption recovery; API CRUD; auth on/off; SSE; contract tests. - **Integration**: UDP/TCP query path; blocked path; allow-over-block; wildcard precedence; CNAME uncloaking block; local records + forward zones; upstream failover/backoff/health; disk-full degradation; querylog.db corruption recovery; API CRUD; auth on/off; SSE; contract tests.
- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; dashboard + live log. - **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; dashboard + live log.
@@ -652,9 +638,7 @@ Exit: documented deployment works end-to-end on the Pi 5.
## 20. Publication ## 20. Publication
The project publishes released binaries and container images from its own Gitea The project publishes released binaries and container images from its own Gitea instance. Building from source stays fully supported and documented; it is no longer the only path.
instance. Building from source stays fully supported and documented; it is no
longer the only path.
- **Trigger.** Pushing an annotated, GPG-signed tag `vX.Y.Z` to `git.mial.net/mokhtar/nxdns`. Nothing else publishes. Pre-release tags are rejected. - **Trigger.** Pushing an annotated, GPG-signed tag `vX.Y.Z` to `git.mial.net/mokhtar/nxdns`. Nothing else publishes. Pre-release tags are rejected.
- **Version.** The tag is authoritative. `build.zig.zon`'s `.version` must equal the tag, and the packaging gate asserts it. Nowhere else stores a version. - **Version.** The tag is authoritative. `build.zig.zon`'s `.version` must equal the tag, and the packaging gate asserts it. Nowhere else stores a version.
@@ -697,7 +681,7 @@ longer the only path.
| # | Decision | | # | Decision |
|---|----------| |---|----------|
| A | Blocklists compile to flat files under `/var/lib/nxdns/blocklists/`; DB stores source metadata only | | A | Blocklists compile to flat files under `/var/lib/nxdns/blocklists/`; DB stores source metadata only |
| B | Rule kinds: exact, parent-walk, wildcard. Regex permanently out of scope | | B | Rule kinds: exact, parent-walk, wildcard, regex (m21, own linear-time engine). Regex *from downloaded lists* stays out of scope |
| C | In scope: local DoH/DoT server, local records, conditional forwarding. Out: HTTP/2, DoQ, DHCP, DNSSEC, clustering | | C | In scope: local DoH/DoT server, local records, conditional forwarding. Out: HTTP/2, DoQ, DHCP, DNSSEC, clustering |
| D | mbedTLS (vendored) terminates server TLS; stdlib TLS for upstream client | | D | mbedTLS (vendored) terminates server TLS; stdlib TLS for upstream client |
| E | `std.Io` injected everywhere; `Threaded` backend (io_uring flag dropped in m11 — Evented networking is stubbed at 0.16.0); no custom thread pool | | E | `std.Io` injected everywhere; `Threaded` backend (io_uring flag dropped in m11 — Evented networking is stubbed at 0.16.0); no custom thread pool |
+31 -88
View File
@@ -1,7 +1,6 @@
# nxdns # nxdns
nxdns is a DNS sinkhole for your LAN. It blocks the names you do not want, and nxdns is a DNS sinkhole for your LAN. It blocks the names you do not want, and forwards the rest over an encrypted connection.
forwards the rest over an encrypted connection.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -12,48 +11,30 @@ flowchart LR
nxdns -- "blocked" --> sink["0.0.0.0 / NXDOMAIN"] nxdns -- "blocked" --> sink["0.0.0.0 / NXDOMAIN"]
``` ```
One static Zig binary. SQLite holds the state and the query log, which the web One static Zig binary. SQLite holds the state and the query log, which the web UI, the REST API and `/metrics` read.
UI, the REST API and `/metrics` read.
## Features ## Features
- Blocklist filtering: subscribe to hosts/domain lists, plus your own allow - Blocklist filtering: subscribe to hosts, domain and Adblock Plus lists — whose `@@` exception lines are honoured — plus your own allow and block rules, exact, wildcard (`*.example.com`) or regular expression
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
- Two configuration modes: a database the web UI edits, or a ZON file you keep - Per-client policy groups: different filtering for the kids' tablet and your workstation
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 - Local DNS records and conditional forwarding for internal zones
- Encrypted upstreams: DNS-over-HTTPS and DNS-over-TLS with failover - Encrypted upstreams: DNS-over-HTTPS and DNS-over-TLS with failover
- Built-in DoH and DoT server endpoints, with certificate hot-reload - Built-in DoH and DoT server endpoints, with certificate hot-reload
- Bounded in-memory DNS cache with TTL-respecting expiry - Bounded in-memory DNS cache with TTL-respecting expiry
- Query log with retention limits, live-streamed over SSE - Query log with retention limits, live-streamed over SSE
- Web UI (embedded in the binary) and a REST API with a served OpenAPI spec - Web UI (embedded in the binary) and a REST API with a served OpenAPI spec
- Prometheus-style `/metrics`, per-client rate limiting, disk-full - Prometheus-style `/metrics`, per-client rate limiting, disk-full self-protection
self-protection
## Install ## Install
**No release exists yet.** This repository has no tags, nothing has been **No release exists yet.** This repository has no tags, nothing has been published to <https://git.mial.net/mokhtar/nxdns/releases>, and no container image has been pushed. Every release URL on this page and in the how-to guides is a 404 today, and `docker pull` finds nothing. Until the first tag ships, building from source is the only way to get nxdns.
published to <https://git.mial.net/mokhtar/nxdns/releases>, and no container
image has been pushed. Every release URL on this page and in the how-to guides
is a 404 today, and `docker pull` finds nothing. Until the first tag ships,
building from source is the only way to get nxdns.
What a tag will publish, once one exists: five assets — two static musl What a tag will publish, once one exists: five assets — two static musl tarballs (`nxdns-<version>-x86_64-linux-musl.tar.gz`, `nxdns-<version>-aarch64-linux-musl.tar.gz`), `IMAGE-DIGEST.txt` naming the multi-architecture container image by digest, `SHA256SUMS.txt` covering those three files, and `SHA256SUMS.txt.asc`, a detached OpenPGP signature over the checksum file. Verify what you downloaded before you run it: [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md), which also says what that signature does and does not prove.
tarballs (`nxdns-<version>-x86_64-linux-musl.tar.gz`,
`nxdns-<version>-aarch64-linux-musl.tar.gz`), `IMAGE-DIGEST.txt` naming the
multi-architecture container image by digest, `SHA256SUMS.txt` covering those
three files, and `SHA256SUMS.txt.asc`, a detached OpenPGP signature over the
checksum file. Verify what you downloaded before you run it:
[docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md), which also
says what that signature does and does not prove.
## Quickstart (docker compose) ## Quickstart (docker compose)
Write 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:
first release will make possible; it does not work today, because there is no
image in the registry to pull:
```sh ```sh
cd deploy/docker cd deploy/docker
@@ -68,90 +49,52 @@ EOF
NXDNS_VERSION=<version> docker compose up -d NXDNS_VERSION=<version> docker compose up -d
``` ```
The compose file defaults to `:latest`; pin a version for anything you intend 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>.
to keep running. To run it before a release exists, build the image yourself and
name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md)
describes. DNS is on port 53, the web UI on <http://localhost:8080>.
The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes 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).
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 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).
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).
## Building from source ## Building from source
Requires [Zig 0.16.0](https://ziglang.org/download/) and Node.js 24 (for Requires [Zig 0.16.0](https://ziglang.org/download/) and Node.js 24 (for the web UI). C dependencies (SQLite, mbedTLS) are vendored and built by `zig build`.
the web UI). C dependencies (SQLite, mbedTLS) are vendored and built by
`zig build`.
```sh ```sh
(cd web && npm ci && npm run build) # web UI -> web/dist (cd admin && npm ci && npm run build) # web UI -> admin/dist
zig build -Dweb-dist=web/dist # native binary -> zig-out/bin/nxdns zig build -Dadmin-dist=admin/dist # native binary -> zig-out/bin/nxdns
zig build test --summary all # unit tests zig build test --summary all # unit tests
``` ```
The release artifacts come out of the same build graph, so the whole release The release artifacts come out of the same build graph, so the whole release build runs on a laptop exactly as it runs on the CI runner:
build runs on a laptop exactly as it runs on the CI runner:
```sh ```sh
(cd web && npm ci && npm run build) # required: dist refuses the placeholder (cd admin && npm ci && npm run build) # required: dist refuses the placeholder
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon) VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \ zig build dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe # tarballs -> zig-out/dist/ -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # tarballs -> zig-out/dist/
zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \ zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe # the release checks -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # the release checks
``` ```
The version comes from `build.zig.zon` because `verify-dist` asserts the two The version comes from `build.zig.zon` because `verify-dist` asserts the two agree; a tag sets both.
agree; a tag sets both.
That is not a claim that your tarball will hash the same as a published one. That is not a claim that your tarball will hash the same as a published one. Nothing in this project measures whether two builds of the same commit on two different machines land on the same bytes, so no document here describes the build as reproducible. The gate that would settle it is a recorded deferral — `specs/milestone-14.md` ruling 12 — and [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) explains what a matching or differing hash is worth in the meantime.
Nothing in this project measures whether two builds of the same commit on two
different machines land on the same bytes, so no document here describes the
build as reproducible. The gate that would settle it is a recorded deferral —
`specs/milestone-14.md` ruling 12 — and
[docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) explains what
a matching or differing hash is worth in the meantime.
## Documentation ## Documentation
Start at [docs/README.md](docs/README.md), which splits the documentation Start at [docs/README.md](docs/README.md), which splits the documentation into a tutorial, how-to guides, reference and explanation.
into a tutorial, how-to guides, reference and explanation.
- [docs/tutorial/first-run.md](docs/tutorial/first-run.md) — build it, resolve - [docs/tutorial/first-run.md](docs/tutorial/first-run.md) — build it, resolve a name, block a domain, on a scratch directory
a name, block a domain, on a scratch directory - [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) — a real install, including the Raspberry Pi 5
- [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) — - [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) — checking the hashes and the signature before you install
a real install, including the Raspberry Pi 5 - [docs/reference/configuration.md](docs/reference/configuration.md) — every configuration field
- [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) — checking
the hashes and the signature before you install
- [docs/reference/configuration.md](docs/reference/configuration.md) — every
configuration field
- [docs/reference/api.md](docs/reference/api.md) — REST API, auth and SSE - [docs/reference/api.md](docs/reference/api.md) — REST API, auth and SSE
- [docs/reference/cli.md](docs/reference/cli.md) — subcommands, flags and exit - [docs/reference/cli.md](docs/reference/cli.md) — subcommands, flags and exit codes
codes - [docs/explanation/architecture.md](docs/explanation/architecture.md) — module map and design
- [docs/explanation/architecture.md](docs/explanation/architecture.md) — module - [PLAN.md](PLAN.md) and [specs/](specs/) — scope, design decisions and per-milestone contracts
map and design
- [PLAN.md](PLAN.md) and [specs/](specs/) — scope, design decisions and
per-milestone contracts
## Licence ## Licence
Copyright (c) 2026 Mokhtar Mial. nxdns is licensed under the European Union Copyright (c) 2026 Mokhtar Mial. nxdns is licensed under the European Union Public Licence v. 1.2 (`EUPL-1.2`); the full text is in [LICENSE](LICENSE).
Public Licence v. 1.2 (`EUPL-1.2`); the full text is in [LICENSE](LICENSE).
Every released tarball and image carries a `THIRD-PARTY-NOTICES` file assembled Every released tarball and image carries a `THIRD-PARTY-NOTICES` file assembled from the reviewed inventory in [licenses/](licenses/), which covers what the artifacts actually contain: musl, the Zig runtime, SQLite, Mbed TLS and its vendored Everest and p256-m code, and the JavaScript and CSS bundled into the admin UI.
from the reviewed inventory in [licenses/](licenses/), which covers what the
artifacts actually contain: musl, the Zig runtime, SQLite, Mbed TLS and its
vendored Everest and p256-m code, and the JavaScript and CSS bundled into the
admin UI.

Before

Width:  |  Height:  |  Size: 303 B

After

Width:  |  Height:  |  Size: 303 B

View File
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "nxdns-web", "name": "nxdns-admin",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nxdns-web", "name": "nxdns-admin",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@stylexjs/stylex": "0.19.0", "@stylexjs/stylex": "0.19.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "nxdns-web", "name": "nxdns-admin",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

@@ -1,11 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
// The set of npm packages whose bytes reach web/dist must be exactly the set // The set of npm packages whose bytes reach admin/dist must be exactly the set
// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3). // recorded in licenses/dependency-identity.txt (milestone-14 ruling 3).
// //
// The shipped build carries no sourcemaps, so this makes a second build with // The shipped build carries no sourcemaps, so this makes a second build with
// them into its own directory: the `sources` list of each chunk names the // them into its own directory: the `sources` list of each chunk names the
// modules that went into it, and the artifact `npm run build` produced stays // modules that went into it, and the artifact `npm run build` produced stays
// untouched. Runs from web/ as `npm run assert-bundled`, on a laptop exactly as // untouched. Runs from admin/ as `npm run assert-bundled`, on a laptop exactly as
// on the runner. // on the runner.
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
@@ -85,10 +85,10 @@ try {
const recorded = recordedPackages(identity); const recorded = recordedPackages(identity);
if (recorded === null) { if (recorded === null) {
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section"); fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into admin/dist]' section");
} }
if (recorded.length === 0) { if (recorded.length === 0) {
fail("assert-bundled: the '[npm packages bundled into web/dist]' section is empty"); fail("assert-bundled: the '[npm packages bundled into admin/dist]' section is empty");
} }
const { added, removed } = comparePackages(recorded, bundled); const { added, removed } = comparePackages(recorded, bundled);
@@ -96,12 +96,12 @@ if (added.length !== 0 || removed.length !== 0) {
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`); process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
fail( fail(
[ [
"the set of npm packages in web/dist has changed (-recorded +current).", "the set of npm packages in admin/dist has changed (-recorded +current).",
"Work out what the change means for licenses/inventory.zon first, then record", "Work out what the change means for licenses/inventory.zon first, then record",
"the new list in that section of licenses/dependency-identity.txt.", "the new list in that section of licenses/dependency-identity.txt.",
].join("\n"), ].join("\n"),
); );
} }
process.stdout.write(`web/dist bundles exactly the ${bundled.length} recorded packages:\n`); process.stdout.write(`admin/dist bundles exactly the ${bundled.length} recorded packages:\n`);
for (const name of bundled) process.stdout.write(`${name}\n`); for (const name of bundled) process.stdout.write(`${name}\n`);
@@ -14,7 +14,7 @@
// brace inside a string blinds the stripper; and with several stylesheets it // brace inside a string blinds the stripper; and with several stylesheets it
// judges each alone, not their load order in the document. // judges each alone, not their load order in the document.
// //
// This check runs from web/ as part of `npm run build`. // This check runs from admin/ as part of `npm run build`.
import { readdirSync, readFileSync } from "node:fs"; import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -110,7 +110,7 @@ for (const sheet of sheets) {
} }
if (failed) { if (failed) {
console.error("Wrap it in a layer declared before StyleX's, as web/src/styles.css does."); console.error("Wrap it in a layer declared before StyleX's, as admin/src/styles.css does.");
process.exit(1); process.exit(1);
} }
console.log( console.log(
@@ -8,7 +8,7 @@
// lockfile, no version and no dependency set changes — only the bundle does. So // lockfile, no version and no dependency set changes — only the bundle does. So
// the bundle is what this reads. // the bundle is what this reads.
const sectionHeading = "[npm packages bundled into web/dist]"; const sectionHeading = "[npm packages bundled into admin/dist]";
// A sourcemap `sources` entry for a dependency ends in // A sourcemap `sources` entry for a dependency ends in
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only // `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
@@ -48,7 +48,7 @@ describe("recordedPackages", () => {
"[some earlier section]", "[some earlier section]",
"ignored", "ignored",
"", "",
"[npm packages bundled into web/dist]", "[npm packages bundled into admin/dist]",
"react", "react",
"@tanstack/react-query", "@tanstack/react-query",
"", "",
@@ -64,7 +64,7 @@ describe("recordedPackages", () => {
it("distinguishes a missing section from an empty one", () => { it("distinguishes a missing section from an empty one", () => {
expect(recordedPackages("[other]\nx\n")).toBeNull(); expect(recordedPackages("[other]\nx\n")).toBeNull();
expect(recordedPackages("[npm packages bundled into web/dist]\n\n[next]\n")).toEqual([]); expect(recordedPackages("[npm packages bundled into admin/dist]\n\n[next]\n")).toEqual([]);
}); });
}); });
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
// Freshness stamp for web/dist (milestone-15 ruling 5). A stale dist has // Freshness stamp for admin/dist (milestone-15 ruling 5). A stale dist has
// already shipped a crashing settings page once. Write mode runs from web/ as // already shipped a crashing settings page once. Write mode runs from admin/ as
// part of `npm run build`; check mode runs from the repository root as a // part of `npm run build`; check mode runs from the repository root as a
// build.zig system command. Every path resolves from this file's own location // build.zig system command. Every path resolves from this file's own location
// so both working directories hash the same set. // so both working directories hash the same set.
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
const webRoot = dirname(dirname(fileURLToPath(import.meta.url))); const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const distDir = join(webRoot, "dist"); const distDir = join(webRoot, "dist");
const stampFile = join(distDir, ".src-hash"); const stampFile = join(distDir, ".src-hash");
const stampRelative = "web/dist/.src-hash"; const stampRelative = "admin/dist/.src-hash";
const inputDirs = ["src", "public"]; const inputDirs = ["src", "public"];
const inputFiles = [ const inputFiles = [
@@ -26,7 +26,7 @@ const inputFiles = [
"tsconfig.node.json", "tsconfig.node.json",
]; ];
const staleMessage = "web/dist is stale: rebuild the frontend (npm run build)"; const staleMessage = "admin/dist is stale: rebuild the frontend (npm run build)";
function fail(message) { function fail(message) {
process.stderr.write(`${message}\n`); process.stderr.write(`${message}\n`);
@@ -39,7 +39,7 @@ function walk(relativeDir) {
try { try {
entries = readdirSync(absolute, { withFileTypes: true }); entries = readdirSync(absolute, { withFileTypes: true });
} catch (err) { } catch (err) {
fail(`stamp-dist: cannot read web/${relativeDir}: ${err.message}`); fail(`stamp-dist: cannot read admin/${relativeDir}: ${err.message}`);
} }
const found = []; const found = [];
for (const entry of entries) { for (const entry of entries) {
@@ -57,9 +57,9 @@ function inputSet() {
const paths = [...inputFiles, ...inputDirs.flatMap(walk)]; const paths = [...inputFiles, ...inputDirs.flatMap(walk)];
for (const path of inputFiles) { for (const path of inputFiles) {
try { try {
if (!statSync(join(webRoot, path)).isFile()) fail(`stamp-dist: web/${path} is not a file`); if (!statSync(join(webRoot, path)).isFile()) fail(`stamp-dist: admin/${path} is not a file`);
} catch (err) { } catch (err) {
fail(`stamp-dist: cannot stat web/${path}: ${err.message}`); fail(`stamp-dist: cannot stat admin/${path}: ${err.message}`);
} }
} }
// Sorted by path so the digest does not depend on directory order. // Sorted by path so the digest does not depend on directory order.
@@ -17,7 +17,9 @@ const BLOCKLISTS = {
last_updated: 1700000000, last_updated: 1700000000,
domain_count: 1000, domain_count: 1000,
wildcard_count: 10, wildcard_count: 10,
exception_count: 7,
skipped_regex_count: 3, skipped_regex_count: 3,
skipped_unsupported_count: 21,
checksum: "abc", checksum: "abc",
}, },
{ {
@@ -29,7 +31,9 @@ const BLOCKLISTS = {
last_updated: null, last_updated: null,
domain_count: 0, domain_count: 0,
wildcard_count: 0, wildcard_count: 0,
exception_count: 0,
skipped_regex_count: 0, skipped_regex_count: 0,
skipped_unsupported_count: 0,
checksum: null, checksum: null,
}, },
], ],
@@ -98,7 +102,9 @@ const SNAPSHOT = {
last_error: "", last_error: "",
domains: 1200, domains: 1200,
wildcards: 12, wildcards: 12,
exceptions: 9,
skipped_regex: 4, skipped_regex: 4,
skipped_unsupported: 17,
}, },
], ],
}; };
@@ -112,8 +118,15 @@ test("renders the source table and the status empty state", async () => {
expect(screen.getByText("Suggested")).toBeTruthy(); expect(screen.getByText("Suggested")).toBeTruthy();
expect(screen.getByText("1000")).toBeTruthy(); expect(screen.getByText("1000")).toBeTruthy();
expect(screen.getByText("10")).toBeTruthy(); expect(screen.getByText("10")).toBeTruthy();
expect(screen.getByText("7")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy(); expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("21")).toBeTruthy();
expect(screen.getByText("never")).toBeTruthy(); expect(screen.getByText("never")).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement; const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement;
expect(enabledToggle.checked).toBe(true); expect(enabledToggle.checked).toBe(true);
@@ -147,7 +160,9 @@ test("update now disables the button, then replaces the status section from the
last_error: "", last_error: "",
domains: 1200, domains: 1200,
wildcards: 12, wildcards: 12,
exceptions: 9,
skipped_regex: 4, skipped_regex: 4,
skipped_unsupported: 17,
}, },
{ {
id: 2, id: 2,
@@ -159,7 +174,9 @@ test("update now disables the button, then replaces the status section from the
last_error: "connect timed out", last_error: "connect timed out",
domains: 0, domains: 0,
wildcards: 0, wildcards: 0,
exceptions: 0,
skipped_regex: 0, skipped_regex: 0,
skipped_unsupported: 0,
}, },
], ],
}; };
@@ -172,7 +189,10 @@ test("update now disables the button, then replaces the status section from the
expect(screen.getByText("connect timed out")).toBeTruthy(); expect(screen.getByText("connect timed out")).toBeTruthy();
expect(screen.getByText("1200")).toBeTruthy(); expect(screen.getByText("1200")).toBeTruthy();
expect(screen.getByText("12")).toBeTruthy(); expect(screen.getByText("12")).toBeTruthy();
expect(screen.getByText("9")).toBeTruthy();
expect(screen.getByText("4")).toBeTruthy(); expect(screen.getByText("4")).toBeTruthy();
expect(screen.getByText("17")).toBeTruthy();
expect(screen.getAllByRole("columnheader", { name: "Skipped unsupported" })).toHaveLength(2);
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull(); expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
// The store notifies one flush before the mutation's success state lands. // The store notifies one flush before the mutation's success state lands.
await screen.findByText(/Update completed/); await screen.findByText(/Update completed/);
@@ -42,6 +42,10 @@ const styles = stylex.create({
marginTop: "1rem", marginTop: "1rem",
color: colors.textMuted, color: colors.textMuted,
}, },
note: {
marginTop: "0.5rem",
color: colors.textMuted,
},
table: { table: {
width: "100%", width: "100%",
minWidth: "max-content", minWidth: "max-content",
@@ -153,7 +157,9 @@ export default function BlocklistsPage() {
<th {...stylex.props(shared.th)}>Enabled</th> <th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th> <th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th> <th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th> <th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last updated</th> <th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th)}> <th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span> <span {...stylex.props(shared.srOnly)}>Actions</span>
@@ -185,7 +191,11 @@ export default function BlocklistsPage() {
</td> </td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td> <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.wildcard_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.exception_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{b.skipped_unsupported_count}
</td>
<td {...stylex.props(shared.td)}> <td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)} {b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td> </td>
@@ -219,6 +229,13 @@ export default function BlocklistsPage() {
))} ))}
</tbody> </tbody>
</table> </table>
<p {...stylex.props(styles.note)}>
Both Skipped columns count lines nxdns read and did not take. Skipped regex lines are patterns
nxdns accepts only from you adopt one you trust as a regex rule. Skipped unsupported lines are
syntax nxdns cannot translate into a DNS decision: cosmetic element hiding, browser-only
modifiers. A skipped unsupported count that dwarfs the domain count usually means the list is
written for a browser extension, and its DNS or hosts variant will block more here.
</p>
</div> </div>
)} )}
<InlineError error={tableError} /> <InlineError error={tableError} />
@@ -87,7 +87,9 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
<th {...stylex.props(shared.th)}>Last success</th> <th {...stylex.props(shared.th)}>Last success</th>
<th {...stylex.props(shared.th)}>Domains</th> <th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th> <th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Exceptions</th>
<th {...stylex.props(shared.th)}>Skipped regex</th> <th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Skipped unsupported</th>
<th {...stylex.props(shared.th)}>Last error</th> <th {...stylex.props(shared.th)}>Last error</th>
</tr> </tr>
</thead> </thead>
@@ -109,7 +111,11 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_success)}</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.domains}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.exceptions}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>
{source.skipped_unsupported}
</td>
<td {...stylex.props(shared.td)}> <td {...stylex.props(shared.td)}>
{source.last_error === "" ? ( {source.last_error === "" ? (
<span {...stylex.props(styles.absent)}></span> <span {...stylex.props(styles.absent)}></span>
@@ -47,9 +47,12 @@ const styles = stylex.create({
export default function ClientEditDialog({ client, groups, onClose }: Props) { export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation(clientUpdateMutation(queryClient)); const mutation = useMutation(clientUpdateMutation(queryClient));
const [name, setName] = useState(client.name);
const [groupId, setGroupId] = useState(client.group_id);
const readOnly = useReadOnlyConfig(); const readOnly = useReadOnlyConfig();
// Adopting the learned name as a typed one is the natural gesture, but only
// where the save can land: under file authority the PUT answers 403, and the
// file's declared name is the one that wins.
const [name, setName] = useState(client.name === "" && !readOnly ? client.learned_name : client.name);
const [groupId, setGroupId] = useState(client.group_id);
return ( return (
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}> <Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
@@ -18,6 +18,7 @@ const CLIENTS = {
id: 1, id: 1,
ip: "192.168.1.10", ip: "192.168.1.10",
name: "laptop", name: "laptop",
learned_name: "laptop-1.lan",
group_id: 1, group_id: 1,
group: "default", group: "default",
hand_edited: true, hand_edited: true,
@@ -28,6 +29,7 @@ const CLIENTS = {
id: 2, id: 2,
ip: "192.168.1.11", ip: "192.168.1.11",
name: "", name: "",
learned_name: "kids-tablet.lan",
group_id: 2, group_id: 2,
group: "kids", group: "kids",
hand_edited: false, hand_edited: false,
@@ -95,6 +97,25 @@ test("renders the client table with group names and one hand-edited badge", asyn
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1); expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
}); });
test("a named row shows the typed name and hides the learned one", async () => {
await renderClientsPage(BASE);
expect(screen.getByText("laptop")).toBeTruthy();
expect(screen.queryByText("laptop-1.lan")).toBeNull();
});
test("an unnamed row shows the learned name with the learned affordance", async () => {
await renderClientsPage(BASE);
// The cell holds the learned name followed by the tag, so the match is on
// the containing span rather than on a bare text node.
const learned = screen.getByText(
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
);
// The affordance is text, not colour, so a screen reader announces it too.
expect(within(learned).getByText("learned")).toBeTruthy();
});
test("shows the DNS-activity empty state when there are no clients", async () => { test("shows the DNS-activity empty state when there are no clients", async () => {
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } }); await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
@@ -133,10 +133,15 @@ export default function ClientsPage() {
<tr key={client.id} {...stylex.props(styles.bodyRow)}> <tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td> <td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
<td {...stylex.props(styles.cell)}> <td {...stylex.props(styles.cell)}>
{client.name === "" ? ( {client.name !== "" ? (
<span {...stylex.props(styles.dash)}></span>
) : (
client.name client.name
) : client.learned_name !== "" ? (
<span {...stylex.props(shared.learnedName)}>
{client.learned_name}
<span {...stylex.props(shared.learnedTag)}>learned</span>
</span>
) : (
<span {...stylex.props(styles.dash)}></span>
)} )}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>} {client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
</td> </td>
@@ -0,0 +1,53 @@
/**
* The client column of the query tables reads as a name wherever one is known,
* with the same precedence the Clients page applies: a hand-typed `name` wins,
* the reverse-DNS `learned_name` stands in muted behind it, and an address with
* neither including one the loaded list has never seen stays bare.
*
* The muted colour is the whole of the affordance here. The Clients page pairs
* it with an outlined "learned" tag, and keeps it: one mention per client is
* information. Repeating that tag down every row of a query table is noise, so
* the tables carry the name alone.
*/
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientsQuery } from "@/lib/queries";
import type { Client } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
/**
* The live stream names clients the loaded list has never seen. Polling folds
* them in on the next tick, which keeps the lookup a single cached query
* instead of a fetch fired per unknown address.
*/
const CLIENTS_POLL_MS = 30_000;
export function useClientNames(): ClientNames {
const { data } = useQuery({ ...clientsQuery(), refetchInterval: CLIENTS_POLL_MS });
return useMemo(
() =>
new Map(
(data ?? []).map((client) => [client.ip, { name: client.name, learned_name: client.learned_name }]),
),
[data],
);
}
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
const client = names.get(ip);
if (client === undefined || (client.name === "" && client.learned_name === "")) {
return <span {...stylex.props(shared.mono)}>{ip}</span>;
}
// The name replaces the address on screen, so the address stays reachable
// as the tooltip rather than disappearing from the row entirely.
if (client.name !== "") return <span title={ip}>{client.name}</span>;
return (
<span title={ip} {...stylex.props(shared.learnedName)}>
{client.learned_name}
</span>
);
}
@@ -5,6 +5,9 @@ import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient"; import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes"; import { createAppRouter } from "@/routes";
/** Wall clock at import; the upstream fixtures date their failures against it. */
const NOW_S = Math.floor(Date.now() / 1000);
const RESPONSES: Record<string, unknown> = { const RESPONSES: Record<string, unknown> = {
"/api/stats?period=24h": { "/api/stats?period=24h": {
period: "24h", period: "24h",
@@ -59,31 +62,65 @@ const RESPONSES: Record<string, unknown> = {
refreshes_gated: 0, refreshes_gated: 0,
snapshot_generation: 3, snapshot_generation: 3,
}, },
"/api/upstream/health": { "/api/upstream/health?period=24h": {
period: "24h",
since: NOW_S - 86_400,
until: NOW_S,
available: 1,
total: 2,
complete: true,
upstreams: [ upstreams: [
{ {
url: "https://dns.example/dns-query", url: "https://dns.example/dns-query",
enabled: true, enabled: true,
available: false, available: false,
consecutive_failures: 4, period: {
total_successes: 90, attempts: 100,
total_failures: 10, successes: 90,
success_rate: 0.9, failures: 10,
last_error: "timeout", success_rate: 0.9,
// 3h30m before the fixture's now, far from a unit boundary.
last_failure_at: NOW_S - 12_600,
last_failure_error: "timeout",
},
}, },
{ {
url: "udp://9.9.9.9:53", url: "udp://9.9.9.9:53",
enabled: true, enabled: true,
available: true, available: true,
consecutive_failures: 0, period: {
total_successes: 100, attempts: 100,
total_failures: 0, successes: 100,
success_rate: 1, failures: 0,
last_error: "", success_rate: 1,
last_failure_at: null,
last_failure_error: null,
},
}, },
], ],
},
"/api/upstream/health?period=1h": {
period: "1h",
since: NOW_S - 3600,
until: NOW_S,
available: 1, available: 1,
total: 2, total: 1,
complete: true,
upstreams: [
{
url: "https://dns.example/dns-query",
enabled: true,
available: true,
period: {
attempts: 7,
successes: 6,
failures: 1,
success_rate: 6 / 7,
last_failure_at: NOW_S - 300,
last_failure_error: "timeout",
},
},
],
}, },
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
}; };
@@ -142,7 +179,7 @@ test("dashboard renders stats, chart, disk card, upstream table and health banne
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy(); expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy(); expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy();
expect(screen.getByText("Disk")).toBeTruthy(); expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.getByText("warn")).toBeTruthy(); expect(screen.getByText("warn")).toBeTruthy();
expect(screen.getAllByText("400.0 MiB").length).toBeGreaterThan(0); expect(screen.getAllByText("400.0 MiB").length).toBeGreaterThan(0);
expect(screen.getByText("12.0 MiB")).toBeTruthy(); expect(screen.getByText("12.0 MiB")).toBeTruthy();
@@ -155,10 +192,32 @@ test("dashboard renders stats, chart, disk card, upstream table and health banne
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy(); expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
expect(screen.getByText("90.0%")).toBeTruthy(); expect(screen.getByText("90.0%")).toBeTruthy();
expect(screen.getByText("100.0%")).toBeTruthy(); expect(screen.getByText("100.0%")).toBeTruthy();
expect(screen.getByText("timeout")).toBeTruthy(); expect(screen.getByText("timeout · 3h ago")).toBeTruthy();
expect(screen.getByText("1/2 available")).toBeTruthy(); expect(screen.getByText("1/2 available")).toBeTruthy();
}); });
test("live state is labeled on its own card, not by a section that disowns the picker", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.queryByRole("region", { name: "Right now" })).toBeNull();
expect(screen.queryByText("Right now")).toBeNull();
expect(screen.queryByText("Snapshot state; the period above does not apply.")).toBeNull();
});
test("the period picker rescopes the upstream table", async () => {
renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" });
await screen.findByText("90.0%");
fireEvent.click(screen.getByRole("button", { name: "1h" }));
await screen.findByText("85.7%");
expect(screen.getByRole("columnheader", { name: "Selected period · 1h" })).toBeTruthy();
expect(screen.queryByText("90.0%")).toBeNull();
});
test("period picker refetches stats and shows the empty chart state", async () => { test("period picker refetches stats and shows the empty chart state", async () => {
renderDashboard(); renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" }); await screen.findByRole("heading", { name: "Dashboard" });
@@ -173,7 +232,7 @@ test("period picker refetches stats and shows the empty chart state", async () =
}); });
test("one failing endpoint degrades its own widget on cold navigation", async () => { test("one failing endpoint degrades its own widget on cold navigation", async () => {
failing.add("/api/upstream/health"); failing.add("/api/upstream/health?period=24h");
renderDashboard(); renderDashboard();
await screen.findByRole("heading", { name: "Dashboard" }); await screen.findByRole("heading", { name: "Dashboard" });
@@ -184,6 +243,6 @@ test("one failing endpoint degrades its own widget on cold navigation", async ()
expect(screen.getByText("1,000")).toBeTruthy(); expect(screen.getByText("1,000")).toBeTruthy();
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy(); expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
expect(screen.getByText("Disk")).toBeTruthy(); expect(screen.getByText("Storage now")).toBeTruthy();
expect(screen.queryByText("https://dns.example/dns-query")).toBeNull(); expect(screen.queryByText("https://dns.example/dns-query")).toBeNull();
}); });
@@ -66,7 +66,7 @@ const styles = stylex.create({
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)", "@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. */ /** The chart takes two thirds beside the storage card from `lg`, one column below. */
panelGrid: { panelGrid: {
display: "grid", display: "grid",
gap: "1rem", gap: "1rem",
@@ -123,7 +123,7 @@ export default function DashboardPage() {
const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData }); const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData }); const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
const health = useQuery(healthQuery()); const health = useQuery(healthQuery());
const upstreamHealth = useQuery(upstreamHealthQuery()); const upstreamHealth = useQuery({ ...upstreamHealthQuery(period), placeholderData: keepPreviousData });
return ( return (
<section {...stylex.props(styles.page)}> <section {...stylex.props(styles.page)}>
@@ -74,8 +74,10 @@ function stateStyle(state: Health["disk"]["state"]) {
export default function DiskCard({ disk }: { disk: Health["disk"] }) { export default function DiskCard({ disk }: { disk: Health["disk"] }) {
return ( return (
<section {...stylex.props(styles.card)}> <section {...stylex.props(styles.card)}>
{/* Live state, unlike the ranged widgets around it; the title says so
rather than a section rule the picker would have to disown. */}
<h2 {...stylex.props(styles.heading)}> <h2 {...stylex.props(styles.heading)}>
Disk Storage now
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span> <span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
</h2> </h2>
<dl {...stylex.props(styles.list)}> <dl {...stylex.props(styles.list)}>
@@ -0,0 +1,151 @@
import { render, screen, within } from "@testing-library/react";
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } from "@/lib/types";
import UpstreamHealthTable from "./UpstreamHealthTable";
const NOW_S = 1_700_000_000;
beforeEach(() => {
vi.spyOn(Date, "now").mockReturnValue(NOW_S * 1000);
});
afterEach(() => {
vi.restoreAllMocks();
});
const ZERO: UpstreamPeriodStats = {
attempts: 0,
successes: 0,
failures: 0,
success_rate: null,
last_failure_at: null,
last_failure_error: null,
};
function period(overrides: Partial<UpstreamPeriodStats> = {}): UpstreamPeriodStats {
return {
attempts: 100,
successes: 90,
failures: 10,
success_rate: 0.9,
// 3h30m ago, far from a unit boundary.
last_failure_at: NOW_S - 12_600,
last_failure_error: "Timeout",
...overrides,
};
}
function entry(overrides: Partial<UpstreamHealthEntry> = {}): UpstreamHealthEntry {
return {
url: "https://dns.example/dns-query",
enabled: true,
available: true,
period: period(),
...overrides,
};
}
function renderTable(upstreams: UpstreamHealthEntry[], overrides: Partial<UpstreamHealth> = {}) {
const health: UpstreamHealth = {
period: "24h",
since: NOW_S - 86_400,
until: NOW_S,
available: upstreams.filter((upstream) => upstream.available).length,
total: upstreams.length,
complete: true,
upstreams,
...overrides,
};
render(<UpstreamHealthTable health={health} />);
}
function rowOf(url: string): HTMLElement {
const cell = screen.getByText(url);
const row = cell.closest("tr");
if (row === null) throw new Error(`no row for ${url}`);
return row;
}
test("the ranged columns sit under a header naming the selected period", () => {
renderTable([entry()]);
expect(screen.getByRole("columnheader", { name: "Selected period · 24h" })).toBeTruthy();
for (const name of ["Upstream", "Status now", "Attempts", "Failures", "Success rate", "Last failure"]) {
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
}
// The unranged yes/no pair the ranged table replaced.
expect(screen.queryByRole("columnheader", { name: "Enabled" })).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Available" })).toBeNull();
});
test("status now is one word from live state, not from the window", () => {
renderTable([
entry({ url: "https://a.example/dns-query" }),
entry({ url: "https://b.example/dns-query", available: false }),
entry({ url: "https://c.example/dns-query", enabled: false, available: false }),
]);
expect(within(rowOf("https://a.example/dns-query")).getByText("Available")).toBeTruthy();
expect(within(rowOf("https://b.example/dns-query")).getByText("Backing off")).toBeTruthy();
expect(within(rowOf("https://c.example/dns-query")).getByText("Disabled")).toBeTruthy();
});
test("last failure pairs the error name with its age, em-dash when the window holds none", () => {
renderTable([
entry({ url: "https://a.example/dns-query" }),
entry({
url: "https://b.example/dns-query",
period: period({ last_failure_at: null, last_failure_error: null }),
}),
]);
expect(within(rowOf("https://a.example/dns-query")).getByText("Timeout · 3h ago")).toBeTruthy();
expect(within(rowOf("https://b.example/dns-query")).getByText("—")).toBeTruthy();
});
test("a window with no attempts renders em-dashes and never a perfect rate", () => {
renderTable([entry({ period: ZERO })]);
const cells = within(rowOf("https://dns.example/dns-query")).getAllByRole("cell");
expect(cells.map((cell) => cell.textContent)).toEqual([
"https://dns.example/dns-query",
"Available",
"0",
"0",
"—",
"—",
]);
expect(screen.queryByText("100.0%")).toBeNull();
expect(screen.queryByText("0.0%")).toBeNull();
});
test("the card says so when every upstream was idle in the window", () => {
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry({ period: ZERO })]);
expect(screen.getByText("No upstream attempts in this period.")).toBeTruthy();
});
test("one upstream with attempts keeps the idle message away", () => {
renderTable([entry({ url: "https://a.example/dns-query", period: ZERO }), entry()]);
expect(screen.queryByText("No upstream attempts in this period.")).toBeNull();
});
test("an incomplete window carries a note; a complete one claims nothing", () => {
renderTable([entry()], { complete: false });
expect(screen.getByText(/history incomplete/i)).toBeTruthy();
});
test("a complete window shows no completeness text at all", () => {
renderTable([entry()], { complete: true });
expect(screen.queryByText(/history incomplete/i)).toBeNull();
expect(screen.queryByText(/complete/i)).toBeNull();
});
test("an empty pool says so instead of drawing a table", () => {
renderTable([]);
expect(screen.getByText("No upstreams configured.")).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});
@@ -0,0 +1,232 @@
import * as stylex from "@stylexjs/stylex";
import { formatAge } from "@/lib/format";
import type { UpstreamHealth, UpstreamHealthEntry, UpstreamPeriodStats } 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({
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,
},
note: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
tableWrap: {
overflowX: "auto",
},
table: {
marginTop: "0.5rem",
width: "100%",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/**
* The two live columns are left outside the span: everything under it answers
* for the selected window, and nothing else on this card does.
*/
groupRow: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
groupHead: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
paddingInline: "1rem",
paddingBottom: "0.25rem",
textAlign: "center",
fontWeight: 500,
},
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,
},
});
/** Live pool state in one word. Configuration first: a disabled upstream is not backing off. */
function statusNow(upstream: UpstreamHealthEntry): "Available" | "Backing off" | "Disabled" {
if (!upstream.enabled) return "Disabled";
return upstream.available ? "Available" : "Backing off";
}
/**
* `success_rate` is null exactly when the window holds no attempt, and that must
* not read as perfect reliability hence the em-dash rather than `100.0%`.
*/
function successRate(period: UpstreamPeriodStats): string {
return period.success_rate === null ? "—" : `${(period.success_rate * 100).toFixed(1)}%`;
}
/**
* The age is formatted once, when the row renders; nothing here ticks. It is
* measured against the browser's clock rather than the response's `until`, so a
* cached response ages visibly instead of freezing at the moment it was served.
*/
function lastFailure(period: UpstreamPeriodStats, nowSeconds: number): string {
if (period.last_failure_at === null) return "—";
const age = formatAge(Math.max(0, nowSeconds - period.last_failure_at));
const error = period.last_failure_error;
return error === null || error === "" ? age : `${error} · ${age}`;
}
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
const nowSeconds = Math.floor(Date.now() / 1000);
const idle = health.upstreams.length > 0 && health.upstreams.every(({ period }) => period.attempts === 0);
return (
<section {...stylex.props(styles.card)}>
<h2 {...stylex.props(styles.heading)}>
Upstreams
<span {...stylex.props(styles.count, shared.tabularNums)}>
{health.available}/{health.total} available
</span>
</h2>
{health.upstreams.length === 0 ? (
<p {...stylex.props(styles.empty)}>No upstreams configured.</p>
) : (
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr {...stylex.props(styles.groupRow)}>
<td colSpan={2} />
<th scope="colgroup" colSpan={4} {...stylex.props(styles.groupHead)}>
Selected period · {health.period}
</th>
</tr>
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.th)}>
Upstream
</th>
<th scope="col" {...stylex.props(styles.th)}>
Status now
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Attempts
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Failures
</th>
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
Success rate
</th>
<th scope="col" {...stylex.props(styles.th)}>
Last failure
</th>
</tr>
</thead>
<tbody>
{health.upstreams.map((upstream) => {
const status = statusNow(upstream);
return (
<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)}>
<span
{...stylex.props(
status === "Backing off" && styles.bad,
status === "Disabled" && styles.muted,
)}
>
{status}
</span>
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{numberFormat.format(upstream.period.attempts)}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{numberFormat.format(upstream.period.failures)}
</td>
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
{successRate(upstream.period)}
</td>
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
{lastFailure(upstream.period, nowSeconds)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{idle && <p {...stylex.props(styles.note)}>No upstream attempts in this period.</p>}
{!health.complete && (
<p {...stylex.props(styles.note)}>
History incomplete: outcomes were dropped in this window, so these counts are a lower bound.
</p>
)}
</section>
);
}
@@ -0,0 +1,194 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import type { Client, LiveQueryEvent } from "@/lib/types";
import { FakeEventSource } from "./fakeEventSource";
import LiveLogPage from "./LiveLogPage";
function client(ip: string, name: string, learnedName: string): Client {
return {
id: Number(ip.split(".").pop()),
ip,
name,
learned_name: learnedName,
group_id: 1,
group: "default",
hand_edited: name !== "",
first_seen: 1_700_000_000,
last_seen: 1_700_000_100,
};
}
const CLIENTS: Client[] = [
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
client("192.0.2.11", "", "laptop.lan"),
client("192.0.2.12", "", ""),
];
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input) !== "/api/clients") {
return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
}
return new Response(JSON.stringify({ clients: CLIENTS }), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
const payload: LiveQueryEvent = {
ts,
domain,
client_ip: "192.0.2.10",
qtype: 1,
blocked: false,
block_reason: "",
response_time_us: 500,
cache_hit: true,
upstream: "",
...overrides,
};
return { data: JSON.stringify(payload) };
}
function renderPage() {
const sources: FakeEventSource[] = [];
const createEventSource = (url: string) => {
const es = new FakeEventSource(url);
sources.push(es);
return es;
};
render(
<QueryClientProvider client={createQueryClient()}>
<LiveLogPage createEventSource={createEventSource} />
</QueryClientProvider>,
);
return sources;
}
test("streams rows, flags blocked ones, and freezes the display", () => {
const sources = renderPage();
expect(screen.getByText("Connecting…")).toBeTruthy();
act(() => sources[0]!.emit("open"));
expect(screen.getByRole("status", { name: "Live" })).toBeTruthy();
expect(screen.getByText("Waiting for queries…")).toBeTruthy();
act(() => {
sources[0]!.emit("query", frame(1000, "ok.example"));
sources[0]!.emit(
"query",
frame(1001, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack", qtype: 28 }),
);
});
expect(screen.getByText("ok.example")).toBeTruthy();
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");
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);
expect(freeze.getAttribute("aria-pressed")).toBe("true");
act(() => sources[0]!.emit("query", frame(1002, "later.example")));
expect(screen.queryByText("later.example")).toBeNull();
expect(screen.getByText(/3 in buffer/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
expect(screen.getByText("later.example")).toBeTruthy();
});
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
const sources = renderPage();
act(() => sources[0]!.emit("open"));
act(() => {
sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" }));
sources[0]!.emit("query", frame(1001, "learned.example", { client_ip: "192.0.2.11" }));
sources[0]!.emit("query", frame(1002, "nameless.example", { client_ip: "192.0.2.12" }));
sources[0]!.emit("query", frame(1003, "stranger.example", { client_ip: "192.0.2.99" }));
});
// A hand-typed name wins outright; the learned name never surfaces for it.
const named = await screen.findByText("Kitchen Pi");
expect(named.getAttribute("title")).toBe("192.0.2.10");
expect(screen.queryByText("pi.lan")).toBeNull();
// A learned name reads muted and nothing more here: the "learned" tag would
// repeat on every row of the table, so the Clients page carries it instead.
const learned = screen.getByText("laptop.lan");
expect(learned.getAttribute("title")).toBe("192.0.2.11");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
// A known client with neither name, and a client the loaded list has never
// seen, both fall back to the bare address with no tooltip standing in.
const nameless = screen.getByText("192.0.2.12");
expect(nameless.getAttribute("title")).toBeNull();
const stranger = screen.getByText("192.0.2.99");
expect(stranger.getAttribute("title")).toBeNull();
expect(screen.getByText("stranger.example").closest("tr")?.textContent).toContain("192.0.2.99");
});
test("rows stream in as bare IPs while the client list is still loading", async () => {
let releaseClients: () => void = () => {};
vi.stubGlobal(
"fetch",
vi.fn(
(input: RequestInfo | URL) =>
new Promise<Response>((resolve) => {
if (String(input) !== "/api/clients") {
resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
return;
}
releaseClients = () =>
resolve(
new Response(JSON.stringify({ clients: CLIENTS }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}),
),
);
const sources = renderPage();
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" })));
expect(screen.getByText("192.0.2.10")).toBeTruthy();
expect(screen.queryByText("Kitchen Pi")).toBeNull();
releaseClients();
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
});
test("repeated connection failures show the viewer-cap state with a retry button", () => {
const sources = renderPage();
act(() => {
sources[0]!.emit("error");
sources[0]!.emit("error");
sources[0]!.emit("error");
});
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(sources).toHaveLength(2);
expect(screen.getByText("Connecting…")).toBeTruthy();
});
@@ -1,4 +1,5 @@
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { useClientNames } from "@/features/clients/clientNames";
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage"; import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
import { RING_CAPACITY } from "./ringBuffer"; import { RING_CAPACITY } from "./ringBuffer";
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries"; import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
@@ -168,6 +169,7 @@ function StatusPill({ status }: { status: StreamStatus }) {
* filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */ * filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) { export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
const live = useLiveQueries({ createEventSource }); const live = useLiveQueries({ createEventSource });
const clientNames = useClientNames();
return ( return (
<section> <section>
@@ -240,7 +242,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
<tbody> <tbody>
{live.rows.map((row) => ( {live.rows.map((row) => (
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}> <tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
<QueryCells row={row} /> <QueryCells row={row} clientNames={clientNames} />
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -1,9 +1,29 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient"; import { createQueryClient } from "@/lib/queryClient";
import type { QueriesPage, QueryRow } from "@/lib/types"; import type { Client, QueriesPage, QueryRow } from "@/lib/types";
import QueryLogPage from "./QueryLogPage"; import QueryLogPage from "./QueryLogPage";
function client(id: number, ip: string, name: string, learnedName: string): Client {
return {
id,
ip,
name,
learned_name: learnedName,
group_id: 1,
group: "default",
hand_edited: name !== "",
first_seen: 1_700_000_000,
last_seen: 1_700_000_100,
};
}
const CLIENTS: Client[] = [
client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
client(2, "192.0.2.11", "", "laptop.lan"),
client(3, "192.0.2.12", "", ""),
];
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow { function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
return { return {
id, id,
@@ -48,6 +68,7 @@ beforeEach(() => {
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL) => { vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
if (url === "/api/clients") return json({ clients: CLIENTS });
const payload = PAGES[url]; const payload = PAGES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), { return new Response(JSON.stringify(payload), {
@@ -90,6 +111,44 @@ test("renders the first page with type names, blocked badge, and formatted cells
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy(); expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
}); });
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [
row(20, "named.example", { client_ip: "192.0.2.10" }),
row(19, "learned.example", { client_ip: "192.0.2.11" }),
row(18, "nameless.example", { client_ip: "192.0.2.12" }),
row(17, "stranger.example", { client_ip: "192.0.2.99" }),
],
next_before: null,
} satisfies QueriesPage);
}),
);
renderPage();
// A hand-typed name wins outright; the learned name never surfaces for it.
const named = await screen.findByText("Kitchen Pi");
expect(named.getAttribute("title")).toBe("192.0.2.10");
expect(screen.queryByText("pi.lan")).toBeNull();
// A learned name reads muted and nothing more here: the "learned" tag would
// repeat on every row of the table, so the Clients page carries it instead.
const learned = screen.getByText("laptop.lan");
expect(learned.getAttribute("title")).toBe("192.0.2.11");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
// A known client with neither name, and a client the loaded list has never
// seen, both fall back to the bare address with no tooltip standing in.
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
});
test("load more appends the next page and stops at the end of the log", async () => { test("load more appends the next page and stops at the end of the log", async () => {
renderPage(); renderPage();
await screen.findByText("first.example"); await screen.findByText("first.example");
@@ -5,6 +5,7 @@ import * as api from "@/lib/api";
import { formatMicros, formatTime } from "@/lib/format"; import { formatMicros, formatTime } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries"; import { queriesInfiniteQuery } from "@/lib/queries";
import type { QueriesFilter, QueryRow } from "@/lib/types"; import type { QueriesFilter, QueryRow } from "@/lib/types";
import { ClientName, useClientNames, type ClientNames } from "@/features/clients/clientNames";
import { qtypeName } from "./qtype"; import { qtypeName } from "./qtype";
import Select from "@/ui/Select"; import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
@@ -162,12 +163,14 @@ export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_re
); );
} }
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) { export function QueryCells({ row, clientNames }: { row: Omit<QueryRow, "id">; clientNames: ClientNames }) {
return ( return (
<> <>
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td> <td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td> <td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, shared.mono)}>{row.client_ip}</td> <td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
<ClientName ip={row.client_ip} names={clientNames} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td> <td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
<td {...stylex.props(styles.cell)}> <td {...stylex.props(styles.cell)}>
<BlockedCell row={row} /> <BlockedCell row={row} />
@@ -212,6 +215,7 @@ export default function QueryLogPage() {
const [applied, setApplied] = useState<QueriesFilter>({}); const [applied, setApplied] = useState<QueriesFilter>({});
const base = useInfiniteQuery(queriesInfiniteQuery(applied)); const base = useInfiniteQuery(queriesInfiniteQuery(applied));
const clientNames = useClientNames();
const pages = base.data?.pages ?? []; const pages = base.data?.pages ?? [];
const rows: QueryRow[] = pages.flatMap((page) => page.queries); const rows: QueryRow[] = pages.flatMap((page) => page.queries);
@@ -336,7 +340,7 @@ export default function QueryLogPage() {
<tbody> <tbody>
{rows.map((row) => ( {rows.map((row) => (
<tr key={row.id} {...stylex.props(styles.row)}> <tr key={row.id} {...stylex.props(styles.row)}>
<QueryCells row={row} /> <QueryCells row={row} clientNames={clientNames} />
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -34,6 +34,7 @@ const RESPONSES: Record<string, unknown> = {
// The API orders groups by name, so the id-1 default is not always first. // The API orders groups by name, so the id-1 default is not always first.
let groups: { id: number; name: string; safe_search: boolean }[]; let groups: { id: number; name: string; safe_search: boolean }[];
let deleted: string[]; let deleted: string[];
let posted: { pattern: string; kind: string }[];
function deleteCalls(): string[] { function deleteCalls(): string[] {
return deleted; return deleted;
@@ -45,6 +46,7 @@ beforeEach(() => {
{ id: 2, name: "Kids", safe_search: true }, { id: 2, name: "Kids", safe_search: true },
]; ];
deleted = []; deleted = [];
posted = [];
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -54,6 +56,7 @@ beforeEach(() => {
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
if (url === "/api/rules" && init?.method === "POST") { if (url === "/api/rules" && init?.method === "POST") {
posted.push(JSON.parse(String(init.body)) as { pattern: string; kind: string });
return new Response(JSON.stringify({ error: "rate limited" }), { return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429, status: 429,
headers: { "content-type": "application/json", "Retry-After": "5" }, headers: { "content-type": "application/json", "Retry-After": "5" },
@@ -116,11 +119,61 @@ test("renders the rule table and the create form with contract enums", async ()
expect(table.getByText("Kids")).toBeTruthy(); expect(table.getByText("Kids")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2); expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard"]); expect(await optionsOf("Kind")).toEqual(["exact", "wildcard", "regex"]);
expect(await optionsOf("Action")).toEqual(["allow", "block"]); expect(await optionsOf("Action")).toEqual(["allow", "block"]);
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]); expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
}); });
test("the kind selector can select the regex option, not only list it", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(trigger("Kind"));
const options = await screen.findAllByRole("option");
const regex = options.find((option) => option.textContent === "regex");
expect(regex).toBeTruthy();
fireEvent.click(regex!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
expect(trigger("Kind").textContent).toContain("regex");
});
async function selectKind(label: string): Promise<void> {
fireEvent.click(trigger("Kind"));
const options = await screen.findAllByRole("option");
fireEvent.click(options.find((option) => option.textContent === label)!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
}
// A regex is stored and matched byte for byte, so whitespace inside it is data,
// not slop the UI may drop. Exact and wildcard are normalized server-side.
test("a regex pattern is posted untrimmed, an exact pattern is trimmed", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
await selectKind("regex");
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " foo|bar " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(posted).toHaveLength(1));
expect(posted[0]).toMatchObject({ pattern: " foo|bar ", kind: "regex" });
await selectKind("exact");
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ads.example.net " } });
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
await waitFor(() => expect(posted).toHaveLength(2));
expect(posted[1]).toMatchObject({ pattern: "ads.example.net", kind: "exact" });
});
test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const input = screen.getByLabelText("Pattern");
expect(input.getAttribute("autocapitalize")).toBe("none");
expect(input.getAttribute("autocorrect")).toBe("off");
expect(input.getAttribute("spellcheck")).toBe("false");
});
test("rule create shows a countdown when rate limited with Retry-After", async () => { test("rule create shows a countdown when rate limited with Retry-After", async () => {
renderRulesRoute(); renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" }); await screen.findByRole("heading", { name: "Rules" });
@@ -15,6 +15,7 @@ import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority
const KIND_OPTIONS = [ const KIND_OPTIONS = [
{ value: "exact", label: "exact" }, { value: "exact", label: "exact" },
{ value: "wildcard", label: "wildcard" }, { value: "wildcard", label: "wildcard" },
{ value: "regex", label: "regex" },
]; ];
const ACTION_OPTIONS = [ const ACTION_OPTIONS = [
@@ -97,10 +98,12 @@ export default function RulesPage() {
function onSubmit(event: FormEvent<HTMLFormElement>) { function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
create.mutate( // A regex pattern is stored and matched byte for byte, so the UI must not
{ group_id: groupId, pattern: pattern.trim(), kind, action }, // edit it: trimming here would make a UI-created rule differ from the same
{ onSuccess: () => setPattern("") }, // bytes posted to /api/rules. Name-shaped kinds are normalized server-side,
); // so trimming them only spares a pasted space a 400.
const sent = kind === "regex" ? pattern : pattern.trim();
create.mutate({ group_id: groupId, pattern: sent, kind, action }, { onSuccess: () => setPattern("") });
} }
function confirmDelete() { function confirmDelete() {
@@ -173,7 +176,13 @@ export default function RulesPage() {
required required
value={pattern} value={pattern}
onChange={(event) => setPattern(event.target.value)} onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com or *.example.com" placeholder="ads.example.com, *.example.com or ^ad[0-9]+-"
// A phone keyboard capitalizing the first letter is silent for
// exact and wildcard (normalized server-side) but fatal for a
// regex, which matches the lowercase query name byte for byte.
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...stylex.props(shared.input, shared.focusRing)} {...stylex.props(shared.input, shared.focusRing)}
/> />
</div> </div>
+2 -1
View File
@@ -118,7 +118,8 @@ export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> => export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
request(`/api/lookup${qs({ domain, group_id: groupId })}`); request(`/api/lookup${qs({ domain, group_id: groupId })}`);
export const getUpstreamHealth = (): Promise<UpstreamHealth> => request("/api/upstream/health"); export const getUpstreamHealth = (period?: Period): Promise<UpstreamHealth> =>
request(`/api/upstream/health${qs({ period })}`);
// Groups // Groups
@@ -9,7 +9,7 @@
// declare, and a string outside a literal union. // declare, and a string outside a literal union.
// //
// Regenerate with: // Regenerate with:
// zig build test -Dintegration -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts" // zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
import type { import type {
Blocklist, Blocklist,
@@ -87,11 +87,13 @@ export const sample_list_blocklists: { blocklists: Blocklist[] } = {
checksum: null, checksum: null,
domain_count: 0, domain_count: 0,
enabled: false, enabled: false,
exception_count: 0,
id: 0, id: 0,
is_suggested: false, is_suggested: false,
last_updated: null, last_updated: null,
name: "ads", name: "ads",
skipped_regex_count: 0, skipped_regex_count: 0,
skipped_unsupported_count: 0,
url: "https://lists.example/ads.txt", url: "https://lists.example/ads.txt",
wildcard_count: 0, wildcard_count: 0,
}, },
@@ -110,12 +112,14 @@ export const sample_update_blocklists_now: { sources: SourceStatus[] } = {
sources: [ sources: [
{ {
domains: 0, domains: 0,
exceptions: 0,
id: 0, id: 0,
last_attempt: 0, last_attempt: 0,
last_error: "", last_error: "",
last_success: 0, last_success: 0,
loaded: false, loaded: false,
skipped_regex: 0, skipped_regex: 0,
skipped_unsupported: 0,
state: "never_fetched", state: "never_fetched",
url: "https://lists.example/ads.txt", url: "https://lists.example/ads.txt",
wildcards: 0, wildcards: 0,
@@ -255,6 +259,7 @@ export const sample_list_clients: { clients: Client[] } = {
id: 0, id: 0,
ip: "192.168.1.50", ip: "192.168.1.50",
last_seen: 0, last_seen: 0,
learned_name: "",
name: "laptop", name: "laptop",
}, },
], ],
@@ -268,6 +273,7 @@ export const sample_update_client: Client = {
id: 0, id: 0,
ip: "192.168.1.50", ip: "192.168.1.50",
last_seen: 0, last_seen: 0,
learned_name: "",
name: "laptop-renamed", name: "laptop-renamed",
}; };
@@ -327,16 +333,23 @@ export const sample_update_upstream: UpstreamEcho = {
export const sample_get_upstream_health: UpstreamHealth = { export const sample_get_upstream_health: UpstreamHealth = {
available: 0, available: 0,
complete: true,
period: "24h",
since: 0,
total: 0, total: 0,
until: 0,
upstreams: [ upstreams: [
{ {
available: true, available: true,
consecutive_failures: 0,
enabled: true, enabled: true,
last_error: "", period: {
success_rate: 0, attempts: 0,
total_failures: 0, failures: 0,
total_successes: 0, last_failure_at: null,
last_failure_error: null,
success_rate: null,
successes: 0,
},
url: "https://dns.example/dns-query", url: "https://dns.example/dns-query",
}, },
], ],
@@ -6,4 +6,4 @@
pub const bytes = @embedFile("contractSamples.gen.ts"); pub const bytes = @embedFile("contractSamples.gen.ts");
/// Repo-relative path, so a failing assertion names the file to regenerate. /// Repo-relative path, so a failing assertion names the file to regenerate.
pub const path = "web/src/lib/contractSamples.gen.ts"; pub const path = "admin/src/lib/contractSamples.gen.ts";
@@ -1,4 +1,4 @@
import { formatBytes, formatMicros, formatTime } from "@/lib/format"; import { formatAge, formatBytes, formatMicros, formatTime } from "@/lib/format";
test("formatTime renders unix seconds in the given locale and zone", () => { test("formatTime renders unix seconds in the given locale and zone", () => {
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node. // 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
@@ -15,6 +15,18 @@ test("formatBytes humanizes with binary units", () => {
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB"); expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
}); });
test("formatAge steps up a unit at each boundary and truncates", () => {
expect(formatAge(0)).toBe("0s ago");
expect(formatAge(59)).toBe("59s ago");
expect(formatAge(60)).toBe("1m ago");
expect(formatAge(3599)).toBe("59m ago");
expect(formatAge(3600)).toBe("1h ago");
expect(formatAge(10800)).toBe("3h ago");
expect(formatAge(86399)).toBe("23h ago");
expect(formatAge(86400)).toBe("1d ago");
expect(formatAge(400000)).toBe("4d ago");
});
test("formatMicros renders milliseconds with one decimal", () => { test("formatMicros renders milliseconds with one decimal", () => {
expect(formatMicros(0)).toBe("0.0 ms"); expect(formatMicros(0)).toBe("0.0 ms");
expect(formatMicros(1234)).toBe("1.2 ms"); expect(formatMicros(1234)).toBe("1.2 ms");
@@ -21,6 +21,24 @@ export function formatBytes(bytes: number): string {
return `${value.toFixed(1)} ${unit}`; return `${value.toFixed(1)} ${unit}`;
} }
const AGE_UNITS = [
{ seconds: 86400, suffix: "d" },
{ seconds: 3600, suffix: "h" },
{ seconds: 60, suffix: "m" },
] as const;
/**
* Seconds of elapsed time a coarse "3h ago". Truncating and single-unit on
* purpose: this labels a snapshot the caller renders once, so a reader must not
* take it for a live count. Nothing re-renders it as it ages.
*/
export function formatAge(seconds: number): string {
for (const unit of AGE_UNITS) {
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix} ago`;
}
return `${Math.floor(seconds)}s ago`;
}
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */ /** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
export function formatMicros(micros: number): string { export function formatMicros(micros: number): string {
return `${(micros / 1000).toFixed(1)} ms`; return `${(micros / 1000).toFixed(1)} ms`;
@@ -23,7 +23,7 @@ export const queryKeys = {
stats: (period: Period) => ["stats", period] as const, stats: (period: Period) => ["stats", period] as const,
timeseries: (period: Period) => ["stats", "timeseries", period] as const, timeseries: (period: Period) => ["stats", "timeseries", period] as const,
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const, queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
upstreamHealth: ["upstream-health"] as const, upstreamHealth: (period: Period) => ["upstream-health", period] as const,
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const, lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
/** Prefix of every `lookup` entry; the invalidation target after any verdict input changes. */ /** Prefix of every `lookup` entry; the invalidation target after any verdict input changes. */
lookupAll: ["lookup"] as const, lookupAll: ["lookup"] as const,
@@ -69,8 +69,15 @@ export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
export const upstreamHealthQuery = () => // The period is part of the key: the upstream aggregates are ranged like the
queryOptions({ queryKey: queryKeys.upstreamHealth, queryFn: api.getUpstreamHealth, refetchInterval: 30_000 }); // stats ones, so the picker has to refetch them rather than reuse a cached
// window under a new label.
export const upstreamHealthQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.upstreamHealth(period),
queryFn: () => api.getUpstreamHealth(period),
refetchInterval: 30_000,
});
export const lookupQuery = (domain: string, groupId?: number) => export const lookupQuery = (domain: string, groupId?: number) =>
queryOptions({ queryKey: queryKeys.lookup(domain, groupId), queryFn: () => api.getLookup(domain, groupId) }); queryOptions({ queryKey: queryKeys.lookup(domain, groupId), queryFn: () => api.getLookup(domain, groupId) });
@@ -119,21 +119,40 @@ export interface LookupResult {
safe_search_rewrite: string | null; safe_search_rewrite: string | null;
} }
export interface UpstreamPeriodStats {
attempts: number;
successes: number;
failures: number;
/** successes/attempts, 0 to 1; null when attempts is 0 — no observations is not perfect reliability. */
success_rate: number | null;
/** The newest failure inside the window, unix seconds; null when the window holds none. */
last_failure_at: number | null;
/** The error name belonging to last_failure_at; null exactly when it is. */
last_failure_error: string | null;
}
export interface UpstreamHealthEntry { export interface UpstreamHealthEntry {
url: string; url: string;
/** Live configuration, not history. */
enabled: boolean; enabled: boolean;
/** Live state; false while the upstream is backing off. */
available: boolean; available: boolean;
consecutive_failures: number; period: UpstreamPeriodStats;
total_successes: number;
total_failures: number;
success_rate: number;
last_error: string;
} }
export interface UpstreamHealth { export interface UpstreamHealth {
upstreams: UpstreamHealthEntry[]; period: Period;
since: number;
until: number;
available: number; available: number;
total: number; total: number;
/**
* No capacity drops known in this process within the selected window; up to about a minute of
* the newest outcomes may not have flushed yet, and outcomes lost in an unclean shutdown are
* not detectable.
*/
complete: boolean;
upstreams: UpstreamHealthEntry[];
} }
export interface Group { export interface Group {
@@ -160,7 +179,9 @@ export interface Blocklist {
last_updated: number | null; last_updated: number | null;
domain_count: number; domain_count: number;
wildcard_count: number; wildcard_count: number;
exception_count: number;
skipped_regex_count: number; skipped_regex_count: number;
skipped_unsupported_count: number;
checksum: string | null; checksum: string | null;
} }
@@ -189,10 +210,12 @@ export interface SourceStatus {
last_error: string; last_error: string;
domains: number; domains: number;
wildcards: number; wildcards: number;
exceptions: number;
skipped_regex: number; skipped_regex: number;
skipped_unsupported: number;
} }
export type RuleKind = "exact" | "wildcard"; export type RuleKind = "exact" | "wildcard" | "regex";
export type RuleAction = "allow" | "block"; export type RuleAction = "allow" | "block";
export interface Rule { export interface Rule {
@@ -252,6 +275,8 @@ export interface Client {
id: number; id: number;
ip: string; ip: string;
name: string; name: string;
/** Learned over reverse DNS. `name` wins whenever it is non-empty. */
learned_name: string;
group_id: number; group_id: number;
group: string; group: string;
hand_edited: boolean; hand_edited: boolean;

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