3 Commits
Author SHA1 Message Date
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
28 changed files with 3792 additions and 227 deletions
+75 -184
View File
@@ -48,8 +48,9 @@ env:
# 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
# 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
# the version out of build.zig.zon instead.
# pass this file's own packaging gate. The package job reads the version out
# of build.zig.zon instead, and the container job takes it from that job's
# output.
jobs:
test:
@@ -179,6 +180,11 @@ jobs:
needs: [frontend]
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:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -215,12 +221,17 @@ jobs:
# 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
# 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
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"
id: zon-version
run: ./zig-out/bin/container_check version
- name: Build the release artifacts
run: |
@@ -257,7 +268,13 @@ jobs:
# The zip round-trip drops the executable bit. That is survivable only
# because the Dockerfile chmods the binary itself and the contents
# assertion compares sha256 of file contents, never modes. The archive
# modes are asserted by verify-dist, above, on the originals.
# 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
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with:
@@ -265,201 +282,69 @@ jobs:
path: |
zig-out/dist/bin
zig-out/dist/stage
zig-out/bin/container_check
if-no-files-found: error
container:
needs: [package]
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:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
# Restored under zig-out/dist because that is where the Dockerfile's
# COPY lines look, with the repository root as the build context. This
# job compiles nothing and bundles nothing: the payload is the one the
# package job already built and verify-dist already checked, which is
# also the point — an image built from a second, independent `dist` run
# would prove nothing about the artifacts the release publishes.
# Restored under zig-out because that is where the Dockerfile's COPY lines
# look for dist/, with the repository root as the build context, and
# because the payload also carries bin/container_check. This job compiles
# nothing and bundles nothing: the payload is the one the package job
# already built and verify-dist already checked, which is also the point —
# an image built from a second, independent `dist` run would prove nothing
# about the artifacts the release publishes.
- name: Download the staged payload built by the package job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: dist-payload
path: zig-out/dist
path: zig-out
# Same single source of truth as the package job (rulings 2 and 5). This
# job still needs the version for the stage directory name it hashes
# against the image and for the VERSION build arg.
- name: Read the version from build.zig.zon
run: |
set -euo pipefail
version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1)
test -n "$version"
echo "CI_VERSION=$version" >> "$GITHUB_ENV"
# The artifact zip carries no modes.
- name: Restore the gate tool's executable bit
run: chmod +x zig-out/bin/container_check
# Image tags, container names and published host ports are all
# daemon-global. This workflow is called by both ci.yml and release.yml
# and the self-hosted runners share one docker daemon, so the fixed
# `nxdns:ci` and `nxdns-smoke` made two concurrent runs collide: the
# second `docker create --name` fails outright, and worse, whichever run
# finishes first deletes the other's container mid-test. The names below
# carry the run identity plus four random bytes — the run id alone is not
# enough, because two *repositories* on the same daemon can issue the
# same one. The published port becomes ephemeral for the same reason.
- 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"
# Everything this gate decides — this run's docker object names, the
# ownership label, the image build, the contents assertion against the
# staged payload, and the smoke test with its topology detection and its
# absolute probe deadline — lives in tools/container_check.zig, along with
# the rationale comments that used to sit in this file. That is the same
# move release.yml made (milestone-14 deviation 24): logic in a `run:`
# block cannot be type-checked, run on a laptop, or covered by a test.
- name: Run the container acceptance gate
run: ./zig-out/bin/container_check gate
# The build args carry the OCI label values (ruling 6); release.yml
# passes the same three and then asserts the resulting
# org.opencontainers.image.version label. BuildKit is not optional here:
# the builder stage is pinned to $BUILDPLATFORM, which the classic
# builder does not define, so DOCKER_BUILDKIT=0 fails at the first FROM.
- name: Build the image
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.
# The gate removes its own container and image on every exit path it
# survives. This step is the exit path for a cancelled job and for a step
# that died before the gate's own defers ran — on a long-lived self-hosted
# daemon those accumulate one layer set per run. The name pair only exists
# if the gate got as far as writing $GITHUB_ENV, so the label sweep covers
# the rest of this run.
#
# Native triple only: this job builds a single-architecture image.
# release.yml covers both platforms against the pushed multi-arch index.
- name: Assert the image contents match the packaged artifacts
run: |
set -euo pipefail
stage="zig-out/dist/stage/nxdns-$CI_VERSION-x86_64-linux-musl"
test -d "$stage"
out=$(mktemp -d)
cid=$(docker create "$CI_IMAGE")
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.
# It covers no more than that. `always()` does not run when the runner or
# the pod itself dies, and the filter below names THIS attempt's label
# value exactly — deliberately, since a concurrent run of another
# repository must not be swept, but that also means a later attempt cannot
# collect an earlier one's leak. What the label buys for those cases is
# discovery, not recovery: `docker ps -a --filter
# label=net.mial.nxdns.ci` and the matching `docker images` list every
# object this workflow has ever left behind, with the repository, run and
# attempt that owns each one. Reclaiming them is a manual sweep today, and
# the hook a janitor job would use later.
- name: Remove this run's docker objects
if: always()
env:
CI_LABEL: net.mial.nxdns.ci=${{ github.repository }}/${{ github.run_id }}/${{ github.run_attempt }}
run: |
set -uo pipefail
if [ -n "${SMOKE_NAME:-}" ]; then
@@ -468,4 +353,10 @@ jobs:
if [ -n "${CI_IMAGE:-}" ]; then
docker image rm -f "$CI_IMAGE" >/dev/null 2>&1 || true
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
+42
View File
@@ -10,6 +10,48 @@ subject rarely does.
## [Unreleased]
## [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
+3
View File
@@ -309,6 +309,7 @@ entry `x.y` and a wildcard entry `x.y`, which together give domain-and-subdomain
- 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).
- 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`.
### 7.3 Reload
@@ -387,6 +388,8 @@ CREATE TABLE clients (
id INTEGER PRIMARY KEY,
ip TEXT NOT NULL UNIQUE, -- canonical text form (v4 dotted / v6 RFC 5952)
name TEXT,
learned_name TEXT,
name_attempt_after INTEGER NOT NULL DEFAULT 0,
group_id INTEGER NOT NULL REFERENCES groups(id),
hand_edited INTEGER NOT NULL DEFAULT 0,
first_seen INTEGER NOT NULL,
+21
View File
@@ -248,6 +248,27 @@ pub fn build(b: *std.Build) void {
});
test_step.dependOn(&b.addRunArtifact(release_tests).step);
// The container acceptance gate. Installed rather than run from the build
// graph for the same reason as the release tool: it needs a live docker
// daemon and the workflow's environment, neither of which a Run step in this
// graph can supply.
const container_check_tool = hostTool(b, "container_check");
b.step("container-check-tool", "Install the container gate tool into zig-out/bin")
.dependOn(&b.addInstallArtifact(container_check_tool, .{}).step);
// Its pure decisions — object naming, the ownership label, the inspect-JSON
// topology read, the probe deadline, the build.zig.zon version parse — are
// what the shell it replaced could never be tested on.
const container_check_tests = b.addTest(.{
.name = "container-check-tool",
.root_module = b.createModule(.{
.root_source_file = b.path("tools/container_check.zig"),
.target = b.graph.host,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(container_check_tests).step);
addDist(b, options, web_assets, .{
.version = version_option,
.version_string = version_string,
+1 -1
View File
@@ -1,6 +1,6 @@
.{
.name = .nxdns,
.version = "0.0.2",
.version = "0.0.3",
.minimum_zig_version = "0.16.0",
.paths = .{""},
.fingerprint = 0x3307b311dded1d91,
+38
View File
@@ -251,6 +251,34 @@ Known clients with a fixed group assignment.
Consumed by the filter engine's exact address-to-group lookup
(`src/filter/matcher.zig`).
#### Learned names
A client row that carries no name of its own can still show one. Every flush
pass the client tracker takes at most 16 unnamed rows that are due, builds each
address's reverse name (`192.168.1.10` becomes `10.1.168.192.in-addr.arpa`), and
matches it against the declared [`forward_zones`](#forward_zones). On a match it
sends one PTR query to that zone's resolver and stores the answer as the row's
*learned* name. A row becomes due again 24 hours after the resolver answered or
returned NXDOMAIN, and 1 hour after any other outcome — no covering zone, a
transport failure, or a reply nxdns rejected — so declaring the missing zone or
fixing the resolver shows names within the hour. This
requires a conditional forward zone that covers the LAN's reverse space — for
example `168.192.in-addr.arpa` pointed at the router. Without such a zone nxdns
sends the reverse name to nobody, and the row stays unnamed.
The PTR query goes to the resolver of the declared zone, wherever the operator
pointed it; nxdns does not second-guess that declaration, and it cannot stop a
LAN resolver from forwarding the query onward.
A learned name is runtime state, like `last_seen`:
- A hand-typed `name` always wins, and a named row is never asked about again.
- Learned names never appear in `nxdns export`, and `nxdns import` never sets
one.
- Each row refreshes once a day. A device rename or a DHCP lease change can
therefore display a stale name for up to 24 hours, until the next refresh
overwrites it or the router reports no name and nxdns clears it.
### client_prefixes
Group assignment by CIDR prefix, for clients without an exact entry.
@@ -395,6 +423,16 @@ name would be a bootstrap problem. Matching is longest suffix
(`src/local/forward_client.zig`) with `upstream.read_timeout_ms` as the read
deadline.
Reverse zones are declared the same way, and one is the prerequisite for
[learned client names](#learned-names):
```zig
.forward_zones = .{
.{ .zone = "lan", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
},
```
## Password and hash
Both fields are optional, and the difference between *absent* and *empty* is the
+642
View File
@@ -0,0 +1,642 @@
# Milestone 25: client names learned over reverse DNS
Goal: the clients table names devices by itself. The tracker already
materialises every querying address into a `clients` row (PLAN §7.2,
`src/server/clients.zig`), but `name` stays NULL until the operator types one —
which defeats the point of auto-materialisation on a LAN whose router already
knows every DHCP hostname. This milestone asks the router: for each unnamed
client, nxdns builds the address's reverse name, matches it against the
operator's conditional forward zones (PLAN §6.5), sends one PTR query to the
zone's resolver, and stores the answer as a *learned* name — runtime state
beside `last_seen`, never configuration. The dashboard shows it; a hand-typed
name always wins; a reverse name no forward zone covers is never sent anywhere.
Design written 2026-08-14 against HEAD `c428bc2`, revised after a Codex review
of the first draft (all 19 findings accepted; the review's fingerprints are
called out inline where a first-draft claim was wrong).
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- **The v1 baseline is editable and there is no migration step** (milestone-24
ruling 1 stands unchanged). The two new columns are lines edited into
`config_schema.ddl_v1` plus the identical lines in PLAN §11.2, kept
byte-identical. A `ddl_v2` or a second `Step` is wrong and must be reverted.
A development database stamped version 1 before the edit fails its first
`SELECT` naming the columns; delete the scratch database.
- The new `src/local/reverse_name.zig` is pure: bytes in, bytes out, no
`std.Io`, no clock, no sockets. (The blanket claim "src/local/ is pure" from
the first draft was false — `forward_client.zig` does socket I/O by design;
the purity requirement here binds the *new* module and the lookup-table
modules it sits beside.) Everything that touches the network or the database
lives in `src/server/`.
- After the API shape change, regenerate
`web/src/lib/contractSamples.gen.ts` with the AGENTS.md command (never a
hand edit) and update `web/src/lib/types.ts` to match, in the same session.
- Verify stdlib claims against `../zig` at tag 0.16.0. `specs/research/` holds
verified notes. One verified here: randomness is on the `Io` interface —
`io.random(buffer)` (`std/Io.zig:2468`); `std.crypto.random` does not exist
in 0.16.0.
- No `std.log.err` in new code. PTR answers are untrusted bytes: never let one
reach a log line, a metric label, or the UI without passing `acceptHostname`
(ruling 4).
## Rulings (binding)
### 1. When: the tracker's flush pass resolves, bounded, serial, last
Resolution is a step appended to the tracker's existing flush pass
(`Tracker.flushOnce`, `src/server/clients.zig:164`), not a new loop and not a
query-path hook. Reasons: the pass already runs every `flush_interval_s` (60 s)
on a dedicated task with a dedicated `config.db` connection (app.zig:523-524,
:754), it already respects the disk-monitor gate, and a name can only be
learned for a row that exists — which the flush itself just wrote. A DNS query
must never wait on naming, and with this placement it structurally cannot.
**Order within one pass is fixed: drain-and-write, then prune (when due), then
resolve.** One `now_s` is read at the top of the pass and used by all three
steps. Resolving before pruning could spend PTR queries on rows the same pass
deletes; the fixed order makes that impossible, and a test proves a row past
the prune cutoff on a due pass causes no exchange.
Per pass, the resolver:
- selects at most `max_per_pass = 16` candidate rows (ruling 3 defines a
candidate), ordered by `name_attempt_after` ascending then ip;
- resolves them **serially** — in-flight is exactly 1. At 16 per 60 s pass the
LAN resolver sees at most ~0.27 queries/second from naming.
**Naming has its own read timeout**, `ptr_read_timeout` = 2 s on the `.awake`
clock, a `pub const` on the resolver — it does *not* inherit the handler's
`forward_read_timeout`, which the operator may have set as high as the
upstream budget. **The budget bounds the whole attempt, not one transport
leg**: `ForwardClient` hands its duration to the UDP receive and then again
to the TCP fallback, so a resolver that answers UDP late with TC=1 and then
stalls TCP would spend up to 2× the budget per attempt — 64 s per pass, not
32. The default `exchangeFn` therefore wraps the entire `ForwardClient`
exchange in `transport.raceWithin(io, ptr_read_timeout, ...)` (the same
mechanism `exchangeTcp` itself uses, forward_client.zig:195), so UDP,
fallback and all share one deadline. The worst case is 16 × 2 s = 32 s of
naming per pass, and that number is a bound, not an estimate. That is acceptable because the drain already ran first: a slow pass
delays the *next* pass's start (the run loop sleeps after `flushOnce`
returns), and one extra interval against a 512-slot pending table on a
household LAN drops nothing. The acceptance test for this: with a stub client
that times out on every exchange, the pass still drains its pending batch
before any exchange is attempted, and attempts stop at `max_per_pass`.
Retry cadence is split by outcome (first draft used one 24 h cadence for
everything, which left a freshly-declared zone, a recovered router, or a fixed
resolver unnamed for a day while forward-zone changes publish live):
- **definitive** outcomes (`answered`, `nxdomain`): next attempt after
`refresh_after_s = 86_400` — the daily refresh that tracks DHCP renames;
- **non-definitive** outcomes (`no_zone`, `failed`, `invalid`): next attempt
after `retry_after_s = 3_600` — an operator who declares the missing zone
or fixes the resolver sees names within the hour, and a dead resolver costs
at most 16 timeouts per hour, not per minute.
Both constants are `pub const` on the resolver; no config knob (household
scale, AGENTS.md: no generality nobody asked for).
### 2. Where: only through a declared forward zone, never the pool
The reverse name (`10.1.168.192.in-addr.arpa`, or the 32-nibble `ip6.arpa`
form) is matched against the live forward-zones table — the same
`forward_zones.Zones.match` the query path uses, bracketed by
`LocalTables.acquire`/`release` (`src/server/local_tables.zig:48`), so naming
always sees the generation the operator last published.
**The zone's `resolver` is copied by value under the shared lock, and the
handle is released before any socket work.** `match` returns a pointer into
the current table generation, and `swap` frees the old generation as soon as
it holds the exclusive lock — a handle held across a PTR exchange, or a
released handle whose `*const Zone` is still dereferenced, is a use-after-free
against a config reload. `validate.Resolver` is a plain value; the copy is the
whole fix. A regression test must place the `swap` **inside the hazard
window** — after the handle release, before the copied resolver is used. A
test that swaps before `runPass` evaluates its arguments exercises nothing
(the first implementation's test had exactly this defect: the stub's exchange
performed the swap, but the resolver had already been copied into the call's
arguments, so the test passed regardless of what production retained). The
required shape: two candidates in one pass, two generations whose zones
cover both but carry **different resolver ports**; the stub `exchangeFn`
performs the swap during the *first* attempt (freeing the old generation
under `testing.allocator`) and records each attempt's resolver. Assertions:
the first attempt's resolver still reads the old generation's port after the
swap (the copy, not freed memory), and the second attempt's resolver reads
the *new* generation's port (each attempt re-acquires; no table pointer or
handle is cached across attempts). With a production `runPass` that retains
`*const Zone` or the handle across the release, the first assertion reads
freed memory and the second reads the stale generation — either fails.
- Match found: one PTR query to that zone's resolver through
`forward_client.ForwardClient`, built per attempt as `Context.viaForwardZone`
builds one (handler.zig:411), with `ptr_read_timeout` (ruling 1).
- No match: **no query is sent to anyone** — not the pool, not any resolver.
The attempt counts under `no_zone` and retries per ruling 1.
Scope of the promise, stated precisely (the first draft's "never to a public
resolver" claimed more than the code enforces): what this milestone
guarantees is that a reverse name reaches **only the resolver of a zone the
operator declared**, and never the upstream pool. `parseResolver`
(validate.zig:274) accepts any IP literal, so an operator who declares
`168.192.in-addr.arpa → udp://8.8.8.8:53` has pointed their private reverse
space at a public resolver — that is their declaration, and nxdns does not
second-guess it, any more than it second-guesses the same zone for forward
queries. Likewise nxdns cannot stop a declared LAN resolver forwarding
onward. The docs (ruling 11) say this in one sentence.
The DNS cache is not consulted and not written: one PTR per client per day is
not worth a cache entry, and bypassing the cache keeps the resolver a plain
consumer of the exchange seam.
### 3. Precedence: the operator's name always wins; NXDOMAIN clears
Schema (ruling 5) keeps learned names in their own column, so precedence is a
display rule, not a write conflict — `name` is never written by this feature
and `learned_name` is never written by the operator.
A **candidate** row satisfies both of:
- `name IS NULL OR name = ''` — a row whose displayed name would come from
learning. `hand_edited` does not appear in the predicate (the first draft
had it; display precedence never consults it, so candidacy must not
either): a hand-edited row the operator grouped but did not name still
benefits, and a named row is skipped whatever its flag — its learned name
would never be shown.
- `name_attempt_after <= now_s` (ruling 6 gives the exact SQL).
Outcomes of an attempt, written through one repo call (ruling 6), each
setting `name_attempt_after` per ruling 1's cadence:
- **Answer with a valid PTR target**: store it in `learned_name`
(lowercased, no trailing dot), overwriting whatever learned name was there —
the router is the authority on its own zone, and a changed answer is a
renamed device, not a conflict. A test covers the overwrite: two attempts,
two different valid targets, second wins.
- **NXDOMAIN, or NOERROR with no matching PTR record** (NODATA): clear
`learned_name` to NULL. The router affirmatively says the address has no
name; keeping a stale one would show a device under its previous owner's
hostname after a lease change.
- **Everything else** — transport error, any RCODE other than NOERROR and
NXDOMAIN (REFUSED, FORMERR, SERVFAIL, unknown values), malformed reply,
invalid hostname (ruling 4): keep the stored `learned_name` as it stands,
count under `failed` (or `invalid`). An outage must not strip names from
the whole dashboard.
**"RCODE" here is the full 12-bit value**: the header's 4 bits extended by
the OPT record's upper 8 bits when the response carries one
(`packet.findOptRecord` locates it; `dns/edns.zig` decodes it). A response
whose header says NOERROR under a nonzero extended RCODE is a failure, not
an answer and not NODATA — classifying on the header nibble alone would
store or clear a name on what the resolver called an error. An OPT record
that fails to parse makes the whole reply malformed (`failed`, keep). The
first implementation had exactly this bug; the regression test is a
NOERROR header + OPT with a nonzero extended RCODE, asserting `failed`
counts and the stored name survives — it must fail with the classification
reverted to header-only.
**Staleness bound, stated honestly:** an address reassigned to a new device
immediately after a successful lookup shows the previous device's hostname for
up to `refresh_after_s` (24 h) — the next refresh then overwrites or clears
it. That bound is accepted: it matches the prune cadence's granularity, and
tightening it means more PTR traffic for a cosmetic lag. The docs state the
bound.
`upsertSeen`, `updateClient`, `pruneStale` and the reconcile engine are
untouched: a pruned row takes its learned name with it, a re-materialised
device is re-learned within a pass, and `updateClient` writing a name removes
the row from candidacy (the name predicate) without touching `learned_name`.
### 4. PTR answers are untrusted input: `acceptHostname` gates everything
The answer bytes come from whatever box the operator pointed a zone at, and
they land in a UI. Validation has three layers, all required:
1. **Transport**: `transport.validateResponse` (ID and question echo — that is
*all* it checks; the first draft leaned on it for more).
2. **Record selection**: walk `packet.answers`; the accepted record is the
**first** whose `rtype` is `.ptr`, whose class is `.in`, and whose owner
name equals the queried reverse name case-insensitively
(`name.eqlIgnoreCase`, name.zig:185). Records failing any of these are
skipped, not errors; an answer section with no accepted record under
RCODE NOERROR is NODATA (ruling 3 clears). Extra accepted records after
the first are ignored.
3. **Hostname shape**: the PTR target decodes via `record.rdataCname`
(record.zig:101 handles PTR) and formats via `name.formatText`; the text is
accepted only if every byte is in `[a-z0-9._-]` after ASCII-lowercasing
`A-Z`, labels are 163 bytes, the whole name is ≤ 253 bytes, no label
starts or ends with `-`, and there is no empty label (which also rejects a
trailing dot — `formatText` emits none, so one appearing is malformed).
Underscore is included because real DHCP hostnames carry it; nothing else
is. A failing target counts under `invalid` and keeps the stored name.
`acceptHostname` is a pure function in `src/local/reverse_name.zig`, tested
against: the empty string, a 254-byte name, a 64-byte label, `a b`, `a\x00b`,
`héllo`, `-x`, `x-.y`, `a..b`, `.a`, `a.` (trailing dot), and accepting
`nas-1.lan`, `my_printer.home`, `x`.
### 5. Schema: two runtime columns, not a reuse of `name`
`clients` gains, in `config_schema.ddl_v1` directly after `name`:
```sql
learned_name TEXT,
name_attempt_after INTEGER NOT NULL DEFAULT 0,
```
with the identical lines in PLAN §11.2. `name_attempt_after` is the epoch
second before which the resolver will not attempt this row again; 0 (the
default, and every pre-existing row) means "eligible now". Storing the *next*
attempt rather than the *last* one keeps the candidate predicate a plain
comparison (no addition that could overflow on a corrupt value, no special
case for "never attempted") and lets one column carry ruling 1's two cadences.
Reusing `name` with a provenance marker was rejected: `name` feeds
`listClients`, which feeds `nxdns export`, and a learned name in an export
would turn runtime state into configuration — it would churn export diffs as
leases move, and under file authority the next reconcile would fight it. With
separate columns:
- **Export/import untouched.** `listClients` selects `name` only
(clients_repo.zig:33-38); no code change, and a test proves an export before
and after a learned name lands is byte-identical.
- **Reconcile untouched.** The engine never touches runtime columns; the
learned columns ride the row exactly as `last_seen` does. Promotion
(declaring an observed address) keeps them; deletion of a declared row drops
them with the row, correctly.
- **File-authority mode needs no write rule.** Naming is a runtime write like
`upsertSeen`, permitted in both authority modes for the same reason. In
file mode the operator's *declared* name wins through the ordinary
precedence (a non-empty `name` removes the row from candidacy after the
reconcile writes it), and the learned name stays display-only runtime
state.
The baseline test in `src/storage/migrations.zig` ("a fresh database reaches
the baseline...") gains `columnExists` probes for both columns.
### 6. Repo surface: two calls, owned by the resolver
`src/storage/repositories/clients_repo.zig`, in the runtime section beside
`upsertSeen` / `pruneStale`:
- `resolveCandidates(database, out, now_s)` — fills a caller-supplied
fixed-capacity buffer (capacity `max_per_pass`, each slot
`logger.max_client_len` bytes — the bound every address text in this
program is sized by) and returns the filled slice or count. **No
allocation**: the candidate set is small by construction and the resolver
runs on the tracker's task, which owns no allocator today. The WHERE
clause, exactly:
```sql
(name IS NULL OR name = '') AND name_attempt_after <= ?1
ORDER BY name_attempt_after, ip LIMIT 16
```
Tests cover: `now_s` smaller than any cadence constant still selects
never-attempted rows (`DEFAULT 0`); the boundary `name_attempt_after =
now_s` selects; `now_s + 1` does not; an extreme stored value
(`i64` max) never selects and never traps.
- `noteNameOutcome(database, ip, outcome)` where the outcome carries
`attempt_after: i64` and one of: store `text`, clear, keep. One UPDATE:
always sets `name_attempt_after`; sets `learned_name` to the text on
store, to NULL on clear, leaves it alone on keep. **A row deleted between
selection and this call makes the UPDATE touch zero rows; that is a
no-op by design, not an error and not a counter** — the device left, and
nothing was learned about nothing.
`ClientRow` gains `learned_name: []const u8` (NULL reads as `""`, like
`name`); `readClientRow` and both SELECTs carry it. `name_attempt_after` is
**not** exposed on `ClientRow` or the API — it is scheduling state with no
operator meaning, and keeping it out keeps the contract surface one field.
`listClients` (export) is not widened — that is the point of ruling 5.
### 7. The resolver module: `src/server/client_names.zig`
New file. A `Resolver` struct owning:
- `stats: Stats` with the six **outcome** counters `attempted`, `answered`,
`nxdomain`, `no_zone`, `invalid`, `failed`, plus two counters *outside* the
outcome sum: `read_failures` (the candidate SELECT failed; the pass skips
naming) and `write_failures` (`noteNameOutcome` failed; the outcome was
still counted). Guarded by the tracker's mutex pattern, with a
`snapshotStats`. Invariant, asserted in a test: `attempted` equals
`answered + nxdomain + no_zone + invalid + failed`. Database failures log
at most one `warn` per pass each for reads and writes, tracker-style
(clients.zig:181-186); network failures are the counters' job and log
nothing new (the `forward_client` module's existing `debug`-level
diagnostics are exempt from this milestone's no-log rule — they predate it,
are debug-level, and suppressing them per-caller would fork the client).
- an **exchange seam**: production code cannot inject into `ForwardClient`
(it is a concrete struct the resolver constructs), so the seam sits above
it — a field
`exchangeFn: *const fn (io, validate.Resolver, query, response_buf) transport.ExchangeError![]u8`
defaulting to a function that builds a stack `ForwardClient` with
`ptr_read_timeout` and calls `exchange`. Tests replace the pointer; the
production default is itself covered by one test against a loopback UDP
socket if one already exists in the suite's patterns, otherwise by the live
smoke. The no-zone test runs the **production** pass code with a counting
stub and asserts the count stays 0.
- a `runPass(io, database, tables, now_s)`: select candidates (ruling 6), and
for each — build the reverse name (pure, ruling 8), acquire/match/**copy
resolver**/release (ruling 2), build the PTR query: header with a random id
filled from `io.random` (`std/Io.zig:2468` — *not* `std.crypto.random`,
which 0.16.0 does not have), RD set, one question, qtype `.ptr`, qclass
`.in`, encoded with `dns/header` + `dns/question.encode`; exchange through
the seam, classify per rulings 34, write per ruling 6. The id is
test-visible through the seam (the stub sees the query bytes), so no
determinism hook is needed beyond the seam itself.
Wiring: `Tracker.flushOnce` gains an optional `*client_names.Resolver`
parameter, invoked **after** the drain/write and prune steps (ruling 1's
order); `app.zig` constructs the Resolver beside the tracker (holding the
`*LocalTables` pointer) and passes it through `Tracker.run`'s arguments
(app.zig:754). The resolver runs on the tracker's task against `tracker_db` —
the dedicated-connection rule (clients.zig:127-131) is satisfied because it
is the *same* task, serial with the flush. A gated pass (disk critical) skips
naming too: naming writes.
### 8. The pure part: `src/local/reverse_name.zig`
New file, pure (no Io, no clock). Two functions plus their tests:
- `reverseName(addr: address.NetAddress, buf: *[max_reverse_len]u8) []const u8`
— `d.c.b.a.in-addr.arpa` for v4, the 32-nibble lowercase `ip6.arpa` form for
v6. `max_reverse_len` is a comptime bound (v6 form: 32 nibbles · 2 + 8 for
`ip6.arpa` = 72; compute, don't hand-wave). An IPv4-mapped v6 address was
already canonicalised to `ip4` by `NetAddress` (clients.zig test at :305
proves the tracker's addresses are canonical) — assert, don't re-handle.
- `acceptHostname(text: []const u8) bool` — ruling 4's gate, with ruling 4's
test table.
`src/platform/address.zig` types are plain values; importing them into
`local/` keeps the module pure. Register the file in `src/tests.zig` the way
its siblings are registered.
### 9. Failure visibility: one metrics group
The resolver must be reachable from `metrics.collect`, which reads
`server.WebState` — so `src/web/server.zig`'s `WebState` gains an optional
`client_names: ?*client_names.Resolver` collaborator, populated by `app.zig`
beside `tracker` (the first draft wired the sample and forgot the state; S2
owns `src/web/server.zig` for this field). `src/web/metrics.zig`: `Sample`
gains `client_names: ?client_names.Resolver.Stats`, collected beside the
tracker's (metrics.zig:191-193) and rendered as
`counterGroup(w, "nxdns_client_names_", ...)` beside `nxdns_clients_`
(metrics.zig:333-334). `counterGroup` renders every struct field, so all
eight counters (six outcomes + `read_failures` + `write_failures`) surface
without per-field code. The render test pins one line (e.g.
`nxdns_client_names_no_zone_total`).
### 10. API and UI: learned is visible and visibly different
- `GET /api/clients` rows carry `learned_name`
(`src/web/openapi.yaml` `required` + `properties` for the client schema;
contract samples regenerated; `web/src/lib/types.ts` client row type gains
`learned_name: string`). `name_attempt_after` stays internal (ruling 6).
- `web/src/features/clients/ClientsPage.tsx`: the name cell shows `name` when
non-empty; otherwise `learned_name` in the page's existing muted text style
with a visually-distinct treatment that marks it as learned (exact
affordance is the implementer's, but: no new colour system, reuse the muted
style the page already has, and the distinction must survive a screen
reader — an `aria-label` or visible suffix, not colour alone). An empty
both shows what it shows today.
- `ClientEditDialog.tsx` may pre-fill its name field with `learned_name` when
`name` is empty — adopting the learned name as a typed name is the natural
gesture — but saving still goes through the ordinary PUT and sets
`hand_edited` server-side as today. This applies in database-authority mode
only; in file mode client edits answer 403 as they do today, and the
file-mode story is ruling 5's (declared name wins via candidacy).
- Mocks in `ClientsPage.test.tsx` gain the field with distinct values;
rendering tests assert: a named row shows `name` and not `learned_name`; an
unnamed row shows `learned_name` with the learned affordance.
### 11. Docs
- `docs/reference/configuration.md`, clients section: a paragraph stating the
learned-name mechanism; that it requires a conditional forward zone
covering the LAN's reverse space (with the `168.192.in-addr.arpa` example);
that hand-typed names win; that learned names never appear in exports; that
a rename or lease change can display stale for up to a day (ruling 3's
bound). The cadence must be described as it is, not rounded up: each pass
attempts **at most 16 due rows**, and a row is due per ruling 1's two
cadences — "every unnamed device, once a minute" overstates both coverage
and rate and must not appear; and one sentence that PTR queries go to the declared zone's
resolver, wherever the operator pointed it (ruling 2's scope).
- The how-to/tutorial page that documents conditional forward zones gains the
reverse-zone example if it lacks one (locate it; do not guess its path).
- PLAN §7.2 gains one sentence: materialised clients are named by PTR through
the declared forward zones; learned names are runtime state.
- `docs/reference/api.md` only if it enumerates client row fields (check).
## Sessions
Three, strictly sequential: S1 → S2 → S3. S2 needs S1's columns and repo
calls; S2 owns the contract regeneration (the API shape changes when S1 widens
`ClientRow`, but the sample regen runs the integration suite, which S2's
wiring changes — one regen at the end of S2 avoids regenerating twice). S3
reads the fields S2's regenerated types carry.
### Session S1: pure module, schema, repo
Owns: `src/local/reverse_name.zig` (new), `src/tests.zig` (registration),
`src/storage/config_schema.zig`, `src/storage/migrations.zig` (test),
`src/storage/repositories/clients_repo.zig`, `PLAN.md` (§11.2 lines only).
- S1.1 ruling 8: `reverse_name.zig` with exhaustive tests (v4, v6, bounds;
`acceptHostname` accept/reject table from ruling 4).
- S1.2 ruling 5: the two DDL lines in both copies, baseline column probes.
- S1.3 ruling 6: repo calls, `ClientRow` widening, tests — including: the
candidate SQL's boundary and extreme-value cases; the ordering; NULL
round-trips as `""`; export is byte-stable across a `noteNameOutcome`;
`updateClient` leaves `learned_name` alone; the zero-row UPDATE no-op.
Acceptance (S1):
- [ ] `zig build test` passes; baseline test proves both columns exist.
- [ ] `reverseName` output for `192.168.1.10` is
`10.1.168.192.in-addr.arpa` and for `fd00::1` is the full 32-nibble
lowercase form; both are matched by a `forward_zones.Zones` built over
`168.192.in-addr.arpa` / a covering `ip6.arpa` zone in a test that ties
the two modules together.
- [ ] `acceptHostname` passes ruling 4's full accept/reject table, including
`a..b`, `.a` and the trailing-dot case.
- [ ] An export taken before and after `noteNameOutcome` on an observed row is
byte-identical.
### Session S2: resolver, wiring, metrics, API contract (needs S1)
Owns: `src/server/client_names.zig` (new), `src/tests.zig` (registration),
`src/server/clients.zig`, `src/app.zig`, `src/web/server.zig` (the
`client_names` field), `src/web/metrics.zig`, `src/web/openapi.yaml`,
`web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts` (regenerated),
`web/src/features/clients/ClientsPage.test.tsx` (mock fields only).
- S2.1 ruling 7: the Resolver, the exchange seam, `flushOnce` hook (ruling 1
order), app and WebState wiring.
- S2.2 rulings 14: classification tests against a stub exchange. Tests must
cover: answered stores and lowercases; a second answered overwrites;
NXDOMAIN clears; NODATA clears; REFUSED and an unknown RCODE keep and count
`failed`; timeout keeps; invalid hostname keeps and counts `invalid`; a
record with wrong type, class, or owner name is skipped; no-zone sends
nothing (the stub's call count is the assertion) and counts;
`name_attempt_after` lands at `now + refresh_after_s` for definitive and
`now + retry_after_s` for non-definitive outcomes; the candidate cap holds;
a named row is never attempted; a gated pass attempts nothing; drain
precedes any exchange when every exchange times out (ruling 1); a row past
the prune cutoff on a due pass causes no exchange (ruling 1); the
two-generation swap test per ruling 2's exact shape (swap inside the
hazard window, both assertions); a NOERROR header under a nonzero OPT
extended RCODE counts `failed` and keeps the stored name (ruling 3); the
default `exchangeFn` bounds UDP plus the TCP fallback under one
`ptr_read_timeout` deadline (ruling 1) — a loopback resolver that answers
UDP with TC=1 late in the budget and then stalls TCP must fail the attempt
within one budget, not two (assert elapsed with margin; inject a shortened
budget locally if the default seam needs one).
- S2.3 ruling 9: WebState field, metrics sample, render pin.
- S2.4 ruling 10's API half: openapi.yaml, regen samples, types.ts, mock
fields.
Acceptance (S2):
- [ ] `zig build test` and `zig build test -Dintegration` pass; the
regenerated samples carry `learned_name` in the client row sample and
do not carry `name_attempt_after`.
- [ ] `cd web && npm run typecheck && npm test` pass with the widened types.
- [ ] The stats-sum invariant test passes (`attempted` = sum of the five
outcomes; `read_failures`/`write_failures` outside it).
- [ ] The no-zone test proves zero exchanges for an uncovered reverse name
through the production pass code.
- [ ] The concurrent-swap regression test passes (ruling 2).
### Session S3: UI and docs (needs S2)
Owns: `web/src/features/clients/ClientsPage.tsx`,
`web/src/features/clients/ClientEditDialog.tsx`,
`web/src/features/clients/ClientsPage.test.tsx` (rendering assertions),
`docs/reference/configuration.md`, `docs/reference/api.md` (if applicable),
the conditional-forwarding doc page, `PLAN.md` (§7.2 sentence only).
- S3.1 ruling 10's UI half.
- S3.2 ruling 11.
Acceptance (S3):
- [ ] `cd web && npm run typecheck && npm test && npm run lint && npm run
build` pass; rendering tests cover named-wins and learned-affordance.
- [ ] The docs name the reverse-zone prerequisite with a concrete
`in-addr.arpa` example, the one-day staleness bound, and the
resolver-is-the-operator's-choice sentence.
### Orchestrator
Verify each session's acceptance before starting the next. After S3, the full
gate set (`zig build test`, `zig build test -Dintegration`, `test-aarch64` if
qemu is present, `cd web && npm test`, `npm run assert-bundled`), then a live
smoke per the verify-against-the-real-network rule: a scratch server with a
forward zone `168.192.in-addr.arpa → udp://<router>:53` (or a local stub
resolver serving PTR), one real query from a LAN client, then wait one flush
interval and confirm the learned name in `GET /api/clients`, in the UI, and in
`nxdns_client_names_answered_total`. Then delete the zone and confirm the next
attempt counts `no_zone` with no upstream traffic (tcpdump or the pool
counters — pool queries must not move); with the hourly non-definitive
cadence, age `name_attempt_after` by SQL in the scratch database rather than
waiting. In database mode, hand-edit the name and confirm it wins and the row
stops being attempted. In file mode, confirm a file-declared name wins after
reconcile and the learned name remains display-only. Record deviations in
`## Recorded (implementation)`.
## Module layout
New files: `src/local/reverse_name.zig`, `src/server/client_names.zig`.
Deleted surface: none.
## File ownership
| File | Session |
| --- | --- |
| `src/local/reverse_name.zig` (new) | S1 |
| `src/storage/config_schema.zig`, `src/storage/migrations.zig` (test) | S1 |
| `src/storage/repositories/clients_repo.zig` | S1 |
| `PLAN.md` | S1 (§11.2 lines), then S3 (§7.2 sentence) — sequential, never concurrent |
| `src/tests.zig` | S1, then S2 — sequential |
| `src/server/client_names.zig` (new) | S2 |
| `src/server/clients.zig`, `src/app.zig`, `src/web/server.zig`, `src/web/metrics.zig` | S2 |
| `src/web/openapi.yaml`, `web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts` | S2 |
| `web/src/features/clients/ClientsPage.test.tsx` | S2 (mock fields), then S3 (assertions) — sequential |
| `web/src/features/clients/ClientsPage.tsx`, `ClientEditDialog.tsx` | S3 |
| `docs/reference/configuration.md`, `docs/reference/api.md`, forwarding doc page | S3 |
## Acceptance (milestone complete)
- [ ] All session acceptance boxes.
- [ ] `config_schema.ddl_v1` and PLAN §11.2 byte-identical, both carrying the
two lines; `migrations.steps` still one step, `target_version` still 1.
- [ ] The live smoke: a real client's hostname appears without any operator
edit; removing the zone stops queries entirely (`no_zone` moves, pool
counters do not).
- [ ] `nxdns export` output is byte-identical before and after names are
learned.
- [ ] Database mode: hand-editing a name wins over the learned name
immediately and permanently (the row leaves candidacy). File mode: a
file-declared name wins after reconcile; the learned name is
display-only runtime state.
- [ ] `src/web/openapi.yaml` reviewed by hand against the changed client row
shape, reviewer says so in `## Recorded` (no automated guard covers
schema-to-response agreement — milestone-24 finding, still true).
## Anti-requirements
- No migration step, no `ddl_v2`, no runtime schema probing.
- No PTR query to the upstream pool, and none for a reverse name no declared
forward zone covers — under any fallback, ever. (The declared resolver
itself is the operator's choice; ruling 2 scopes the promise.)
- No reuse of `clients.name` for learned names, and no learned data in
`nxdns export` / `import` ZON or in reconcile semantics.
- No mDNS, NetBIOS, DHCP-lease-file parsing, or any second naming source.
PTR through declared zones is the mechanism; a router that serves no
reverse zone yields `no_zone` counts and an unnamed row, visibly.
- No per-query or query-path resolution: naming rides the flush pass only.
- No config knobs for cadence, caps, or timeouts; constants per ruling 1.
- No DNS-cache participation for PTR probes.
- No unbounded anything: candidates per pass, in-flight, per-attempt timeout,
name length, and retry cadence are all bounded above by rulings 1 and 4.
- No storing or displaying a PTR target that fails `acceptHostname` — not
even truncated or escaped.
- No new per-attempt log lines from this milestone's code; counters and the
metrics group are the surface. `forward_client.zig`'s existing debug
diagnostics stand as they are.
- No allocation on the naming path: candidate storage is fixed-capacity
(ruling 6).
## Recorded (implementation)
- `src/server/phase7_integration_test.zig` sits outside the ownership table
but gained a mechanical `, null` argument at every `flushOnce`/`run` call
site, following the new `names: ?*client_names.Resolver` parameter.
- Review: one external round found four defects (extended RCODE ignored the
OPT upper bits; `exchangeWithin` did not bound the UDP+TCP pair under one
deadline; the swap test asserted nothing observable; a docs cadence
sentence). All four were fixed; the re-review of
`src/server/client_names.zig` returned no findings.
- Every fix shipped with a test the author watched fail with the fix
reverted. For the swap test the hazard was injected (a pass-level resolver
cache that skips re-acquisition) and the second-port assertion failed; the
one-deadline test's bound is `budget + late/2` (550 ms) because the
unbounded path takes ~700 ms and a 2×-budget bound would pass it.
- `src/web/openapi.yaml` was reviewed by hand against `ClientRow`
(clients_repo.zig:312): the nine required fields match one-to-one and
`name_attempt_after` is absent from the response, as intended.
- Live smoke (scratch server, file mode, zone `127.in-addr.arpa` at a local
stub PTR resolver): one real query materialised the client; the flush pass
learned `smoke-host.lan` with no operator edit, visible in
`GET /api/clients` and `nxdns_client_names_answered_total`. Removing the
zone and re-arming `name_attempt_after` produced `no_zone` with zero
packets to the stub and no pool movement. The learned name survived a
restart's reconcile. A file-declared `name` won in the API after reconcile
with the learned name still present as display-only state, and the named
row left candidacy (`attempted` stayed 0 over a full flush interval). The
database-mode hand-edit path runs the same candidacy SQL
(`name IS NULL OR name = ''`) and is covered by the repo unit tests rather
than a second live run. The UI half is covered by the ClientsPage
rendering tests, not a live browser check.
- The first smoke attempt polled a stale pre-milestone binary out of
`zig-out/bin` — `zig build test` does not refresh the install step. Rebuild
before any live check.
+6 -1
View File
@@ -34,6 +34,7 @@ const api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig");
const cert_store = @import("server/cert_store.zig");
const cli = @import("cli.zig");
const client_names = @import("server/client_names.zig");
const clients = @import("server/clients.zig");
const config_export = @import("config/export.zig");
const db = @import("storage/db.zig");
@@ -462,6 +463,9 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
var paused: pause.Pause = .{};
var tracker: clients.Tracker = .init(cfg.logging.retention_days);
// Naming rides the tracker's pass, on the tracker's task and connection
// (milestone-25 ruling 1), and reads the live forward zones.
var client_names_resolver: client_names.Resolver = .init(&tables);
// The queue holds waiting tasks in intrusive lists, so neither the buffer
// nor the `Logger` may move once a task has touched either.
@@ -635,6 +639,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
.handler = &h,
.pause = &paused,
.tracker = &tracker,
.client_names = &client_names_resolver,
.manager = &manager,
.pool = &pool,
.monitor = &monitor,
@@ -751,7 +756,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db, gate });
try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io });
try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io });
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate });
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate, &client_names_resolver });
try group.concurrent(io, runMaintenance, .{ &h, if (web_limiter) |*l| l else null, io });
// Started last (ruling 26), canceled by the same `group.cancel`; its inner
+204
View File
@@ -0,0 +1,204 @@
//! Reverse DNS names and the hostname gate for learned client names
//! (milestone 25). Pure: bytes in, bytes out — no `std.Io`, no clock, no
//! sockets. Everything that queries a resolver or writes a row lives in
//! `src/server/`.
const std = @import("std");
const address = @import("../platform/address.zig");
const v4_suffix = "in-addr.arpa";
const v6_suffix = "ip6.arpa";
/// The v6 form is the longest: 32 nibbles, each followed by a dot, then
/// `ip6.arpa`.
pub const max_reverse_len: usize = 32 * 2 + v6_suffix.len;
/// A PTR owner name is one label per byte (v4) or per nibble (v6), least
/// significant first, under `in-addr.arpa` / `ip6.arpa`. Lowercase hex for v6,
/// no trailing dot — the form `forward_zones.Zones.match` expects.
///
/// The tracker canonicalises an IPv4-mapped v6 address to `.ip4` before a
/// `NetAddress` reaches here, so this function never sees one.
pub fn reverseName(addr: address.NetAddress, buf: *[max_reverse_len]u8) []const u8 {
var w = std.Io.Writer.fixed(buf);
switch (addr) {
.ip4 => |b| {
w.print("{d}.{d}.{d}.{d}.{s}", .{ b[3], b[2], b[1], b[0], v4_suffix }) catch unreachable;
},
.ip6 => |b| {
std.debug.assert(!isIp4Mapped(b));
var i: usize = b.len;
while (i > 0) {
i -= 1;
const byte = b[i];
w.writeByte(hex_digits[byte & 0x0f]) catch unreachable;
w.writeByte('.') catch unreachable;
w.writeByte(hex_digits[byte >> 4]) catch unreachable;
w.writeByte('.') catch unreachable;
}
w.writeAll(v6_suffix) catch unreachable;
},
}
return w.buffered();
}
const hex_digits = "0123456789abcdef";
fn isIp4Mapped(b: [16]u8) bool {
return std.mem.eql(u8, b[0..10], &[_]u8{0} ** 10) and b[10] == 0xff and b[11] == 0xff;
}
/// The gate every PTR target passes before it is stored, logged or displayed.
/// The bytes come from whatever box the operator pointed a forward zone at, so
/// nothing weaker is enough.
///
/// Accepts only `[a-z0-9._-]` after ASCII-lowercasing `A-Z`; labels are 163
/// bytes and the whole name is at most 253; no label starts or ends with `-`.
/// An empty label is rejected, which also rejects a trailing dot — `formatText`
/// emits none, so one appearing means the reply was malformed.
///
/// Underscore is accepted because real DHCP hostnames carry it. Nothing else
/// outside the set is.
pub fn acceptHostname(text: []const u8) bool {
if (text.len == 0 or text.len > 253) return false;
var label_len: usize = 0;
var prev: u8 = 0;
for (text) |raw| {
const ch = std.ascii.toLower(raw);
if (ch == '.') {
if (label_len == 0) return false;
if (prev == '-') return false;
label_len = 0;
prev = ch;
continue;
}
if (label_len == 0 and ch == '-') return false;
if (!isHostByte(ch)) return false;
label_len += 1;
if (label_len > 63) return false;
prev = ch;
}
if (label_len == 0) return false;
if (prev == '-') return false;
return true;
}
fn isHostByte(ch: u8) bool {
return (ch >= 'a' and ch <= 'z') or (ch >= '0' and ch <= '9') or ch == '_' or ch == '-';
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "reverseName reverses the octets of a v4 address" {
var buf: [max_reverse_len]u8 = undefined;
try testing.expectEqualStrings(
"10.1.168.192.in-addr.arpa",
reverseName(try address.NetAddress.parse("192.168.1.10"), &buf),
);
try testing.expectEqualStrings(
"0.0.0.0.in-addr.arpa",
reverseName(try address.NetAddress.parse("0.0.0.0"), &buf),
);
try testing.expectEqualStrings(
"255.255.255.255.in-addr.arpa",
reverseName(try address.NetAddress.parse("255.255.255.255"), &buf),
);
}
test "reverseName writes the 32-nibble lowercase ip6.arpa form" {
var buf: [max_reverse_len]u8 = undefined;
try testing.expectEqualStrings(
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.d.f.ip6.arpa",
reverseName(try address.NetAddress.parse("fd00::1"), &buf),
);
// Every nibble distinct, so a swapped high/low half would show.
try testing.expectEqualStrings(
"b.a.9.8.7.6.5.4.3.2.1.0.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1.0.f.e.d.c.ip6.arpa",
reverseName(try address.NetAddress.parse("cdef:0123:4567:89ab:cdef:0123:4567:89ab"), &buf),
);
}
test "the v6 form is exactly max_reverse_len bytes and the v4 form is shorter" {
var buf: [max_reverse_len]u8 = undefined;
const v6 = reverseName(try address.NetAddress.parse("cdef:0123:4567:89ab:cdef:0123:4567:89ab"), &buf);
try testing.expectEqual(max_reverse_len, v6.len);
const v4 = reverseName(try address.NetAddress.parse("255.255.255.255"), &buf);
try testing.expect(v4.len < max_reverse_len);
}
test "an IPv4-mapped v6 literal is already canonical, so reverseName sees v4" {
var buf: [max_reverse_len]u8 = undefined;
try testing.expectEqualStrings(
"10.1.168.192.in-addr.arpa",
reverseName(try address.NetAddress.parse("::ffff:192.168.1.10"), &buf),
);
}
test "acceptHostname accepts and rejects ruling 4's table" {
// 254 bytes, every label within 63: only the total length rejects it.
const long_name = "a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 62;
try testing.expectEqual(@as(usize, 254), long_name.len);
const long_label = "a" ** 64;
const cases = [_]struct { text: []const u8, want: bool }{
.{ .text = "", .want = false },
.{ .text = long_name, .want = false },
.{ .text = long_label, .want = false },
.{ .text = "a b", .want = false },
.{ .text = "a\x00b", .want = false },
.{ .text = "héllo", .want = false },
.{ .text = "-x", .want = false },
.{ .text = "x-.y", .want = false },
.{ .text = "a..b", .want = false },
.{ .text = ".a", .want = false },
.{ .text = "a.", .want = false },
.{ .text = "nas-1.lan", .want = true },
.{ .text = "my_printer.home", .want = true },
.{ .text = "x", .want = true },
};
for (cases) |case| {
testing.expectEqual(case.want, acceptHostname(case.text)) catch |err| {
std.debug.print("acceptHostname(\"{s}\")\n", .{case.text});
return err;
};
}
}
test "a reverse name matches the forward zone declared over its reverse space" {
const forward_zones = @import("forward_zones.zig");
var zones = try forward_zones.Zones.build(testing.allocator, &.{
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "0.0.d.f.ip6.arpa", .resolver = "udp://[fd00::1]:53" },
});
defer zones.deinit(testing.allocator);
var buf: [max_reverse_len]u8 = undefined;
const v4 = reverseName(try address.NetAddress.parse("192.168.1.10"), &buf);
try testing.expectEqualStrings("168.192.in-addr.arpa", zones.match(v4).?.zone);
var buf6: [max_reverse_len]u8 = undefined;
const v6 = reverseName(try address.NetAddress.parse("fd00::1"), &buf6);
try testing.expectEqualStrings("0.0.d.f.ip6.arpa", zones.match(v6).?.zone);
// An address outside both declared reverse zones matches nothing, which is
// the `no_zone` outcome: no query is sent to anyone.
const outside = reverseName(try address.NetAddress.parse("10.0.0.1"), &buf);
try testing.expectEqual(@as(?*const forward_zones.Zone, null), zones.match(outside));
}
test "acceptHostname takes the boundary lengths and mixed case" {
try testing.expect(acceptHostname("a" ** 63));
// 253 bytes, the longest name accepted.
try testing.expect(acceptHostname("a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 61));
try testing.expect(acceptHostname("NAS-1.LAN"));
try testing.expect(!acceptHostname("x-"));
try testing.expect(!acceptHostname("a.-b"));
try testing.expect(!acceptHostname("a.b-.c"));
}
File diff suppressed because it is too large Load Diff
+167 -26
View File
@@ -21,6 +21,7 @@
const std = @import("std");
const address = @import("../platform/address.zig");
const client_names = @import("client_names.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const db = @import("../storage/db.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
@@ -135,6 +136,7 @@ pub const Tracker = struct {
io: std.Io,
database: *db.Db,
monitor: ?*disk_monitor.Monitor,
names: ?*client_names.Resolver,
) std.Io.Cancelable!void {
const interval: std.Io.Clock.Duration = .{
.raw = .fromSeconds(flush_interval_s),
@@ -143,12 +145,18 @@ pub const Tracker = struct {
while (true) {
try interval.sleep(io);
const writes_allowed = if (monitor) |m| m.writesAllowed() else true;
self.flushOnce(io, database, writes_allowed);
self.flushOnce(io, database, writes_allowed, names);
}
}
/// One pass: drain the table, write a row per client, and prune on every
/// `prune_every_passes`-th pass.
/// One pass: drain the table, write a row per client, prune on every
/// `prune_every_passes`-th pass, and then learn names for the rows that
/// have none (milestone-25 ruling 1).
///
/// The order of the three steps is fixed and one `now_s` serves all three.
/// Resolving before pruning would spend PTR queries on rows the same pass
/// deletes; resolving before the drain would make a slow resolver delay the
/// writes the pass exists for.
///
/// A gated pass does nothing at all, not even count: the work it skipped is
/// still owed, and the pending addresses it leaves behind are re-tracked by
@@ -161,9 +169,17 @@ pub const Tracker = struct {
/// Only `run` may call this concurrently with itself — the drain buffer is
/// this call's stack, but the pass counter and the prune schedule assume a
/// single caller.
pub fn flushOnce(self: *Tracker, io: std.Io, database: *db.Db, writes_allowed: bool) void {
pub fn flushOnce(
self: *Tracker,
io: std.Io,
database: *db.Db,
writes_allowed: bool,
names: ?*client_names.Resolver,
) void {
// A gated pass skips naming too: naming writes.
if (!writes_allowed) return;
const now_s = std.Io.Clock.real.now(io).toSeconds();
var drained: [max_pending]Pending = undefined;
const batch = self.drain(io, &drained);
@@ -193,18 +209,21 @@ pub const Tracker = struct {
const due = self.passes % prune_every_passes == 0;
self.mutex.unlock(io);
if (!due) return;
const cutoff = std.Io.Clock.real.now(io).toSeconds() - @as(i64, self.retention_days) * 86_400;
if (clients_repo.pruneStale(database, cutoff)) |deleted| {
self.mutex.lockUncancelable(io);
self.stats.pruned += deleted;
self.mutex.unlock(io);
} else |err| {
log.warn("pruning clients before {d} failed: {s}", .{ cutoff, @errorName(err) });
self.mutex.lockUncancelable(io);
self.stats.flush_failures += 1;
self.mutex.unlock(io);
if (due) {
const cutoff = now_s - @as(i64, self.retention_days) * 86_400;
if (clients_repo.pruneStale(database, cutoff)) |deleted| {
self.mutex.lockUncancelable(io);
self.stats.pruned += deleted;
self.mutex.unlock(io);
} else |err| {
log.warn("pruning clients before {d} failed: {s}", .{ cutoff, @errorName(err) });
self.mutex.lockUncancelable(io);
self.stats.flush_failures += 1;
self.mutex.unlock(io);
}
}
if (names) |resolver| resolver.runPass(io, database, now_s);
}
pub fn snapshotStats(self: *Tracker, io: std.Io) Stats {
@@ -277,7 +296,7 @@ test "a client tracked twice before a flush yields one row at the later time" {
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000030));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000030), try lastSeen(&database, "192.168.1.10"));
@@ -305,7 +324,7 @@ test "distinct clients each get a row and ipv6 text is canonical" {
_ = tracker.trackAt(io, address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.10", 53)), 1700000003);
try testing.expectEqual(@as(u32, 3), tracker.pendingClients(io));
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 3), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000003), try lastSeen(&database, "192.168.1.10"));
@@ -343,7 +362,7 @@ test "a full table drops further clients and counts them" {
// A tracked client still refreshes while the table is full, and the flush
// makes room for the next newcomer.
_ = tracker.trackAt(io, .{ .ip4 = .{ 0, 0, 0, 0 } }, 1700000060);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, Tracker.max_pending), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000060), try lastSeen(&database, "0.0.0.0"));
@@ -366,7 +385,7 @@ test "a flush touches a hand-edited row without changing what the operator set"
var tracker: Tracker = .init(30);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000000), try lastSeen(&database, "192.168.1.10"));
@@ -394,7 +413,7 @@ test "a gated pass writes nothing and keeps the pending clients" {
var tracker: Tracker = .init(30);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, monitor.writesAllowed());
tracker.flushOnce(io, &database, monitor.writesAllowed(), null);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
@@ -403,7 +422,7 @@ test "a gated pass writes nothing and keeps the pending clients" {
// Free space recovers and the same pending client lands.
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
tracker.flushOnce(io, &database, monitor.writesAllowed());
tracker.flushOnce(io, &database, monitor.writesAllowed(), null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 1), tracker.passes);
}
@@ -423,7 +442,7 @@ test "a failing upsert counts and leaves the client to be tracked again" {
var tracker: Tracker = .init(30);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
_ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
const stats = tracker.snapshotStats(io);
@@ -433,7 +452,7 @@ test "a failing upsert counts and leaves the client to be tracked again" {
try database.exec("DROP TRIGGER refuse_insert;");
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}
@@ -453,12 +472,12 @@ test "the pass that comes due prunes the clients that went quiet" {
var tracker: Tracker = .init(30);
// Every pass before the due one leaves both rows alone.
for (0..Tracker.prune_every_passes - 1) |_| {
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
}
try testing.expectEqual(@as(i64, 2), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).pruned);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, now - 29 * day), try lastSeen(&database, "10.0.0.2"));
@@ -479,7 +498,7 @@ test "a shorter retention prunes what the default keeps" {
var tracker: Tracker = .init(1);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
@@ -501,6 +520,7 @@ test "the run loop flushes on its interval and returns on cancel" {
io,
&database,
@as(?*disk_monitor.Monitor, null),
@as(?*client_names.Resolver, null),
});
// The first flush is one interval away, so cancelling immediately proves the
// loop starts by sleeping rather than by writing.
@@ -508,3 +528,124 @@ test "the run loop flushes on its interval and returns on cancel" {
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
}
// --- the naming step's place in the pass (milestone-25 ruling 1) ------------
const forward_zones = @import("../local/forward_zones.zig");
const local_tables = @import("local_tables.zig");
const validate = @import("../config/validate.zig");
/// Counts exchanges and times out on every one, and records how many client
/// rows existed when the first exchange was attempted.
const CountingExchange = struct {
var calls: usize = 0;
var database: ?*db.Db = null;
var rows_at_first_call: i64 = -1;
fn reset(target: *db.Db) void {
calls = 0;
database = target;
rows_at_first_call = -1;
}
fn exchange(
_: std.Io,
_: validate.Resolver,
_: []const u8,
_: []u8,
) @import("../upstream/transport.zig").ExchangeError![]u8 {
if (calls == 0) {
rows_at_first_call = clients_repo.countClients(database.?) catch -1;
}
calls += 1;
return error.Timeout;
}
};
fn namingTables(io: std.Io, tables: *local_tables.LocalTables) !void {
tables.swap(io, testing.allocator, .empty, try forward_zones.Zones.build(
testing.allocator,
&.{.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" }},
));
}
test "the drain lands before any exchange, and attempts stop at the cap" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tables: local_tables.LocalTables = .empty;
defer tables.deinit(testing.allocator);
try namingTables(io, &tables);
var names: client_names.Resolver = .init(&tables);
names.exchange_fn = CountingExchange.exchange;
CountingExchange.reset(&database);
var tracker: Tracker = .init(30);
const pending = clients_repo.max_per_pass + 4;
for (0..pending) |i| {
_ = tracker.trackAt(io, .{ .ip4 = .{ 192, 168, 2, @intCast(i) } }, 1700000000);
}
tracker.flushOnce(io, &database, true, &names);
// Every pending row was written before the first exchange went out, which
// is what keeps a slow resolver off the drain.
try testing.expectEqual(@as(i64, @intCast(pending)), CountingExchange.rows_at_first_call);
try testing.expectEqual(clients_repo.max_per_pass, CountingExchange.calls);
try testing.expectEqual(@as(u64, @intCast(pending)), tracker.snapshotStats(io).flushed);
try testing.expectEqual(@as(u64, clients_repo.max_per_pass), names.snapshotStats(io).failed);
}
test "a row the due pass prunes is never asked about" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tables: local_tables.LocalTables = .empty;
defer tables.deinit(testing.allocator);
try namingTables(io, &tables);
var names: client_names.Resolver = .init(&tables);
names.exchange_fn = CountingExchange.exchange;
CountingExchange.reset(&database);
const now = std.Io.Clock.real.now(io).toSeconds();
try clients_repo.upsertSeen(&database, "192.168.1.10", now - 40 * 86_400);
var tracker: Tracker = .init(30);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true, &names);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
try testing.expectEqual(@as(u64, 0), names.snapshotStats(io).attempted);
}
test "a gated pass attempts no naming either" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tables: local_tables.LocalTables = .empty;
defer tables.deinit(testing.allocator);
try namingTables(io, &tables);
var names: client_names.Resolver = .init(&tables);
names.exchange_fn = CountingExchange.exchange;
CountingExchange.reset(&database);
try clients_repo.upsertSeen(&database, "192.168.1.10", 1700000000);
var tracker: Tracker = .init(30);
tracker.flushOnce(io, &database, false, &names);
try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
try testing.expectEqual(@as(u64, 0), names.snapshotStats(io).attempted);
}
+1 -1
View File
@@ -844,7 +844,7 @@ test "S7 case 10: the querying client is materialised as a row" {
// Two queries from one client are one pending entry, and the forced pass
// stands in for the 60-second flush interval (S4 As-built seam).
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
+2
View File
@@ -29,6 +29,8 @@ pub const ddl_v1: [:0]const u8 =
\\ id INTEGER PRIMARY KEY,
\\ ip TEXT NOT NULL UNIQUE, -- canonical text form (v4 dotted / v6 RFC 5952)
\\ name TEXT,
\\ learned_name TEXT,
\\ name_attempt_after INTEGER NOT NULL DEFAULT 0,
\\ group_id INTEGER NOT NULL REFERENCES groups(id),
\\ hand_edited INTEGER NOT NULL DEFAULT 0,
\\ first_seen INTEGER NOT NULL,
+2
View File
@@ -269,6 +269,8 @@ test "a fresh database reaches the baseline with every v1 column and rule kind"
try testing.expectEqual(@as(u32, 1), try migrate(&database));
try testing.expectEqual(@as(u32, 1), target_version);
try testing.expect(try columnExists(&database, "clients", "learned_name"));
try testing.expect(try columnExists(&database, "clients", "name_attempt_after"));
try testing.expect(try columnExists(&database, "upstreams", "tls_name"));
try testing.expect(try columnExists(&database, "blocklist_sources", "exception_count"));
try testing.expect(try columnExists(&database, "blocklist_sources", "skipped_unsupported_count"));
+327 -2
View File
@@ -18,6 +18,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const logger = @import("../logger.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
@@ -122,6 +123,123 @@ pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32 {
return @intCast(@min(deleted, std.math.maxInt(u32)));
}
// --- learned names (milestone 25) ------------------------------------------
//
// `learned_name` and `name_attempt_after` are runtime state beside `last_seen`,
// never configuration: `listClients` does not select them, so an export cannot
// carry them, and the reconcile engine never writes them. The operator's `name`
// is never touched here, and the operator never writes `learned_name` — so
// precedence is a display rule, not a write conflict.
/// How many rows one naming pass may take. The candidate buffer is sized by
/// this, and the SQL's LIMIT repeats it as a literal.
pub const max_per_pass: usize = 16;
/// One selected address. Fixed-size storage because the naming path allocates
/// nothing: `logger.max_client_len` is the bound every address text in this
/// program is sized by.
pub const Candidate = struct {
buf: [logger.max_client_len]u8 = undefined,
len: usize = 0,
pub fn ip(self: *const Candidate) []const u8 {
return self.buf[0..self.len];
}
};
pub const CandidateBuf = [max_per_pass]Candidate;
const resolve_candidates_sql =
\\SELECT ip FROM clients
\\ WHERE (name IS NULL OR name = '') AND name_attempt_after <= ?1
\\ ORDER BY name_attempt_after, ip LIMIT 16
;
/// Fills `out` with the rows whose displayed name would come from learning and
/// whose next attempt is due, and returns how many slots it filled.
///
/// `hand_edited` is deliberately absent from the predicate: display precedence
/// never consults it, so candidacy must not either. A row with a name is
/// skipped whatever its flag, because its learned name would never be shown.
///
/// `name_attempt_after` holds the epoch second before which the row is not
/// attempted again, so the predicate is a plain comparison: 0 (the column
/// default) means eligible now, and no arithmetic can overflow on a corrupt
/// value.
///
/// `error.Mismatch`: a stored `ip` longer than `logger.max_client_len`, which
/// means something other than nxdns wrote the row.
pub fn resolveCandidates(database: *db.Db, out: *CandidateBuf, now_s: i64) db.Error!usize {
var stmt = try database.prepare(resolve_candidates_sql);
defer stmt.deinit();
try stmt.bindInt(1, now_s);
var count: usize = 0;
while (try stmt.step()) : (count += 1) {
const ip = stmt.columnText(0);
if (ip.len > logger.max_client_len) return error.Mismatch;
@memcpy(out[count].buf[0..ip.len], ip);
out[count].len = ip.len;
}
return count;
}
/// What one naming attempt decided about a row's learned name.
pub const LearnedName = union(enum) {
/// The resolver answered a target that passed `acceptHostname`.
store: []const u8,
/// The resolver said the address has no name (NXDOMAIN or NODATA).
clear,
/// Anything else — an outage must not strip names from the dashboard.
keep,
};
pub const NameOutcome = struct {
/// The epoch second before which this row is not attempted again.
attempt_after: i64,
learned: LearnedName,
};
const store_learned_sql =
"UPDATE clients SET learned_name = ?2, name_attempt_after = ?3 WHERE ip = ?1";
const clear_learned_sql =
"UPDATE clients SET learned_name = NULL, name_attempt_after = ?2 WHERE ip = ?1";
const keep_learned_sql =
"UPDATE clients SET name_attempt_after = ?2 WHERE ip = ?1";
/// Records one attempt: always the next attempt time, and the learned name only
/// when the outcome decided one.
///
/// A row deleted between selection and this call makes the UPDATE touch zero
/// rows. That is a no-op by design — the device left, and nothing was learned
/// about nothing — so it is neither an error nor a counted failure.
pub fn noteNameOutcome(database: *db.Db, ip: []const u8, outcome: NameOutcome) db.Error!void {
switch (outcome.learned) {
.store => |text| {
var stmt = try database.prepare(store_learned_sql);
defer stmt.deinit();
try stmt.bindText(1, ip);
try stmt.bindText(2, text);
try stmt.bindInt(3, outcome.attempt_after);
try stmt.exec();
},
.clear => {
var stmt = try database.prepare(clear_learned_sql);
defer stmt.deinit();
try stmt.bindText(1, ip);
try stmt.bindInt(2, outcome.attempt_after);
try stmt.exec();
},
.keep => {
var stmt = try database.prepare(keep_learned_sql);
defer stmt.deinit();
try stmt.bindText(1, ip);
try stmt.bindInt(2, outcome.attempt_after);
try stmt.exec();
},
}
}
pub fn deleteAllClients(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM clients;");
}
@@ -197,6 +315,11 @@ pub const ClientRow = struct {
/// `clients.name` is nullable; a NULL reads as `""`, as it does on the
/// import path.
name: []const u8,
/// The name learned over reverse DNS, or `""` when nothing was learned.
/// Display-only runtime state: `name` wins whenever it is non-empty, and
/// this never reaches an export. `name_attempt_after` is deliberately not
/// here — it is scheduling state with no operator meaning.
learned_name: []const u8,
group_id: i64,
group: []const u8,
hand_edited: bool,
@@ -221,14 +344,16 @@ pub const ClientEdit = struct {
};
const list_client_rows_sql =
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen,
\\ c.learned_name
\\ FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ ORDER BY c.ip
;
const get_client_sql =
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen,
\\ c.learned_name
\\ FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ WHERE c.id = ?1
@@ -263,10 +388,14 @@ fn readClientRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientRow {
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 4);
errdefer gpa.free(group);
// `clients.learned_name` is nullable; NULL reads as "", like `name`.
const learned_name = try stmt.columnTextAlloc(gpa, 8);
errdefer gpa.free(learned_name);
return .{
.id = stmt.columnInt(0),
.ip = ip,
.name = name,
.learned_name = learned_name,
.group_id = stmt.columnInt(3),
.group = group,
.hand_edited = stmt.columnBool(5),
@@ -721,6 +850,202 @@ test "pruneStale spares hand-edited rows however stale" {
try testing.expectEqual(@as(i64, 3), try countClients(&database));
}
// --- learned names ---------------------------------------------------------
/// `columnText` is borrowed until the statement dies, so the value is copied
/// into the caller's buffer.
fn learnedName(database: *db.Db, ip: []const u8, buf: []u8) !?[]const u8 {
var stmt = try database.prepare("SELECT learned_name FROM clients WHERE ip = ?1");
defer stmt.deinit();
try stmt.bindText(1, ip);
try testing.expect(try stmt.step());
if (stmt.isNull(0)) return null;
const text = stmt.columnText(0);
@memcpy(buf[0..text.len], text);
return buf[0..text.len];
}
fn attemptAfter(database: *db.Db, ip: []const u8) !i64 {
var stmt = try database.prepare("SELECT name_attempt_after FROM clients WHERE ip = ?1");
defer stmt.deinit();
try stmt.bindText(1, ip);
try testing.expect(try stmt.step());
return stmt.columnInt(0);
}
test "resolveCandidates selects unnamed rows in attempt-then-ip order" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.30", 1700000000);
try upsertSeen(&database, "192.168.1.10", 1700000000);
try upsertSeen(&database, "192.168.1.20", 1700000000);
// Attempted already, and due later than the other two.
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 500, .learned = .keep });
var buf: CandidateBuf = undefined;
const count = try resolveCandidates(&database, &buf, 1000);
try testing.expectEqual(@as(usize, 3), count);
try testing.expectEqualStrings("192.168.1.20", buf[0].ip());
try testing.expectEqualStrings("192.168.1.30", buf[1].ip());
try testing.expectEqualStrings("192.168.1.10", buf[2].ip());
}
test "resolveCandidates skips named rows whatever their hand_edited flag" {
var database = try openMigrated();
defer database.close();
_ = try insertClientRow(&database, .{ .ip = "192.168.1.7", .name = "printer", .group_id = 1 }, 1);
// Hand-edited but unnamed: grouped, not named, so it still benefits.
_ = try insertClientRow(&database, .{ .ip = "192.168.1.8", .group_id = 1 }, 1);
try upsertSeen(&database, "192.168.1.9", 1);
// An empty name is as unnamed as NULL.
try database.exec("UPDATE clients SET name = '' WHERE ip = '192.168.1.9';");
var buf: CandidateBuf = undefined;
const count = try resolveCandidates(&database, &buf, 1000);
try testing.expectEqual(@as(usize, 2), count);
try testing.expectEqualStrings("192.168.1.8", buf[0].ip());
try testing.expectEqualStrings("192.168.1.9", buf[1].ip());
}
test "a never-attempted row is due at any now_s, and the cutoff is inclusive" {
var database = try openMigrated();
defer database.close();
var buf: CandidateBuf = undefined;
try upsertSeen(&database, "192.168.1.10", 1700000000);
// `now_s` smaller than any cadence constant still selects the DEFAULT 0 row.
try testing.expectEqual(@as(usize, 1), try resolveCandidates(&database, &buf, 0));
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 1700086400, .learned = .keep });
try testing.expectEqual(@as(usize, 1), try resolveCandidates(&database, &buf, 1700086400));
try testing.expectEqual(@as(usize, 0), try resolveCandidates(&database, &buf, 1700086399));
}
test "an extreme name_attempt_after never selects and never traps" {
var database = try openMigrated();
defer database.close();
var buf: CandidateBuf = undefined;
try upsertSeen(&database, "192.168.1.10", 1700000000);
try noteNameOutcome(&database, "192.168.1.10", .{
.attempt_after = std.math.maxInt(i64),
.learned = .keep,
});
try testing.expectEqual(@as(usize, 0), try resolveCandidates(&database, &buf, std.math.maxInt(i64) - 1));
try testing.expectEqual(@as(usize, 1), try resolveCandidates(&database, &buf, std.math.maxInt(i64)));
}
test "resolveCandidates stops at max_per_pass" {
var database = try openMigrated();
defer database.close();
for (0..max_per_pass + 4) |i| {
var ip_buf: [logger.max_client_len]u8 = undefined;
const ip = try std.fmt.bufPrint(&ip_buf, "192.168.2.{d}", .{i});
try upsertSeen(&database, ip, 1700000000);
}
var buf: CandidateBuf = undefined;
try testing.expectEqual(max_per_pass, try resolveCandidates(&database, &buf, 1700000000));
}
test "noteNameOutcome stores, overwrites, clears and keeps" {
var database = try openMigrated();
defer database.close();
var name_buf: [logger.max_client_len]u8 = undefined;
try upsertSeen(&database, "192.168.1.10", 1700000000);
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 10, .learned = .{ .store = "nas.lan" } });
try testing.expectEqualStrings("nas.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
try testing.expectEqual(@as(i64, 10), try attemptAfter(&database, "192.168.1.10"));
// The router is the authority on its own zone: a changed answer wins.
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 20, .learned = .{ .store = "tv.lan" } });
try testing.expectEqualStrings("tv.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
// Keep leaves the stored name and only moves the schedule.
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 30, .learned = .keep });
try testing.expectEqualStrings("tv.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
try testing.expectEqual(@as(i64, 30), try attemptAfter(&database, "192.168.1.10"));
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 40, .learned = .clear });
try testing.expectEqual(@as(?[]const u8, null), try learnedName(&database, "192.168.1.10", &name_buf));
try testing.expectEqual(@as(i64, 40), try attemptAfter(&database, "192.168.1.10"));
}
test "noteNameOutcome on a row that no longer exists is a no-op" {
var database = try openMigrated();
defer database.close();
try noteNameOutcome(&database, "192.168.1.99", .{ .attempt_after = 10, .learned = .{ .store = "gone.lan" } });
try noteNameOutcome(&database, "192.168.1.99", .{ .attempt_after = 10, .learned = .clear });
try noteNameOutcome(&database, "192.168.1.99", .{ .attempt_after = 10, .learned = .keep });
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "a learned name reads back on ClientRow and a NULL reads as the empty string" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.10", 1700000000);
try upsertSeen(&database, "192.168.1.11", 1700000000);
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 10, .learned = .{ .store = "nas.lan" } });
var rows = try listClientRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientRows(testing.allocator, rows.items);
try testing.expectEqualStrings("nas.lan", rows.items[0].learned_name);
try testing.expectEqualStrings("", rows.items[1].learned_name);
const fetched = (try getClient(&database, testing.allocator, rows.items[0].id)).?;
defer freeClientRow(testing.allocator, fetched);
try testing.expectEqualStrings("nas.lan", fetched.learned_name);
}
test "updateClient leaves learned_name alone and removes the row from candidacy" {
var database = try openMigrated();
defer database.close();
var name_buf: [logger.max_client_len]u8 = undefined;
try upsertSeen(&database, "192.168.1.10", 1700000000);
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 0, .learned = .{ .store = "nas.lan" } });
const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.10'");
try updateClient(&database, id, .{ .name = "the nas", .group_id = 1 });
try testing.expectEqualStrings("nas.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
var buf: CandidateBuf = undefined;
try testing.expectEqual(@as(usize, 0), try resolveCandidates(&database, &buf, 1700000000));
}
test "an export is byte-identical across a learned name landing" {
const export_mod = @import("../../config/export.zig");
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try upsertSeen(&database, "192.168.1.99", 1700000000);
var before: std.Io.Writer.Allocating = .init(testing.allocator);
defer before.deinit();
try export_mod.writeToWriter(testing.allocator, &database, &before.writer);
try noteNameOutcome(&database, "192.168.1.99", .{
.attempt_after = 1700086400,
.learned = .{ .store = "phone.lan" },
});
var after: std.Io.Writer.Allocating = .init(testing.allocator);
defer after.deinit();
try export_mod.writeToWriter(testing.allocator, &database, &after.writer);
try testing.expectEqualStrings(before.written(), after.written());
}
test "client_prefixes round-trip in prefix order with group names resolved" {
var database = try openMigrated();
defer database.close();
+2
View File
@@ -71,6 +71,7 @@ comptime {
_ = @import("local/records.zig");
_ = @import("local/forward_zones.zig");
_ = @import("local/forward_client.zig");
_ = @import("local/reverse_name.zig");
_ = @import("cache/dns_cache.zig");
_ = @import("server/rate_limiter.zig");
_ = @import("storage/repositories/queries_repo.zig");
@@ -81,6 +82,7 @@ comptime {
_ = @import("storage/retention.zig");
_ = @import("storage/phase6_integration_test.zig");
_ = @import("server/pause.zig");
_ = @import("server/client_names.zig");
_ = @import("server/clients.zig");
_ = @import("server/shutdown.zig");
_ = @import("server/phase7_integration_test.zig");
+19
View File
@@ -24,6 +24,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const cert_store = @import("../server/cert_store.zig");
const client_names = @import("../server/client_names.zig");
const clients = @import("../server/clients.zig");
const dns_cache = @import("../cache/dns_cache.zig");
const dns_handler = @import("../server/handler.zig");
@@ -122,6 +123,7 @@ pub const Sample = struct {
cache: ?CacheSample = null,
limiter: ?LimiterSample = null,
tracker: ?TrackerSample = null,
client_names: ?client_names.Resolver.Stats = null,
retention: ?retention_mod.Stats = null,
blocklist: ?BlocklistSample = null,
disk: ?DiskSample = null,
@@ -193,6 +195,8 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
.pending_clients = tracker.pendingClients(io),
};
if (state.client_names) |names| sample.client_names = names.snapshotStats(io);
if (state.retention) |retention| sample.retention = retention.snapshotStats();
if (state.manager) |manager| {
@@ -340,6 +344,10 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
);
}
if (sample.client_names) |names| {
try counterGroup(w, "nxdns_client_names_", "Learned client name counter", names);
}
if (sample.retention) |retention| {
try counterGroup(w, "nxdns_retention_", "Query log retention counter", retention);
}
@@ -594,6 +602,7 @@ fn writeLabelValue(w: *std.Io.Writer, value: []const u8) std.Io.Writer.Error!voi
// tests
// ---------------------------------------------------------------------------
const local_tables = @import("../server/local_tables.zig");
const logger_mod = @import("../storage/logger.zig");
const testing = std.testing;
@@ -645,6 +654,7 @@ test "a full sample renders the whole exposition, byte for byte" {
.stats = .{ .tracked = 4, .flushed = 3, .dropped_full = 0, .pruned = 1, .flush_failures = 0 },
.pending_clients = 2,
},
.client_names = .{ .attempted = 6, .answered = 3, .nxdomain = 1, .no_zone = 2, .invalid = 0, .failed = 0, .read_failures = 0, .write_failures = 1 },
.retention = .{ .passes = 7, .rows_pruned = 100, .checkpoints = 7, .vacuums = 1 },
.blocklist = .{ .refreshes_gated = 2, .generation = 4 },
.disk = .{
@@ -681,6 +691,9 @@ test "a full sample renders the whole exposition, byte for byte" {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_tracked_clients 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_dropped_full_total 0\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_pending 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_no_zone_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_answered_total 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_write_failures_total 1\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_retention_rows_pruned_total 100\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_refreshes_gated_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_generation 4\n"));
@@ -1242,12 +1255,17 @@ test "collect reads the live counters of the components it is given" {
var tracker: clients.Tracker = .init(30);
var retention: retention_mod.Retention = .init(.{});
var tables: local_tables.LocalTables = .empty;
var names: client_names.Resolver = .init(&tables);
names.stats.no_zone = 4;
names.stats.attempted = 4;
var state: server.WebState = .{
.gpa = testing.allocator,
.handler = &handler,
.logger = &query_logger,
.tracker = &tracker,
.client_names = &names,
.retention = &retention,
};
@@ -1263,6 +1281,7 @@ test "collect reads the live counters of the components it is given" {
try testing.expectEqual(@as(u64, 3), sample.limiter.?.stats.refused);
try testing.expectEqual(@as(u64, 90), sample.logger.rows_written);
try testing.expectEqual(@as(u64, 0), sample.tracker.?.pending_clients);
try testing.expectEqual(@as(u64, 4), sample.client_names.?.no_zone);
try testing.expectEqual(@as(u64, 0), sample.retention.?.passes);
try testing.expectEqual(@as(?BlocklistSample, null), sample.blocklist);
try testing.expectEqual(@as(usize, 0), sample.upstreams.len);
+8 -1
View File
@@ -2024,13 +2024,20 @@ components:
Client:
type: object
required: [id, ip, name, group_id, group, hand_edited, first_seen, last_seen]
required: [id, ip, name, learned_name, group_id, group, hand_edited, first_seen, last_seen]
properties:
id: { type: integer }
ip: { type: string }
name:
type: string
description: Empty when the client was never named.
learned_name:
type: string
description: |
The name learned over reverse DNS, or empty when nothing was
learned. Display-only runtime state: `name` wins whenever it is
non-empty, and a learned name never appears in an export. The
server writes it; a client cannot.
group_id: { type: integer }
group: { type: string }
hand_edited: { type: boolean }
+3
View File
@@ -25,6 +25,7 @@ const address = @import("../platform/address.zig");
const api_limiter = @import("api_limiter.zig");
const auth = @import("auth.zig");
const cert_store = @import("../server/cert_store.zig");
const client_names = @import("../server/client_names.zig");
const clients = @import("../server/clients.zig");
const db = @import("../storage/db.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
@@ -130,6 +131,8 @@ pub const WebState = struct {
handler: ?*dns_handler.Handler = null,
pause: ?*pause_mod.Pause = null,
tracker: ?*clients.Tracker = null,
/// The learned-name resolver, for `metrics.collect` (milestone-25 ruling 9).
client_names: ?*client_names.Resolver = null,
manager: ?*manager_mod.Manager = null,
pool: ?*pool_mod.Pool = null,
monitor: ?*disk_monitor.Monitor = null,
+4
View File
@@ -35,3 +35,7 @@ Regenerate with:
```sh
openssl ecparam -name prime256v1 -genkey -noout -out mismatched_key.pem
```
`container-smoke.zon` is the smallest configuration that lets the daemon reach
`serving`. `tools/container_check.zig` copies it into the smoke container, which
is the only thing that reads it.
+4
View File
@@ -0,0 +1,4 @@
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}
+997
View File
@@ -0,0 +1,997 @@
//! The container acceptance gate for `.gitea/workflows/gates.yml`, and the one
//! place that reads the version out of `build.zig.zon` for that workflow.
//!
//! It exists for the reason `tools/release.zig` exists (milestone-14 deviation
//! 24): decision-bearing logic that only lives inside a YAML `run:` block cannot
//! be type-checked, run on a laptop, or covered by a test. What moved in here
//! was a `sed` parse of `build.zig.zon` duplicated across two jobs, the naming
//! of this run's docker objects, the image-contents assertion, and a smoke test
//! whose retry loop advertised a 30-second budget while actually allowing up to
//! 210 seconds of connect timeouts.
//!
//! The workflow keeps what is genuinely the runner's: the job graph, SHA-pinned
//! actions, artifact upload and download, and an `always()` cleanup backstop for
//! the case where this program never runs at all.
//!
//! Usage:
//!
//! container_check version read build.zig.zon into $GITHUB_OUTPUT/$GITHUB_ENV
//! container_check gate build the image and run the full acceptance
//!
//! Configuration comes from the environment, never from arguments, on the same
//! grounds as release.zig: `argv` is world-readable through `/proc`.
//!
//! ## Why the plumbing below is a copy of release.zig's and not a shared module
//!
//! release.zig is deliberately one self-contained file, and so is this one. A
//! shared `tools/ci.zig` would be the right move at three callers; at two it
//! buys a coupling between the release path and the gate path that neither
//! wants. Revisit when a third tool appears.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Io = std.Io;
const max_input_bytes = 1 << 30;
/// The members of the image that must equal the packaged copies. 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, before this gate, nothing checked it. Comparing against the
/// staged payload rather than merely asserting the paths exist costs nothing and
/// catches a stale or empty copy.
const image_members = [_][]const u8{ "nxdns", "LICENSE", "THIRD-PARTY-NOTICES" };
/// Native triple only: this gate builds a single-architecture image. release.yml
/// covers both platforms against the pushed multi-arch index.
const native_triple = "x86_64-linux-musl";
/// Leaked objects have to be discoverable by something other than a random name,
/// because the name only exists in a `$GITHUB_ENV` file that dies with the
/// runner pod. The label is written onto both the image and the container.
const ownership_label = "net.mial.nxdns.ci";
/// The health probe's absolute wall-clock budget. The shell loop this replaces
/// claimed 30 seconds and meant "30 iterations of up to three 2-second connect
/// timeouts plus a 1-second sleep", which is a real ceiling near 210 seconds. A
/// deadline is the honest shape: the container either answers within a minute of
/// being started or the gate has found something.
const probe_budget_ns: u64 = 60 * std.time.ns_per_s;
const probe_interval: Io.Clock.Duration = .{ .raw = .fromSeconds(1), .clock = .awake };
/// The ceiling on one curl attempt; see `attemptSeconds`. Ten seconds is long
/// enough that a merely slow first response is not mistaken for a wedge, and
/// short enough that the budget still buys several attempts.
const max_attempt_seconds: u64 = 10;
/// The OCI `created` label value. Fixed rather than the current time: this image
/// is never published, and a timestamp would be the only thing that changes
/// between two builds of the same commit.
const created_label = "1970-01-01T00:00:00Z";
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const Ctx = struct {
arena: Allocator,
gpa: Allocator,
io: Io,
env: *std.process.Environ.Map,
out: *Io.Writer,
failures: usize = 0,
fn pass(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void {
ctx.out.print("container-check: PASS " ++ check ++ ": " ++ template ++ "\n", args) catch {};
ctx.out.flush() catch {};
}
fn note(ctx: *Ctx, comptime template: []const u8, args: anytype) void {
ctx.out.print("container-check: " ++ template ++ "\n", args) catch {};
ctx.out.flush() catch {};
}
/// Records a failure and keeps going. Used where reporting every member of a
/// comparison is more useful than stopping at the first mismatch.
fn soft(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) void {
ctx.failures += 1;
ctx.out.print("container-check: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {};
ctx.out.flush() catch {};
}
/// Names the check and exits. Nothing that holds a live container calls
/// this: `std.process.exit` does not run `defer`, so the cleanup paths
/// unwind through `error.CheckFailed` instead.
fn fatal(ctx: *Ctx, comptime check: []const u8, comptime template: []const u8, args: anytype) noreturn {
ctx.out.print("container-check: FAIL " ++ check ++ ": " ++ template ++ "\n", args) catch {};
ctx.out.flush() catch {};
std.process.exit(1);
}
fn get(ctx: *Ctx, name: []const u8) []const u8 {
return ctx.env.get(name) orelse "";
}
fn require(ctx: *Ctx, name: []const u8) []const u8 {
const value = ctx.get(name);
if (value.len == 0) ctx.fatal("environment", "{s} is empty or unset", .{name});
return value;
}
fn fmt(ctx: *Ctx, comptime template: []const u8, args: anytype) []const u8 {
return std.fmt.allocPrint(ctx.arena, template, args) catch @panic("OOM");
}
};
/// A phase that already reported why it failed.
const CheckFailed = error.CheckFailed;
pub fn main(init: std.process.Init) !u8 {
const arena = init.arena.allocator();
const argv = try init.minimal.args.toSlice(arena);
var out_buffer: [8192]u8 = undefined;
var out = Io.File.stdout().writerStreaming(init.io, &out_buffer);
var ctx: Ctx = .{
.arena = arena,
.gpa = init.gpa,
.io = init.io,
.env = init.environ_map,
.out = &out.interface,
};
if (argv.len < 2) std.process.fatal("usage: container_check <subcommand>; see tools/container_check.zig", .{});
const command = argv[1];
const result = dispatch(&ctx, command);
ctx.out.flush() catch {};
result catch |err| switch (err) {
error.CheckFailed => return 1,
else => return err,
};
return if (ctx.failures == 0) 0 else 1;
}
fn dispatch(ctx: *Ctx, command: []const u8) !void {
if (std.mem.eql(u8, command, "version")) return version(ctx);
if (std.mem.eql(u8, command, "gate")) return gate(ctx);
std.process.fatal("unknown subcommand '{s}'; see tools/container_check.zig", .{command});
}
// ---------------------------------------------------------------------------
// Pure helpers. Everything below this line that can be tested without a docker
// daemon is tested at the foot of this file.
// ---------------------------------------------------------------------------
/// The suffix that separates this run's docker objects from every other run's.
///
/// Image tags, container names and published host ports are all daemon-global.
/// gates.yml is called by both ci.yml and release.yml, and the self-hosted
/// runners share one docker daemon, so the fixed `nxdns:ci` and `nxdns-smoke`
/// this replaced made two concurrent runs collide: the second `docker create
/// --name` fails outright, and worse, whichever run finishes first deletes the
/// other's container mid-test. The run id alone is not enough either, because
/// two *repositories* on the same daemon can issue the same one — hence the
/// random bytes.
fn objectSuffix(arena: Allocator, run_id: []const u8, attempt: []const u8, random_hex: []const u8) []const u8 {
const id = if (run_id.len != 0) run_id else "0";
const try_number = if (attempt.len != 0) attempt else "1";
return std.fmt.allocPrint(arena, "{s}-{s}-{s}", .{ id, try_number, random_hex }) catch @panic("OOM");
}
/// `<repo>/<run_id>/<attempt>`. Label only: the repository component never
/// enters a docker object NAME, so nothing here needs sanitising for the
/// stricter name grammar.
fn labelValue(arena: Allocator, repository: []const u8, run_id: []const u8, attempt: []const u8) []const u8 {
const id = if (run_id.len != 0) run_id else "0";
const try_number = if (attempt.len != 0) attempt else "1";
return std.fmt.allocPrint(arena, "{s}/{s}/{s}", .{ repository, id, try_number }) catch @panic("OOM");
}
/// The staged directory the image is hashed against, as the package job's
/// upload laid it out under `zig-out/dist`.
fn stagePath(arena: Allocator, version_text: []const u8) []const u8 {
return std.fmt.allocPrint(
arena,
"zig-out/dist/stage/nxdns-{s}-{s}",
.{ version_text, native_triple },
) catch @panic("OOM");
}
/// The network names in a `docker inspect <id>` payload, which is a JSON array
/// of one object. An empty result means this program is not running inside a
/// container the daemon knows about, which is the host-runner topology.
fn inspectNetworks(arena: Allocator, payload: []const u8) []const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch return list.items;
const items = switch (parsed) {
.array => |array| array.items,
else => return list.items,
};
for (items) |item| {
if (item != .object) continue;
const settings = item.object.get("NetworkSettings") orelse continue;
if (settings != .object) continue;
const networks = settings.object.get("Networks") orelse continue;
if (networks != .object) continue;
var it = networks.object.iterator();
while (it.next()) |entry| list.append(arena, entry.key_ptr.*) catch @panic("OOM");
}
return list.items;
}
/// The container's address on the first network it joined. Diagnostic only —
/// see `probeUrl` for why it is never a passing route.
fn inspectAddress(arena: Allocator, payload: []const u8) []const u8 {
const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, payload, .{}) catch return "";
const items = switch (parsed) {
.array => |array| array.items,
else => return "",
};
for (items) |item| {
if (item != .object) continue;
const settings = item.object.get("NetworkSettings") orelse continue;
if (settings != .object) continue;
const networks = settings.object.get("Networks") orelse continue;
if (networks != .object) continue;
var it = networks.object.iterator();
while (it.next()) |entry| {
if (entry.value_ptr.* != .object) continue;
const address = entry.value_ptr.object.get("IPAddress") orelse continue;
if (address == .string and address.string.len != 0) return address.string;
}
}
return "";
}
/// `docker port <name> 8080/tcp` prints one `host:port` line per binding. The
/// host half can be an IPv6 literal, so the port is what follows the LAST colon.
fn parsePublishedPort(output: []const u8) []const u8 {
var lines = std.mem.splitScalar(u8, output, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
const at = std.mem.lastIndexOfScalar(u8, line, ':') orelse continue;
const port = line[at + 1 ..];
if (port.len == 0) continue;
return port;
}
return "";
}
/// The `--max-time` a single curl attempt gets, in whole seconds.
///
/// `--connect-timeout` bounds only the TCP connect. A handler that accepts the
/// connection and then wedges — a deadlocked writer, a listener up before the
/// database is — holds curl open forever, and the loop's own deadline never gets
/// to run, because it is only consulted between attempts. So each attempt is
/// capped at the lesser of `max_attempt_seconds` and whatever is left of the
/// budget, and never below one second: a zero would mean "no limit" to curl,
/// which is the exact failure being defended against.
fn attemptSeconds(started_ns: i96, now_ns: i96, budget_ns: u64) u64 {
const spent: u128 = if (now_ns <= started_ns) 0 else @intCast(now_ns - started_ns);
const left: u128 = if (spent >= budget_ns) 0 else budget_ns - spent;
const left_seconds: u64 = @intCast(left / std.time.ns_per_s);
return @max(1, @min(max_attempt_seconds, left_seconds));
}
/// Which of several joined networks the smoke container should join.
///
/// The runner's per-job network is always user-defined, so the three built-in
/// names can never be it. Picking one of them would produce a container whose
/// name does not resolve — docker's embedded DNS serves user-defined networks
/// only — and the failure would read as "the daemon never came up" rather than
/// as a wrong network. Returns null when nothing qualifies, which leaves the
/// caller with the first entry and a note in the log.
fn preferredNetwork(networks: []const []const u8) ?[]const u8 {
const builtin = [_][]const u8{ "bridge", "host", "none" };
for (networks) |candidate| {
var is_builtin = false;
for (builtin) |name| {
if (std.mem.eql(u8, candidate, name)) is_builtin = true;
}
if (!is_builtin) return candidate;
}
return null;
}
fn deadlineExpired(started_ns: i96, now_ns: i96, budget_ns: u64) bool {
if (now_ns <= started_ns) return false;
return @as(u128, @intCast(now_ns - started_ns)) >= budget_ns;
}
fn elapsedSeconds(started_ns: i96, now_ns: i96) f64 {
if (now_ns <= started_ns) return 0;
const delta: f64 = @floatFromInt(@as(i64, @intCast(now_ns - started_ns)));
return delta / @as(f64, std.time.ns_per_s);
}
/// The version field of `build.zig.zon`, parsed exactly as
/// `tools/verify_dist.zig`'s `checkZonVersion` parses it. Two readers of one
/// file must not disagree about what it says, and the `sed` expression this
/// replaces disagreed with the zon grammar in every case involving a comment.
fn parseZonVersion(arena: Allocator, source: [:0]const u8) ![]const u8 {
const Manifest = struct { version: []const u8 };
const manifest = try std.zon.parse.fromSliceAlloc(Manifest, arena, source, null, .{
.ignore_unknown_fields = true,
.free_on_error = false,
});
return manifest.version;
}
fn sha256Hex(bytes: []const u8) [64]u8 {
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
return std.fmt.bytesToHex(digest, .lower);
}
// ---------------------------------------------------------------------------
// Process and file plumbing
// ---------------------------------------------------------------------------
const Run = struct {
code: u8,
stdout: []const u8,
stderr: []const u8,
fn ok(run: Run) bool {
return run.code == 0;
}
fn combined(run: Run, arena: Allocator) []const u8 {
return std.mem.concat(arena, u8, &.{ run.stdout, run.stderr }) catch @panic("OOM");
}
fn trimmedStdout(run: Run) []const u8 {
return std.mem.trim(u8, run.stdout, " \t\r\n");
}
};
const RunOptions = struct {
/// Extra environment for the child only. `DOCKER_BUILDKIT` travels this way
/// so no sibling process inherits it.
env: []const [2][]const u8 = &.{},
cwd: ?[]const u8 = null,
};
fn runCommand(ctx: *Ctx, argv: []const []const u8, options: RunOptions) !Run {
var child_env: ?std.process.Environ.Map = null;
defer if (child_env) |*map| map.deinit();
if (options.env.len != 0) {
var map = try ctx.env.clone(ctx.gpa);
for (options.env) |pair| try map.put(pair[0], pair[1]);
child_env = map;
}
const cwd: std.process.Child.Cwd = if (options.cwd) |path| .{ .path = path } else .inherit;
const result = try std.process.run(ctx.gpa, ctx.io, .{
.argv = argv,
.cwd = cwd,
.environ_map = if (child_env) |*map| map else null,
.stdout_limit = .limited(max_input_bytes),
.stderr_limit = .limited(max_input_bytes),
});
defer ctx.gpa.free(result.stdout);
defer ctx.gpa.free(result.stderr);
return .{
.code = termCode(result.term),
.stdout = try ctx.arena.dupe(u8, result.stdout),
.stderr = try ctx.arena.dupe(u8, result.stderr),
};
}
fn termCode(term: std.process.Child.Term) u8 {
return switch (term) {
.exited => |code| code,
else => 255,
};
}
/// Runs a command and reports its output before failing. Used wherever a
/// non-zero exit is a gate failure rather than information.
fn mustRun(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8, options: RunOptions) ![]const u8 {
const run = try runCommand(ctx, argv, options);
if (!run.ok()) {
ctx.soft(check, "`{s}` exited {d}: {s}", .{
argv[0], run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
});
return CheckFailed;
}
return run.stdout;
}
fn readFile(ctx: *Ctx, path: []const u8) ![]const u8 {
return Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes));
}
fn writeFileMode(ctx: *Ctx, path: []const u8, bytes: []const u8, mode: std.posix.mode_t) !void {
var handle = try Io.Dir.cwd().createFile(ctx.io, path, .{});
defer handle.close(ctx.io);
try handle.writeStreamingAll(ctx.io, bytes);
// After the write, not through the creation mode, which `open(2)` masks
// with the process umask.
try handle.setPermissions(ctx.io, .fromMode(mode));
}
/// `$GITHUB_ENV` and `$GITHUB_OUTPUT` are append-only files the runner reads
/// after the step. There is no append mode on `Io.Dir`, and both files are small.
fn appendLine(ctx: *Ctx, env_name: []const u8, line: []const u8) !void {
const path = ctx.get(env_name);
if (path.len == 0) {
ctx.note("{s} is unset; not recording `{s}`", .{ env_name, line });
return;
}
const existing = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch "";
const separator: []const u8 = if (existing.len == 0 or existing[existing.len - 1] == '\n') "" else "\n";
const merged = try std.mem.concat(ctx.arena, u8, &.{ existing, separator, line, "\n" });
try writeFileMode(ctx, path, merged, 0o644);
}
fn runnerTemp(ctx: *Ctx) []const u8 {
const temp = ctx.get("RUNNER_TEMP");
return if (temp.len != 0) temp else "/tmp";
}
/// A fresh directory under `RUNNER_TEMP`. The name is claimed by an exclusive
/// create rather than by a random suffix: a collision is a retry, not a silent
/// share.
///
/// `createDirPathStatus`, not `createDirPath`, because the latter has `mkdir -p`
/// semantics — it succeeds on a directory that is already there and never
/// reports `error.PathAlreadyExists`, which made the retry below dead code. This
/// program then extracted the image into a stale or concurrently-held directory
/// and deleted the whole tree on the way out.
fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 {
const base = runnerTemp(ctx);
var attempt: usize = 0;
while (attempt < 4096) : (attempt += 1) {
const path = ctx.fmt("{s}/{s}.{s}-{d}", .{
base, prefix, ctx.get("GITHUB_RUN_ID"), attempt,
});
const status = try Io.Dir.cwd().createDirPathStatus(ctx.io, path, .default_dir);
if (status == .existed) continue;
var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true });
defer dir.close(ctx.io);
try dir.setPermissions(ctx.io, .fromMode(0o700));
return path;
}
ctx.fatal("temp-dir", "cannot create a {s}.* directory under {s}", .{ prefix, base });
}
// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------
/// 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 gate builds
/// the version the repository declares. This runs once, in the package job, and
/// the container job receives the answer as a job output — the two `sed` blocks
/// it replaced were two independent parses that could in principle disagree.
fn version(ctx: *Ctx) !void {
const source = Io.Dir.cwd().readFileAllocOptions(
ctx.io,
"build.zig.zon",
ctx.arena,
.limited(max_input_bytes),
.of(u8),
0,
) catch |err| {
ctx.soft("zon-version", "cannot read build.zig.zon: {t}", .{err});
return CheckFailed;
};
const declared = parseZonVersion(ctx.arena, source) catch |err| {
ctx.soft("zon-version", "cannot parse build.zig.zon: {t}", .{err});
return CheckFailed;
};
if (declared.len == 0) {
ctx.soft("zon-version", "build.zig.zon declares an empty version", .{});
return CheckFailed;
}
try appendLine(ctx, "GITHUB_OUTPUT", ctx.fmt("version={s}", .{declared}));
try appendLine(ctx, "GITHUB_ENV", ctx.fmt("CI_VERSION={s}", .{declared}));
ctx.pass("zon-version", "'{s}'", .{declared});
}
/// The four bytes that keep two runs apart, from the `Io` interface's CSPRNG. A
/// clock-seeded PRNG would defeat the purpose: two jobs starting in the same
/// millisecond are exactly the collision these bytes exist to prevent.
fn randomBytes(ctx: *Ctx) [4]u8 {
var bytes: [4]u8 = undefined;
ctx.io.random(&bytes);
return bytes;
}
const Names = struct {
image: []const u8,
container: []const u8,
label: []const u8,
};
/// The identity of this run's docker objects, published to `$GITHUB_ENV` before
/// anything is built. The order matters: the workflow's `always()` backstop can
/// only remove what it can name, and a build that dies halfway still leaves
/// layers behind.
fn claimNames(ctx: *Ctx) !Names {
const random_hex = std.fmt.bytesToHex(randomBytes(ctx), .lower);
const suffix = objectSuffix(
ctx.arena,
ctx.get("GITHUB_RUN_ID"),
ctx.get("GITHUB_RUN_ATTEMPT"),
&random_hex,
);
const names: Names = .{
.image = ctx.fmt("nxdns:ci-{s}", .{suffix}),
.container = ctx.fmt("nxdns-smoke-{s}", .{suffix}),
.label = labelValue(
ctx.arena,
ctx.require("GITHUB_REPOSITORY"),
ctx.get("GITHUB_RUN_ID"),
ctx.get("GITHUB_RUN_ATTEMPT"),
),
};
try appendLine(ctx, "GITHUB_ENV", ctx.fmt("CI_IMAGE={s}", .{names.image}));
try appendLine(ctx, "GITHUB_ENV", ctx.fmt("SMOKE_NAME={s}", .{names.container}));
ctx.note("image {s}, container {s}, label {s}={s}", .{
names.image, names.container, ownership_label, names.label,
});
return names;
}
fn gate(ctx: *Ctx) !void {
const version_text = ctx.require("CI_VERSION");
const revision = ctx.require("GITHUB_SHA");
const names = try claimNames(ctx);
try buildImage(ctx, names, version_text, revision);
defer _ = runCommand(ctx, &.{ "docker", "image", "rm", "-f", names.image }, .{}) catch {};
_ = try mustRun(ctx, "image-version-command", &.{ "docker", "run", "--rm", names.image, "version" }, .{});
ctx.pass("image-version-command", "`{s} version` exited 0", .{names.image});
try assertContents(ctx, names, version_text);
try smoke(ctx, names);
}
/// The build args carry the OCI label values (ruling 6); release.yml passes the
/// same three and then asserts the resulting `org.opencontainers.image.version`
/// label. BuildKit is not optional here: the builder stage is pinned to
/// `$BUILDPLATFORM`, which the classic builder does not define, so
/// `DOCKER_BUILDKIT=0` fails at the first `FROM`.
fn buildImage(ctx: *Ctx, names: Names, version_text: []const u8, revision: []const u8) !void {
const buildkit = [_][2][]const u8{.{ "DOCKER_BUILDKIT", "1" }};
_ = try mustRun(ctx, "image-build", &.{
"docker", "build",
"-t", names.image,
"-f", "deploy/docker/Dockerfile",
"--label", ctx.fmt("{s}={s}", .{ ownership_label, names.label }),
"--build-arg", ctx.fmt("VERSION={s}", .{version_text}),
"--build-arg", ctx.fmt("REVISION={s}", .{revision}),
"--build-arg", ctx.fmt("CREATED={s}", .{created_label}),
".",
}, .{ .env = &buildkit });
ctx.pass("image-build", "{s} built from deploy/docker/Dockerfile", .{names.image});
}
/// See `image_members` for what this asserts and why the licence files are in
/// it. The comparison hashes file contents in this process rather than shelling
/// out to `sha256sum`, and it never compares modes: the artifact zip round-trip
/// drops the executable bit, which is survivable only because the Dockerfile
/// chmods the binary itself and `verify-dist` already asserted the archive modes
/// on the originals in the package job.
fn assertContents(ctx: *Ctx, names: Names, version_text: []const u8) !void {
const stage = stagePath(ctx.arena, version_text);
var stage_dir = Io.Dir.cwd().openDir(ctx.io, stage, .{}) catch |err| {
ctx.soft("image-contents", "the staged payload '{s}' is missing: {t}", .{ stage, err });
return CheckFailed;
};
stage_dir.close(ctx.io);
const out = try makeTempDir(ctx, "container-check");
defer Io.Dir.cwd().deleteTree(ctx.io, out) catch {};
// A stopped container, not a running one: `docker cp` reads the filesystem
// of an image's container without ever starting its entrypoint.
const created = try mustRun(ctx, "image-contents", &.{ "docker", "create", names.image }, .{});
const cid = std.mem.trim(u8, created, " \t\r\n");
defer _ = runCommand(ctx, &.{ "docker", "rm", "-f", cid }, .{}) catch {};
const before = ctx.failures;
for (image_members) |member| {
_ = try mustRun(ctx, "image-contents", &.{
"docker", "cp", ctx.fmt("{s}:/{s}", .{ cid, member }), ctx.fmt("{s}/{s}", .{ out, member }),
}, .{});
const want = sha256Hex(try readFile(ctx, ctx.fmt("{s}/{s}", .{ stage, member })));
const got = sha256Hex(try readFile(ctx, ctx.fmt("{s}/{s}", .{ out, member })));
if (std.mem.eql(u8, &want, &got)) {
ctx.pass("image-contents", "/{s} matches ({s})", .{ member, &got });
} else {
ctx.soft("image-contents", "/{s} DIFFERS: image {s}, packaged {s}", .{ member, &got, &want });
}
}
if (ctx.failures != before) {
ctx.note("the image does not carry the artifacts this run packaged", .{});
return CheckFailed;
}
}
/// Where the health probe connects, and why there is exactly one answer.
///
/// The gates job itself runs in a container on the runner's per-job network. A
/// published port binds on the *daemon's* host, not in here, and docker does not
/// route between the default bridge and that network — a bridge-IP curl hangs to
/// its connect timeout. So when this program is containerised, the smoke
/// container joins the job's own network, where its name resolves and its port
/// is reachable, and that name is the sole passing route. On a host runner the
/// inspect finds no container and the published port is the sole passing route.
///
/// The container's own bridge address used to be a third fallback. It is now
/// diagnostic logging only: as a passing route it made the in-container topology
/// silently accept a probe that had actually taken the path a real deployment
/// never takes, which is the opposite of what a gate is for.
const Topology = union(enum) {
in_network: []const u8,
published,
};
fn detectTopology(ctx: *Ctx) Topology {
var host_buffer: [std.posix.HOST_NAME_MAX]u8 = undefined;
const hostname = std.posix.gethostname(&host_buffer) catch {
ctx.note("no hostname; assuming a host runner and probing the published port", .{});
return .published;
};
const run = runCommand(ctx, &.{ "docker", "inspect", hostname }, .{}) catch |err| {
ctx.note("could not run `docker inspect {s}` ({t}); probing the published port", .{ hostname, err });
return .published;
};
if (!run.ok()) {
ctx.note("`docker inspect {s}` found no container; this is a host runner", .{hostname});
return .published;
}
const networks = inspectNetworks(ctx.arena, run.stdout);
if (networks.len == 0) {
ctx.note("this job's container has joined no network; probing the published port", .{});
return .published;
}
const chosen = preferredNetwork(networks) orelse networks[0];
if (networks.len > 1) {
// Still a guess once more than one network is user-defined, and a guess
// that silently picks the wrong one fails as an unreachable name rather
// than as anything diagnosable. Say so while it is cheap to read.
ctx.note("this job's container is on {d} networks; joining '{s}'", .{ networks.len, chosen });
}
return .{ .in_network = chosen };
}
fn smoke(ctx: *Ctx, names: Names) !void {
const topology = detectTopology(ctx);
// 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 fixture through the socket instead.
//
// `-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.
var argv: std.ArrayList([]const u8) = .empty;
try argv.appendSlice(ctx.arena, &.{ "docker", "create", "--name", names.container });
try argv.appendSlice(ctx.arena, &.{ "--label", ctx.fmt("{s}={s}", .{ ownership_label, names.label }) });
switch (topology) {
.in_network => |network| try argv.appendSlice(ctx.arena, &.{ "--network", network }),
.published => {},
}
try argv.appendSlice(ctx.arena, &.{ "-p", "127.0.0.1::8080" });
try argv.appendSlice(ctx.arena, &.{ "--sysctl", "net.ipv4.ip_unprivileged_port_start=0" });
try argv.appendSlice(ctx.arena, &.{ names.image, "run", "--config=/etc/nxdns/config.zon" });
_ = try mustRun(ctx, "smoke", argv.items, .{});
defer _ = runCommand(ctx, &.{ "docker", "rm", "-f", names.container }, .{}) catch {};
_ = try mustRun(ctx, "smoke", &.{
"docker", "cp", "tests/fixtures/container-smoke.zon",
ctx.fmt("{s}:/etc/nxdns/config.zon", .{names.container}),
}, .{});
_ = try mustRun(ctx, "smoke", &.{ "docker", "start", names.container }, .{});
// Before anything that assumes a live container. `docker port` fails on one
// that has already exited, and that failure would otherwise be the whole
// diagnosis the log gets — the container's own stderr never reaches CI.
if (!try isRunning(ctx, names.container)) {
ctx.soft("smoke", "the container exited during startup", .{});
dumpLogs(ctx, names.container);
return CheckFailed;
}
const url = try probeUrl(ctx, names, topology);
try probeHealth(ctx, names, url);
try stopGracefully(ctx, names.container);
}
fn probeUrl(ctx: *Ctx, names: Names, topology: Topology) ![]const u8 {
const inspected = try runCommand(ctx, &.{ "docker", "inspect", names.container }, .{});
const address = if (inspected.ok()) inspectAddress(ctx.arena, inspected.stdout) else "";
switch (topology) {
.in_network => {
ctx.note("probing by container name over the job's network (container ip {s})", .{
if (address.len != 0) address else "none",
});
return ctx.fmt("http://{s}:8080/api/health", .{names.container});
},
.published => {
const ports = try mustRun(ctx, "smoke", &.{ "docker", "port", names.container, "8080/tcp" }, .{});
const port = parsePublishedPort(ports);
if (port.len == 0) {
ctx.soft("smoke", "8080/tcp is not published on the host: '{s}'", .{
std.mem.trimEnd(u8, ports, "\n"),
});
return CheckFailed;
}
ctx.note("probing the published loopback port {s} (container ip {s})", .{
port, if (address.len != 0) address else "none",
});
return ctx.fmt("http://127.0.0.1:{s}/api/health", .{port});
},
}
}
fn probeHealth(ctx: *Ctx, names: Names, url: []const u8) !void {
// curl rather than `std.http.Client`: zig 0.16's client has no connect
// timeout that survives a black-holed route, and a probe that can hang
// forever defeats the deadline this loop exists to enforce.
const started = Io.Clock.awake.now(ctx.io);
while (true) {
// First, so no attempt is ever launched with the budget already spent.
// Checking after the attempt instead let a round that started with less
// than a second left run on the one-second floor `attemptSeconds`
// applies, and report a pass past the deadline. A budget that expires
// during the sleep now fails, which is what "absolute deadline" means.
const now = Io.Clock.awake.now(ctx.io);
if (deadlineExpired(started.nanoseconds, now.nanoseconds, probe_budget_ns)) {
ctx.soft("smoke", "no response from {s} within {d:.1}s", .{
url, elapsedSeconds(started.nanoseconds, now.nanoseconds),
});
dumpLogs(ctx, names.container);
return CheckFailed;
}
if (!try isRunning(ctx, names.container)) {
ctx.soft("smoke", "the container exited after {d:.1}s, before answering {s}", .{
elapsedSeconds(started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds), url,
});
dumpLogs(ctx, names.container);
return CheckFailed;
}
const max_time = attemptSeconds(
started.nanoseconds,
Io.Clock.awake.now(ctx.io).nanoseconds,
probe_budget_ns,
);
const attempt = try runCommand(ctx, &.{
"curl", "-fsS",
"--connect-timeout", "2",
"--max-time", ctx.fmt("{d}", .{max_time}),
url,
}, .{});
if (attempt.ok()) {
ctx.pass("smoke", "{s} answered after {d:.1}s: {s}", .{
url,
elapsedSeconds(started.nanoseconds, Io.Clock.awake.now(ctx.io).nanoseconds),
std.mem.trim(u8, attempt.stdout, " \t\r\n"),
});
return;
}
probe_interval.sleep(ctx.io) catch |err| {
ctx.soft("smoke", "the probe interval was interrupted: {t}", .{err});
return CheckFailed;
};
}
}
/// SIGTERM has to bring the daemon down cleanly, because that is what every
/// container runtime sends and a non-zero code there is a shutdown bug.
fn stopGracefully(ctx: *Ctx, container: []const u8) !void {
_ = try mustRun(ctx, "smoke-stop", &.{ "docker", "stop", "-t", "30", container }, .{});
const inspected = try mustRun(ctx, "smoke-stop", &.{
"docker", "inspect", "-f", "{{.State.ExitCode}}", container,
}, .{});
const text = std.mem.trim(u8, inspected, " \t\r\n");
dumpLogs(ctx, container);
const code = std.fmt.parseInt(i32, text, 10) catch {
ctx.soft("smoke-stop", "docker reported a non-numeric exit code '{s}'", .{text});
return CheckFailed;
};
if (code != 0) {
ctx.soft("smoke-stop", "the container exited {d} after SIGTERM, not 0", .{code});
return CheckFailed;
}
ctx.pass("smoke-stop", "the container exited 0 after SIGTERM", .{});
}
fn isRunning(ctx: *Ctx, container: []const u8) !bool {
const run = try runCommand(ctx, &.{
"docker", "inspect", "-f", "{{.State.Running}}", container,
}, .{});
if (!run.ok()) return false;
return std.mem.eql(u8, run.trimmedStdout(), "true");
}
fn dumpLogs(ctx: *Ctx, container: []const u8) void {
const run = runCommand(ctx, &.{ "docker", "logs", container }, .{}) catch return;
ctx.note("docker logs {s}:\n{s}", .{
container, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "objectSuffix carries the run identity and the random bytes" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expectEqualStrings("42-2-deadbeef", objectSuffix(arena, "42", "2", "deadbeef"));
// A workflow_dispatch on a runner that sets neither still has to produce a
// usable name rather than `nxdns:ci--`.
try testing.expectEqualStrings("0-1-cafe", objectSuffix(arena, "", "", "cafe"));
}
test "labelValue is repo/run/attempt" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expectEqualStrings("m5r/nxdns/42/2", labelValue(arena, "m5r/nxdns", "42", "2"));
try testing.expectEqualStrings("m5r/nxdns/0/1", labelValue(arena, "m5r/nxdns", "", ""));
}
test "stagePath names the native staged directory" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
try testing.expectEqualStrings(
"zig-out/dist/stage/nxdns-0.0.2-x86_64-linux-musl",
stagePath(arena_state.allocator(), "0.0.2"),
);
}
test "inspectNetworks reads none, one and several networks" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
try testing.expectEqual(@as(usize, 0), inspectNetworks(arena, "[]").len);
try testing.expectEqual(@as(usize, 0), inspectNetworks(arena, "not json").len);
// A daemon error body is an object, not an array, and must not read as a
// network list.
try testing.expectEqual(@as(usize, 0), inspectNetworks(arena, "{\"message\":\"no such object\"}").len);
const one =
\\[{"NetworkSettings":{"Networks":{"job-net":{"IPAddress":"172.20.0.3"}}}}]
;
const single = inspectNetworks(arena, one);
try testing.expectEqual(@as(usize, 1), single.len);
try testing.expectEqualStrings("job-net", single[0]);
try testing.expectEqualStrings("172.20.0.3", inspectAddress(arena, one));
const two =
\\[{"NetworkSettings":{"Networks":{"a":{"IPAddress":"172.20.0.3"},"b":{"IPAddress":"172.21.0.4"}}}}]
;
try testing.expectEqual(@as(usize, 2), inspectNetworks(arena, two).len);
try testing.expectEqualStrings("", inspectAddress(arena, "[]"));
}
test "parsePublishedPort takes the port after the last colon" {
try testing.expectEqualStrings("49153", parsePublishedPort("127.0.0.1:49153\n"));
// An IPv6 binding carries colons in the host half.
try testing.expectEqualStrings("49154", parsePublishedPort(":::49154\n"));
try testing.expectEqualStrings("49153", parsePublishedPort("127.0.0.1:49153\n:::49154\n"));
try testing.expectEqualStrings("", parsePublishedPort(""));
try testing.expectEqualStrings("", parsePublishedPort("\n\n"));
}
test "preferredNetwork skips the built-in networks" {
try testing.expectEqualStrings(
"job-net",
preferredNetwork(&.{ "bridge", "job-net" }).?,
);
try testing.expectEqualStrings("job-net", preferredNetwork(&.{"job-net"}).?);
// Nothing user-defined to pick, so the caller falls back and says so.
try testing.expect(preferredNetwork(&.{ "bridge", "host", "none" }) == null);
try testing.expect(preferredNetwork(&.{}) == null);
// The first user-defined name wins, not the first name.
try testing.expectEqualStrings("a", preferredNetwork(&.{ "host", "a", "b" }).?);
}
test "each curl attempt is capped by what is left of the budget" {
const start: i96 = 1_000_000_000;
// Early on, the ceiling rather than the budget is what binds.
try testing.expectEqual(@as(u64, 10), attemptSeconds(start, start, probe_budget_ns));
try testing.expectEqual(
@as(u64, 10),
attemptSeconds(start, start + 40 * std.time.ns_per_s, probe_budget_ns),
);
// Near the deadline the remaining budget binds instead, so one attempt can
// no longer outlive the loop that is supposed to bound it.
try testing.expectEqual(
@as(u64, 5),
attemptSeconds(start, start + 55 * std.time.ns_per_s, probe_budget_ns),
);
// Never zero: curl reads `--max-time 0` as "no limit". `probeHealth` checks
// the deadline before it computes this, so a spent budget no longer reaches
// the floor through that loop; the floor stays because the helper must not
// hand curl an unbounded attempt whatever the caller does.
try testing.expectEqual(
@as(u64, 1),
attemptSeconds(start, start + 60 * std.time.ns_per_s, probe_budget_ns),
);
try testing.expectEqual(
@as(u64, 1),
attemptSeconds(start, start + 600 * std.time.ns_per_s, probe_budget_ns),
);
// A clock that reads backwards must not shorten the attempt either.
try testing.expectEqual(
@as(u64, 10),
attemptSeconds(start, start - std.time.ns_per_s, probe_budget_ns),
);
}
test "the probe deadline is absolute wall clock" {
const start: i96 = 1_000_000_000;
try testing.expect(!deadlineExpired(start, start, probe_budget_ns));
try testing.expect(!deadlineExpired(start, start + 59 * std.time.ns_per_s, probe_budget_ns));
try testing.expect(deadlineExpired(start, start + 60 * std.time.ns_per_s, probe_budget_ns));
// A clock that reads backwards must not end the loop instantly.
try testing.expect(!deadlineExpired(start, start - std.time.ns_per_s, probe_budget_ns));
try testing.expectApproxEqAbs(
@as(f64, 1.5),
elapsedSeconds(start, start + 1_500_000_000),
0.001,
);
}
test "parseZonVersion reads the version through the zon grammar" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const source =
\\.{
\\ .name = .nxdns,
\\ // .version = "9.9.9" is a comment, and sed did not know that
\\ .version = "0.0.2",
\\ .minimum_zig_version = "0.16.0",
\\ .dependencies = .{},
\\ .paths = .{""},
\\}
;
try testing.expectEqualStrings("0.0.2", try parseZonVersion(arena, source));
try testing.expectError(error.ParseZon, parseZonVersion(arena, ".{ .name = .nxdns }"));
}
test "sha256Hex matches the known empty-input digest" {
try testing.expectEqualStrings(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
&sha256Hex(""),
);
}
+10 -6
View File
@@ -717,8 +717,14 @@ fn runnerTemp(ctx: *Ctx) []const u8 {
}
/// A fresh directory under `RUNNER_TEMP`, named so `scrub` can find it. The name
/// is claimed by an exclusive `makeDir` rather than by a random suffix: a
/// collision is a retry, not a silent share.
/// is claimed by an exclusive create rather than by a random suffix: a collision
/// is a retry, not a silent share.
///
/// `createDirPathStatus`, not `createDirPath`, because the latter has `mkdir -p`
/// semantics — it succeeds on a directory that is already there and never
/// reports `error.PathAlreadyExists`, which made the retry below dead code. This
/// program then adopted a stale or concurrently-held directory, wrote the
/// signing material into it, and deleted the whole tree on the way out.
fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 {
const base = runnerTemp(ctx);
var attempt: usize = 0;
@@ -726,10 +732,8 @@ fn makeTempDir(ctx: *Ctx, prefix: []const u8) ![]const u8 {
const path = ctx.fmt("{s}/{s}.{s}-{d}", .{
base, prefix, ctx.get("GITHUB_RUN_ID"), attempt,
});
Io.Dir.cwd().createDirPath(ctx.io, path) catch |err| switch (err) {
error.PathAlreadyExists => continue,
else => return err,
};
const status = try Io.Dir.cwd().createDirPathStatus(ctx.io, path, .default_dir);
if (status == .existed) continue;
var dir = try Io.Dir.cwd().openDir(ctx.io, path, .{ .iterate = true });
defer dir.close(ctx.io);
try dir.setPermissions(ctx.io, .fromMode(0o700));
@@ -47,9 +47,12 @@ const styles = stylex.create({
export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientUpdateMutation(queryClient));
const [name, setName] = useState(client.name);
const [groupId, setGroupId] = useState(client.group_id);
const readOnly = useReadOnlyConfig();
// 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 (
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
@@ -18,6 +18,7 @@ const CLIENTS = {
id: 1,
ip: "192.168.1.10",
name: "laptop",
learned_name: "laptop-1.lan",
group_id: 1,
group: "default",
hand_edited: true,
@@ -28,6 +29,7 @@ const CLIENTS = {
id: 2,
ip: "192.168.1.11",
name: "",
learned_name: "kids-tablet.lan",
group_id: 2,
group: "kids",
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);
});
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 () => {
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
+25 -3
View File
@@ -56,6 +56,23 @@ const styles = stylex.create({
dash: {
color: colors.textMuted,
},
/**
* A learned name is runtime state, not something the operator typed, so it
* reads muted and carries an outlined "learned" tag. The tag is real text
* a screen reader announces it because colour alone is not an affordance.
*/
learnedTag: {
marginLeft: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
borderRadius: "0.25rem",
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
@@ -133,10 +150,15 @@ export default function ClientsPage() {
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
<td {...stylex.props(styles.cell)}>
{client.name === "" ? (
<span {...stylex.props(styles.dash)}></span>
) : (
{client.name !== "" ? (
client.name
) : client.learned_name !== "" ? (
<span {...stylex.props(styles.dash)}>
{client.learned_name}
<span {...stylex.props(styles.learnedTag)}>learned</span>
</span>
) : (
<span {...stylex.props(styles.dash)}></span>
)}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
</td>
+2
View File
@@ -259,6 +259,7 @@ export const sample_list_clients: { clients: Client[] } = {
id: 0,
ip: "192.168.1.50",
last_seen: 0,
learned_name: "",
name: "laptop",
},
],
@@ -272,6 +273,7 @@ export const sample_update_client: Client = {
id: 0,
ip: "192.168.1.50",
last_seen: 0,
learned_name: "",
name: "laptop-renamed",
};
+2
View File
@@ -256,6 +256,8 @@ export interface Client {
id: number;
ip: string;
name: string;
/** Learned over reverse DNS. `name` wins whenever it is non-empty. */
learned_name: string;
group_id: number;
group: string;
hand_edited: boolean;