Compare commits
3
Commits
v0.0.2
...
efbe355070
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efbe355070
|
||
|
|
fc60214b3e
|
||
|
|
3c794b645b
|
+75
-184
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
.{
|
||||
.name = .nxdns,
|
||||
.version = "0.0.2",
|
||||
.version = "0.0.3",
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.paths = .{""},
|
||||
.fingerprint = 0x3307b311dded1d91,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–63 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 3–4, 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 1–4: 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
@@ -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
|
||||
|
||||
@@ -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 1–63
|
||||
/// 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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+4
@@ -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.
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
|
||||
}
|
||||
@@ -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
@@ -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: [] } });
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user