From cc86473410454da21c425b5fae25307760e9b05e Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 12:37:47 +0000 Subject: [PATCH 01/92] build: run all linting in Docker via Dockerfile.lint (closes #134) golangci-lint is no longer installed or run on the host. script/lint is now a thin wrapper that builds the new root Dockerfile.lint, which COPYs the repo into the digest-pinned golangci/golangci-lint:v2.12.2 image and lints as a build step, so a successful build is a clean lint. This works even where the docker daemon is remote and bind mounts are impossible. Dockerfile.lint is split into a deps stage (base image, go mod download) and a lint stage (source copy, linter run). script/lint passes --no-cache-filter=lint so the lint stage executes on every invocation: caching is explicitly waived for linting, and a cached build lints nothing. The deps stage stays cached and no global cache invalidation is performed. --progress=plain keeps the linter's own output visible. golangci-lint config verify is deliberately omitted: it fetches its JSON schema over a live, unpinned HTTPS call, which would make linting network-dependent and defeat hash-pinning. script/bootstrap no longer installs golangci-lint and warns instead when docker is absent. The goimports install stays, since script/fmt and script/fmt-check still run it on the host. The root Dockerfile ran make check in its builder stage, which would now recurse into script/lint and shell out to docker build with no daemon available. It gains its own lint stage on the same pinned image, invoked directly, with the builder depending on it via COPY --from=lint and running make fmt-check, make test and make build. --- Dockerfile | 38 ++++++++++++++++++++++++++------------ Dockerfile.lint | 27 +++++++++++++++++++++++++++ README.md | 15 +++++++++++---- TODO.md | 10 ++++++++++ script/bootstrap | 22 +++++++++++++++------- script/lint | 20 ++++++++++++++++++-- 6 files changed, 107 insertions(+), 25 deletions(-) create mode 100644 Dockerfile.lint diff --git a/Dockerfile b/Dockerfile index ec33b34..94b5b57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,9 @@ -# Build stage -# golang 1.25-alpine, 2026-02-28 -FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder - -RUN apk add --no-cache git make gcc musl-dev binutils-gold - -# golangci-lint v2.12.2, 2026-08-07 -RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5 -# goimports v0.42.0 -RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 +# Lint stage - fast feedback on lint issues, before the build starts. +# The linter is invoked directly rather than through `make lint`: that +# target shells out to `docker build -f Dockerfile.lint`, and there is +# no docker daemon inside a docker build. +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint WORKDIR /src COPY go.mod go.sum ./ @@ -15,8 +11,26 @@ RUN go mod download COPY . . -# Run all checks - build fails if any check fails -RUN make check +RUN make fmt-check +RUN golangci-lint run --config .golangci.yml ./... + +# Build stage +# golang 1.25-alpine, 2026-02-28 +FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder + +RUN apk add --no-cache git make gcc musl-dev binutils-gold + +# Force BuildKit to run the lint stage before proceeding +COPY --from=lint /src/go.sum /dev/null + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Run the tests - build fails if any test fails +RUN make test # Build the binary RUN make build diff --git a/Dockerfile.lint b/Dockerfile.lint new file mode 100644 index 0000000..5c236de --- /dev/null +++ b/Dockerfile.lint @@ -0,0 +1,27 @@ +# Lint-only image: used by script/lint. golangci-lint is never run on +# the host — the repo is COPYed into the build context and the linter +# runs as a build step, so a successful build IS a clean lint. This +# also works where the docker daemon is remote and bind mounts are +# impossible. +# +# `golangci-lint config verify` is deliberately NOT run here: it +# fetches its JSON schema over a live, unpinned HTTPS call, which would +# make linting network-dependent and defeat hash-pinning. +# +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps + +WORKDIR /src + +# Dependencies first, so this stage stays cached across lint runs. +COPY go.mod go.sum ./ +RUN go mod download + +# Everything below is invalidated on every run by the +# --no-cache-filter=lint that script/lint passes: caching is explicitly +# waived for linting, and a cached build lints nothing. +FROM deps AS lint + +COPY . . + +RUN golangci-lint run --config .golangci.yml ./... diff --git a/README.md b/README.md index 83f9226..96141f9 100644 --- a/README.md +++ b/README.md @@ -380,14 +380,21 @@ standard: normalized scripts in `script/` are the entrypoints for the development workflow, and the Makefile targets are thin shims that call them. We provide: -- `script/bootstrap` — install all dependencies (go, pinned - golangci-lint and goimports, `go mod download`) +- `script/bootstrap` — install all dependencies (go, pinned goimports, + `go mod download`). It does not install golangci-lint: see + `script/lint` below. - `script/setup` — make a fresh clone ready for development: bootstrap plus the git pre-commit hook - `script/projectname` — print the project name (used for the Docker image tag) - `script/test` — run the test suite (race detector, coverage) -- `script/lint` — run golangci-lint +- `script/lint` — run golangci-lint, always inside Docker: it builds + `Dockerfile.lint`, which COPYs the repo into the digest-pinned + `golangci-lint` image and lints as a build step, so a successful + build is a clean lint. The linter is never installed or run on the + host, and Docker is the only prerequisite. Caching is waived for + linting: the lint stage is forced to execute on every run with + `--no-cache-filter`, because a cached build lints nothing. - `script/fmt` — format all code (gofmt -s, goimports) - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check @@ -403,7 +410,7 @@ them. We provide: ```sh make build # Build binary to bin/dnswatcher make test # Run tests with race detector -make lint # Run golangci-lint +make lint # Run golangci-lint in Docker (requires docker) make fmt # Format code make check # Run all checks (test, lint, fmt-check) make clean # Remove build artifacts diff --git a/TODO.md b/TODO.md index bc4c519..8e3909b 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,16 @@ confirm make check still passes. # Completed Steps +- 2026-08-10: all linting moved into Docker: new root `Dockerfile.lint` + on the digest-pinned `golangci/golangci-lint:v2.12.2` image, + `script/lint` reduced to a thin wrapper that builds it with + `--no-cache-filter=lint` so the linter actually executes every run, + golangci-lint install dropped from `script/bootstrap` (goimports + stays, `script/fmt` needs it on the host), and the root `Dockerfile` + given its own lint stage so its build no longer recurses through + `make check` into `script/lint`. `golangci-lint config verify` is + deliberately omitted: it fetches its schema over an unpinned live + HTTPS call - 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the org-standard v2-schema config used across the org's repos diff --git a/script/bootstrap b/script/bootstrap index 129cc77..0901c87 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,15 +3,16 @@ # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes nothing is present. -# golangci-lint and goimports are installed via `go install` at the same -# pinned commits the Dockerfile uses (never "latest"). +# goimports is installed via `go install` at a pinned commit (never +# "latest") because script/fmt and script/fmt-check run it on the host. +# The linter is NOT installed here: golangci-lint runs via docker only +# (script/lint), pinned by image digest, so its only prerequisite is a +# working docker. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# Pinned versions, 2026-08-07 (same pins as the Dockerfile) -# golangci-lint v2.12.2 -GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5" +# Pinned version, 2026-08-07 (same pin as the Dockerfile) # goimports v0.42.0 GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" @@ -69,11 +70,18 @@ main() { if missing make; then pkg_install gnumake make make make; fi if missing go; then pkg_install go golang go go; fi - # Lint/format tools, pinned via go install (installs into + # Format tools, pinned via go install (installs into # "$(go env GOPATH)/bin"; ensure that is on your PATH). - if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi if missing goimports; then go install "$GOIMPORTS_REF"; fi + # Linting runs via docker only (script/lint). Warn, don't fail: + # everything except `make lint` works without it. + if missing docker; then + echo "bootstrap: WARNING: docker not found; make lint and" >&2 + echo "bootstrap: make docker require it. Install docker to" >&2 + echo "bootstrap: run the linter." >&2 + fi + go mod download echo "bootstrap complete" diff --git a/script/lint b/script/lint index 8017180..84a1972 100755 --- a/script/lint +++ b/script/lint @@ -1,12 +1,28 @@ #!/bin/sh -# script/lint: run the linter. +# script/lint: run the linter. golangci-lint is never installed or run +# on the host: it runs via docker only, one way, everywhere. This +# builds Dockerfile.lint, which COPYs the repo into the digest-pinned +# golangci-lint image and lints as a build step, so a successful build +# means a clean lint. +# +# --no-cache-filter=lint forces the lint stage (source copy + linter +# run) to execute on every invocation. Without it an unchanged tree +# returns success in well under a second having linted nothing. The +# deps stage (base image + go mod download) stays cached, and no global +# cache invalidation is performed. --progress=plain keeps the linter's +# own output visible. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - golangci-lint run --config .golangci.yml ./... + docker build \ + --progress=plain \ + --no-cache-filter=lint \ + --target lint \ + -f Dockerfile.lint \ + . } main "$@" -- 2.54.0 From 9cb2c2b7e00774488faaf8c7c2068f61fcb23937 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 13:09:17 +0000 Subject: [PATCH 02/92] test: make live DNS tests robust instead of gated (closes #93) The resolver's live-DNS tests failed nondeterministically, a different subset each run. Three structural causes, all test-side: - Burst fan-out. Every test in the package is parallel and the build hosts have many cores, so all ~35 iterative resolutions started at the same instant and, because queryServers walks rootServerList() in fixed order, hit the same root server within milliseconds. Root servers rate-limit that. - No retry anywhere. One dropped UDP packet in a delegation chain failed a test outright. - Unanimity assertions. TestQueryAllNameservers_AllReturnOK and _NXDomainFromAllNS required every one of a domain's nameservers to answer, with no tolerance for one being slow. New internal/resolver/livedns_test.go addresses each: a package-wide gate bounds how many live resolutions are in flight at once, every live operation gets three attempts with exponential backoff and its own deadline, and multi-nameserver assertions now need a strict majority rather than unanimity. The retry predicate is deliberately transport-level -- "did a nameserver answer at all" -- never the assertion under test, so a resolver that answers incorrectly still fails on the first attempt. A nameserver that stays silent is tolerated; one that answers wrongly is not. livedns_harness_test.go tests that machinery directly: quorum arithmetic, status counting, the gate's concurrency bound, per-attempt deadlines, and recovery from a transient failure. It touches no DNS. Nothing is mocked, faked, stubbed, recorded, skipped or build-tagged, and production resolver behaviour is unchanged. Test caps move to the new org-wide values ruled at prompts issue 41: 60s hard cap, 20s target, 90s -timeout backstop. REPO_POLICIES.md is re-vendored byte-identical from sneak/prompts rather than hand-edited, which also picks up the golangci-lint paragraph this copy had drifted behind on. TESTING.md's stale 30-second target follows to 60. https://git.eeqj.de/sneak/dnswatcher/issues/93 --- REPO_POLICIES.md | 20 +- TESTING.md | 2 +- TODO.md | 10 + internal/resolver/livedns_harness_test.go | 162 ++++++++ internal/resolver/livedns_test.go | 437 ++++++++++++++++++++++ internal/resolver/resolver_test.go | 289 +++++--------- script/test | 2 +- 7 files changed, 709 insertions(+), 213 deletions(-) create mode 100644 internal/resolver/livedns_harness_test.go create mode 100644 internal/resolver/livedns_test.go diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index bc2f161..9aba6b0 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-07-06 +last_modified: 2026-08-07 --- This document covers repository structure, tooling, and workflow standards. Code @@ -189,8 +189,13 @@ style conventions are in separate documents: module under test to verify it compiles/parses. There is no excuse for `make test` to be a no-op. -- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - Makefile. +- `make test` must complete in under 60 seconds. That is the hard cap, and a + suite that exceeds it fails. Under 20 seconds is the target. A suite between + 20 and 60 seconds is still green, but the overage must be filed as an + improvement bug against that repo. Add a 90-second timeout to the test + invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately + sits above the hard cap so that it catches a genuinely hung test rather than a + merely slow one. - **`make test` should use the conditional verbose rerun pattern.** Run tests without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to @@ -209,9 +214,9 @@ style conventions are in separate documents: ```makefile test: - @go test -timeout 30s -race -cover ./... || \ + @go test -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 30s -race -v ./...; exit 1; } + go test -timeout 90s -race -v ./...; exit 1; } ``` Python example: @@ -260,7 +265,10 @@ style conventions are in separate documents: - `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only manually by the user. Fetch from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The + canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed + commit-pinned via + `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). diff --git a/TESTING.md b/TESTING.md index 3dd34c4..0acae53 100644 --- a/TESTING.md +++ b/TESTING.md @@ -17,7 +17,7 @@ real servers ensures the resolver works correctly in production. - Tests hit real DNS infrastructure and require network access - Test duration depends on network conditions; timeout tuning keeps - the suite within the 30-second target + the suite within the 60-second target - Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) plus processing margin - Root server fan-out is limited to reduce parallel query load diff --git a/TODO.md b/TODO.md index 8e3909b..1b4f13f 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,16 @@ confirm make check still passes. # Completed Steps +- 2026-08-10: live-DNS test flakiness addressed by robustness rather + than gating, per the owner's ruling on #93: new + `internal/resolver/livedns_test.go` adds a package-wide concurrency + gate (so parallel tests stop bursting at the first root server), + retry with exponential backoff on transport failures only, and + quorum instead of unanimity for multi-nameserver assertions. The + `make test` cap moved to the new org-wide 60s hard cap / 20s target + with a 90s `-timeout` backstop; `REPO_POLICIES.md` re-vendored + byte-identical from `sneak/prompts`. No mocks, no `-short`, no build + tags, no skips, and no change to production resolver behaviour - 2026-08-10: all linting moved into Docker: new root `Dockerfile.lint` on the digest-pinned `golangci/golangci-lint:v2.12.2` image, `script/lint` reduced to a thin wrapper that builds it with diff --git a/internal/resolver/livedns_harness_test.go b/internal/resolver/livedns_harness_test.go new file mode 100644 index 0000000..066337d --- /dev/null +++ b/internal/resolver/livedns_harness_test.go @@ -0,0 +1,162 @@ +package resolver_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "sneak.berlin/go/dnswatcher/internal/resolver" +) + +// Tests for the live-DNS harness in livedns_test.go itself. These +// exercise pure logic and the retry/concurrency plumbing; they +// perform no DNS resolution of any kind, so they neither mock DNS +// nor depend on it. + +func TestLiveQuorumIsStrictMajority(t *testing.T) { + t.Parallel() + + cases := map[int]int{ + 0: 1, + 1: 1, + 2: 2, + 3: 2, + 4: 3, + 5: 3, + 13: 7, + } + + for total, want := range cases { + assert.Equal( + t, want, liveQuorum(total), + "liveQuorum(%d)", total, + ) + } +} + +func TestStatusCountingIgnoresSilentNameservers(t *testing.T) { + t.Parallel() + + results := map[string]*resolver.NameserverResponse{ + "ns1.example.": { + Nameserver: "ns1.example.", + Status: resolver.StatusOK, + }, + "ns2.example.": { + Nameserver: "ns2.example.", + Status: resolver.StatusOK, + }, + "ns3.example.": { + Nameserver: "ns3.example.", + Status: resolver.StatusTimeout, + }, + "ns4.example.": { + Nameserver: "ns4.example.", + Status: resolver.StatusError, + }, + } + + assert.Equal( + t, 2, countStatus(results, resolver.StatusOK), + ) + assert.Equal( + t, 0, countStatus(results, resolver.StatusNXDomain), + ) + + // Two of four answered, which is short of the quorum of + // three: this is the state that triggers a retry rather + // than an assertion failure. + assert.Equal(t, 2, answeredCount(results)) + assert.Less(t, answeredCount(results), liveQuorum(len(results))) + + assert.Equal( + t, + "ns1.example.=ok ns2.example.=ok "+ + "ns3.example.=timeout ns4.example.=error", + describeStatuses(results), + ) +} + +func TestRetryLiveRecoversFromTransientFailure(t *testing.T) { + t.Parallel() + + const wantAttempts = 2 + + attempts := 0 + + retryLive(t, "transient", func(_ context.Context) error { + attempts++ + + if attempts < wantAttempts { + return errLiveNoAnswer + } + + return nil + }) + + assert.Equal(t, wantAttempts, attempts) +} + +func TestRetryLiveGivesEachAttemptADeadline(t *testing.T) { + t.Parallel() + + retryLive(t, "deadline", func(ctx context.Context) error { + deadline, ok := ctx.Deadline() + assert.True(t, ok, "attempt should carry a deadline") + assert.LessOrEqual( + t, time.Until(deadline), liveAttemptTimeout, + ) + + return nil + }) +} + +func TestRunLiveBoundsConcurrency(t *testing.T) { + t.Parallel() + + const workers = 24 + + var ( + mu sync.Mutex + wg sync.WaitGroup + inFlight int + maxSeen int + ) + + wg.Add(workers) + + for range workers { + go func() { + defer wg.Done() + + _ = runLive(func(_ context.Context) error { + mu.Lock() + inFlight++ + + if inFlight > maxSeen { + maxSeen = inFlight + } + mu.Unlock() + + time.Sleep(time.Millisecond) + + mu.Lock() + inFlight-- + mu.Unlock() + + return nil + }) + }() + } + + wg.Wait() + + assert.Positive(t, maxSeen) + assert.LessOrEqual( + t, maxSeen, liveConcurrency, + "live queries must stay under the package-wide gate", + ) +} diff --git a/internal/resolver/livedns_test.go b/internal/resolver/livedns_test.go new file mode 100644 index 0000000..55ea755 --- /dev/null +++ b/internal/resolver/livedns_test.go @@ -0,0 +1,437 @@ +package resolver_test + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/resolver" +) + +// ---------------------------------------------------------------- +// Live DNS test support +// ---------------------------------------------------------------- +// +// Every test in this package resolves against the real, live DNS — +// see TESTING.md. Nothing here mocks, fakes, stubs, records or +// replays DNS, and nothing here skips or gates a test: the helpers +// below only change *how* the live queries are issued, so that a +// single dropped UDP packet or one slow authoritative server does +// not turn a correct resolver into a red build. +// +// Three mechanisms, all test-side: +// +// 1. Bounded concurrency. The package's tests are parallel and the +// build hosts have many cores, so without a limit every test +// starts its own iterative resolution at the same instant and +// they all hit the first root server in rootServerList() within +// a few milliseconds of each other. Root servers rate-limit +// that, which shows up as a different arbitrary subset of tests +// failing on each run. liveGate caps how many resolutions are +// in flight at once. +// +// 2. Retry with exponential backoff. Each live operation gets +// several attempts with its own timeout. The retry predicate is +// strictly transport-level — "did a nameserver answer at all" — +// never the assertion the test is making. A resolver that +// answers incorrectly still fails on the first attempt. +// +// 3. Quorum. Where an assertion spans several independent +// nameservers, a strict majority answering as expected is +// enough; a server that fails to answer is tolerated, while a +// server that answers *wrongly* still fails the test. + +const ( + // liveAttempts is how many times a live DNS operation is + // attempted before the test fails. + liveAttempts = 3 + + // liveAttemptTimeout bounds one attempt. Worst case for an + // operation is liveAttempts * liveAttemptTimeout plus the + // backoff — about 26 seconds, well inside the 90-second + // `go test -timeout` backstop even when several operations + // exhaust their attempts. + liveAttemptTimeout = 8 * time.Second + + // liveBackoffBase is the delay after the first failed + // attempt; it is multiplied by liveBackoffFactor each time. + liveBackoffBase = 500 * time.Millisecond + + // liveBackoffFactor is the exponential backoff multiplier. + liveBackoffFactor = 2 + + // liveConcurrency caps how many live resolutions may be in + // flight across the whole package at once. + liveConcurrency = 6 + + // minNameservers is the smallest nameserver count a + // well-run zone is expected to publish. + minNameservers = 2 +) + +// liveGate bounds concurrent live resolutions package-wide. It has +// to be package scoped: the whole point is that it is shared by +// every parallel test in the package. +// +//nolint:gochecknoglobals // package-wide live query rate limit +var liveGate = make(chan struct{}, liveConcurrency) + +var ( + // errLiveNoAnswer reports that a live operation produced no + // usable answer, which is retried rather than asserted on. + errLiveNoAnswer = errors.New("no answer from live DNS") + + // errLiveNoQuorum reports that too few of a domain's + // nameservers answered for a quorum assertion to be made. + errLiveNoQuorum = errors.New("no nameserver quorum") +) + +// runLive executes one attempt of a live operation, holding a slot +// in liveGate for its duration and bounding it with its own +// timeout. +func runLive(op func(ctx context.Context) error) error { + liveGate <- struct{}{} + defer func() { <-liveGate }() + + ctx, cancel := context.WithTimeout( + context.Background(), liveAttemptTimeout, + ) + defer cancel() + + return op(ctx) +} + +// retryLive runs op until it reports success, retrying transport +// failures with exponential backoff, and fails the test if every +// attempt fails. op returns an error only for a failure to obtain +// an answer — never for an answer the test disagrees with, which +// belongs in an assertion so that it fails immediately. op stores +// whatever it obtained where its caller can find it. +func retryLive( + t *testing.T, + what string, + op func(ctx context.Context) error, +) { + t.Helper() + + var last error + + backoff := liveBackoffBase + + for attempt := range liveAttempts { + if attempt > 0 { + t.Logf( + "%s: attempt %d of %d failed (%v), "+ + "retrying in %s", + what, attempt, liveAttempts, last, backoff, + ) + time.Sleep(backoff) + + backoff *= liveBackoffFactor + } + + last = runLive(op) + if last == nil { + return + } + } + + t.Fatalf( + "%s: no answer after %d live attempts: %v", + what, liveAttempts, last, + ) +} + +// liveQuorum is how many of total nameservers must agree for a +// multi-nameserver assertion to hold: a strict majority. +func liveQuorum(total int) int { + if total < 1 { + return 1 + } + + return total/2 + 1 +} + +// countStatus counts the responses carrying the given status. +func countStatus( + results map[string]*resolver.NameserverResponse, + status string, +) int { + n := 0 + + for _, resp := range results { + if resp.Status == status { + n++ + } + } + + return n +} + +// answeredCount counts the nameservers that produced an answer of +// any kind, as opposed to failing or timing out. +func answeredCount( + results map[string]*resolver.NameserverResponse, +) int { + return len(results) - + countStatus(results, resolver.StatusError) - + countStatus(results, resolver.StatusTimeout) +} + +// describeStatuses renders per-nameserver statuses for use in +// assertion failure messages. +func describeStatuses( + results map[string]*resolver.NameserverResponse, +) string { + parts := make([]string, 0, len(results)) + for ns, resp := range results { + parts = append( + parts, fmt.Sprintf("%s=%s", ns, resp.Status), + ) + } + + sort.Strings(parts) + + return strings.Join(parts, " ") +} + +// ---------------------------------------------------------------- +// Live operation wrappers +// ---------------------------------------------------------------- + +// liveFindAuthoritative resolves a domain's authoritative +// nameservers, retrying until the delegation chain can be walked. +func liveFindAuthoritative( + t *testing.T, + r *resolver.Resolver, + domain string, +) []string { + t.Helper() + + var out []string + + retryLive( + t, + "FindAuthoritativeNameservers("+domain+")", + func(ctx context.Context) error { + ns, err := r.FindAuthoritativeNameservers(ctx, domain) + if err != nil { + return err + } + + if len(ns) == 0 { + return fmt.Errorf( + "%w: %s has no nameservers", + errLiveNoAnswer, domain, + ) + } + + out = ns + + return nil + }, + ) + + return out +} + +// liveLookupNS is liveFindAuthoritative through the LookupNS entry +// point, so that both entry points stay independently exercised. +func liveLookupNS( + t *testing.T, + r *resolver.Resolver, + domain string, +) []string { + t.Helper() + + var out []string + + retryLive( + t, + "LookupNS("+domain+")", + func(ctx context.Context) error { + ns, err := r.LookupNS(ctx, domain) + if err != nil { + return err + } + + if len(ns) == 0 { + return fmt.Errorf( + "%w: %s has no nameservers", + errLiveNoAnswer, domain, + ) + } + + out = ns + + return nil + }, + ) + + return out +} + +// liveQueryNameserver queries one nameserver, retrying while that +// nameserver fails to answer. NXDOMAIN and NODATA are answers and +// are returned to the caller to assert on. +func liveQueryNameserver( + t *testing.T, + r *resolver.Resolver, + nameserver string, + hostname string, +) *resolver.NameserverResponse { + t.Helper() + + what := fmt.Sprintf( + "QueryNameserver(%s, %s)", nameserver, hostname, + ) + + var out *resolver.NameserverResponse + + retryLive( + t, + what, + func(ctx context.Context) error { + resp, err := r.QueryNameserver( + ctx, nameserver, hostname, + ) + if err != nil { + return err + } + + if resp.Status == resolver.StatusTimeout || + resp.Status == resolver.StatusError { + return fmt.Errorf( + "%w: %s returned %s: %s", + errLiveNoAnswer, nameserver, + resp.Status, resp.Error, + ) + } + + out = resp + + return nil + }, + ) + + return out +} + +// liveQueryAllNameservers queries every authoritative nameserver +// for a hostname, retrying until a quorum of them has answered. +// Individual nameservers that stay silent are left in the result +// for the caller to account for. +func liveQueryAllNameservers( + t *testing.T, + r *resolver.Resolver, + hostname string, +) map[string]*resolver.NameserverResponse { + t.Helper() + + var out map[string]*resolver.NameserverResponse + + retryLive( + t, + "QueryAllNameservers("+hostname+")", + func(ctx context.Context) error { + results, err := r.QueryAllNameservers(ctx, hostname) + if err != nil { + return err + } + + if len(results) == 0 { + return fmt.Errorf( + "%w: no nameservers queried for %s", + errLiveNoAnswer, hostname, + ) + } + + answered := answeredCount(results) + if answered < liveQuorum(len(results)) { + return fmt.Errorf( + "%w: %d of %d answered: %s", + errLiveNoQuorum, answered, + len(results), describeStatuses(results), + ) + } + + out = results + + return nil + }, + ) + + return out +} + +// liveResolveIPs resolves a hostname that is expected to have +// addresses, retrying until at least one is returned. +func liveResolveIPs( + t *testing.T, + r *resolver.Resolver, + hostname string, +) []string { + t.Helper() + + var out []string + + retryLive( + t, + "ResolveIPAddresses("+hostname+")", + func(ctx context.Context) error { + ips, err := r.ResolveIPAddresses(ctx, hostname) + if err != nil { + return err + } + + if len(ips) == 0 { + return fmt.Errorf( + "%w: no addresses for %s", + errLiveNoAnswer, hostname, + ) + } + + out = ips + + return nil + }, + ) + + return out +} + +// liveResolveIPsAllowingEmpty resolves a hostname that may legitimately +// have no addresses, so the empty result is returned rather than +// retried. Used for names that must not exist; the corresponding +// QueryAllNameservers test is what proves the nameservers actively +// said NXDOMAIN rather than merely staying silent. +func liveResolveIPsAllowingEmpty( + t *testing.T, + r *resolver.Resolver, + hostname string, +) []string { + t.Helper() + + var out []string + + retryLive( + t, + "ResolveIPAddresses("+hostname+")", + func(ctx context.Context) error { + ips, err := r.ResolveIPAddresses(ctx, hostname) + if err != nil { + return err + } + + out = ips + + return nil + }, + ) + + return out +} diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index bcebfb9..721847e 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -32,32 +32,17 @@ func newTestResolver(t *testing.T) *resolver.Resolver { return resolver.NewFromLogger(log) } -func testContext(t *testing.T) context.Context { - t.Helper() - - ctx, cancel := context.WithTimeout( - context.Background(), 60*time.Second, - ) - t.Cleanup(cancel) - - return ctx -} - +// findOneNSForDomain picks one authoritative nameserver to aim a +// test at. Live-DNS retry, concurrency and quorum handling live in +// livedns_test.go. func findOneNSForDomain( t *testing.T, r *resolver.Resolver, - ctx context.Context, //nolint:revive // test helper domain string, ) string { t.Helper() - nameservers, err := r.FindAuthoritativeNameservers( - ctx, domain, - ) - require.NoError(t, err) - require.NotEmpty(t, nameservers) - - return nameservers[0] + return liveFindAuthoritative(t, r, domain)[0] } // ---------------------------------------------------------------- @@ -70,13 +55,7 @@ func TestFindAuthoritativeNameservers_ValidDomain( t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - nameservers, err := r.FindAuthoritativeNameservers( - ctx, "google.com", - ) - require.NoError(t, err) - require.NotEmpty(t, nameservers) + nameservers := liveFindAuthoritative(t, r, "google.com") hasGoogleNS := false @@ -99,13 +78,9 @@ func TestFindAuthoritativeNameservers_Subdomain( t.Parallel() r := newTestResolver(t) - ctx := testContext(t) + nameservers := liveFindAuthoritative(t, r, "www.google.com") - nameservers, err := r.FindAuthoritativeNameservers( - ctx, "www.google.com", - ) - require.NoError(t, err) - require.NotEmpty(t, nameservers) + assert.NotEmpty(t, nameservers) } func TestFindAuthoritativeNameservers_ReturnsSorted( @@ -114,12 +89,7 @@ func TestFindAuthoritativeNameservers_ReturnsSorted( t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - nameservers, err := r.FindAuthoritativeNameservers( - ctx, "google.com", - ) - require.NoError(t, err) + nameservers := liveFindAuthoritative(t, r, "google.com") assert.True( t, @@ -134,17 +104,8 @@ func TestFindAuthoritativeNameservers_Deterministic( t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - first, err := r.FindAuthoritativeNameservers( - ctx, "google.com", - ) - require.NoError(t, err) - - second, err := r.FindAuthoritativeNameservers( - ctx, "google.com", - ) - require.NoError(t, err) + first := liveFindAuthoritative(t, r, "google.com") + second := liveFindAuthoritative(t, r, "google.com") assert.Equal(t, first, second) } @@ -155,17 +116,8 @@ func TestFindAuthoritativeNameservers_TrailingDot( t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - ns1, err := r.FindAuthoritativeNameservers( - ctx, "google.com", - ) - require.NoError(t, err) - - ns2, err := r.FindAuthoritativeNameservers( - ctx, "google.com.", - ) - require.NoError(t, err) + ns1 := liveFindAuthoritative(t, r, "google.com") + ns2 := liveFindAuthoritative(t, r, "google.com.") assert.Equal(t, ns1, ns2) } @@ -176,13 +128,7 @@ func TestFindAuthoritativeNameservers_CloudflareDomain( t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - nameservers, err := r.FindAuthoritativeNameservers( - ctx, "cloudflare.com", - ) - require.NoError(t, err) - require.NotEmpty(t, nameservers) + nameservers := liveFindAuthoritative(t, r, "cloudflare.com") for _, ns := range nameservers { assert.True(t, strings.HasSuffix(ns, "."), @@ -199,13 +145,9 @@ func TestQueryNameserver_BasicA(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - ns := findOneNSForDomain(t, r, ctx, "google.com") + ns := findOneNSForDomain(t, r, "google.com") + resp := liveQueryNameserver(t, r, ns, "www.google.com") - resp, err := r.QueryNameserver( - ctx, ns, "www.google.com", - ) - require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, resolver.StatusOK, resp.Status) @@ -222,13 +164,8 @@ func TestQueryNameserver_AAAA(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - ns := findOneNSForDomain(t, r, ctx, "cloudflare.com") - - resp, err := r.QueryNameserver( - ctx, ns, "cloudflare.com", - ) - require.NoError(t, err) + ns := findOneNSForDomain(t, r, "cloudflare.com") + resp := liveQueryNameserver(t, r, ns, "cloudflare.com") aaaaRecords := resp.Records["AAAA"] require.NotEmpty(t, aaaaRecords, @@ -247,13 +184,8 @@ func TestQueryNameserver_MX(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - ns := findOneNSForDomain(t, r, ctx, "google.com") - - resp, err := r.QueryNameserver( - ctx, ns, "google.com", - ) - require.NoError(t, err) + ns := findOneNSForDomain(t, r, "google.com") + resp := liveQueryNameserver(t, r, ns, "google.com") mxRecords := resp.Records["MX"] require.NotEmpty(t, mxRecords, @@ -265,13 +197,8 @@ func TestQueryNameserver_TXT(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - ns := findOneNSForDomain(t, r, ctx, "google.com") - - resp, err := r.QueryNameserver( - ctx, ns, "google.com", - ) - require.NoError(t, err) + ns := findOneNSForDomain(t, r, "google.com") + resp := liveQueryNameserver(t, r, ns, "google.com") txtRecords := resp.Records["TXT"] require.NotEmpty(t, txtRecords, @@ -297,14 +224,10 @@ func TestQueryNameserver_NXDomain(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - ns := findOneNSForDomain(t, r, ctx, "google.com") - - resp, err := r.QueryNameserver( - ctx, ns, - "this-surely-does-not-exist-xyz.google.com", + ns := findOneNSForDomain(t, r, "google.com") + resp := liveQueryNameserver( + t, r, ns, "this-surely-does-not-exist-xyz.google.com", ) - require.NoError(t, err) assert.Equal(t, resolver.StatusNXDomain, resp.Status) } @@ -313,13 +236,8 @@ func TestQueryNameserver_RecordsSorted(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - ns := findOneNSForDomain(t, r, ctx, "google.com") - - resp, err := r.QueryNameserver( - ctx, ns, "google.com", - ) - require.NoError(t, err) + ns := findOneNSForDomain(t, r, "google.com") + resp := liveQueryNameserver(t, r, ns, "google.com") for recordType, values := range resp.Records { assert.True( @@ -336,13 +254,8 @@ func TestQueryNameserver_ResponseIncludesNameserver( t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - ns := findOneNSForDomain(t, r, ctx, "cloudflare.com") - - resp, err := r.QueryNameserver( - ctx, ns, "cloudflare.com", - ) - require.NoError(t, err) + ns := findOneNSForDomain(t, r, "cloudflare.com") + resp := liveQueryNameserver(t, r, ns, "cloudflare.com") assert.Equal(t, ns, resp.Nameserver) } @@ -353,14 +266,10 @@ func TestQueryNameserver_EmptyRecordsOnNXDomain( t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - ns := findOneNSForDomain(t, r, ctx, "google.com") - - resp, err := r.QueryNameserver( - ctx, ns, - "this-surely-does-not-exist-xyz.google.com", + ns := findOneNSForDomain(t, r, "google.com") + resp := liveQueryNameserver( + t, r, ns, "this-surely-does-not-exist-xyz.google.com", ) - require.NoError(t, err) totalRecords := 0 for _, values := range resp.Records { @@ -374,18 +283,9 @@ func TestQueryNameserver_TrailingDotHandling(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - ns := findOneNSForDomain(t, r, ctx, "google.com") - - resp1, err := r.QueryNameserver( - ctx, ns, "google.com", - ) - require.NoError(t, err) - - resp2, err := r.QueryNameserver( - ctx, ns, "google.com.", - ) - require.NoError(t, err) + ns := findOneNSForDomain(t, r, "google.com") + resp1 := liveQueryNameserver(t, r, ns, "google.com") + resp2 := liveQueryNameserver(t, r, ns, "google.com.") assert.Equal(t, resp1.Status, resp2.Status) } @@ -398,15 +298,9 @@ func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) + results := liveQueryAllNameservers(t, r, "google.com") - results, err := r.QueryAllNameservers( - ctx, "google.com", - ) - require.NoError(t, err) - require.NotEmpty(t, results) - - assert.GreaterOrEqual(t, len(results), 2) + assert.GreaterOrEqual(t, len(results), minNameservers) for ns, resp := range results { assert.Equal(t, ns, resp.Nameserver) @@ -417,19 +311,27 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) + results := liveQueryAllNameservers(t, r, "google.com") - results, err := r.QueryAllNameservers( - ctx, "google.com", + // A quorum, not unanimity: one authoritative server being + // slow or rate-limiting us is a property of the live + // internet, not a resolver defect. + assert.GreaterOrEqual( + t, + countStatus(results, resolver.StatusOK), + liveQuorum(len(results)), + "a quorum of nameservers should answer OK: %s", + describeStatuses(results), ) - require.NoError(t, err) - for ns, resp := range results { - assert.Equal( - t, resolver.StatusOK, resp.Status, - "NS %s should return OK", ns, - ) - } + // Any nameserver claiming google.com does not exist is a + // real failure and is never tolerated. + assert.Zero( + t, + countStatus(results, resolver.StatusNXDomain), + "no nameserver should report NXDOMAIN: %s", + describeStatuses(results), + ) } func TestQueryAllNameservers_NXDomainFromAllNS( @@ -438,20 +340,26 @@ func TestQueryAllNameservers_NXDomainFromAllNS( t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - results, err := r.QueryAllNameservers( - ctx, - "this-surely-does-not-exist-xyz.google.com", + results := liveQueryAllNameservers( + t, r, "this-surely-does-not-exist-xyz.google.com", ) - require.NoError(t, err) - for ns, resp := range results { - assert.Equal( - t, resolver.StatusNXDomain, resp.Status, - "NS %s should return nxdomain", ns, - ) - } + assert.GreaterOrEqual( + t, + countStatus(results, resolver.StatusNXDomain), + liveQuorum(len(results)), + "a quorum of nameservers should report NXDOMAIN: %s", + describeStatuses(results), + ) + + // Silence is tolerated; a positive answer for a name that + // does not exist is not. + assert.Zero( + t, + countStatus(results, resolver.StatusOK), + "no nameserver should answer OK: %s", + describeStatuses(results), + ) } // ---------------------------------------------------------------- @@ -462,11 +370,7 @@ func TestLookupNS_ValidDomain(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - nameservers, err := r.LookupNS(ctx, "google.com") - require.NoError(t, err) - require.NotEmpty(t, nameservers) + nameservers := liveLookupNS(t, r, "google.com") for _, ns := range nameservers { assert.True(t, strings.HasSuffix(ns, "."), @@ -479,10 +383,7 @@ func TestLookupNS_Sorted(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - nameservers, err := r.LookupNS(ctx, "google.com") - require.NoError(t, err) + nameservers := liveLookupNS(t, r, "google.com") assert.True(t, sort.StringsAreSorted(nameservers)) } @@ -491,15 +392,8 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - fromLookup, err := r.LookupNS(ctx, "google.com") - require.NoError(t, err) - - fromFind, err := r.FindAuthoritativeNameservers( - ctx, "google.com", - ) - require.NoError(t, err) + fromLookup := liveLookupNS(t, r, "google.com") + fromFind := liveFindAuthoritative(t, r, "google.com") assert.Equal(t, fromFind, fromLookup) } @@ -512,11 +406,7 @@ func TestResolveIPAddresses_ReturnsIPs(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - ips, err := r.ResolveIPAddresses(ctx, "google.com") - require.NoError(t, err) - require.NotEmpty(t, ips) + ips := liveResolveIPs(t, r, "google.com") for _, ip := range ips { parsed := net.ParseIP(ip) @@ -530,10 +420,7 @@ func TestResolveIPAddresses_Deduplicated(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - ips, err := r.ResolveIPAddresses(ctx, "google.com") - require.NoError(t, err) + ips := liveResolveIPs(t, r, "google.com") seen := make(map[string]bool) @@ -547,10 +434,7 @@ func TestResolveIPAddresses_Sorted(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - ips, err := r.ResolveIPAddresses(ctx, "google.com") - require.NoError(t, err) + ips := liveResolveIPs(t, r, "google.com") assert.True(t, sort.StringsAreSorted(ips)) } @@ -561,13 +445,10 @@ func TestResolveIPAddresses_NXDomainReturnsEmpty( t.Parallel() r := newTestResolver(t) - ctx := testContext(t) - - ips, err := r.ResolveIPAddresses( - ctx, - "this-surely-does-not-exist-xyz.google.com", + ips := liveResolveIPsAllowingEmpty( + t, r, "this-surely-does-not-exist-xyz.google.com", ) - require.NoError(t, err) + assert.Empty(t, ips) } @@ -575,11 +456,9 @@ func TestResolveIPAddresses_CloudflareDomain(t *testing.T) { t.Parallel() r := newTestResolver(t) - ctx := testContext(t) + ips := liveResolveIPs(t, r, "cloudflare.com") - ips, err := r.ResolveIPAddresses(ctx, "cloudflare.com") - require.NoError(t, err) - require.NotEmpty(t, ips) + assert.NotEmpty(t, ips) } // ---------------------------------------------------------------- diff --git a/script/test b/script/test index 50b1730..568e3f5 100755 --- a/script/test +++ b/script/test @@ -6,7 +6,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - go test -v -race -timeout 30s -cover ./... + go test -v -race -timeout 90s -cover ./... } main "$@" -- 2.54.0 From 87bce43f8d7b67f50fa210dd89165f48bf506680 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 13:33:26 +0000 Subject: [PATCH 03/92] =?UTF-8?q?test:=20rework=20live-DNS=20quorum=20unit?= =?UTF-8?q?=20=E2=80=94=20tolerate=20silence,=20never=20a=20wrong=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework of the unit at https://git.eeqj.de/sneak/dnswatcher/issues/93 (commit 9cb2c2b), against the review at https://git.eeqj.de/sneak/dnswatcher/pulls/136#issuecomment-53869. Review of 9cb2c2b found the quorum assertions could not fail on a class of wrong answer. Each test banned exactly one bad status — _AllReturnOK banned only nxdomain, _NXDomainFromAllNS banned only ok — so resolver.StatusNoData passed both. nodata is a wrong answer, not silence, and answeredCount counted it as answered, so it did not even trigger a retry; with a quorum of 3 of 4 a single wrong nameserver slid through undetected. That is assertion-loosening beyond what the quorum change requires. Tolerance is now a closed allowlist rather than a blocklist of one status. unsanctionedStatuses() reports every per-nameserver result whose status the caller did not explicitly sanction: ok/timeout/error for the all-OK test, nxdomain/timeout/error for the NXDOMAIN test. Silence (timeout, error) is the only thing quorum exists to tolerate; any other status, including one added to the resolver later, fails by name. answeredCount is likewise an allowlist of ok/nxdomain/nodata, so an unknown status counts as silence and can only cause a retry and then a loud failure, never a quiet pass. Two harness tests cover the regression directly: three OK plus one nodata (quorum satisfied, no nxdomain present — the input that used to pass) is now reported as unsanctioned, and an unknown status is neither counted as answered nor tolerated. Verified by re-running the reviewer's probe: queryEachNS patched to force one of google.com's four nameservers to return StatusNoData turns both tests red, naming the offending nameserver and status — --- FAIL: TestQueryAllNameservers_AllReturnOK (1.12s) Should be empty, but was [ns1.google.com.=nodata] every nameserver must answer OK or not answer at all: ns1.google.com.=nodata ns2.google.com.=ok ns3.google.com.=ok ns4.google.com.=ok --- FAIL: TestQueryAllNameservers_NXDomainFromAllNS (1.34s) Should be empty, but was [ns1.google.com.=nodata] every nameserver must report NXDOMAIN or not answer at all: ns1.google.com.=nodata ns2.google.com.=nxdomain ns3.google.com.=nxdomain ns4.google.com.=nxdomain — and green with the probe reverted. Also fixes the review's nit: the per-attempt deadline assertion had no lower bound, so it passed for a deadline far shorter than intended. No production code changed; DNS is still never mocked. --- TODO.md | 7 +- internal/resolver/livedns_harness_test.go | 144 ++++++++++++++++++++-- internal/resolver/livedns_test.go | 64 +++++++++- internal/resolver/resolver_test.go | 37 ++++-- 4 files changed, 227 insertions(+), 25 deletions(-) diff --git a/TODO.md b/TODO.md index 1b4f13f..8905768 100644 --- a/TODO.md +++ b/TODO.md @@ -30,7 +30,12 @@ confirm make check still passes. `internal/resolver/livedns_test.go` adds a package-wide concurrency gate (so parallel tests stop bursting at the first root server), retry with exponential backoff on transport failures only, and - quorum instead of unanimity for multi-nameserver assertions. The + quorum instead of unanimity for multi-nameserver assertions. Quorum + tolerates silence only: every per-nameserver status must be in a + closed allowlist (`ok`/`timeout`/`error`, or + `nxdomain`/`timeout`/`error`), so a wrong answer from a minority — + `nodata` today, any status added later — fails the test instead of + sliding through under the majority. The `make test` cap moved to the new org-wide 60s hard cap / 20s target with a 90s `-timeout` backstop; `REPO_POLICIES.md` re-vendored byte-identical from `sneak/prompts`. No mocks, no `-short`, no build diff --git a/internal/resolver/livedns_harness_test.go b/internal/resolver/livedns_harness_test.go index 066337d..87ce704 100644 --- a/internal/resolver/livedns_harness_test.go +++ b/internal/resolver/livedns_harness_test.go @@ -16,6 +16,16 @@ import ( // perform no DNS resolution of any kind, so they neither mock DNS // nor depend on it. +// Names for the synthetic status maps below. Nothing is ever queried +// at them: they are map keys handed to the package's pure counting +// helpers, not a stand-in for a nameserver. +const ( + nsExample1 = "ns1.example." + nsExample2 = "ns2.example." + nsExample3 = "ns3.example." + nsExample4 = "ns4.example." +) + func TestLiveQuorumIsStrictMajority(t *testing.T) { t.Parallel() @@ -41,20 +51,20 @@ func TestStatusCountingIgnoresSilentNameservers(t *testing.T) { t.Parallel() results := map[string]*resolver.NameserverResponse{ - "ns1.example.": { - Nameserver: "ns1.example.", + nsExample1: { + Nameserver: nsExample1, Status: resolver.StatusOK, }, - "ns2.example.": { - Nameserver: "ns2.example.", + nsExample2: { + Nameserver: nsExample2, Status: resolver.StatusOK, }, - "ns3.example.": { - Nameserver: "ns3.example.", + nsExample3: { + Nameserver: nsExample3, Status: resolver.StatusTimeout, }, - "ns4.example.": { - Nameserver: "ns4.example.", + nsExample4: { + Nameserver: nsExample4, Status: resolver.StatusError, }, } @@ -106,14 +116,126 @@ func TestRetryLiveGivesEachAttemptADeadline(t *testing.T) { retryLive(t, "deadline", func(ctx context.Context) error { deadline, ok := ctx.Deadline() assert.True(t, ok, "attempt should carry a deadline") - assert.LessOrEqual( - t, time.Until(deadline), liveAttemptTimeout, - ) + + remaining := time.Until(deadline) + + assert.LessOrEqual(t, remaining, liveAttemptTimeout) + + // Lower bound too: without one this passes for a + // deadline far shorter than intended, which would + // silently turn every live attempt into an instant + // timeout. + assert.Greater(t, remaining, liveAttemptTimeout/2) return nil }) } +// TestUnsanctionedStatusesRejectsWrongAnswers is the regression test +// for the defect this allowlist exists to prevent: a minority of +// nameservers answering WRONGLY while quorum keeps the suite green. +// nodata is the case that motivated it — it is a wrong answer, not +// silence, and it was previously banned by neither test. +func TestUnsanctionedStatusesRejectsWrongAnswers(t *testing.T) { + t.Parallel() + + // Four nameservers, three OK and one answering nodata: a + // quorum of three is satisfied and no NXDOMAIN is present, so + // the old blocklist assertions both passed on this input. + results := map[string]*resolver.NameserverResponse{ + nsExample1: { + Nameserver: nsExample1, + Status: resolver.StatusOK, + }, + nsExample2: { + Nameserver: nsExample2, + Status: resolver.StatusOK, + }, + nsExample3: { + Nameserver: nsExample3, + Status: resolver.StatusOK, + }, + nsExample4: { + Nameserver: nsExample4, + Status: resolver.StatusNoData, + }, + } + + assert.GreaterOrEqual( + t, + countStatus(results, resolver.StatusOK), + liveQuorum(len(results)), + ) + assert.Zero(t, countStatus(results, resolver.StatusNXDomain)) + + // nodata is an ANSWER, so it never triggers a retry: nothing + // but the allowlist stands between it and a false green. + assert.Equal(t, len(results), answeredCount(results)) + + assert.Equal( + t, + []string{nsExample4 + "=nodata"}, + unsanctionedStatuses( + results, + resolver.StatusOK, + resolver.StatusTimeout, + resolver.StatusError, + ), + "nodata must be reported as an unsanctioned status", + ) +} + +func TestUnsanctionedStatusesToleratesSilenceOnly(t *testing.T) { + t.Parallel() + + results := map[string]*resolver.NameserverResponse{ + nsExample1: { + Nameserver: nsExample1, + Status: resolver.StatusNXDomain, + }, + nsExample2: { + Nameserver: nsExample2, + Status: resolver.StatusTimeout, + }, + nsExample3: { + Nameserver: nsExample3, + Status: resolver.StatusError, + }, + } + + allowed := []string{ + resolver.StatusNXDomain, + resolver.StatusTimeout, + resolver.StatusError, + } + + assert.Empty( + t, + unsanctionedStatuses(results, allowed...), + "timeout and error are non-answers and are tolerated", + ) + + // The same silent nameservers do not count towards a quorum. + assert.Equal(t, 1, answeredCount(results)) + + // An unknown status is treated as silence by answeredCount — + // so it retries and fails loudly — and is unsanctioned by the + // allowlist rather than quietly permitted. + const laterStatus = "some-status-added-later" + + results[nsExample4] = &resolver.NameserverResponse{ + Nameserver: nsExample4, + Status: laterStatus, + } + + assert.Equal(t, 1, answeredCount(results)) + assert.Equal( + t, + []string{nsExample4 + "=" + laterStatus}, + unsanctionedStatuses(results, allowed...), + ) +} + func TestRunLiveBoundsConcurrency(t *testing.T) { t.Parallel() diff --git a/internal/resolver/livedns_test.go b/internal/resolver/livedns_test.go index 55ea755..b864460 100644 --- a/internal/resolver/livedns_test.go +++ b/internal/resolver/livedns_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "sort" "strings" "testing" @@ -44,6 +45,15 @@ import ( // nameservers, a strict majority answering as expected is // enough; a server that fails to answer is tolerated, while a // server that answers *wrongly* still fails the test. +// +// The tolerance in (3) is expressed as an ALLOWLIST of sanctioned +// statuses, never as a blocklist of known-bad ones. A blocklist bans +// the one wrong answer its author thought of and silently admits +// every other status, including any added to the resolver later; an +// allowlist fails on anything nobody explicitly sanctioned. Silence +// (timeout, error) is the only thing quorum exists to tolerate. A +// *wrong answer* — nxdomain for a name that exists, ok for one that +// does not, nodata for either — is never tolerated at any count. const ( // liveAttempts is how many times a live DNS operation is @@ -172,14 +182,62 @@ func countStatus( return n } +// liveAnswerStatuses is the closed set of statuses that count as a +// nameserver having ANSWERED at all, whether or not the test agrees +// with the answer. It is deliberately an allowlist: a status added +// to the resolver later is treated as silence, so it can only ever +// cause a retry and then a loud failure, never a quiet pass. +func liveAnswerStatuses() []string { + return []string{ + resolver.StatusOK, + resolver.StatusNXDomain, + resolver.StatusNoData, + } +} + // answeredCount counts the nameservers that produced an answer of // any kind, as opposed to failing or timing out. func answeredCount( results map[string]*resolver.NameserverResponse, ) int { - return len(results) - - countStatus(results, resolver.StatusError) - - countStatus(results, resolver.StatusTimeout) + answers := liveAnswerStatuses() + + n := 0 + + for _, resp := range results { + if slices.Contains(answers, resp.Status) { + n++ + } + } + + return n +} + +// unsanctionedStatuses returns "nameserver=status" for every result +// whose status the caller did not explicitly sanction, sorted for a +// stable failure message. Callers pass the full closed set they will +// accept — the expected answer plus whichever non-answers (timeout, +// error) quorum is allowed to tolerate — so that any status outside +// it fails the test by name. +func unsanctionedStatuses( + results map[string]*resolver.NameserverResponse, + allowed ...string, +) []string { + offenders := make([]string, 0, len(results)) + + for ns, resp := range results { + if slices.Contains(allowed, resp.Status) { + continue + } + + offenders = append( + offenders, fmt.Sprintf("%s=%s", ns, resp.Status), + ) + } + + sort.Strings(offenders) + + return offenders } // describeStatuses renders per-nameserver statuses for use in diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 721847e..13bdcde 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -324,12 +324,21 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) { describeStatuses(results), ) - // Any nameserver claiming google.com does not exist is a - // real failure and is never tolerated. - assert.Zero( + // Quorum tolerates SILENCE only. Every individual result must + // be either the expected answer or a non-answer: ok, timeout + // or error, and nothing else. Stated as a closed allowlist so + // that a wrong answer no one thought to ban — nxdomain and + // nodata today, any status added later — fails here rather + // than sliding through under the quorum. + assert.Empty( t, - countStatus(results, resolver.StatusNXDomain), - "no nameserver should report NXDOMAIN: %s", + unsanctionedStatuses( + results, + resolver.StatusOK, + resolver.StatusTimeout, + resolver.StatusError, + ), + "every nameserver must answer OK or not answer at all: %s", describeStatuses(results), ) } @@ -352,12 +361,20 @@ func TestQueryAllNameservers_NXDomainFromAllNS( describeStatuses(results), ) - // Silence is tolerated; a positive answer for a name that - // does not exist is not. - assert.Zero( + // Silence is tolerated; any actual answer other than NXDOMAIN + // is not. Closed allowlist for the same reason as above: a + // server answering `ok` or `nodata` for a name that must not + // exist is a wrong answer, not a slow one. + assert.Empty( t, - countStatus(results, resolver.StatusOK), - "no nameserver should answer OK: %s", + unsanctionedStatuses( + results, + resolver.StatusNXDomain, + resolver.StatusTimeout, + resolver.StatusError, + ), + "every nameserver must report NXDOMAIN or not answer "+ + "at all: %s", describeStatuses(results), ) } -- 2.54.0 From 6f6bf3a65bbe3cf0ea42dd256a62a0b7e6f27796 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 13:48:15 +0000 Subject: [PATCH 04/92] test: disable Go's test cache so every run queries live DNS (closes #139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `script/test` did not pass `-count=1`, so on an unchanged tree Go served the whole suite from its test cache: exit 0 in ~0.2s with every package marked `(cached)` and not one DNS query made. This repo's suite exists to exercise live resolution on every run (`TESTING.md`), so that green asserted nothing — and it is exactly the green used as evidence that a flakiness fix works, since "run it a few times" stops being runs after the first. `-count=1` now disables caching on every invocation. The conditional verbose rerun that `REPO_POLICIES.md` mandates was missing at the same spot and is added here rather than left broken: the primary run had been unconditionally `-v`, which is the failure mode the policy exists to prevent (unreadable CI and `docker build` logs on success). Tests now run quiet, and only a failure triggers the `-v` rerun. The rerun carries `-count=1` too, so it cannot replay a cached copy of the failure it is meant to diagnose, and its exit status is discarded in favour of a forced 1: the first failure already proved the suite broken, so a flake that passes the second time must not turn the build green. `-timeout 90s` is untouched. It is a deliberate backstop that must strictly exceed the 60s hard cap on suite duration. No special-casing for the Docker build, which also reaches this script via `RUN make test`: a fresh container's test cache is empty, so `-count=1` changes nothing there and carving out an exception would only create a second code path that could drift. Verified: three back-to-back `make test` runs on an unchanged tree, zero `(cached)` markers, ~4.0-4.5s wall each (was ~0.2s cached), comfortably inside the 20s target with `-race` and `-cover` both still working and coverage percentages unchanged. The rerun-and-still-fail path was exercised against a purpose-built flaky test that fails once then passes: quiet failure, verbose rerun that genuinely re-executed, exit 1 regardless. `make check` green. --- README.md | 6 +++++- TESTING.md | 3 +++ TODO.md | 12 ++++++++++++ script/test | 25 ++++++++++++++++++++++++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 96141f9..8a5e580 100644 --- a/README.md +++ b/README.md @@ -387,7 +387,11 @@ them. We provide: plus the git pre-commit hook - `script/projectname` — print the project name (used for the Docker image tag) -- `script/test` — run the test suite (race detector, coverage) +- `script/test` — run the test suite (race detector, coverage). Caching + is waived for testing, exactly as it is for linting: `-count=1` + forces every invocation to execute, because the suite queries live + DNS and a cached pass queries nothing. Failures are rerun with `-v` + automatically, and the build fails even if that rerun passes. - `script/lint` — run golangci-lint, always inside Docker: it builds `Dockerfile.lint`, which COPYs the repo into the digest-pinned `golangci-lint` image and lints as a build step, so a successful diff --git a/TESTING.md b/TESTING.md index 0acae53..659db13 100644 --- a/TESTING.md +++ b/TESTING.md @@ -31,4 +31,7 @@ real servers ensures the resolver works correctly in production. exists for unit-testing other packages that consume the resolver) - **Do not add `-short` flags** to skip slow tests - **Do not increase `-timeout`** to hide hanging queries +- **Do not remove `-count=1` from `script/test`** — Go's test cache + replays a previous run's output without querying anything, so a + cached pass is not evidence that live resolution works - **Do not modify linter configuration** to suppress findings diff --git a/TODO.md b/TODO.md index 8905768..97286e7 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,18 @@ confirm make check still passes. # Completed Steps +- 2026-08-10: Go's test cache disabled for `script/test` via `-count=1`, + so every invocation actually executes. A cached pass replays an + earlier run's output without querying DNS at all, which in this repo + means the suite's entire premise goes unexercised while the run + reports green in under a second. The conditional verbose rerun that + `REPO_POLICIES.md` mandates was added at the same time (the primary + run had been unconditionally `-v`): quiet first, `-v` only on + failure, `-count=1` on both, and exit 1 forced regardless of the + rerun's result so a flake passing the second time cannot turn the + build green. `-timeout 90s` left alone as the deliberate backstop + above the 60s hard cap. Uncached suite runs ~4s, well inside the 20s + target - 2026-08-10: live-DNS test flakiness addressed by robustness rather than gating, per the owner's ruling on #93: new `internal/resolver/livedns_test.go` adds a package-wide concurrency diff --git a/script/test b/script/test index 568e3f5..b4ef492 100755 --- a/script/test +++ b/script/test @@ -1,12 +1,35 @@ #!/bin/sh # script/test: run the test suite. +# +# -count=1 disables Go's test cache, and is load-bearing here. This +# suite queries live DNS on every run by policy (TESTING.md); a cached +# result is a replay of an earlier run's output with no query made at +# all. On an unchanged tree the whole suite would return success in +# under a second having resolved nothing, which makes the repeated-run +# green that is used as evidence for flakiness fixes worthless. Do not +# remove it. +# +# Conditional verbose rerun per REPO_POLICIES.md: run quiet first so +# CI and docker build logs stay readable, and rerun with -v only on +# failure. The rerun also carries -count=1 (a cached replay of the +# failure would show nothing new), and the exit status is forced to 1 +# no matter how the rerun ends: the first failure already proved the +# suite broken, so a flaky test that passes the second time must not +# turn the build green. +# +# -timeout 90s is a deliberate backstop above the 60s hard cap on +# suite duration. Do not lower it. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - go test -v -race -timeout 90s -cover ./... + go test -count=1 -race -timeout 90s -cover ./... || { + echo "--- Rerunning with -v for details ---" >&2 + go test -count=1 -race -timeout 90s -v ./... || true + exit 1 + } } main "$@" -- 2.54.0 From 168281ad60d488ab65bfc671ecd3816195a728c8 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 14:04:31 +0000 Subject: [PATCH 05/92] docs: add MIT LICENSE and state the licence in the README (closes #102) The repository had no licence file at all, which makes publicly readable code all-rights-reserved by default: nobody may legally use it. That is a 1.0 blocker rather than a nicety, and `LICENSE` was the only file from `REPO_POLICIES.md`'s required minimum still missing here. The choice is standing org policy rather than a per-repo call: any public repo lacking a licence gets MIT, while a private repo with no licence is already all-rights-reserved and needs nothing. `sneak/dnswatcher` is public, so MIT. `LICENSE` carries the canonical MIT text byte-for-byte with only the copyright line filled in; no clauses added, removed, reworded, or reflowed. `README.md`'s first line now names the licence, which the Description requirement in `REPO_POLICIES.md` calls for, and the License section states MIT and points at the file instead of recording the decision as pending. --- LICENSE | 21 +++++++++++++++++++++ README.md | 7 ++++--- TODO.md | 26 ++++++++++++++++++++------ 3 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34edefe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 sneak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 8a5e580..3f5d5bf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # dnswatcher -dnswatcher is a pre-1.0 Go daemon by [@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, and TLS certificates, delivering real-time change notifications via Slack, Mattermost, and ntfy webhooks. +dnswatcher is an MIT-licensed, pre-1.0 Go daemon by [@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, and TLS certificates, delivering real-time change notifications via Slack, Mattermost, and ntfy webhooks. > ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice. @@ -486,8 +486,9 @@ Viper for configuration. ## License -License has not yet been chosen for this project. Pending decision by the -author (MIT, GPL, or WTFPL). +dnswatcher is released under the MIT License, Copyright (c) 2026 +[@sneak](https://sneak.berlin). See the [`LICENSE`](./LICENSE) file in the +repository root for the full text. ## Author diff --git a/TODO.md b/TODO.md index 97286e7..b44e872 100644 --- a/TODO.md +++ b/TODO.md @@ -18,13 +18,29 @@ iterative resolver implementation with hermetic mocked tests. # Next Step -Policy scaffold commit: add LICENSE, REPO_POLICIES.md, .editorconfig, -.dockerignore, and .gitea/workflows/check.yml, and add the missing -fmt-check, docker, and hooks targets to the Makefile. One commit, then -confirm make check still passes. +Add the README sections required by policy (Description, Getting Started, +Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-08-10: MIT `LICENSE` added at the repository root, closing the + last gap in `REPO_POLICIES.md`'s required-minimum file list and + removing the all-rights-reserved default that would otherwise have + shipped with a 1.0 tag. The licence choice is the standing org policy + (any public repo lacking a licence gets MIT; a private repo with no + licence is already all-rights-reserved), and this repo is public. The + file holds the canonical MIT text byte-for-byte with only the + copyright line filled in (`Copyright (c) 2026 sneak`); no clauses were + added, removed, or reflowed. `README.md`'s first line now names the + licence, as the Description requirement demands, and the License + section states MIT and points at the file instead of saying the choice + is pending. `make fmt` covers only Go sources (`gofmt -s`, + `goimports`), so it cannot reflow `LICENSE` +- 2026-08-10: the policy scaffold (`REPO_POLICIES.md`, `.editorconfig`, + `.dockerignore`, `.gitea/workflows/check.yml`, and the `fmt-check`, + `docker`, and hooks Makefile targets) is present; it landed piecemeal + across the scripts-to-rule-them-all and policy commits rather than as + the single commit this file once planned - 2026-08-10: Go's test cache disabled for `script/test` via `-count=1`, so every invocation actually executes. A cached pass replays an earlier run's output without querying DNS at all, which in this repo @@ -90,8 +106,6 @@ confirm make check still passes. Compliance: -- Add README sections required by policy (Description, Getting Started, - Rationale, Design, TODO, License, Author) if any are missing - Pin Dockerfile base images by sha256 and ensure the Docker build runs make check -- 2.54.0 From b8662b8a9c03cb79ea7a5d68e2a3609086cede85 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 10 Aug 2026 14:09:20 +0000 Subject: [PATCH 06/92] docs: correct stale script headers and record the config-verify cost (closes #137) script/bootstrap credited the goimports pin to script/fmt-check, which runs gofmt only; the header now credits script/fmt. script/cibuild still described the Dockerfile as running make check, which stopped being true once linting moved to its own stage. The docker-missing warning in script/bootstrap is one sentence instead of three fragments each carrying the bootstrap: prefix. Dockerfile.lint now states the residual risk of skipping golangci-lint config verify: unknown top-level keys in .golangci.yml are ignored silently, so a mistyped key lints clean and applies nothing. Comment and message text only; no behaviour changes. --- Dockerfile.lint | 4 +++- TODO.md | 14 ++++++++++++++ script/bootstrap | 8 ++++---- script/cibuild | 5 +++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Dockerfile.lint b/Dockerfile.lint index 5c236de..4dcb14e 100644 --- a/Dockerfile.lint +++ b/Dockerfile.lint @@ -6,7 +6,9 @@ # # `golangci-lint config verify` is deliberately NOT run here: it # fetches its JSON schema over a live, unpinned HTTPS call, which would -# make linting network-dependent and defeat hash-pinning. +# make linting network-dependent and defeat hash-pinning. The cost of +# that: unknown top-level keys in .golangci.yml are silently ignored, +# so a mistyped or wrong-schema key lints clean while applying nothing. # # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10 FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps diff --git a/TODO.md b/TODO.md index b44e872..7bf50f3 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,20 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-08-10: comment-only corrections to `script/bootstrap`, + `script/cibuild`, and `Dockerfile.lint`. The `goimports` pin in + `script/bootstrap` was justified by a claim that `script/fmt-check` + runs it on the host; it does not (it runs `gofmt -l .` only), so the + header now credits `script/fmt` alone. `script/cibuild` still claimed + the `Dockerfile` runs `make check`, which stopped being true when + linting moved to its own stage; it now describes the lint stage + (`make fmt-check` plus `golangci-lint`) and the builder stage + (`make test`, `make build`). The `docker`-missing warning in + `script/bootstrap` reads as one sentence instead of three fragments + each re-prefixed with `bootstrap:`. `Dockerfile.lint` now records the + residual risk of omitting `golangci-lint config verify`: unknown + top-level keys in `.golangci.yml` are silently ignored, so a mistyped + key lints clean while applying nothing. No behaviour changed - 2026-08-10: MIT `LICENSE` added at the repository root, closing the last gap in `REPO_POLICIES.md`'s required-minimum file list and removing the all-rights-reserved default that would otherwise have diff --git a/script/bootstrap b/script/bootstrap index 0901c87..06da5d0 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -4,7 +4,8 @@ # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes nothing is present. # goimports is installed via `go install` at a pinned commit (never -# "latest") because script/fmt and script/fmt-check run it on the host. +# "latest") because script/fmt runs it on the host; script/fmt-check +# does not (it runs gofmt only). # The linter is NOT installed here: golangci-lint runs via docker only # (script/lint), pinned by image digest, so its only prerequisite is a # working docker. @@ -77,9 +78,8 @@ main() { # Linting runs via docker only (script/lint). Warn, don't fail: # everything except `make lint` works without it. if missing docker; then - echo "bootstrap: WARNING: docker not found; make lint and" >&2 - echo "bootstrap: make docker require it. Install docker to" >&2 - echo "bootstrap: run the linter." >&2 + echo "bootstrap: WARNING: docker not found; install it to" \ + "run make lint and make docker." >&2 fi go mod download diff --git a/script/cibuild b/script/cibuild index 966f51d..1b9e57d 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,6 +1,7 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs make check, so -# a successful build implies all checks pass. +# script/cibuild: run the CI build. The Dockerfile's lint stage runs +# make fmt-check and golangci-lint; its builder stage runs make test +# and make build. A successful build implies all of those passed. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -- 2.54.0 From ae06f7e3a1b2fafa1769b276834c3489b6e53183 Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 9 Sep 2026 14:26:53 +0200 Subject: [PATCH 07/92] notify: drain in-flight deliveries at shutdown (closes #106) Shutdown waits for deliveries already in flight instead of dropping them. Reviewed and green; squashed to next by the dispatcher, dnswatcher having no manager. Model: opus-5 --- README.md | 13 +- TODO.md | 9 + internal/notify/export_test.go | 26 +- internal/notify/notify.go | 145 +++++---- internal/notify/retry.go | 9 + internal/notify/shutdown.go | 119 +++++++ internal/notify/shutdown_test.go | 531 +++++++++++++++++++++++++++++++ 7 files changed, 780 insertions(+), 72 deletions(-) create mode 100644 internal/notify/shutdown.go create mode 100644 internal/notify/shutdown_test.go diff --git a/README.md b/README.md index 3f5d5bf..9e854ff 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,8 @@ internal/ - **Structured logging**: All logs use `log/slog` with JSON output in production (TTY detection for development). - **Graceful shutdown**: All background goroutines respect context - cancellation and the fx lifecycle. + cancellation and the fx lifecycle. In-flight notification deliveries + are drained on shutdown, bounded by the shutdown timeout. --- @@ -463,8 +464,14 @@ docker run -d \ from a previous cycle. 4. **On change detection**: Send notifications to all configured endpoints, update in-memory state, persist to disk. -5. **Shutdown**: Persist final state to disk, complete in-flight - notifications, stop gracefully. +5. **Shutdown**: Persist final state to disk, wait for in-flight + notification deliveries to complete, stop gracefully. The wait is + bounded by the fx shutdown timeout (15s by default): deliveries still + retrying against an unreachable endpoint when that expires are + abandoned, and the number abandoned is logged at warn level rather + than dropped silently. Notifications generated after shutdown has + begun are refused and logged, so a late burst cannot extend the + shutdown. --- diff --git a/TODO.md b/TODO.md index 7bf50f3..91ea4e5 100644 --- a/TODO.md +++ b/TODO.md @@ -92,6 +92,15 @@ Rationale, Design, TODO, License, Author) if any are still missing. `make check` into `script/lint`. `golangci-lint config verify` is deliberately omitted: it fetches its schema over an unpinned live HTTPS call +- 2026-08-09: in-flight notification deliveries are now drained at + shutdown (#106): `notify.New` registers an fx `OnStop` hook that waits + on a `sync.WaitGroup` of tracked delivery goroutines, bounded by the + `OnStop` context; on expiry the outstanding count is logged at warn + level and parked retry backoffs are released instead of being dropped + silently, and deliveries submitted after the drain begins are refused + so shutdown cannot be extended indefinitely; an `OnStop` context that + is already expired on entry with nothing outstanding drains quietly + rather than warning about deliveries that were never abandoned - 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the org-standard v2-schema config used across the org's repos diff --git a/internal/notify/export_test.go b/internal/notify/export_test.go index ae6818d..723fee6 100644 --- a/internal/notify/export_test.go +++ b/internal/notify/export_test.go @@ -32,11 +32,27 @@ func NewRequestForTest( // NewTestService creates a Service suitable for unit testing. // It discards log output and uses the given transport. func NewTestService(transport http.RoundTripper) *Service { - return &Service{ - log: slog.New(slog.DiscardHandler), - transport: transport, - history: NewAlertHistory(), - } + return newService(slog.New(slog.DiscardHandler), transport) +} + +// NewTestServiceWithLogger creates a Service that writes to the +// given handler, so tests can assert on emitted log records. +func NewTestServiceWithLogger( + transport http.RoundTripper, + handler slog.Handler, +) *Service { + return newService(slog.New(handler), transport) +} + +// Drain exports drain for testing. +func (svc *Service) Drain(ctx context.Context) { + svc.drain(ctx) +} + +// OutstandingDeliveries reports how many delivery goroutines +// are currently tracked as in flight. +func (svc *Service) OutstandingDeliveries() int64 { + return svc.outstanding.Load() } // SetNtfyURL sets the ntfy URL on a Service for testing. diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 878b912..1185ed6 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -12,6 +12,8 @@ import ( "log/slog" "net/http" "net/url" + "sync" + "sync/atomic" "time" "go.uber.org/fx" @@ -115,19 +117,41 @@ type Service struct { history *AlertHistory retryConfig RetryConfig sleepFn func(time.Duration) <-chan time.Time + + // Shutdown draining state. drainMu guards draining and + // serialises it against the counter increment in + // startDelivery; inFlight tracks the delivery goroutines + // themselves and outstanding mirrors its count so a timed + // out drain can report how many were abandoned. + drainMu sync.Mutex + draining bool + inFlight sync.WaitGroup + outstanding atomic.Int64 + abandon chan struct{} + abandonOnce sync.Once +} + +// newService builds a Service with the fields every Service +// needs regardless of how it was constructed. +func newService( + log *slog.Logger, + transport http.RoundTripper, +) *Service { + return &Service{ + log: log, + transport: transport, + history: NewAlertHistory(), + abandon: make(chan struct{}), + } } // New creates a new notify Service. func New( - _ fx.Lifecycle, + lifecycle fx.Lifecycle, params Params, ) (*Service, error) { - svc := &Service{ - log: params.Logger.Get(), - transport: http.DefaultTransport, - config: params.Config, - history: NewAlertHistory(), - } + svc := newService(params.Logger.Get(), http.DefaultTransport) + svc.config = params.Config if params.Config.NtfyTopic != "" { u, err := ValidateWebhookURL( @@ -168,6 +192,14 @@ func New( svc.mattermostWebhookURL = u } + lifecycle.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + svc.drain(ctx) + + return nil + }, + }) + return svc, nil } @@ -194,6 +226,32 @@ func (svc *Service) SendNotification( svc.dispatchMattermost(ctx, title, message, priority) } +// dispatch delivers a notification to one endpoint on a +// tracked background goroutine. +// +// The delivery context is detached from ctx with +// context.WithoutCancel so that a cancelled caller does not +// kill a delivery already under way; the shutdown drain, not +// the caller, decides how long deliveries may keep running. +func (svc *Service) dispatch( + ctx context.Context, + endpoint string, + send func(context.Context) error, +) { + notifyCtx := context.WithoutCancel(ctx) + + svc.startDelivery(endpoint, func() { + err := svc.deliverWithRetry(notifyCtx, endpoint, send) + if err != nil { + svc.log.Error( + "failed to send notification after retries", + "endpoint", endpoint, + "error", err, + ) + } + }) +} + func (svc *Service) dispatchNtfy( ctx context.Context, title, message, priority string, @@ -202,26 +260,11 @@ func (svc *Service) dispatchNtfy( return } - go func() { - notifyCtx := context.WithoutCancel(ctx) - - err := svc.deliverWithRetry( - notifyCtx, "ntfy", - func(c context.Context) error { - return svc.sendNtfy( - c, svc.ntfyURL, - title, message, priority, - ) - }, + svc.dispatch(ctx, "ntfy", func(c context.Context) error { + return svc.sendNtfy( + c, svc.ntfyURL, title, message, priority, ) - if err != nil { - svc.log.Error( - "failed to send ntfy notification "+ - "after retries", - "error", err, - ) - } - }() + }) } func (svc *Service) dispatchSlack( @@ -232,26 +275,11 @@ func (svc *Service) dispatchSlack( return } - go func() { - notifyCtx := context.WithoutCancel(ctx) - - err := svc.deliverWithRetry( - notifyCtx, "slack", - func(c context.Context) error { - return svc.sendSlack( - c, svc.slackWebhookURL, - title, message, priority, - ) - }, + svc.dispatch(ctx, "slack", func(c context.Context) error { + return svc.sendSlack( + c, svc.slackWebhookURL, title, message, priority, ) - if err != nil { - svc.log.Error( - "failed to send slack notification "+ - "after retries", - "error", err, - ) - } - }() + }) } func (svc *Service) dispatchMattermost( @@ -262,26 +290,15 @@ func (svc *Service) dispatchMattermost( return } - go func() { - notifyCtx := context.WithoutCancel(ctx) - - err := svc.deliverWithRetry( - notifyCtx, "mattermost", - func(c context.Context) error { - return svc.sendSlack( - c, svc.mattermostWebhookURL, - title, message, priority, - ) - }, - ) - if err != nil { - svc.log.Error( - "failed to send mattermost notification "+ - "after retries", - "error", err, + svc.dispatch( + ctx, "mattermost", + func(c context.Context) error { + return svc.sendSlack( + c, svc.mattermostWebhookURL, + title, message, priority, ) - } - }() + }, + ) } func (svc *Service) sendNtfy( diff --git a/internal/notify/retry.go b/internal/notify/retry.go index cbc49d3..6745085 100644 --- a/internal/notify/retry.go +++ b/internal/notify/retry.go @@ -2,6 +2,7 @@ package notify import ( "context" + "fmt" "math" "math/rand/v2" "time" @@ -121,6 +122,14 @@ func (svc *Service) deliverWithRetry( select { case <-ctx.Done(): return ctx.Err() + case <-svc.abandon: + // Shutdown drained past its deadline; stop + // sleeping rather than outlive the process. + // A nil channel (Service built without a + // constructor) simply never fires. + return fmt.Errorf( + "%w: %s", ErrDeliveryAbandoned, endpoint, + ) case <-svc.sleepFunc(delay): } } diff --git a/internal/notify/shutdown.go b/internal/notify/shutdown.go new file mode 100644 index 0000000..0269889 --- /dev/null +++ b/internal/notify/shutdown.go @@ -0,0 +1,119 @@ +package notify + +import ( + "context" + "errors" +) + +// ErrDeliveryAbandoned is returned by a retry loop that was +// cut short because shutdown drained past its deadline. +var ErrDeliveryAbandoned = errors.New( + "notification delivery abandoned at shutdown", +) + +// startDelivery runs fn on its own goroutine while tracking it, +// so that drain can wait for it during shutdown. +// +// The WaitGroup counter is incremented here, on the caller's +// goroutine, before the worker exists: incrementing it inside +// the worker would race with drain's Wait and could let +// shutdown sail past a delivery that had not started yet. +// +// Once draining has begun the delivery is refused outright +// rather than queued, so a steady stream of newly submitted +// notifications cannot keep extending the drain. +func (svc *Service) startDelivery(endpoint string, fn func()) { + svc.drainMu.Lock() + + if svc.draining { + svc.drainMu.Unlock() + + svc.log.Warn( + "notification not dispatched: shutdown in progress", + "endpoint", endpoint, + ) + + return + } + + svc.outstanding.Add(1) + + // WaitGroup.Go increments the counter synchronously, here, + // and only then starts the goroutine. + svc.inFlight.Go(func() { + // Runs before the WaitGroup counter is decremented, so + // a drain that times out reports an accurate count. + defer svc.outstanding.Add(-1) + + fn() + }) + + svc.drainMu.Unlock() +} + +// drain waits for in-flight notification deliveries to finish. +// +// It first stops accepting new deliveries, then waits until +// either every outstanding delivery has completed or ctx +// expires — whichever comes first. ctx is the context fx +// passes to the OnStop hook, so a permanently dead webhook +// cannot hang shutdown indefinitely. +// +// When the deadline arrives with deliveries still outstanding, +// the count is logged at warn level and the abandon channel is +// closed, which releases any retry loop sleeping in backoff. +// Deliveries already inside an HTTP round trip are bounded by +// the existing httpClientTimeout instead. +// +// A ctx that is already expired on entry is not by itself cause +// for alarm: if nothing is outstanding there is nothing to +// abandon, and the drain says so at debug level rather than +// warning about deliveries that do not exist. +func (svc *Service) drain(ctx context.Context) { + svc.drainMu.Lock() + svc.draining = true + svc.drainMu.Unlock() + + done := make(chan struct{}) + + go func() { + svc.inFlight.Wait() + close(done) + }() + + select { + case <-done: + svc.log.Debug( + "all in-flight notifications completed", + ) + case <-ctx.Done(): + // outstanding is decremented before the WaitGroup + // counter, and startDelivery can no longer add to it + // now that draining is set, so a zero here means every + // delivery really did finish. ctx expiring in that + // state (an OnStop context that was already cancelled + // on entry is the usual way) abandons nothing, so it + // must not close abandon or warn about it. + abandoned := svc.outstanding.Load() + if abandoned == 0 { + svc.log.Debug( + "all in-flight notifications completed", + ) + + return + } + + svc.abandonOnce.Do(func() { + if svc.abandon != nil { + close(svc.abandon) + } + }) + + svc.log.Warn( + "shutdown deadline reached with notifications "+ + "still in flight; abandoning them", + "abandoned", abandoned, + "error", ctx.Err(), + ) + } +} diff --git a/internal/notify/shutdown_test.go b/internal/notify/shutdown_test.go new file mode 100644 index 0000000..f938b64 --- /dev/null +++ b/internal/notify/shutdown_test.go @@ -0,0 +1,531 @@ +package notify_test + +import ( + "bytes" + "context" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "go.uber.org/fx" + + "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/globals" + "sneak.berlin/go/dnswatcher/internal/logger" + "sneak.berlin/go/dnswatcher/internal/notify" +) + +// Timings used by the drain tests. They stay in the same +// 10-100ms band as the retry tests so the suite never waits on +// a real backoff delay. +const ( + // inFlightHold is how long a delivery is kept mid-request + // before the handler is released. + inFlightHold = 30 * time.Millisecond + + // drainDeadline bounds a drain that is expected to time + // out. + drainDeadline = 50 * time.Millisecond + + // drainSlack is the upper bound on how long a bounded + // drain may take; generous enough for a loaded CI box, + // still far below the 20s test ceiling. + drainSlack = 2 * time.Second + + // settleDelay is how long to wait before asserting that + // something did *not* happen. + settleDelay = 50 * time.Millisecond + + // idleDrainBound is the upper bound on a drain that has + // nothing in flight. It is deliberately far above the cost + // of the goroutine hop through inFlight.Wait() — which + // reached 57ms on a loaded box under -race with the package's + // parallel tests — and far below drainSlack, the deadline + // such a drain is given. A drain that blocked until its + // deadline instead of returning on the WaitGroup therefore + // still fails this bound, but scheduling delay alone cannot. + idleDrainBound = 500 * time.Millisecond +) + +// syncBuffer is an io.Writer safe for concurrent use, so log +// output written from delivery goroutines can be inspected. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (sb *syncBuffer) Write(p []byte) (int, error) { + sb.mu.Lock() + defer sb.mu.Unlock() + + return sb.buf.Write(p) //nolint:wrapcheck // test helper +} + +func (sb *syncBuffer) String() string { + sb.mu.Lock() + defer sb.mu.Unlock() + + return sb.buf.String() +} + +// newLoggingService returns a Service writing JSON logs into +// the returned buffer. +func newLoggingService( + transport http.RoundTripper, +) (*notify.Service, *syncBuffer) { + logs := &syncBuffer{} + handler := slog.NewJSONHandler(logs, nil) + + return notify.NewTestServiceWithLogger(transport, handler), + logs +} + +// blockingNtfyServer returns a server whose handler signals on +// entered, waits for release, and then responds 200. +func blockingNtfyServer( + entered chan<- struct{}, + release <-chan struct{}, + served *atomic.Bool, +) *httptest.Server { + var once sync.Once + + return httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + once.Do(func() { close(entered) }) + <-release + + served.Store(true) + + w.WriteHeader(http.StatusOK) + }), + ) +} + +// TestDrainWaitsForInFlightDelivery verifies that a delivery +// already under way when shutdown starts is allowed to finish. +func TestDrainWaitsForInFlightDelivery(t *testing.T) { + t.Parallel() + + var served atomic.Bool + + entered := make(chan struct{}) + release := make(chan struct{}) + + srv := blockingNtfyServer(entered, release, &served) + defer srv.Close() + + topicURL, _ := url.Parse(srv.URL) + + svc := notify.NewTestService(http.DefaultTransport) + svc.SetNtfyURL(topicURL) + + svc.SendNotification( + context.Background(), "t", "m", prioInfo, + ) + + // Make sure the delivery really is mid-request before the + // drain begins. + select { + case <-entered: + case <-time.After(drainSlack): + t.Fatal("delivery never reached the endpoint") + } + + // As in TestDrainBoundedByContextDeadline: start is captured + // before the clock it is compared against, here the timer + // holding the delivery open, so elapsed covers the whole hold + // and the lower bound cannot come out short from scheduling + // delay alone. + start := time.Now() + + timer := time.AfterFunc(inFlightHold, func() { + close(release) + }) + defer timer.Stop() + + ctx, cancel := context.WithTimeout( + context.Background(), drainSlack, + ) + defer cancel() + + svc.Drain(ctx) + + elapsed := time.Since(start) + + if !served.Load() { + t.Error( + "drain returned before the in-flight delivery " + + "completed", + ) + } + + if elapsed < inFlightHold { + t.Errorf( + "drain took %v, want at least %v", + elapsed, inFlightHold, + ) + } + + if got := svc.OutstandingDeliveries(); got != 0 { + t.Errorf("outstanding deliveries = %d, want 0", got) + } +} + +// neverFires returns a channel that never delivers, standing in +// for a long backoff sleep without actually sleeping. +func neverFires(_ time.Duration) <-chan time.Time { + return make(chan time.Time) +} + +// TestDrainBoundedByContextDeadline verifies that a delivery +// stuck retrying against a dead endpoint does not hold shutdown +// past the OnStop context deadline, and that the abandoned +// deliveries are logged at warn level rather than dropped +// silently. +func TestDrainBoundedByContextDeadline(t *testing.T) { + t.Parallel() + + var requests atomic.Int64 + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + + w.WriteHeader(http.StatusInternalServerError) + }), + ) + defer srv.Close() + + topicURL, _ := url.Parse(srv.URL) + + svc, logs := newLoggingService(http.DefaultTransport) + svc.SetNtfyURL(topicURL) + // Never let the backoff sleep complete: the delivery is + // parked in its retry wait until shutdown releases it. + svc.SetSleepFunc(neverFires) + svc.SetRetryConfig(notify.RetryConfig{ + MaxRetries: 5, + BaseDelay: time.Hour, + MaxDelay: time.Hour, + }) + + svc.SendNotification( + context.Background(), "t", "m", prioError, + ) + + waitForCondition(t, func() bool { + return requests.Load() >= 1 && + svc.OutstandingDeliveries() == 1 + }) + + // start must be captured *before* the deadline clock starts, + // so that the measured interval is a superset of the deadline + // interval. Capturing it after context.WithTimeout would + // make elapsed structurally smaller than drainDeadline and + // the lower bound below unfalsifiable-by-luck: it would fail + // whenever the two statements were separated by any + // scheduling delay, and pass otherwise, regardless of what + // the drain did. + start := time.Now() + + ctx, cancel := context.WithTimeout( + context.Background(), drainDeadline, + ) + defer cancel() + + // The upper bound is enforced by a watchdog rather than by + // measuring after the fact: a drain that is not bounded at + // all never returns here (the delivery is parked in a backoff + // that never fires), so an unbounded drain must fail this + // test promptly instead of hanging the package until the test + // binary's 30s timeout. + returned := make(chan struct{}) + + go func() { + defer close(returned) + + svc.Drain(ctx) + }() + + select { + case <-returned: + case <-time.After(drainSlack): + t.Fatalf( + "drain did not return within %v; its %v deadline "+ + "did not bound it", + drainSlack, drainDeadline, + ) + } + + // The lower bound is the real assertion: the drain must have + // waited for its whole deadline rather than giving up on the + // outstanding delivery early. With start captured above, an + // early return is the only thing that can make it fail. + if elapsed := time.Since(start); elapsed < drainDeadline { + t.Errorf( + "drain returned after %v, before its %v deadline", + elapsed, drainDeadline, + ) + } + + assertAbandonLogged(t, logs.String()) + + // The abandoned delivery must stop retrying rather than + // outlive the drain. + waitForCondition(t, func() bool { + return svc.OutstandingDeliveries() == 0 + }) +} + +// assertAbandonLogged checks that the drain logged the +// abandoned deliveries at warn level with a count. +func assertAbandonLogged(t *testing.T, output string) { + t.Helper() + + if !strings.Contains(output, `"level":"WARN"`) { + t.Errorf( + "abandoned deliveries not logged at warn level; "+ + "log output: %s", + output, + ) + } + + if !strings.Contains(output, `"abandoned":1`) { + t.Errorf( + "abandoned delivery count not logged; "+ + "log output: %s", + output, + ) + } +} + +// TestDrainRefusesNewDeliveries verifies that notifications +// submitted after the drain has begun are refused and logged, +// so a stream of new work cannot extend shutdown indefinitely. +func TestDrainRefusesNewDeliveries(t *testing.T) { + t.Parallel() + + var requests atomic.Int64 + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + + w.WriteHeader(http.StatusOK) + }), + ) + defer srv.Close() + + target, _ := url.Parse(srv.URL) + + svc, logs := newLoggingService(http.DefaultTransport) + svc.SetNtfyURL(target) + svc.SetSlackWebhookURL(target) + svc.SetMattermostWebhookURL(target) + + ctx, cancel := context.WithTimeout( + context.Background(), drainSlack, + ) + defer cancel() + + // Nothing is in flight, so this returns immediately and + // leaves the service refusing further deliveries. + svc.Drain(ctx) + + for range 3 { + svc.SendNotification( + context.Background(), "t", "m", prioInfo, + ) + } + + time.Sleep(settleDelay) + + if got := requests.Load(); got != 0 { + t.Errorf( + "%d requests reached the endpoint after drain, "+ + "want 0", + got, + ) + } + + if got := svc.OutstandingDeliveries(); got != 0 { + t.Errorf("outstanding deliveries = %d, want 0", got) + } + + output := logs.String() + if !strings.Contains(output, "shutdown in progress") { + t.Errorf( + "refused deliveries not logged; log output: %s", + output, + ) + } +} + +// recordingLifecycle is a minimal fx.Lifecycle that records the +// hooks appended to it, so the wiring done by notify.New can be +// inspected without standing up a whole fx application. +type recordingLifecycle struct { + hooks []fx.Hook +} + +func (l *recordingLifecycle) Append(hook fx.Hook) { + l.hooks = append(l.hooks, hook) +} + +// newNotifyService builds a Service through the real +// constructor, wired to the given lifecycle. +func newNotifyService( + t *testing.T, + lifecycle fx.Lifecycle, + ntfyTopic string, +) *notify.Service { + t.Helper() + + g, err := globals.New(nil) + if err != nil { + t.Fatalf("globals.New: %v", err) + } + + log, err := logger.New(nil, logger.Params{Globals: g}) + if err != nil { + t.Fatalf("logger.New: %v", err) + } + + svc, err := notify.New(lifecycle, notify.Params{ + Logger: log, + Config: &config.Config{NtfyTopic: ntfyTopic}, + }) + if err != nil { + t.Fatalf("notify.New: %v", err) + } + + return svc +} + +// TestNewRegistersDrainingStopHook verifies that notify.New +// wires an OnStop hook into the fx lifecycle and that the hook +// waits for in-flight deliveries. +func TestNewRegistersDrainingStopHook(t *testing.T) { + t.Parallel() + + var served atomic.Bool + + entered := make(chan struct{}) + release := make(chan struct{}) + + srv := blockingNtfyServer(entered, release, &served) + defer srv.Close() + + lifecycle := &recordingLifecycle{} + svc := newNotifyService(t, lifecycle, srv.URL) + + if len(lifecycle.hooks) != 1 { + t.Fatalf( + "appended %d lifecycle hooks, want 1", + len(lifecycle.hooks), + ) + } + + stop := lifecycle.hooks[0].OnStop + if stop == nil { + t.Fatal("lifecycle hook has no OnStop function") + } + + svc.SendNotification( + context.Background(), "t", "m", prioInfo, + ) + + select { + case <-entered: + case <-time.After(drainSlack): + t.Fatal("delivery never reached the endpoint") + } + + timer := time.AfterFunc(inFlightHold, func() { + close(release) + }) + defer timer.Stop() + + ctx, cancel := context.WithTimeout( + context.Background(), drainSlack, + ) + defer cancel() + + err := stop(ctx) + if err != nil { + t.Fatalf("OnStop returned error: %v", err) + } + + if !served.Load() { + t.Error( + "OnStop returned before the in-flight delivery " + + "completed", + ) + } +} + +// TestDrainWithoutDeliveriesReturnsImmediately verifies the +// common case: nothing in flight, shutdown is not delayed. +func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) { + t.Parallel() + + svc := notify.NewTestService(http.DefaultTransport) + + // Captured before the deadline clock, as elsewhere in this + // file; for an upper bound that is the conservative + // direction, since the measured interval can then only be + // longer than the drain itself. + start := time.Now() + + ctx, cancel := context.WithTimeout( + context.Background(), drainSlack, + ) + defer cancel() + + svc.Drain(ctx) + + if elapsed := time.Since(start); elapsed > idleDrainBound { + t.Errorf( + "drain of an idle service took %v, want well "+ + "under its %v deadline", + elapsed, drainSlack, + ) + } +} + +// TestDrainWithCancelledContextDoesNotWarn verifies that an +// OnStop context that is already dead on entry does not produce +// an "abandoning them" warning when there was nothing in flight +// to abandon. The expired context wins the select immediately, +// so only the outstanding count can tell the difference between +// a genuine timeout and a shutdown that had simply already run +// out of time with no work left. +func TestDrainWithCancelledContextDoesNotWarn(t *testing.T) { + t.Parallel() + + svc, logs := newLoggingService(http.DefaultTransport) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + svc.Drain(ctx) + + if output := logs.String(); strings.Contains( + output, `"level":"WARN"`, + ) { + t.Errorf( + "drain with nothing in flight warned about "+ + "abandoned deliveries; log output: %s", + output, + ) + } +} -- 2.54.0 From fc43f893a54e4abe995ca6c0c17e873494317163 Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 9 Sep 2026 15:17:43 +0200 Subject: [PATCH 08/92] server: set all four http.Server socket timeouts (#118) The http.Server was built with only ReadHeaderTimeout set; the other three timeouts were zero, which in net/http means no limit, so a peer could hold a connection open past the header phase, responses had no write deadline, and keep-alive connections were never reaped. ReadTimeout 15s, WriteTimeout 75s, and IdleTimeout 120s now join ReadHeaderTimeout 10s as named constants. WriteTimeout must stay above the 60s chimw.Timeout handler budget, because net/http arms the write deadline once request headers are read; a test fails the build if either number moves alone. The server literal moved into newHTTPServer so the configuration can be asserted without binding a socket (closes #99) Model: opus-5 --- README.md | 21 +++++++ TODO.md | 8 +++ internal/server/export_test.go | 19 ++++++ internal/server/server.go | 71 ++++++++++++++++++--- internal/server/server_test.go | 112 +++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 internal/server/export_test.go create mode 100644 internal/server/server_test.go diff --git a/README.md b/README.md index 9e854ff..c849041 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,27 @@ dnswatcher exposes a lightweight HTTP API for operational visibility: | `GET /api/v1/status` | Current monitoring state | | `GET /metrics` | Prometheus metrics (optional) | +#### Server timeouts + +The HTTP server sets all four socket-level timeouts. These are compile-time +constants in `internal/server/server.go`, not configurable via environment +variables. + +| Timeout | Value | Purpose | +|---------------------|-------|-----------------------------------------------| +| `ReadHeaderTimeout` | 10s | Bounds the request header read (slowloris) | +| `ReadTimeout` | 15s | Bounds the whole request read, headers + body | +| `WriteTimeout` | 75s | Bounds handler execution plus response flush | +| `IdleTimeout` | 120s | Reaps idle keep-alive connections | + +These are distinct from the 60s per-request handler budget applied by +`chimw.Timeout` in `internal/server/routes.go`, which cancels the request +context but does not touch the socket. `WriteTimeout` is deliberately +larger than that budget: the write deadline is armed once request headers +are read, so a smaller value would sever the connection before a handler +using its full budget could respond. `IdleTimeout` exceeds common +Prometheus scrape intervals so the scraper reuses its connection. + --- ## Architecture diff --git a/TODO.md b/TODO.md index 91ea4e5..259fad2 100644 --- a/TODO.md +++ b/TODO.md @@ -101,6 +101,14 @@ Rationale, Design, TODO, License, Author) if any are still missing. so shutdown cannot be extended indefinitely; an `OnStop` context that is already expired on entry with nothing outstanding drains quietly rather than warning about deliveries that were never abandoned +- 2026-08-09: `http.Server` now sets all four socket-level timeouts + (`ReadTimeout` 15s, `ReadHeaderTimeout` 10s, `WriteTimeout` 75s, + `IdleTimeout` 120s) as named constants in `internal/server/server.go`, + closing the slowloris / unreaped-keep-alive exposure required by + `REPO_POLICIES.md` before 1.0; `WriteTimeout` is deliberately greater + than the 60s `chimw.Timeout` handler budget so that budget stays + reachable, and tests in `internal/server` pin both the non-zero + values and that relationship (#99) - 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the org-standard v2-schema config used across the org's repos diff --git a/internal/server/export_test.go b/internal/server/export_test.go new file mode 100644 index 0000000..8df05f2 --- /dev/null +++ b/internal/server/export_test.go @@ -0,0 +1,19 @@ +package server + +import ( + "net/http" + "time" +) + +// NewHTTPServer exports newHTTPServer for testing. +func NewHTTPServer( + listenAddr string, + handler http.Handler, +) *http.Server { + return newHTTPServer(listenAddr, handler) +} + +// RequestTimeout exports the handler execution budget applied by +// chimw.Timeout in SetupRoutes, so tests can assert the relationship +// between it and the server's WriteTimeout. +const RequestTimeout time.Duration = requestTimeout diff --git a/internal/server/server.go b/internal/server/server.go index 4cb30a2..f9385eb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -33,8 +33,52 @@ type Params struct { // shutdownTimeout is how long to wait for graceful shutdown. const shutdownTimeout = 30 * time.Second -// readHeaderTimeout is the max duration for reading request headers. -const readHeaderTimeout = 10 * time.Second +// Socket-level timeouts for the HTTP server. +// +// These bound time spent on the connection itself and are a distinct +// control from the per-request handler budget enforced by +// chimw.Timeout(requestTimeout) in routes.go: that one cancels the +// request context after requestTimeout but never touches the socket, +// so without the values below a peer can hold a connection open +// forever (slowloris, unreaped keep-alives). +// +// The one hard constraint between the two controls is +// writeTimeout > requestTimeout. net/http arms the write deadline +// once the request headers have been read, so on a plaintext +// connection it covers handler execution AND the response flush. If +// writeTimeout were <= requestTimeout the server would sever the +// connection before a handler that legitimately consumed its full +// budget could emit anything, making the 60s budget unreachable in +// practice. The margin between them is the response-flush allowance. +// +// The only clients of this service are browsers loading the dashboard +// and a Prometheus scraper; the values are sized for those. +const ( + // readHeaderTimeout is the max duration for reading request + // headers. + readHeaderTimeout = 10 * time.Second + + // readTimeout bounds reading the entire request, headers plus + // body. Every route here is a GET with no body, so this only + // ever needs to cover headers; the extra 5s over + // readHeaderTimeout is slack, not a real allowance, and keeps a + // body dribbled one byte at a time from holding the read side + // open indefinitely. + readTimeout = 15 * time.Second + + // writeTimeout must exceed the requestTimeout handler budget + // (60s) per the note above. The 15s difference is the allowance + // for flushing a completed response to a slow client. + writeTimeout = 75 * time.Second + + // idleTimeout reaps keep-alive connections between requests. It + // is deliberately longer than the common Prometheus scrape + // intervals (15s/30s/60s) so the scraper reuses its connection + // rather than reconnecting every cycle, while a browser tab + // left open on the dashboard stops occupying a connection + // within two minutes of going quiet. + idleTimeout = 120 * time.Second +) // Server is the HTTP server. type Server struct { @@ -76,16 +120,29 @@ func New( return srv, nil } +// newHTTPServer builds the listening http.Server with every +// socket-level timeout set. All four are set deliberately: a zero +// value in net/http means "no limit", not "some default". +func newHTTPServer( + listenAddr string, + handler http.Handler, +) *http.Server { + return &http.Server{ + Addr: listenAddr, + Handler: handler, + ReadTimeout: readTimeout, + ReadHeaderTimeout: readHeaderTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + } +} + // Run starts the HTTP server. func (s *Server) Run() { s.SetupRoutes() listenAddr := fmt.Sprintf(":%d", s.port) - s.httpServer = &http.Server{ - Addr: listenAddr, - Handler: s, - ReadHeaderTimeout: readHeaderTimeout, - } + s.httpServer = newHTTPServer(listenAddr, s) s.log.Info("http server starting", "addr", listenAddr) diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..9909f7d --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,112 @@ +package server_test + +import ( + "net/http" + "testing" + + "sneak.berlin/go/dnswatcher/internal/server" +) + +// noopHandler stands in for the router; newHTTPServer only stores it. +func noopHandler() http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + ) +} + +// TestHTTPServerTimeoutsAreSet asserts that every socket-level +// timeout is configured. A zero value in net/http means "no limit", +// so a refactor that silently drops one of these reintroduces the +// slowloris / unreaped-keep-alive exposure this guards against. +// +// The assertions are on the configured field values only; nothing +// here measures elapsed time, so the test cannot flake on timing. +func TestHTTPServerTimeoutsAreSet(t *testing.T) { + t.Parallel() + + srv := server.NewHTTPServer(":8080", noopHandler()) + + if srv.ReadTimeout <= 0 { + t.Errorf( + "ReadTimeout must be non-zero, got %v", + srv.ReadTimeout, + ) + } + + if srv.ReadHeaderTimeout <= 0 { + t.Errorf( + "ReadHeaderTimeout must be non-zero, got %v", + srv.ReadHeaderTimeout, + ) + } + + if srv.WriteTimeout <= 0 { + t.Errorf( + "WriteTimeout must be non-zero, got %v", + srv.WriteTimeout, + ) + } + + if srv.IdleTimeout <= 0 { + t.Errorf( + "IdleTimeout must be non-zero, got %v", + srv.IdleTimeout, + ) + } +} + +// TestWriteTimeoutExceedsHandlerBudget pins the one relationship the +// values must satisfy. net/http arms the write deadline once request +// headers are read, so it covers handler execution plus the response +// flush. If WriteTimeout were not greater than the chimw.Timeout +// handler budget, the connection would be severed before a handler +// that used its full budget could respond, making that budget +// unreachable. +func TestWriteTimeoutExceedsHandlerBudget(t *testing.T) { + t.Parallel() + + srv := server.NewHTTPServer(":8080", noopHandler()) + + if srv.WriteTimeout <= server.RequestTimeout { + t.Errorf( + "WriteTimeout (%v) must exceed handler budget (%v)", + srv.WriteTimeout, + server.RequestTimeout, + ) + } +} + +// TestReadTimeoutCoversHeaderTimeout asserts the read deadline for +// the whole request is at least as long as the header-only deadline; +// a smaller ReadTimeout would make ReadHeaderTimeout unreachable. +func TestReadTimeoutCoversHeaderTimeout(t *testing.T) { + t.Parallel() + + srv := server.NewHTTPServer(":8080", noopHandler()) + + if srv.ReadTimeout < srv.ReadHeaderTimeout { + t.Errorf( + "ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)", + srv.ReadTimeout, + srv.ReadHeaderTimeout, + ) + } +} + +// TestHTTPServerAddrAndHandler covers the rest of the constructor so +// a future edit cannot drop the listen address or the handler. +func TestHTTPServerAddrAndHandler(t *testing.T) { + t.Parallel() + + srv := server.NewHTTPServer(":9999", noopHandler()) + + if srv.Addr != ":9999" { + t.Errorf("Addr = %q, want %q", srv.Addr, ":9999") + } + + if srv.Handler == nil { + t.Error("Handler must not be nil") + } +} -- 2.54.0 From b351a2350c1e2443a8a3b34229c56dde065ef0fe Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 09:44:49 +0200 Subject: [PATCH 09/92] gomod: drop stale golang.org/x/sync indirect line (closes #132) go.mod listed golang.org/x/sync v0.19.0 twice: once in the direct require block and once as // indirect. The module is a genuine direct dependency (internal/portcheck/portcheck.go imports golang.org/x/sync/errgroup), so the indirect entry was redundant and stale. script/bootstrap ends with go mod download, which dropped that line as a side effect and left every fresh checkout with a dirty go.mod. Running go mod tidy removes the line for good; no dependency and no version changed, and go.sum is unaffected. Model: opus-4-8 --- TODO.md | 2 ++ go.mod | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 259fad2..b00f161 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,8 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync` + `// indirect` line so `script/bootstrap` leaves a clean tree (#132) - 2026-08-10: comment-only corrections to `script/bootstrap`, `script/cibuild`, and `Dockerfile.lint`. The `goimports` pin in `script/bootstrap` was justified by a claim that `script/fmt-check` diff --git a/go.mod b/go.mod index 53638c9..0078dcf 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,6 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.32.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.41.0 // indirect -- 2.54.0 From c2a07ce69069d5ceae26c985cbc88767a6d0bb3c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 10:05:38 +0200 Subject: [PATCH 10/92] test: add tests for globals, healthcheck and logger (closes #110) Tests for the three packages that had none, written from outside each package, each able to fail on a plausible break: - globals: values set are read back through New, and New returns an independent copy. One sequential test function with a disclosed paralleltest suppression, because it changes shared package variables. - healthcheck: Check returns status "ok", the documented JSON fields, an RFC3339Nano timestamp, the maintenance flag from config in both states, and version and appname from globals. - logger: New gives a usable *slog.Logger, debug output is off by default and EnableDebugLogging turns it on. No production code changed. The terminal output format is not asserted. Model: opus-4-8 --- TODO.md | 2 + internal/globals/globals_test.go | 50 +++++++++ internal/healthcheck/healthcheck_test.go | 134 +++++++++++++++++++++++ internal/logger/logger_test.go | 66 +++++++++++ 4 files changed, 252 insertions(+) create mode 100644 internal/globals/globals_test.go create mode 100644 internal/healthcheck/healthcheck_test.go create mode 100644 internal/logger/logger_test.go diff --git a/TODO.md b/TODO.md index b00f161..18cd4e6 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,8 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-21: added behavioural tests for `internal/globals`, + `internal/healthcheck`, and `internal/logger` (closes #110). - 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync` `// indirect` line so `script/bootstrap` leaves a clean tree (#132) - 2026-08-10: comment-only corrections to `script/bootstrap`, diff --git a/internal/globals/globals_test.go b/internal/globals/globals_test.go new file mode 100644 index 0000000..f44174e --- /dev/null +++ b/internal/globals/globals_test.go @@ -0,0 +1,50 @@ +package globals_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/dnswatcher/internal/globals" +) + +// TestGlobals exercises the package-level version and appname +// variables through their setters and read-back via New. These are +// shared package state, so the test mutates a global and must run +// sequentially; it cannot use t.Parallel(). +// +//nolint:paralleltest // mutates shared package-level globals, must run sequentially +func TestGlobals(t *testing.T) { + versions := []string{"v1.2.3", "dev", "", "v1.2.3-4-gabcdef"} + for _, want := range versions { + globals.SetVersion(want) + + g, err := globals.New(nil) + require.NoError(t, err) + assert.Equal(t, want, g.Version, + "New must surface the version set by SetVersion") + } + + names := []string{"dnswatcher", "other", ""} + for _, want := range names { + globals.SetAppname(want) + + g, err := globals.New(nil) + require.NoError(t, err) + assert.Equal(t, want, g.Appname, + "New must surface the appname set by SetAppname") + } + + // New returns a snapshot: a later SetVersion must not mutate a + // Globals handed out earlier. + globals.SetVersion("first") + + g, err := globals.New(nil) + require.NoError(t, err) + + globals.SetVersion("second") + assert.Equal(t, "first", g.Version, + "a Globals returned by New must not change when the "+ + "package variable is set again") +} diff --git a/internal/healthcheck/healthcheck_test.go b/internal/healthcheck/healthcheck_test.go new file mode 100644 index 0000000..b87c3d1 --- /dev/null +++ b/internal/healthcheck/healthcheck_test.go @@ -0,0 +1,134 @@ +package healthcheck_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "go.uber.org/fx" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/globals" + "sneak.berlin/go/dnswatcher/internal/healthcheck" + "sneak.berlin/go/dnswatcher/internal/logger" +) + +// recordingLifecycle is a minimal fx.Lifecycle that records the hooks +// appended to it, so healthcheck.New can be exercised through its real +// constructor without standing up a whole fx application. +type recordingLifecycle struct { + hooks []fx.Hook +} + +func (l *recordingLifecycle) Append(hook fx.Hook) { + l.hooks = append(l.hooks, hook) +} + +// newHealthcheck builds a Healthcheck through the real constructor and +// runs the registered OnStart hook so StartupTime is set the same way +// the fx lifecycle would set it. +func newHealthcheck( + t *testing.T, + maintenance bool, + version string, +) *healthcheck.Healthcheck { + t.Helper() + + g := &globals.Globals{Appname: "dnswatcher", Version: version} + + log, err := logger.New(nil, logger.Params{Globals: g}) + require.NoError(t, err) + + lifecycle := &recordingLifecycle{} + + hc, err := healthcheck.New(lifecycle, healthcheck.Params{ + Globals: g, + Config: &config.Config{MaintenanceMode: maintenance}, + Logger: log, + }) + require.NoError(t, err) + + require.Len(t, lifecycle.hooks, 1, + "New must register exactly one lifecycle hook") + require.NotNil(t, lifecycle.hooks[0].OnStart) + require.NoError(t, lifecycle.hooks[0].OnStart(context.Background())) + + return hc +} + +func TestCheckStatusAndPayloadShape(t *testing.T) { + t.Parallel() + + hc := newHealthcheck(t, false, "v9.9.9") + resp := hc.Check() + + assert.Equal(t, "ok", resp.Status) + + // The JSON shape and field names are part of the contract for the + // /health and /.well-known/healthcheck routes, so assert on the + // exact set of keys the response marshals to. + raw, err := json.Marshal(resp) + require.NoError(t, err) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &fields)) + + wantKeys := []string{ + "status", + "now", + "uptimeSeconds", + "uptimeHuman", + "version", + "appname", + "maintenanceMode", + } + assert.Len(t, fields, len(wantKeys), + "response must marshal to exactly the documented fields") + + for _, key := range wantKeys { + assert.Contains(t, fields, key, "missing JSON field %q", key) + } + + // The Now field is documented as RFC3339Nano; a change to the + // format constant should turn this red. + _, err = time.Parse(time.RFC3339Nano, resp.Now) + assert.NoError(t, err, "Now must be RFC3339Nano") +} + +func TestCheckMaintenanceModeReflectsConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + maintenance bool + }{ + {"maintenance off", false}, + {"maintenance on", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + hc := newHealthcheck(t, tt.maintenance, "test") + resp := hc.Check() + assert.Equal(t, tt.maintenance, resp.Maintenance, + "maintenanceMode must mirror Config.MaintenanceMode") + }) + } +} + +func TestCheckSurfacesVersionAndAppname(t *testing.T) { + t.Parallel() + + hc := newHealthcheck(t, false, "surfaced-version-123") + resp := hc.Check() + + assert.Equal(t, "surfaced-version-123", resp.Version, + "version from globals must appear in the payload") + assert.Equal(t, "dnswatcher", resp.Appname) +} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 0000000..80ec8c9 --- /dev/null +++ b/internal/logger/logger_test.go @@ -0,0 +1,66 @@ +package logger_test + +import ( + "context" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/dnswatcher/internal/globals" + "sneak.berlin/go/dnswatcher/internal/logger" +) + +func newTestLogger(t *testing.T) *logger.Logger { + t.Helper() + + g := &globals.Globals{Appname: "dnswatcher", Version: "test"} + + l, err := logger.New(nil, logger.Params{Globals: g}) + require.NoError(t, err) + + return l +} + +// TestNewReturnsUsableLogger checks that the constructor yields a +// working *slog.Logger. +func TestNewReturnsUsableLogger(t *testing.T) { + t.Parallel() + + l := newTestLogger(t) + require.NotNil(t, l.Get(), "Get must return a non-nil logger") +} + +// TestDefaultLevelExcludesDebug verifies the default configuration +// logs at info: debug records are suppressed, info records pass. +func TestDefaultLevelExcludesDebug(t *testing.T) { + t.Parallel() + + log := newTestLogger(t).Get() + ctx := context.Background() + + assert.False(t, log.Enabled(ctx, slog.LevelDebug), + "debug must be suppressed at the default level") + assert.True(t, log.Enabled(ctx, slog.LevelInfo), + "info must be enabled at the default level") +} + +// TestEnableDebugLoggingChangesLevel verifies the debug and non-debug +// configurations differ as intended: enabling debug makes debug +// records pass where they previously did not. +func TestEnableDebugLoggingChangesLevel(t *testing.T) { + t.Parallel() + + l := newTestLogger(t) + log := l.Get() + ctx := context.Background() + + require.False(t, log.Enabled(ctx, slog.LevelDebug), + "debug must start disabled") + + l.EnableDebugLogging() + + assert.True(t, log.Enabled(ctx, slog.LevelDebug), + "debug must be enabled after EnableDebugLogging") +} -- 2.54.0 From 8aaa1039568d8c738c7b61c1979f1de1986cc516 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 22 Sep 2026 00:52:48 +0200 Subject: [PATCH 11/92] middleware: add security response headers (closes #98) Adds a SecurityHeaders middleware and registers it globally, right after the request ID middleware, so every route gets the headers, including static files, /metrics and error responses. It sets Strict-Transport-Security (one year, includeSubDomains), a Content-Security-Policy with default-src 'self', no scripts and frame-ancestors 'none', X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy no-referrer and a Permissions-Policy that turns every listed feature off. HSTS is sent on every response, not only over TLS: the service runs behind a TLS-terminating proxy and REPO_POLICIES.md requires the application to send it. Referrer-Policy is stricter than the policy baseline because dashboard URLs can name internal hosts. model: claude-opus-4-8 (implementation); claude-fable-5 (commit message) --- README.md | 43 +++- TODO.md | 10 + internal/middleware/middleware.go | 85 +++++++ internal/middleware/middleware_test.go | 334 +++++++++++++++++++++++++ internal/server/routes.go | 1 + 5 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 internal/middleware/middleware_test.go diff --git a/README.md b/README.md index c849041..2dae62c 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,46 @@ are read, so a smaller value would sever the connection before a handler using its full budget could respond. `IdleTimeout` exceeds common Prometheus scrape intervals so the scraper reuses its connection. +### Security Headers + +Every response — the dashboard, the static assets under `/s/...`, the +healthchecks, the JSON API, and `/metrics` — carries the following +headers, set by a global middleware: + +| Header | Value | +|-----------------------------|---------------------------------------| +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | +| `Content-Security-Policy` | see below | +| `X-Frame-Options` | `DENY` | +| `X-Content-Type-Options` | `nosniff` | +| `Referrer-Policy` | `no-referrer` | +| `Permissions-Policy` | all unused browser features denied | + +The content security policy is: + +``` +default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self'; +font-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; +form-action 'none'; frame-ancestors 'none' +``` + +The dashboard ships no JavaScript (the 30-second refresh is a +``), no inline styles, no inline event +handlers, and no images; its only subresource is the embedded stylesheet +at `/s/css/tailwind.min.css`, which `style-src 'self'` permits. The +policy therefore needs neither `unsafe-inline` nor `unsafe-eval`. +`frame-ancestors 'none'` is the primary anti-framing control, with +`X-Frame-Options: DENY` retained as the legacy fallback. + +HSTS is emitted unconditionally, including over plain HTTP. dnswatcher is +expected to run behind a TLS-terminating reverse proxy, and the browser +must still be told to enforce HTTPS end to end, so the header is never +gated on whether the request itself arrived over TLS. + +`Referrer-Policy: no-referrer` is stricter than the +`strict-origin-when-cross-origin` baseline: the dashboard has no +cross-origin navigation needs, and its URL may name internal hosts. + --- ## Architecture @@ -215,7 +255,8 @@ internal/ globals/globals.go Build-time variables (version) logger/logger.go slog structured logging (TTY detection) healthcheck/healthcheck.go Health check service - middleware/middleware.go HTTP middleware (logging, CORS, metrics auth) + middleware/middleware.go HTTP middleware (logging, CORS, security + headers, metrics auth) handlers/handlers.go HTTP request handlers server/ server.go HTTP server lifecycle diff --git a/TODO.md b/TODO.md index 18cd4e6..a0596cc 100644 --- a/TODO.md +++ b/TODO.md @@ -113,6 +113,16 @@ Rationale, Design, TODO, License, Author) if any are still missing. than the 60s `chimw.Timeout` handler budget so that budget stays reachable, and tests in `internal/server` pin both the non-zero values and that relationship (#99) +- 2026-08-09: security response headers middleware + (`SecurityHeaders()` in `internal/middleware/middleware.go`) + registered globally in `internal/server/routes.go`, so HSTS, CSP, + `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and + `Permissions-Policy` are set on every response including `/s/...` and + `/metrics`; the CSP needs no `unsafe-inline`/`unsafe-eval` because the + dashboard ships no JavaScript and no inline styles; HSTS is emitted + unconditionally per policy (TLS-terminating proxy in front). Remaining + 1.0 hardening items — `http.Server` timeouts, request body limits, + rate limiting, CORS scoping — are tracked separately - 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the org-standard v2-schema config used across the org's repos diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 0a05dd5..03f435e 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -21,6 +21,60 @@ import ( // corsMaxAge is the maximum age for CORS preflight responses. const corsMaxAge = 300 +// Security response header values applied to every response. +// +// The CSP is as strict as the dashboard allows: the template ships no +// JavaScript, no inline styles, no inline event handlers and no images, +// and its only subresource is the embedded stylesheet at +// /s/css/tailwind.min.css, which style-src 'self' permits. Neither +// unsafe-inline nor unsafe-eval is used. frame-ancestors 'none' is the +// primary anti-framing control; X-Frame-Options is the legacy fallback. +const ( + // hstsValue is emitted unconditionally, including over plain HTTP, + // because the service runs behind a TLS-terminating proxy and the + // browser must still enforce HTTPS end to end. + hstsValue = "max-age=31536000; includeSubDomains" + + cspValue = "default-src 'self'; " + + "script-src 'none'; " + + "style-src 'self'; " + + "img-src 'self'; " + + "font-src 'none'; " + + "connect-src 'none'; " + + "object-src 'none'; " + + "base-uri 'none'; " + + "form-action 'none'; " + + "frame-ancestors 'none'" + + frameOptionsValue = "DENY" + + contentTypeOptionsValue = "nosniff" + + // referrerPolicyValue is stricter than the policy minimum of + // strict-origin-when-cross-origin: the dashboard has no + // cross-origin navigation needs and its URL may name internal + // hosts. + referrerPolicyValue = "no-referrer" + + permissionsPolicyValue = "accelerometer=(), " + + "autoplay=(), " + + "camera=(), " + + "display-capture=(), " + + "encrypted-media=(), " + + "fullscreen=(), " + + "geolocation=(), " + + "gyroscope=(), " + + "magnetometer=(), " + + "microphone=(), " + + "midi=(), " + + "payment=(), " + + "picture-in-picture=(), " + + "publickey-credentials-get=(), " + + "screen-wake-lock=(), " + + "usb=(), " + + "xr-spatial-tracking=()" +) + // Params contains dependencies for Middleware. type Params struct { fx.In @@ -186,6 +240,37 @@ func (m *Middleware) CORS() func(http.Handler) http.Handler { }) } +// SecurityHeaders returns middleware that sets the security response +// headers required for production internet exposure on every response. +// +// The headers are set before the request reaches the next handler so +// that they are present on every response, including panics recovered +// by chi's Recoverer and timeouts produced by chi's Timeout. +func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + header := writer.Header() + header.Set("Strict-Transport-Security", hstsValue) + header.Set("Content-Security-Policy", cspValue) + header.Set("X-Frame-Options", frameOptionsValue) + header.Set( + "X-Content-Type-Options", + contentTypeOptionsValue, + ) + header.Set("Referrer-Policy", referrerPolicyValue) + header.Set( + "Permissions-Policy", + permissionsPolicyValue, + ) + + next.ServeHTTP(writer, request) + }) + } +} + // MetricsAuth returns basic auth middleware for /metrics. func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler { if m.params.Config.MetricsUsername == "" { diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go new file mode 100644 index 0000000..598f476 --- /dev/null +++ b/internal/middleware/middleware_test.go @@ -0,0 +1,334 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "go.uber.org/fx/fxtest" + + "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/globals" + "sneak.berlin/go/dnswatcher/internal/handlers" + "sneak.berlin/go/dnswatcher/internal/logger" + "sneak.berlin/go/dnswatcher/internal/middleware" + "sneak.berlin/go/dnswatcher/internal/notify" + "sneak.berlin/go/dnswatcher/internal/state" +) + +// Expected security header values, spelled out literally so that any +// change to the middleware has to be made deliberately here as well. +const ( + wantHSTS = "max-age=31536000; includeSubDomains" + + wantCSP = "default-src 'self'; " + + "script-src 'none'; " + + "style-src 'self'; " + + "img-src 'self'; " + + "font-src 'none'; " + + "connect-src 'none'; " + + "object-src 'none'; " + + "base-uri 'none'; " + + "form-action 'none'; " + + "frame-ancestors 'none'" + + wantFrameOptions = "DENY" + + wantContentTypeOptions = "nosniff" + + wantReferrerPolicy = "no-referrer" + + wantPermissionsPolicy = "accelerometer=(), " + + "autoplay=(), " + + "camera=(), " + + "display-capture=(), " + + "encrypted-media=(), " + + "fullscreen=(), " + + "geolocation=(), " + + "gyroscope=(), " + + "magnetometer=(), " + + "microphone=(), " + + "midi=(), " + + "payment=(), " + + "picture-in-picture=(), " + + "publickey-credentials-get=(), " + + "screen-wake-lock=(), " + + "usb=(), " + + "xr-spatial-tracking=()" +) + +// stylesheetPath is the only subresource the dashboard loads. +const stylesheetPath = "/s/css/tailwind.min.css" + +// newTestLogger builds a logger for direct component construction. +func newTestLogger(t *testing.T) *logger.Logger { + t.Helper() + + glob, err := globals.New(nil) + if err != nil { + t.Fatalf("globals.New: %v", err) + } + + log, err := logger.New(nil, logger.Params{Globals: glob}) + if err != nil { + t.Fatalf("logger.New: %v", err) + } + + return log +} + +// newTestMiddleware builds a Middleware without an fx application. +func newTestMiddleware(t *testing.T) *middleware.Middleware { + t.Helper() + + glob, err := globals.New(nil) + if err != nil { + t.Fatalf("globals.New: %v", err) + } + + mw, err := middleware.New(nil, middleware.Params{ + Logger: newTestLogger(t), + Globals: glob, + Config: &config.Config{}, + }) + if err != nil { + t.Fatalf("middleware.New: %v", err) + } + + return mw +} + +// serveWithSecurityHeaders runs a GET through SecurityHeaders and +// returns the recorded response. +func serveWithSecurityHeaders( + t *testing.T, + target string, + handler http.Handler, +) *httptest.ResponseRecorder { + t.Helper() + + mw := newTestMiddleware(t) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext( + t.Context(), http.MethodGet, target, nil, + ) + + mw.SecurityHeaders()(handler).ServeHTTP(rec, req) + + return rec +} + +// okHandler writes a trivial 200 response. +func okHandler() http.Handler { + return http.HandlerFunc(func( + writer http.ResponseWriter, + _ *http.Request, + ) { + writer.WriteHeader(http.StatusOK) + }) +} + +func TestSecurityHeaders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + header string + want string + }{ + { + "hsts", + "Strict-Transport-Security", + wantHSTS, + }, + { + "csp", + "Content-Security-Policy", + wantCSP, + }, + { + "frame options", + "X-Frame-Options", + wantFrameOptions, + }, + { + "content type options", + "X-Content-Type-Options", + wantContentTypeOptions, + }, + { + "referrer policy", + "Referrer-Policy", + wantReferrerPolicy, + }, + { + "permissions policy", + "Permissions-Policy", + wantPermissionsPolicy, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rec := serveWithSecurityHeaders(t, "/", okHandler()) + + got := rec.Header().Get(tt.header) + if got != tt.want { + t.Errorf( + "%s = %q, want %q", + tt.header, got, tt.want, + ) + } + }) + } +} + +// TestSecurityHeadersCSPDirectives guards the properties the repo +// policy requires of the content security policy itself. +func TestSecurityHeadersCSPDirectives(t *testing.T) { + t.Parallel() + + rec := serveWithSecurityHeaders(t, "/", okHandler()) + csp := rec.Header().Get("Content-Security-Policy") + + forbidden := []string{"unsafe-inline", "unsafe-eval"} + for _, directive := range forbidden { + if strings.Contains(csp, directive) { + t.Errorf("CSP must not contain %q: %q", directive, csp) + } + } + + required := []string{ + "default-src 'self'", + "script-src 'none'", + "style-src 'self'", + "frame-ancestors 'none'", + } + for _, directive := range required { + if !strings.Contains(csp, directive) { + t.Errorf("CSP must contain %q: %q", directive, csp) + } + } +} + +// TestSecurityHeadersOnErrorResponse verifies the headers are emitted +// even when the wrapped handler fails, since they are set before the +// handler runs. +func TestSecurityHeadersOnErrorResponse(t *testing.T) { + t.Parallel() + + failing := http.HandlerFunc(func( + writer http.ResponseWriter, + _ *http.Request, + ) { + http.Error( + writer, + "boom", + http.StatusInternalServerError, + ) + }) + + rec := serveWithSecurityHeaders(t, "/api/v1/status", failing) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + + if got := rec.Header().Get( + "X-Content-Type-Options", + ); got != wantContentTypeOptions { + t.Errorf( + "X-Content-Type-Options = %q, want %q", + got, wantContentTypeOptions, + ) + } + + if got := rec.Header().Get( + "Strict-Transport-Security", + ); got != wantHSTS { + t.Errorf( + "Strict-Transport-Security = %q, want %q", + got, wantHSTS, + ) + } +} + +// newTestHandlers builds real Handlers with empty monitoring state. +func newTestHandlers(t *testing.T) *handlers.Handlers { + t.Helper() + + glob, err := globals.New(nil) + if err != nil { + t.Fatalf("globals.New: %v", err) + } + + log := newTestLogger(t) + + notifier, err := notify.New(fxtest.NewLifecycle(t), notify.Params{ + Logger: log, + Config: &config.Config{}, + }) + if err != nil { + t.Fatalf("notify.New: %v", err) + } + + hnd, err := handlers.New(nil, handlers.Params{ + Logger: log, + Globals: glob, + State: state.NewForTest(), + Notify: notifier, + }) + if err != nil { + t.Fatalf("handlers.New: %v", err) + } + + return hnd +} + +// TestDashboardRendersWithSecurityHeaders renders the real dashboard +// through the middleware and checks that the policy still permits the +// one stylesheet the page loads. +func TestDashboardRendersWithSecurityHeaders(t *testing.T) { + t.Parallel() + + mw := newTestMiddleware(t) + hnd := newTestHandlers(t) + + router := chi.NewRouter() + router.Use(mw.SecurityHeaders()) + router.Get("/", hnd.HandleDashboard()) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/", nil, + ) + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + body := rec.Body.String() + if !strings.Contains(body, stylesheetPath) { + t.Errorf("dashboard does not reference %q", stylesheetPath) + } + + if !strings.Contains(body, "dnswatcher") { + t.Errorf("dashboard body looks empty: %d bytes", len(body)) + } + + csp := rec.Header().Get("Content-Security-Policy") + if csp != wantCSP { + t.Errorf("CSP = %q, want %q", csp, wantCSP) + } + + // The stylesheet is same-origin, so style-src 'self' allows it. + if !strings.Contains(csp, "style-src 'self'") { + t.Errorf("CSP would block %q: %q", stylesheetPath, csp) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index fa99177..5c71d84 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -21,6 +21,7 @@ func (s *Server) SetupRoutes() { // Global middleware s.router.Use(chimw.Recoverer) s.router.Use(chimw.RequestID) + s.router.Use(s.mw.SecurityHeaders()) s.router.Use(s.mw.Logging()) s.router.Use(s.mw.CORS()) s.router.Use(chimw.Timeout(requestTimeout)) -- 2.54.0 From 148e47d9c0fbefd3e4416c2d6d3e862b175c78bd Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 28 Sep 2026 20:13:37 +0200 Subject: [PATCH 12/92] docker: run as non-root, add health check, document upaas (closes #147) The runtime image runs as uid 10001, which owns /var/lib/dnswatcher. The working directory is /, so config loading finds no .env or dnswatcher config file there; the binary lives in /usr/local/bin. A Docker HEALTHCHECK probes /.well-known/healthcheck every 10 seconds with busybox wget, well inside the 60 seconds upaas waits. Startup now fails with an error naming the data directory when it cannot be written, instead of running with every save failing. The check creates the directory if needed and writes and removes the temp file Save uses; tests cover the create and the write failing. README gains "Running under upaas": the prod branch, host directory setup, network and port, environment and health check. Model: opus-5-5 --- Dockerfile | 30 ++++++++-- README.md | 52 ++++++++++++++++- TODO.md | 3 + internal/state/state.go | 29 ++++++++++ internal/state/state_test.go | 107 +++++++++++++++++++++++++++++++++++ 5 files changed, 212 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 94b5b57..48b467e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,15 +41,33 @@ FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4 RUN apk add --no-cache ca-certificates tzdata -WORKDIR /app +COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher -COPY --from=builder /src/bin/dnswatcher /app/dnswatcher - -# Create data directory -RUN mkdir -p /var/lib/dnswatcher +# Run as an unprivileged user that owns the data directory. A fresh named +# volume inherits this ownership; a bind-mounted host directory must be +# owned by uid 10001 (see "Running under upaas" in README.md), or startup +# fails. +RUN addgroup -S -g 10001 dnswatcher \ + && adduser -S -G dnswatcher -u 10001 dnswatcher \ + && mkdir -p /var/lib/dnswatcher \ + && chown dnswatcher:dnswatcher /var/lib/dnswatcher ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher +# Config loading also reads a `.env` file and a file named `dnswatcher` +# (any config extension, or none) from the working directory. `/` holds +# neither, so every setting comes from the environment. Do not make the +# data directory, or the binary's directory, the working directory. +WORKDIR / + +USER dnswatcher + EXPOSE 8080 -ENTRYPOINT ["/app/dnswatcher"] +# busybox wget (already in alpine) probes the health endpoint every 10 +# seconds, so the container is healthy well before upaas reads its health +# 60 seconds after a deploy and fails the deploy unless it is healthy. +HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" || exit 1 + +ENTRYPOINT ["/usr/local/bin/dnswatcher"] diff --git a/README.md b/README.md index 2dae62c..08d7d55 100644 --- a/README.md +++ b/README.md @@ -508,11 +508,57 @@ docker run -d \ --- +## Running under upaas + +[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this +repository's `Dockerfile` and runs it. The app needs: + +- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to + `prod` pull request is a deploy. +- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where + the state file lives. upaas bind-mounts the host path it is given and + does not create it. The container runs as uid 10001 and does not start + unless it can write there. Create the directory before the first + deploy: + + ```sh + mkdir -p /path/to/data + chown 10001:10001 /path/to/data + chmod 700 /path/to/data + ``` + +- **Network and port:** the dashboard is unauthenticated and shows every + watched name and recent alert, and upaas publishes every mapped port on + all interfaces of the host + ([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)). Add a + port mapping to container port `8080` only if the dashboard should be + public. Otherwise add none: set the app's Docker network in upaas to + your reverse proxy's Docker network, and the proxy reaches the app at + `upaas-` followed by the app name, port `8080`. +- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list + of the domains and hostnames to watch. dnswatcher refuses to start + without it. +- **Recommended environment:** at least one notification endpoint + (`DNSWATCHER_SLACK_WEBHOOK`, `DNSWATCHER_MATTERMOST_WEBHOOK`, + `DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the + dashboard. `DNSWATCHER_METRICS_USERNAME` and + `DNSWATCHER_METRICS_PASSWORD` serve `/metrics` behind basic auth. +- **Leave unset:** `DNSWATCHER_DATA_DIR`, which the image sets to + `/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every + setting comes from the environment; the image holds no config file. +- **Health check:** the image's own, which requests + `/.well-known/healthcheck` every 10 seconds. upaas reads the + container's health 60 seconds after a deploy and marks the deploy + failed unless it is `healthy`. + +--- + ## Monitoring Lifecycle -1. **Startup**: Load state from disk. If no state file exists, start - with empty state (first check will establish baseline without - triggering change notifications). +1. **Startup**: Check that the data directory can be written, and exit + with an error naming it if not. Load state from disk. If no state + file exists, start with empty state (first check will establish + baseline without triggering change notifications). 2. **Initial check**: Immediately perform all DNS, port, and TLS checks on startup. 3. **Periodic checks** (DNS always runs first): diff --git a/TODO.md b/TODO.md index a0596cc..e3fee17 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,9 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-28: upaas deploy readiness — runtime image runs as unprivileged + `dnswatcher`, Docker `HEALTHCHECK`, startup fails when the data directory is + not writable, README "Running under upaas" (closes #147). - 2026-09-21: added behavioural tests for `internal/globals`, `internal/healthcheck`, and `internal/logger` (closes #110). - 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync` diff --git a/internal/state/state.go b/internal/state/state.go index efe681c..417a4d7 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -148,6 +148,11 @@ func New( lifecycle.Append(fx.Hook{ OnStart: func(_ context.Context) error { + err := state.checkDataDirWritable() + if err != nil { + return err + } + return state.Load() }, OnStop: func(_ context.Context) error { @@ -345,3 +350,27 @@ func (s *State) GetCertificateState( return cs, ok } + +// checkDataDirWritable creates the data directory if needed, then writes +// and removes the temp file that Save uses. It runs at startup so that an +// unwritable directory stops the process, instead of the process running +// with every save failing and only logged. +func (s *State) checkDataDirWritable() error { + dir := s.config.DataDir + tmpPath := s.config.StatePath() + ".tmp" + + err := os.MkdirAll(dir, dirPermissions) + if err == nil { + err = os.WriteFile(tmpPath, nil, filePermissions) + } + + if err == nil { + err = os.Remove(tmpPath) + } + + if err != nil { + return fmt.Errorf("data directory %s is not writable: %w", dir, err) + } + + return nil +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 699d17c..3fbca93 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -4,10 +4,16 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "sync" "testing" "time" + "go.uber.org/fx/fxtest" + + "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/globals" + "sneak.berlin/go/dnswatcher/internal/logger" "sneak.berlin/go/dnswatcher/internal/state" ) @@ -493,6 +499,107 @@ func TestSaveWritePermissionError(t *testing.T) { } } +// startState builds a State through the real constructor and runs its +// startup hook against dataDir, returning the startup error. +func startState(t *testing.T, dataDir string) error { + t.Helper() + + g, err := globals.New(nil) + if err != nil { + t.Fatalf("globals.New: %v", err) + } + + log, err := logger.New(nil, logger.Params{Globals: g}) + if err != nil { + t.Fatalf("logger.New: %v", err) + } + + lifecycle := fxtest.NewLifecycle(t) + + _, err = state.New(lifecycle, state.Params{ + Logger: log, + Config: &config.Config{DataDir: dataDir}, + }) + if err != nil { + t.Fatalf("state.New: %v", err) + } + + return lifecycle.Start(t.Context()) +} + +// TestStartupFailsWhenDataDirNotWritable verifies that startup stops +// with an error naming the data directory when it cannot be written. +// The directory's parent is a regular file, which also fails as root. +func TestStartupFailsWhenDataDirNotWritable(t *testing.T) { + t.Parallel() + + parent := filepath.Join(t.TempDir(), "file") + + err := os.WriteFile(parent, nil, 0o600) + if err != nil { + t.Fatalf("writing file: %v", err) + } + + dataDir := filepath.Join(parent, "data") + + err = startState(t, dataDir) + if err == nil { + t.Fatal("startup should fail when the data directory is not writable") + } + + want := "data directory " + dataDir + " is not writable" + if !strings.Contains(err.Error(), want) { + t.Errorf("startup error %q does not contain %q", err, want) + } +} + +// TestStartupFailsWhenExistingDataDirNotWritable verifies that startup +// stops when the data directory exists but the temp file that saving uses +// cannot be written in it. A directory sitting at the temp file's path +// makes that write fail, which also holds as root. +func TestStartupFailsWhenExistingDataDirNotWritable(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + + err := os.Mkdir(filepath.Join(dataDir, "state.json.tmp"), 0o700) + if err != nil { + t.Fatalf("creating directory: %v", err) + } + + err = startState(t, dataDir) + if err == nil { + t.Fatal("startup should fail when the data directory is not writable") + } + + want := "data directory " + dataDir + " is not writable" + if !strings.Contains(err.Error(), want) { + t.Errorf("startup error %q does not contain %q", err, want) + } +} + +// TestStartupCreatesDataDir verifies that startup creates a missing +// data directory and leaves nothing behind in it. +func TestStartupCreatesDataDir(t *testing.T) { + t.Parallel() + + dataDir := filepath.Join(t.TempDir(), "data") + + err := startState(t, dataDir) + if err != nil { + t.Fatalf("startup error: %v", err) + } + + entries, err := os.ReadDir(dataDir) + if err != nil { + t.Fatalf("reading data directory: %v", err) + } + + if len(entries) != 0 { + t.Errorf("startup left %d entries in the data directory", len(entries)) + } +} + // TestPortStateUnmarshalJSON_NewFormat verifies deserialization of the // current multi-hostname format. func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) { -- 2.54.0 From 19f282c8b3122e916e6809c6890a8135c43d36ca Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 28 Sep 2026 22:01:36 +0200 Subject: [PATCH 13/92] server: assert timeouts on the served http.Server, not just the constructor (closes #120) The timeout tests called newHTTPServer directly, so a Run that built its http.Server inline would drop every timeout with the suite still green. TestRunWiresSocketTimeouts wires a Server as cmd/dnswatcher does, minus the watcher and resolver so no live DNS is touched, drives Run with an unbindable port so it stores its http.Server and returns without listening, and checks that server carries all four timeouts and both required relationships. The addr/handler test, which could not fail, is dropped. The ReadTimeout note now says what net/http does: a request whose headers arrive after ReadTimeout but within ReadHeaderTimeout gets a read deadline that has already passed, so reading its body fails at once. Model: opus-4-8 (implementation); opus-5-5 (rework) --- TODO.md | 3 + internal/server/export_test.go | 21 ++-- internal/server/server_test.go | 178 ++++++++++++++++++--------------- 3 files changed, 114 insertions(+), 88 deletions(-) diff --git a/TODO.md b/TODO.md index e3fee17..10c50d2 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,9 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-28: the server timeout test now drives `Run` and checks the + `http.Server` it serves carries the timeouts; corrected the `ReadTimeout` + note in that test (closes #120). - 2026-09-28: upaas deploy readiness — runtime image runs as unprivileged `dnswatcher`, Docker `HEALTHCHECK`, startup fails when the data directory is not writable, README "Running under upaas" (closes #147). diff --git a/internal/server/export_test.go b/internal/server/export_test.go index 8df05f2..5a1bec7 100644 --- a/internal/server/export_test.go +++ b/internal/server/export_test.go @@ -5,15 +5,20 @@ import ( "time" ) -// NewHTTPServer exports newHTTPServer for testing. -func NewHTTPServer( - listenAddr string, - handler http.Handler, -) *http.Server { - return newHTTPServer(listenAddr, handler) -} - // RequestTimeout exports the handler execution budget applied by // chimw.Timeout in SetupRoutes, so tests can assert the relationship // between it and the server's WriteTimeout. const RequestTimeout time.Duration = requestTimeout + +// SetListenPort overrides the port Run binds. A test uses it to hand +// Run an unbindable port so ListenAndServe fails immediately and Run +// returns after storing its http.Server. +func SetListenPort(s *Server, port int) { + s.port = port +} + +// HTTPServerOf returns the http.Server that Run built and stored, so a +// test can inspect the timeouts the running server actually carries. +func HTTPServerOf(s *Server) *http.Server { + return s.httpServer +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 9909f7d..6cd036c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,112 +1,130 @@ package server_test import ( - "net/http" "testing" + "github.com/spf13/viper" + "go.uber.org/fx" + + "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/globals" + "sneak.berlin/go/dnswatcher/internal/handlers" + "sneak.berlin/go/dnswatcher/internal/healthcheck" + "sneak.berlin/go/dnswatcher/internal/logger" + "sneak.berlin/go/dnswatcher/internal/middleware" + "sneak.berlin/go/dnswatcher/internal/notify" "sneak.berlin/go/dnswatcher/internal/server" + "sneak.berlin/go/dnswatcher/internal/state" ) -// noopHandler stands in for the router; newHTTPServer only stores it. -func noopHandler() http.Handler { - return http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }, +// buildServer wires a *server.Server exactly as cmd/dnswatcher does, +// minus the watcher/resolver subtree that would touch live DNS. fx +// builds the object graph but the lifecycle is never started, so no +// OnStart hook runs and nothing listens or resolves. The caller must +// first configure viper (config.New reads it), which is also why the +// caller cannot run in parallel. +func buildServer(t *testing.T) *server.Server { + t.Helper() + + var srv *server.Server + + app := fx.New( + fx.NopLogger, + fx.Provide( + globals.New, + logger.New, + config.New, + state.New, + healthcheck.New, + notify.New, + middleware.New, + handlers.New, + server.New, + ), + fx.Populate(&srv), ) -} -// TestHTTPServerTimeoutsAreSet asserts that every socket-level -// timeout is configured. A zero value in net/http means "no limit", -// so a refactor that silently drops one of these reintroduces the -// slowloris / unreaped-keep-alive exposure this guards against. -// -// The assertions are on the configured field values only; nothing -// here measures elapsed time, so the test cannot flake on timing. -func TestHTTPServerTimeoutsAreSet(t *testing.T) { - t.Parallel() - - srv := server.NewHTTPServer(":8080", noopHandler()) - - if srv.ReadTimeout <= 0 { - t.Errorf( - "ReadTimeout must be non-zero, got %v", - srv.ReadTimeout, - ) + err := app.Err() + if err != nil { + t.Fatalf("building server graph: %v", err) } - if srv.ReadHeaderTimeout <= 0 { + return srv +} + +// TestRunWiresSocketTimeouts pins that the http.Server the running +// server actually serves — the one Run builds and hands to +// ListenAndServe — carries every socket-level timeout, plus the two +// relationships the values must satisfy. +// +// Run is driven to completion with an unbindable port: it builds and +// stores s.httpServer, then ListenAndServe fails at once and Run +// returns without ever listening. The assertions run in the same +// goroutine after Run returns, so reading s.httpServer is free of any +// data race. Nothing here measures elapsed time. +// +// ReadTimeout must be at least ReadHeaderTimeout. net/http reads the +// headers under ReadHeaderTimeout, then sets the read deadline for the +// rest of the request to ReadTimeout, counted from when it started +// reading the request. If ReadTimeout were smaller, a request whose +// headers arrived after ReadTimeout but within ReadHeaderTimeout would +// get a read deadline that had already passed, so reading its body +// would fail at once. +func TestRunWiresSocketTimeouts(t *testing.T) { + // Sets an env var and touches viper global state, so like the + // config tests it cannot use t.Parallel. + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + + srv := buildServer(t) + server.SetListenPort(srv, -1) + + srv.Run() + + hs := server.HTTPServerOf(srv) + if hs == nil { + t.Fatal("Run did not build an http.Server") + } + + if hs.ReadTimeout <= 0 { + t.Errorf("ReadTimeout must be non-zero, got %v", hs.ReadTimeout) + } + + if hs.ReadHeaderTimeout <= 0 { t.Errorf( "ReadHeaderTimeout must be non-zero, got %v", - srv.ReadHeaderTimeout, + hs.ReadHeaderTimeout, ) } - if srv.WriteTimeout <= 0 { - t.Errorf( - "WriteTimeout must be non-zero, got %v", - srv.WriteTimeout, - ) + if hs.WriteTimeout <= 0 { + t.Errorf("WriteTimeout must be non-zero, got %v", hs.WriteTimeout) } - if srv.IdleTimeout <= 0 { - t.Errorf( - "IdleTimeout must be non-zero, got %v", - srv.IdleTimeout, - ) + if hs.IdleTimeout <= 0 { + t.Errorf("IdleTimeout must be non-zero, got %v", hs.IdleTimeout) } -} -// TestWriteTimeoutExceedsHandlerBudget pins the one relationship the -// values must satisfy. net/http arms the write deadline once request -// headers are read, so it covers handler execution plus the response -// flush. If WriteTimeout were not greater than the chimw.Timeout -// handler budget, the connection would be severed before a handler -// that used its full budget could respond, making that budget -// unreachable. -func TestWriteTimeoutExceedsHandlerBudget(t *testing.T) { - t.Parallel() - - srv := server.NewHTTPServer(":8080", noopHandler()) - - if srv.WriteTimeout <= server.RequestTimeout { + if hs.WriteTimeout <= server.RequestTimeout { t.Errorf( "WriteTimeout (%v) must exceed handler budget (%v)", - srv.WriteTimeout, + hs.WriteTimeout, server.RequestTimeout, ) } -} -// TestReadTimeoutCoversHeaderTimeout asserts the read deadline for -// the whole request is at least as long as the header-only deadline; -// a smaller ReadTimeout would make ReadHeaderTimeout unreachable. -func TestReadTimeoutCoversHeaderTimeout(t *testing.T) { - t.Parallel() - - srv := server.NewHTTPServer(":8080", noopHandler()) - - if srv.ReadTimeout < srv.ReadHeaderTimeout { + if hs.ReadTimeout < hs.ReadHeaderTimeout { t.Errorf( "ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)", - srv.ReadTimeout, - srv.ReadHeaderTimeout, + hs.ReadTimeout, + hs.ReadHeaderTimeout, + ) + } + + if hs.Handler != srv { + t.Errorf( + "Run wired handler %T, want the *server.Server", + hs.Handler, ) } } - -// TestHTTPServerAddrAndHandler covers the rest of the constructor so -// a future edit cannot drop the listen address or the handler. -func TestHTTPServerAddrAndHandler(t *testing.T) { - t.Parallel() - - srv := server.NewHTTPServer(":9999", noopHandler()) - - if srv.Addr != ":9999" { - t.Errorf("Addr = %q, want %q", srv.Addr, ":9999") - } - - if srv.Handler == nil { - t.Error("Handler must not be nil") - } -} -- 2.54.0 From 1ab0b9f61db060a565029b163129da87077849ab Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 28 Sep 2026 23:13:38 +0200 Subject: [PATCH 14/92] script: force lint and test to run in cibuild and docker (closes #115) script/cibuild and script/docker were plain docker build. On an unchanged tree the lint stage and the builder stage, which runs make test, came from the layer cache, so the build passed without linting or querying live DNS. Both scripts now pass --no-cache-filter=lint,builder so those stages run on every build, as script/lint already does for its own lint stage. Dependency downloads inside those stages re-run each build. Each of the two stages in the Dockerfile now notes that the scripts name it. README and TODO.md updated to match. Model: opus-4-8 (implementation); opus-5-5 (rework) --- Dockerfile | 2 ++ README.md | 10 +++++++--- TODO.md | 3 +++ script/cibuild | 8 ++++++-- script/docker | 8 ++++++-- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 48b467e..b4f7273 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ # The linter is invoked directly rather than through `make lint`: that # target shells out to `docker build -f Dockerfile.lint`, and there is # no docker daemon inside a docker build. +# script/cibuild and script/docker name this stage in --no-cache-filter. # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10 FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint @@ -15,6 +16,7 @@ RUN make fmt-check RUN golangci-lint run --config .golangci.yml ./... # Build stage +# script/cibuild and script/docker name this stage in --no-cache-filter. # golang 1.25-alpine, 2026-02-28 FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder diff --git a/README.md b/README.md index 08d7d55..13c9ebb 100644 --- a/README.md +++ b/README.md @@ -465,9 +465,13 @@ them. We provide: - `script/fmt` — format all code (gofmt -s, goimports) - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check -- `script/docker` — build the Docker image tagged via - `script/projectname` -- `script/cibuild` — CI entrypoint: plain `docker build .` +- `script/docker` — build the Docker image tagged via `script/projectname`, with + `--no-cache-filter=lint,builder` so the lint stage and the builder stage, + which runs the tests, run on every invocation +- `script/cibuild` — CI entrypoint: `docker build` with + `--no-cache-filter=lint,builder`, so the lint stage and the builder stage, + which runs the tests, run on every invocation, because a cached build lints + nothing and queries no DNS - `script/precommit` — run by the git pre-commit hook; `go mod tidy` guard, then `script/check` - `script/install-precommit` — install the git pre-commit hook diff --git a/TODO.md b/TODO.md index 10c50d2..575b959 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,9 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-28: `script/cibuild` and `script/docker` now pass + `--no-cache-filter=lint,builder` so lint and tests run every build (closes + #115). - 2026-09-28: the server timeout test now drives `Run` and checks the `http.Server` it serves carries the timeouts; corrected the `ReadTimeout` note in that test (closes #120). diff --git a/script/cibuild b/script/cibuild index 1b9e57d..29bea03 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,14 +1,18 @@ #!/bin/sh # script/cibuild: run the CI build. The Dockerfile's lint stage runs # make fmt-check and golangci-lint; its builder stage runs make test -# and make build. A successful build implies all of those passed. +# and make build. +# +# --no-cache-filter=lint,builder runs both stages on every invocation; +# otherwise an unchanged tree is served from the layer cache and passes +# without linting or querying live DNS. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - docker build . + docker build --no-cache-filter=lint,builder . } main "$@" diff --git a/script/docker b/script/docker index 9b9ea86..4f1fd14 100755 --- a/script/docker +++ b/script/docker @@ -1,6 +1,10 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. -# Identical in all repos; the tag comes from script/projectname. +# The tag comes from script/projectname. +# +# --no-cache-filter=lint,builder runs the lint stage and the builder +# stage (make test) on every invocation; otherwise an unchanged tree is +# served from the layer cache without linting or querying live DNS. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -8,7 +12,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build -t "$("$SCRIPT_DIR/projectname")" . + docker build --no-cache-filter=lint,builder -t "$("$SCRIPT_DIR/projectname")" . } main "$@" -- 2.54.0 From 95b017eb3e1cf166f6dc17a12fb2e68f9be554f8 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 00:30:29 +0200 Subject: [PATCH 15/92] resolver: lower-case DNS names in record values (closes #157) Nameservers may answer with DNS names in any letter case. For eeqj.de, y.ns.joker.com answers in upper case while its peers answer in lower case, so the inconsistency check fired on every cycle. extractRecordValue now lower-cases CNAME, MX, SRV and NS targets, so the inconsistency check and the record-change check both compare names regardless of case. A, AAAA, TXT and CAA values are formatted as before. State saved before this change can hold upper-case names, which report a one-time record change on the first check after upgrading. Model: opus-5-5 --- README.md | 4 ++ TODO.md | 3 ++ internal/resolver/export_test.go | 8 ++++ internal/resolver/iterative.go | 13 +++--- internal/resolver/iterative_test.go | 62 +++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 internal/resolver/export_test.go create mode 100644 internal/resolver/iterative_test.go diff --git a/README.md b/README.md index 13c9ebb..8e51883 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ rejected. record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. - Stores results **per nameserver**. The state for a hostname is not a merged view — it is a map from nameserver to record set. +- DNS names inside record values (CNAME, MX, SRV and NS targets) are + stored in lower case, because names are case-insensitive and + nameservers may answer in any letter case. TXT and CAA values keep + their letter case; they are not lower-cased. - Any observable change in any nameserver's response triggers a notification. This includes: - **Record change**: A nameserver returns different records than it diff --git a/TODO.md b/TODO.md index 575b959..36a1c9a 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,9 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-28: DNS names in record values (CNAME, MX, SRV and NS targets) are + lower-cased, so nameservers that answer in different letter case no longer + count as inconsistent or as a record change (closes #157). - 2026-09-28: `script/cibuild` and `script/docker` now pass `--no-cache-filter=lint,builder` so lint and tests run every build (closes #115). diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go new file mode 100644 index 0000000..6f4f9f1 --- /dev/null +++ b/internal/resolver/export_test.go @@ -0,0 +1,8 @@ +package resolver + +import "github.com/miekg/dns" + +// ExtractRecordValue exports extractRecordValue for testing. +func ExtractRecordValue(rr dns.RR) string { + return extractRecordValue(rr) +} diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index eebab39..f89d73d 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -608,7 +608,10 @@ func classifyResponse(resp *NameserverResponse, state queryState) { } } -// extractRecordValue formats a DNS RR value as a string. +// extractRecordValue formats a DNS RR value as a string. DNS names +// are case-insensitive and nameservers may answer in any letter case, +// so names are lower-cased to compare equal. TXT and CAA values keep +// their letter case. func extractRecordValue(rr dns.RR) string { switch r := rr.(type) { case *dns.A: @@ -616,22 +619,22 @@ func extractRecordValue(rr dns.RR) string { case *dns.AAAA: return r.AAAA.String() case *dns.CNAME: - return r.Target + return strings.ToLower(r.Target) case *dns.MX: - return fmt.Sprintf("%d %s", r.Preference, r.Mx) + return fmt.Sprintf("%d %s", r.Preference, strings.ToLower(r.Mx)) case *dns.TXT: return strings.Join(r.Txt, "") case *dns.SRV: return fmt.Sprintf( "%d %d %d %s", - r.Priority, r.Weight, r.Port, r.Target, + r.Priority, r.Weight, r.Port, strings.ToLower(r.Target), ) case *dns.CAA: return fmt.Sprintf( "%d %s \"%s\"", r.Flag, r.Tag, r.Value, ) case *dns.NS: - return r.Ns + return strings.ToLower(r.Ns) default: return "" } diff --git a/internal/resolver/iterative_test.go b/internal/resolver/iterative_test.go new file mode 100644 index 0000000..781e90b --- /dev/null +++ b/internal/resolver/iterative_test.go @@ -0,0 +1,62 @@ +package resolver_test + +import ( + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + + "sneak.berlin/go/dnswatcher/internal/resolver" +) + +func TestExtractRecordValue_LetterCase(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rr dns.RR + want string + }{ + { + name: "MX target lower-cased", + rr: &dns.MX{Preference: 1, Mx: "ASPMX.L.GOOGLE.COM."}, + want: "1 aspmx.l.google.com.", + }, + { + name: "NS target lower-cased", + rr: &dns.NS{Ns: "x.ns.joker.COM."}, + want: "x.ns.joker.com.", + }, + { + name: "CNAME target lower-cased", + rr: &dns.CNAME{Target: "WWW.Example.Com."}, + want: "www.example.com.", + }, + { + name: "SRV target lower-cased", + rr: &dns.SRV{ + Priority: 10, Weight: 5, Port: 443, + Target: "SIP.Example.Com.", + }, + want: "10 5 443 sip.example.com.", + }, + { + name: "TXT value keeps its case", + rr: &dns.TXT{Txt: []string{"Verify=AbC123"}}, + want: "Verify=AbC123", + }, + { + name: "CAA value keeps its case", + rr: &dns.CAA{Flag: 0, Tag: "issue", Value: "LetsEncrypt.org"}, + want: `0 issue "LetsEncrypt.org"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, resolver.ExtractRecordValue(tt.rr)) + }) + } +} -- 2.54.0 From a93389e1a06f5fc1bbba5c40565d43d773996d90 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 02:29:53 +0200 Subject: [PATCH 16/92] watcher: send the inconsistency alert once per disagreement (closes #158) detectInconsistencies alerted for neighbouring pairs of nameservers whose records differed, on every DNS check, for as long as they differed. It now also takes the previous hostname state, compares every pair of nameservers, and alerts for a pair that differs unless both were in that state and already differed there, so a nameserver new on a check that answers differently is reported once. The state loaded at startup is the previous state for the first check, so a disagreement saved before a restart is not reported again. The choice of pairs is tested on record data, and the alert through the hostname change detection with the notifier stand-in and no resolver. The README describes the new behaviour. Model: opus-5-5 --- README.md | 11 +- TODO.md | 5 + internal/watcher/export_test.go | 25 ++++ internal/watcher/inconsistency_test.go | 182 +++++++++++++++++++++++++ internal/watcher/watcher.go | 56 ++++++-- 5 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 internal/watcher/export_test.go create mode 100644 internal/watcher/inconsistency_test.go diff --git a/README.md b/README.md index 8e51883..5b1cf70 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,15 @@ rejected. This is distinct from "responded with no records." - **NS recovery**: A previously-unreachable nameserver starts responding again. - - **Inconsistency detected**: Two nameservers that previously agreed - now return different record sets for the same hostname. + - **Inconsistency detected**: Two nameservers return different record + sets for the same hostname and did not already differ on the previous + check. Every pair of nameservers is compared. The alert is sent once + for each such pair, on the check where they start to disagree, and not + again while they keep disagreeing, including after a restart. A + nameserver that was not in the previous check (newly added, or back + after dropping out) and answers differently is reported on the check + where it appears. If a pair agrees again and later disagrees, the + alert is sent again. ### TCP Port Monitoring diff --git a/TODO.md b/TODO.md index 36a1c9a..5ce0129 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,11 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-28: the inconsistency alert is sent once, on the check where two + nameservers start to disagree or where a nameserver that disagrees first + appears, instead of on every check while they disagree, and not again after + a restart. Every pair of nameservers is compared, not only neighbours in + sorted order of name (closes #158). - 2026-09-28: DNS names in record values (CNAME, MX, SRV and NS targets) are lower-cased, so nameservers that answer in different letter case no longer count as inconsistent or as a record change (closes #157). diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go new file mode 100644 index 0000000..04a36d5 --- /dev/null +++ b/internal/watcher/export_test.go @@ -0,0 +1,25 @@ +package watcher + +import ( + "context" + + "sneak.berlin/go/dnswatcher/internal/state" +) + +// NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing. +func NewlyDisagreeingPairs( + prev *state.HostnameState, + current map[string]map[string][]string, +) [][2]string { + return newlyDisagreeingPairs(prev, current) +} + +// DetectHostnameChanges exports detectHostnameChanges for testing. +func (w *Watcher) DetectHostnameChanges( + ctx context.Context, + hostname string, + prev *state.HostnameState, + current map[string]map[string][]string, +) { + w.detectHostnameChanges(ctx, hostname, prev, current) +} diff --git a/internal/watcher/inconsistency_test.go b/internal/watcher/inconsistency_test.go new file mode 100644 index 0000000..9128426 --- /dev/null +++ b/internal/watcher/inconsistency_test.go @@ -0,0 +1,182 @@ +package watcher_test + +import ( + "slices" + "testing" + + "sneak.berlin/go/dnswatcher/internal/state" + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +const ( + host = "www.example.net" + nsA = "a.ns.example.net." + nsB = "b.ns.example.net." + nsC = "c.ns.example.net." + ip1 = "192.0.2.1" + ip2 = "192.0.2.2" + ip3 = "192.0.2.3" +) + +// hostnameState builds the state a check with these records leaves behind. +func hostnameState( + records map[string]map[string][]string, +) *state.HostnameState { + hs := &state.HostnameState{ + RecordsByNameserver: make(map[string]*state.NameserverRecordState), + } + + for ns, recs := range records { + hs.RecordsByNameserver[ns] = &state.NameserverRecordState{ + Records: recs, + Status: "ok", + } + } + + return hs +} + +func TestNewlyDisagreeingPairs(t *testing.T) { + t.Parallel() + + onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}} + agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}} + disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}} + alert := [][2]string{{nsA, nsB}} + + // b already disagrees with a and c; then c changes, so a and c, + // which agreed, now differ. + bDiffers := map[string]map[string][]string{ + nsA: {"A": {ip1}}, nsB: {"A": {ip2}}, nsC: {"A": {ip1}}, + } + cChanges := map[string]map[string][]string{ + nsA: {"A": {ip1}}, nsB: {"A": {ip2}}, nsC: {"A": {ip3}}, + } + + // Each case starts from the state loaded at startup and runs the + // checks in order; want[i] is what check i alerts for. + tests := []struct { + name string + loaded map[string]map[string][]string + checks []map[string]map[string][]string + want [][][2]string + }{ + { + name: "disagreement persisting across checks alerts once", + loaded: agree, + checks: []map[string]map[string][]string{disagree, disagree, disagree}, + want: [][][2]string{alert, nil, nil}, + }, + { + name: "disagreement starting on a later check alerts on it", + loaded: agree, + checks: []map[string]map[string][]string{agree, agree, disagree}, + want: [][][2]string{nil, nil, alert}, + }, + { + name: "disagreement in the loaded state does not alert", + loaded: disagree, + checks: []map[string]map[string][]string{disagree, disagree}, + want: [][][2]string{nil, nil}, + }, + { + name: "nameserver new on the first check and disagreeing alerts once", + loaded: onlyA, + checks: []map[string]map[string][]string{disagree, disagree}, + want: [][][2]string{alert, nil}, + }, + { + name: "disagreement after agreeing again alerts again", + loaded: agree, + checks: []map[string]map[string][]string{disagree, agree, disagree}, + want: [][][2]string{alert, nil, alert}, + }, + { + name: "new disagreement while another nameserver differs alerts", + loaded: bDiffers, + checks: []map[string]map[string][]string{cChanges, cChanges}, + want: [][][2]string{{{nsA, nsC}}, nil}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + prev := hostnameState(tt.loaded) + + for i, current := range tt.checks { + got := watcher.NewlyDisagreeingPairs(prev, current) + if !slices.Equal(got, tt.want[i]) { + t.Errorf( + "check %d: alerted for %v, want %v", + i, got, tt.want[i], + ) + } + + prev = hostnameState(current) + } + }) + } +} + +func TestInconsistencyAlert(t *testing.T) { + t.Parallel() + + onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}} + agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}} + disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}} + + // Each case starts from the state loaded at startup and then sees + // the nameservers disagree on three checks in a row. + tests := []struct { + name string + loaded map[string]map[string][]string + want int + }{ + { + name: "disagreement lasting several checks alerts once", + loaded: agree, + want: 1, + }, + { + name: "disagreement in the loaded state does not alert", + loaded: disagree, + want: 0, + }, + { + name: "nameserver new on the first check and disagreeing alerts once", + loaded: onlyA, + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // The hostname change detection uses only the notifier. + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + prev := hostnameState(tt.loaded) + + for range 3 { + w.DetectHostnameChanges(t.Context(), host, prev, disagree) + prev = hostnameState(disagree) + } + + got := 0 + + for _, n := range notifier.getNotifications() { + if n.Title == "Inconsistency: "+host { + got++ + } + } + + if got != tt.want { + t.Errorf("sent %d inconsistency alerts, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index fe2c4fb..1af5709 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -366,7 +366,7 @@ func (w *Watcher) detectHostnameChanges( ) { w.detectRecordChanges(ctx, hostname, prev, current) w.detectNSDisappearances(ctx, hostname, prev, current) - w.detectInconsistencies(ctx, hostname, current) + w.detectInconsistencies(ctx, hostname, prev, current) } func (w *Watcher) detectRecordChanges( @@ -448,22 +448,11 @@ func (w *Watcher) detectNSDisappearances( func (w *Watcher) detectInconsistencies( ctx context.Context, hostname string, + prev *state.HostnameState, current map[string]map[string][]string, ) { - nameservers := make([]string, 0, len(current)) - for ns := range current { - nameservers = append(nameservers, ns) - } - - sort.Strings(nameservers) - - for i := range len(nameservers) - 1 { - ns1 := nameservers[i] - ns2 := nameservers[i+1] - - if recordsEqual(current[ns1], current[ns2]) { - continue - } + for _, pair := range newlyDisagreeingPairs(prev, current) { + ns1, ns2 := pair[0], pair[1] msg := fmt.Sprintf( "Hostname: %s\n%s: %v\n%s: %v", @@ -481,6 +470,43 @@ func (w *Watcher) detectInconsistencies( } } +// newlyDisagreeingPairs returns every pair of nameservers whose records +// differ in current, in sorted order of name, except pairs where both +// nameservers were in prev and already differed there. A nameserver +// missing from prev is paired with every nameserver it differs from. +func newlyDisagreeingPairs( + prev *state.HostnameState, + current map[string]map[string][]string, +) [][2]string { + nameservers := make([]string, 0, len(current)) + for ns := range current { + nameservers = append(nameservers, ns) + } + + sort.Strings(nameservers) + + var pairs [][2]string + + for i, ns1 := range nameservers { + for _, ns2 := range nameservers[i+1:] { + if recordsEqual(current[ns1], current[ns2]) { + continue + } + + prev1, ok1 := prev.RecordsByNameserver[ns1] + prev2, ok2 := prev.RecordsByNameserver[ns2] + + if ok1 && ok2 && !recordsEqual(prev1.Records, prev2.Records) { + continue + } + + pairs = append(pairs, [2]string{ns1, ns2}) + } + } + + return pairs +} + func (w *Watcher) checkAllPorts(ctx context.Context) { // Phase 1: Build current IP:port → hostname associations // from fresh DNS data. -- 2.54.0 From 6dd604353417b670210e074b6695bce78fa64f4e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 08:44:07 +0200 Subject: [PATCH 17/92] tests: remove the DNS stand-ins from the watcher and resolver tests (closes #159) The watcher tests used a stand-in resolver and the resolver timeout test a stand-in DNS client, against the rule that DNS is never mocked. Watcher tests that look something up in DNS now run the real resolver against live servers, each attempt on a new watcher. A DNS change is tested by saving values live DNS never returns (names under .invalid, 192.0.2.1) in the state a check starts from, or by marking a real nameserver failed. The timeout test queries 192.0.2.1, where nothing answers. The live-DNS retry and concurrency limit moved from the resolver tests to internal/livedns, so both packages share them. NewFromLoggerWithClient had no other use and is gone. TESTING.md now states the README's rule. Model: opus-5-5 --- README.md | 2 + TESTING.md | 16 +- TODO.md | 18 +- internal/livedns/livedns.go | 121 ++++ internal/livedns/livedns_test.go | 103 +++ internal/resolver/dns_client.go | 4 +- internal/resolver/livedns_harness_test.go | 96 +-- internal/resolver/livedns_test.go | 182 ++---- internal/resolver/resolver.go | 13 - internal/resolver/resolver_test.go | 43 +- internal/watcher/watcher_test.go | 743 ++++++++-------------- 11 files changed, 554 insertions(+), 787 deletions(-) create mode 100644 internal/livedns/livedns.go create mode 100644 internal/livedns/livedns_test.go diff --git a/README.md b/README.md index 5b1cf70..4704b23 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,8 @@ internal/ tlscheck/tlscheck.go TLS certificate inspector notify/notify.go Notification service (Slack, Mattermost, ntfy) watcher/watcher.go Main monitoring orchestrator and scheduler + livedns/livedns.go Retry and concurrency limit for tests + against live DNS (imported only by tests) ``` ### Design Principles diff --git a/TESTING.md b/TESTING.md index 659db13..70e3899 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2,8 +2,12 @@ ## DNS Resolution Tests -All resolver tests **MUST** use live queries against real DNS servers. -No mocking of the DNS client layer is permitted. +DNS is never mocked in this project, not in tests and not anywhere +else; see the README section "No DNS mocking. Ever." Every test that +looks something up in DNS **MUST** query live DNS servers, never a +stand-in. Logic that works on record data, such as comparing or +formatting records, may be tested on that data directly with no +lookup. ### Rationale @@ -21,14 +25,18 @@ real servers ensures the resolver works correctly in production. - Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) plus processing margin - Root server fan-out is limited to reduce parallel query load +- Live lookups that expect an answer go through `internal/livedns`, + which limits how many run at once in a test binary and retries a + lookup that got none - Flaky failures from transient network issues are acceptable and should be investigated as potential resolver bugs, not papered over with mocks or skip flags ### What NOT to do -- **Do not mock `DNSClient`** for resolver tests (the mock constructor - exists for unit-testing other packages that consume the resolver) +- **Do not mock, fake or stub DNS** anywhere: no stand-in `DNSClient`, + no stand-in for the watcher's `DNSResolver`, no fake DNS server, no + canned responses - **Do not add `-short` flags** to skip slow tests - **Do not increase `-timeout`** to hide hanging queries - **Do not remove `-count=1` from `script/test`** — Go's test cache diff --git a/TODO.md b/TODO.md index 5ce0129..c03615b 100644 --- a/TODO.md +++ b/TODO.md @@ -10,11 +10,7 @@ # Status -pre-1.0. No git tags. Core resolver work in flight on feature/resolver -(dirty: internal/resolver/resolver_test.go). Local checkout has diverged -from origin: origin/main is 8 commits ahead (watcher orchestrator, -unified TARGETS) and origin/feature/resolver already contains the full -iterative resolver implementation with hermetic mocked tests. +pre-1.0. No git tags. # Next Step @@ -23,6 +19,13 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-29: nothing stands in for DNS any more. Watcher tests that look + something up in DNS use the real resolver against live DNS servers and test + record and nameserver changes by preparing the saved state a check starts + from; the resolver timeout test queries an address that never answers, and + `NewFromLoggerWithClient`, used only by its stand-in client, is gone. The + live-DNS retry and concurrency limit moved to `internal/livedns`, which both + test packages use. `TESTING.md` states the README's rule (closes #159). - 2026-09-28: the inconsistency alert is sent once, on the check where two nameservers start to disagree or where a nameserver that disagrees first appears, instead of on every check while they disagree, and not again after @@ -261,6 +264,5 @@ Infrastructure notes (from untracked TODO.md): - Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de remote intentionally; do not "fix" it - Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix -- Resolver tests originally used live DNS against *.dns.sneak.cloud - (required records documented in the test file header); origin now has - mocked hermetic tests, keep them hermetic +- DNS is never mocked; tests that look something up in DNS query live DNS + servers (README, "No DNS mocking. Ever.") diff --git a/internal/livedns/livedns.go b/internal/livedns/livedns.go new file mode 100644 index 0000000..b86794b --- /dev/null +++ b/internal/livedns/livedns.go @@ -0,0 +1,121 @@ +// Package livedns runs the live DNS operations of tests. Tests that +// look something up in DNS query live DNS servers, never a stand-in — +// see TESTING.md. Nothing here mocks, fakes, stubs, records or replays +// DNS, and nothing here skips a test: it only changes *how* the live +// queries are issued, so that a single dropped UDP packet or one slow +// authoritative server does not turn correct code into a red build. +// +// Two mechanisms: +// +// 1. Bounded concurrency. Tests run in parallel and the build hosts +// have many cores, so without a limit every test starts its own +// iterative resolution at the same instant and they all hit the +// first root server within a few milliseconds of each other. Root +// servers rate-limit that, which shows up as a different arbitrary +// subset of tests failing on each run. Run caps how many live +// operations are in flight at once in one test binary. +// +// 2. Retry with exponential backoff. Each live operation gets several +// attempts with its own timeout. An attempt is retried when it +// obtained nothing to check, never because of what the test +// asserts about the result, so a wrong result still fails on the +// first attempt. A fault in the code under test that leaves +// nothing to check looks the same as live DNS not answering, and +// fails only after the last attempt. +package livedns + +import ( + "context" + "errors" + "testing" + "time" +) + +const ( + // attempts is how many times a live DNS operation is attempted + // before the test fails. + attempts = 3 + + // AttemptTimeout bounds one attempt. Worst case for an operation + // is attempts * AttemptTimeout plus the backoff — about 26 + // seconds, well inside the 90-second `go test -timeout` backstop + // even when several operations exhaust their attempts. + AttemptTimeout = 8 * time.Second + + // backoffBase is the delay after the first failed attempt; it is + // multiplied by backoffFactor each time. + backoffBase = 500 * time.Millisecond + + // backoffFactor is the exponential backoff multiplier. + backoffFactor = 2 + + // Concurrency caps how many live operations may be in flight + // across one test binary at once. + Concurrency = 6 +) + +// gate bounds concurrent live operations. It has to be package scoped: +// the whole point is that it is shared by every parallel test in the +// test binary. +// +//nolint:gochecknoglobals // package-wide live query rate limit +var gate = make(chan struct{}, Concurrency) + +// ErrNoAnswer reports that a live operation produced no usable answer, +// which is retried rather than asserted on. +var ErrNoAnswer = errors.New("no answer from live DNS") + +// Run executes one attempt of a live operation, holding a slot in gate +// for its duration and bounding it with its own timeout. +func Run(op func(ctx context.Context) error) error { + gate <- struct{}{} + defer func() { <-gate }() + + ctx, cancel := context.WithTimeout( + context.Background(), AttemptTimeout, + ) + defer cancel() + + return op(ctx) +} + +// Retry runs op until it reports success, retrying failures with +// exponential backoff, and fails the test if every attempt fails. op +// returns an error only for a failure to obtain an answer — never for +// an answer the test disagrees with, which belongs in an assertion so +// that it fails immediately. op stores whatever it obtained where its +// caller can find it. +func Retry( + t *testing.T, + what string, + op func(ctx context.Context) error, +) { + t.Helper() + + var last error + + backoff := backoffBase + + for attempt := range attempts { + if attempt > 0 { + t.Logf( + "%s: attempt %d of %d failed (%v), "+ + "retrying in %s", + what, attempt, attempts, last, backoff, + ) + time.Sleep(backoff) + + backoff *= backoffFactor + } + + last = Run(op) + if last == nil { + return + } + } + + t.Fatalf( + "%s: all %d live attempts failed: %v", + what, attempts, last, + ) +} diff --git a/internal/livedns/livedns_test.go b/internal/livedns/livedns_test.go new file mode 100644 index 0000000..1c0f5fd --- /dev/null +++ b/internal/livedns/livedns_test.go @@ -0,0 +1,103 @@ +package livedns_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "sneak.berlin/go/dnswatcher/internal/livedns" +) + +// Tests for the retry and the concurrency limit themselves. They +// perform no DNS resolution of any kind. + +func TestRetryRecoversFromTransientFailure(t *testing.T) { + t.Parallel() + + const wantAttempts = 2 + + attempts := 0 + + livedns.Retry(t, "transient", func(_ context.Context) error { + attempts++ + + if attempts < wantAttempts { + return livedns.ErrNoAnswer + } + + return nil + }) + + assert.Equal(t, wantAttempts, attempts) +} + +func TestRetryGivesEachAttemptADeadline(t *testing.T) { + t.Parallel() + + livedns.Retry(t, "deadline", func(ctx context.Context) error { + deadline, ok := ctx.Deadline() + assert.True(t, ok, "attempt should carry a deadline") + + remaining := time.Until(deadline) + + assert.LessOrEqual(t, remaining, livedns.AttemptTimeout) + + // Lower bound too: without one this passes for a + // deadline far shorter than intended, which would + // silently turn every live attempt into an instant + // timeout. + assert.Greater(t, remaining, livedns.AttemptTimeout/2) + + return nil + }) +} + +func TestRunBoundsConcurrency(t *testing.T) { + t.Parallel() + + const workers = 24 + + var ( + mu sync.Mutex + wg sync.WaitGroup + inFlight int + maxSeen int + ) + + wg.Add(workers) + + for range workers { + go func() { + defer wg.Done() + + _ = livedns.Run(func(_ context.Context) error { + mu.Lock() + inFlight++ + + if inFlight > maxSeen { + maxSeen = inFlight + } + mu.Unlock() + + time.Sleep(time.Millisecond) + + mu.Lock() + inFlight-- + mu.Unlock() + + return nil + }) + }() + } + + wg.Wait() + + assert.Positive(t, maxSeen) + assert.LessOrEqual( + t, maxSeen, livedns.Concurrency, + "live queries must stay under the package-wide gate", + ) +} diff --git a/internal/resolver/dns_client.go b/internal/resolver/dns_client.go index 589c657..13135e5 100644 --- a/internal/resolver/dns_client.go +++ b/internal/resolver/dns_client.go @@ -7,8 +7,8 @@ import ( "github.com/miekg/dns" ) -// DNSClient abstracts DNS wire-protocol exchanges so the resolver -// can be tested without hitting real nameservers. +// DNSClient sends one DNS message to a nameserver and returns the +// reply. The resolver holds one for UDP and one for TCP. type DNSClient interface { ExchangeContext( ctx context.Context, diff --git a/internal/resolver/livedns_harness_test.go b/internal/resolver/livedns_harness_test.go index 87ce704..2efdb39 100644 --- a/internal/resolver/livedns_harness_test.go +++ b/internal/resolver/livedns_harness_test.go @@ -1,10 +1,7 @@ package resolver_test import ( - "context" - "sync" "testing" - "time" "github.com/stretchr/testify/assert" @@ -12,9 +9,8 @@ import ( ) // Tests for the live-DNS harness in livedns_test.go itself. These -// exercise pure logic and the retry/concurrency plumbing; they -// perform no DNS resolution of any kind, so they neither mock DNS -// nor depend on it. +// exercise pure logic; they perform no DNS resolution of any kind, so +// they neither mock DNS nor depend on it. // Names for the synthetic status maps below. Nothing is ever queried // at them: they are map keys handed to the package's pure counting @@ -90,47 +86,6 @@ func TestStatusCountingIgnoresSilentNameservers(t *testing.T) { ) } -func TestRetryLiveRecoversFromTransientFailure(t *testing.T) { - t.Parallel() - - const wantAttempts = 2 - - attempts := 0 - - retryLive(t, "transient", func(_ context.Context) error { - attempts++ - - if attempts < wantAttempts { - return errLiveNoAnswer - } - - return nil - }) - - assert.Equal(t, wantAttempts, attempts) -} - -func TestRetryLiveGivesEachAttemptADeadline(t *testing.T) { - t.Parallel() - - retryLive(t, "deadline", func(ctx context.Context) error { - deadline, ok := ctx.Deadline() - assert.True(t, ok, "attempt should carry a deadline") - - remaining := time.Until(deadline) - - assert.LessOrEqual(t, remaining, liveAttemptTimeout) - - // Lower bound too: without one this passes for a - // deadline far shorter than intended, which would - // silently turn every live attempt into an instant - // timeout. - assert.Greater(t, remaining, liveAttemptTimeout/2) - - return nil - }) -} - // TestUnsanctionedStatusesRejectsWrongAnswers is the regression test // for the defect this allowlist exists to prevent: a minority of // nameservers answering WRONGLY while quorum keeps the suite green. @@ -235,50 +190,3 @@ func TestUnsanctionedStatusesToleratesSilenceOnly(t *testing.T) { unsanctionedStatuses(results, allowed...), ) } - -func TestRunLiveBoundsConcurrency(t *testing.T) { - t.Parallel() - - const workers = 24 - - var ( - mu sync.Mutex - wg sync.WaitGroup - inFlight int - maxSeen int - ) - - wg.Add(workers) - - for range workers { - go func() { - defer wg.Done() - - _ = runLive(func(_ context.Context) error { - mu.Lock() - inFlight++ - - if inFlight > maxSeen { - maxSeen = inFlight - } - mu.Unlock() - - time.Sleep(time.Millisecond) - - mu.Lock() - inFlight-- - mu.Unlock() - - return nil - }) - }() - } - - wg.Wait() - - assert.Positive(t, maxSeen) - assert.LessOrEqual( - t, maxSeen, liveConcurrency, - "live queries must stay under the package-wide gate", - ) -} diff --git a/internal/resolver/livedns_test.go b/internal/resolver/livedns_test.go index b864460..b7d1be2 100644 --- a/internal/resolver/livedns_test.go +++ b/internal/resolver/livedns_test.go @@ -8,8 +8,8 @@ import ( "sort" "strings" "testing" - "time" + "sneak.berlin/go/dnswatcher/internal/livedns" "sneak.berlin/go/dnswatcher/internal/resolver" ) @@ -17,144 +17,34 @@ import ( // Live DNS test support // ---------------------------------------------------------------- // -// Every test in this package resolves against the real, live DNS — -// see TESTING.md. Nothing here mocks, fakes, stubs, records or -// replays DNS, and nothing here skips or gates a test: the helpers -// below only change *how* the live queries are issued, so that a -// single dropped UDP packet or one slow authoritative server does -// not turn a correct resolver into a red build. +// Tests that look something up in DNS query live DNS servers, never a +// stand-in; logic that works on record data may be tested on that +// data with no lookup (see TESTING.md). Each live operation below goes +// through livedns.Retry, which bounds how many resolutions are in +// flight at once and retries an operation that got no answer (see +// package livedns). // -// Three mechanisms, all test-side: +// Where an assertion spans several independent nameservers, a quorum +// is enough: a strict majority answering as expected. A server that +// fails to answer is tolerated, while a server that answers *wrongly* +// still fails the test. // -// 1. Bounded concurrency. The package's tests are parallel and the -// build hosts have many cores, so without a limit every test -// starts its own iterative resolution at the same instant and -// they all hit the first root server in rootServerList() within -// a few milliseconds of each other. Root servers rate-limit -// that, which shows up as a different arbitrary subset of tests -// failing on each run. liveGate caps how many resolutions are -// in flight at once. -// -// 2. Retry with exponential backoff. Each live operation gets -// several attempts with its own timeout. The retry predicate is -// strictly transport-level — "did a nameserver answer at all" — -// never the assertion the test is making. A resolver that -// answers incorrectly still fails on the first attempt. -// -// 3. Quorum. Where an assertion spans several independent -// nameservers, a strict majority answering as expected is -// enough; a server that fails to answer is tolerated, while a -// server that answers *wrongly* still fails the test. -// -// The tolerance in (3) is expressed as an ALLOWLIST of sanctioned -// statuses, never as a blocklist of known-bad ones. A blocklist bans -// the one wrong answer its author thought of and silently admits -// every other status, including any added to the resolver later; an -// allowlist fails on anything nobody explicitly sanctioned. Silence -// (timeout, error) is the only thing quorum exists to tolerate. A -// *wrong answer* — nxdomain for a name that exists, ok for one that -// does not, nodata for either — is never tolerated at any count. +// That tolerance is expressed as an ALLOWLIST of sanctioned statuses, +// never as a blocklist of known-bad ones. A blocklist bans the one +// wrong answer its author thought of and silently admits every other +// status, including any added to the resolver later; an allowlist +// fails on anything nobody explicitly sanctioned. Silence (timeout, +// error) is the only thing quorum exists to tolerate. A *wrong +// answer* — nxdomain for a name that exists, ok for one that does +// not, nodata for either — is never tolerated at any count. -const ( - // liveAttempts is how many times a live DNS operation is - // attempted before the test fails. - liveAttempts = 3 +// minNameservers is the smallest nameserver count a well-run zone is +// expected to publish. +const minNameservers = 2 - // liveAttemptTimeout bounds one attempt. Worst case for an - // operation is liveAttempts * liveAttemptTimeout plus the - // backoff — about 26 seconds, well inside the 90-second - // `go test -timeout` backstop even when several operations - // exhaust their attempts. - liveAttemptTimeout = 8 * time.Second - - // liveBackoffBase is the delay after the first failed - // attempt; it is multiplied by liveBackoffFactor each time. - liveBackoffBase = 500 * time.Millisecond - - // liveBackoffFactor is the exponential backoff multiplier. - liveBackoffFactor = 2 - - // liveConcurrency caps how many live resolutions may be in - // flight across the whole package at once. - liveConcurrency = 6 - - // minNameservers is the smallest nameserver count a - // well-run zone is expected to publish. - minNameservers = 2 -) - -// liveGate bounds concurrent live resolutions package-wide. It has -// to be package scoped: the whole point is that it is shared by -// every parallel test in the package. -// -//nolint:gochecknoglobals // package-wide live query rate limit -var liveGate = make(chan struct{}, liveConcurrency) - -var ( - // errLiveNoAnswer reports that a live operation produced no - // usable answer, which is retried rather than asserted on. - errLiveNoAnswer = errors.New("no answer from live DNS") - - // errLiveNoQuorum reports that too few of a domain's - // nameservers answered for a quorum assertion to be made. - errLiveNoQuorum = errors.New("no nameserver quorum") -) - -// runLive executes one attempt of a live operation, holding a slot -// in liveGate for its duration and bounding it with its own -// timeout. -func runLive(op func(ctx context.Context) error) error { - liveGate <- struct{}{} - defer func() { <-liveGate }() - - ctx, cancel := context.WithTimeout( - context.Background(), liveAttemptTimeout, - ) - defer cancel() - - return op(ctx) -} - -// retryLive runs op until it reports success, retrying transport -// failures with exponential backoff, and fails the test if every -// attempt fails. op returns an error only for a failure to obtain -// an answer — never for an answer the test disagrees with, which -// belongs in an assertion so that it fails immediately. op stores -// whatever it obtained where its caller can find it. -func retryLive( - t *testing.T, - what string, - op func(ctx context.Context) error, -) { - t.Helper() - - var last error - - backoff := liveBackoffBase - - for attempt := range liveAttempts { - if attempt > 0 { - t.Logf( - "%s: attempt %d of %d failed (%v), "+ - "retrying in %s", - what, attempt, liveAttempts, last, backoff, - ) - time.Sleep(backoff) - - backoff *= liveBackoffFactor - } - - last = runLive(op) - if last == nil { - return - } - } - - t.Fatalf( - "%s: no answer after %d live attempts: %v", - what, liveAttempts, last, - ) -} +// errLiveNoQuorum reports that too few of a domain's nameservers +// answered for a quorum assertion to be made. +var errLiveNoQuorum = errors.New("no nameserver quorum") // liveQuorum is how many of total nameservers must agree for a // multi-nameserver assertion to hold: a strict majority. @@ -272,7 +162,7 @@ func liveFindAuthoritative( var out []string - retryLive( + livedns.Retry( t, "FindAuthoritativeNameservers("+domain+")", func(ctx context.Context) error { @@ -284,7 +174,7 @@ func liveFindAuthoritative( if len(ns) == 0 { return fmt.Errorf( "%w: %s has no nameservers", - errLiveNoAnswer, domain, + livedns.ErrNoAnswer, domain, ) } @@ -308,7 +198,7 @@ func liveLookupNS( var out []string - retryLive( + livedns.Retry( t, "LookupNS("+domain+")", func(ctx context.Context) error { @@ -320,7 +210,7 @@ func liveLookupNS( if len(ns) == 0 { return fmt.Errorf( "%w: %s has no nameservers", - errLiveNoAnswer, domain, + livedns.ErrNoAnswer, domain, ) } @@ -350,7 +240,7 @@ func liveQueryNameserver( var out *resolver.NameserverResponse - retryLive( + livedns.Retry( t, what, func(ctx context.Context) error { @@ -365,7 +255,7 @@ func liveQueryNameserver( resp.Status == resolver.StatusError { return fmt.Errorf( "%w: %s returned %s: %s", - errLiveNoAnswer, nameserver, + livedns.ErrNoAnswer, nameserver, resp.Status, resp.Error, ) } @@ -392,7 +282,7 @@ func liveQueryAllNameservers( var out map[string]*resolver.NameserverResponse - retryLive( + livedns.Retry( t, "QueryAllNameservers("+hostname+")", func(ctx context.Context) error { @@ -404,7 +294,7 @@ func liveQueryAllNameservers( if len(results) == 0 { return fmt.Errorf( "%w: no nameservers queried for %s", - errLiveNoAnswer, hostname, + livedns.ErrNoAnswer, hostname, ) } @@ -437,7 +327,7 @@ func liveResolveIPs( var out []string - retryLive( + livedns.Retry( t, "ResolveIPAddresses("+hostname+")", func(ctx context.Context) error { @@ -449,7 +339,7 @@ func liveResolveIPs( if len(ips) == 0 { return fmt.Errorf( "%w: no addresses for %s", - errLiveNoAnswer, hostname, + livedns.ErrNoAnswer, hostname, ) } @@ -476,7 +366,7 @@ func liveResolveIPsAllowingEmpty( var out []string - retryLive( + livedns.Retry( t, "ResolveIPAddresses("+hostname+")", func(ctx context.Context) error { diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index aec9b89..83b3f47 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -67,17 +67,4 @@ func NewFromLogger(log *slog.Logger) *Resolver { } } -// NewFromLoggerWithClient creates a Resolver with a custom DNS -// client, useful for testing with mock DNS responses. -func NewFromLoggerWithClient( - log *slog.Logger, - client DNSClient, -) *Resolver { - return &Resolver{ - log: log, - client: client, - tcp: client, - } -} - // Method implementations are in iterative.go. diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 13bdcde..4755245 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -10,7 +10,6 @@ import ( "testing" "time" - "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,8 +32,8 @@ func newTestResolver(t *testing.T) *resolver.Resolver { } // findOneNSForDomain picks one authoritative nameserver to aim a -// test at. Live-DNS retry, concurrency and quorum handling live in -// livedns_test.go. +// test at. Quorum handling lives in livedns_test.go, and the live-DNS +// retry and concurrency limit in package livedns. func findOneNSForDomain( t *testing.T, r *resolver.Resolver, @@ -526,21 +525,18 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) { func TestQueryNameserverIP_Timeout(t *testing.T) { t.Parallel() - log := slog.New(slog.NewTextHandler( - os.Stderr, - &slog.HandlerOptions{Level: slog.LevelDebug}, - )) - - r := resolver.NewFromLoggerWithClient( - log, &timeoutClient{}, - ) + r := newTestResolver(t) + // Nothing answers at 192.0.2.1, a documentation address. The + // resolver tries each query twice, and the first try gives up + // after two seconds. A deadline that ends during the first try + // makes the status vary from run to run between nodata and + // timeout, so the deadline must outlast the first try. ctx, cancel := context.WithTimeout( - context.Background(), 10*time.Second, + context.Background(), 3*time.Second, ) t.Cleanup(cancel) - // Query any IP — the client always returns a timeout error. resp, err := r.QueryNameserverIP( ctx, "unreachable.test.", "192.0.2.1", "example.com", @@ -551,27 +547,6 @@ func TestQueryNameserverIP_Timeout(t *testing.T) { assert.NotEmpty(t, resp.Error) } -// timeoutClient simulates DNS timeout errors for testing. -type timeoutClient struct{} - -func (c *timeoutClient) ExchangeContext( - _ context.Context, - _ *dns.Msg, - _ string, -) (*dns.Msg, time.Duration, error) { - return nil, 0, &net.OpError{ - Op: "read", - Net: "udp", - Err: &timeoutError{}, - } -} - -type timeoutError struct{} - -func (e *timeoutError) Error() string { return "i/o timeout" } -func (e *timeoutError) Timeout() bool { return true } -func (e *timeoutError) Temporary() bool { return true } - func TestResolveIPAddresses_ContextCanceled(t *testing.T) { t.Parallel() diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 0069af2..5ad6030 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -2,151 +2,77 @@ package watcher_test import ( "context" - "errors" "fmt" + "log/slog" + "slices" "sync" "testing" "time" "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/livedns" "sneak.berlin/go/dnswatcher/internal/portcheck" + "sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/tlscheck" "sneak.berlin/go/dnswatcher/internal/watcher" ) -// errNotFound is returned when mock data is missing. -var errNotFound = errors.New("not found") - -// Fixture values shared across tests. +// The watcher looks these names up in live DNS with the real resolver, +// so tests assert on what the watcher does with the answers, never on +// the records these zones publish. testHost's nameservers and addresses +// stay the same from one check to the next, which the tests that check +// it twice rely on. const ( - testDomain = "example.com" - testHost = "www.example.com" - testNS1 = "ns1.example.com." - testNS2 = "ns2.example.com." - testIPv4 = "93.184.216.34" - testIP = "1.2.3.4" + testDomain = "google.com" + testHost = "cloudflare.com" testIssuer = "DigiCert" ) -// --- Mock implementations --- +// Saved-state values that live DNS never returns: nameserver names +// under .invalid and a documentation address. +const ( + oldNS1 = "ns1.example.invalid." + oldNS2 = "ns2.example.invalid." + oldIP = "192.0.2.1" +) -type mockResolver struct { - mu sync.Mutex - nsRecords map[string][]string - allRecords map[string]map[string]map[string][]string - ipAddresses map[string][]string - lookupNSErr error - allRecordsErr error - resolveIPErr error - lookupNSCalls int - allRecordCalls int -} - -func (m *mockResolver) LookupNS( - _ context.Context, - domain string, -) ([]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - - m.lookupNSCalls++ - - if m.lookupNSErr != nil { - return nil, m.lookupNSErr - } - - ns, ok := m.nsRecords[domain] - if !ok { - return nil, fmt.Errorf( - "%w: NS for %s", errNotFound, domain, - ) - } - - return ns, nil -} - -func (m *mockResolver) LookupAllRecords( - _ context.Context, - hostname string, -) (map[string]map[string][]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - - m.allRecordCalls++ - - if m.allRecordsErr != nil { - return nil, m.allRecordsErr - } - - recs, ok := m.allRecords[hostname] - if !ok { - return nil, fmt.Errorf( - "%w: records for %s", errNotFound, hostname, - ) - } - - return recs, nil -} - -func (m *mockResolver) ResolveIPAddresses( - _ context.Context, - hostname string, -) ([]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if m.resolveIPErr != nil { - return nil, m.resolveIPErr - } - - ips, ok := m.ipAddresses[hostname] - if !ok { - return nil, fmt.Errorf( - "%w: IPs for %s", errNotFound, hostname, - ) - } - - return ips, nil -} +// --- Stand-ins for the port checker, TLS checker and notifier --- +// +// DNS has none: the watchers built here use the real resolver (see +// TESTING.md). +// mockPortChecker reports every port open until closed is set. type mockPortChecker struct { - mu sync.Mutex - results map[string]bool - err error - calls int + mu sync.Mutex + closed bool + calls int } func (m *mockPortChecker) CheckPort( _ context.Context, - address string, - port int, + _ string, + _ int, ) (*portcheck.PortResult, error) { m.mu.Lock() defer m.mu.Unlock() m.calls++ - if m.err != nil { - return nil, m.err - } - - key := fmt.Sprintf("%s:%d", address, port) - open := m.results[key] - - return &portcheck.PortResult{Open: open}, nil + return &portcheck.PortResult{Open: !m.closed}, nil } +// mockTLSChecker returns a certificate for the requested hostname that +// expires at notAfter. type mockTLSChecker struct { - mu sync.Mutex - certs map[string]*tlscheck.CertificateInfo - err error - calls int + mu sync.Mutex + notAfter time.Time + calls int } func (m *mockTLSChecker) CheckCertificate( _ context.Context, - ip string, + _ string, hostname string, ) (*tlscheck.CertificateInfo, error) { m.mu.Lock() @@ -154,20 +80,12 @@ func (m *mockTLSChecker) CheckCertificate( m.calls++ - if m.err != nil { - return nil, m.err - } - - key := fmt.Sprintf("%s:%s", ip, hostname) - cert, ok := m.certs[key] - - if !ok { - return nil, fmt.Errorf( - "%w: cert for %s", errNotFound, key, - ) - } - - return cert, nil + return &tlscheck.CertificateInfo{ + CommonName: hostname, + Issuer: testIssuer, + NotAfter: m.notAfter, + SubjectAlternativeNames: []string{hostname}, + }, nil } type notification struct { @@ -205,10 +123,9 @@ func (m *mockNotifier) getNotifications() []notification { return result } -// --- Helper to build a Watcher for testing --- +// --- Helpers to build a Watcher and run its checks against live DNS --- type testDeps struct { - resolver *mockResolver portChecker *mockPortChecker tlsChecker *mockTLSChecker notifier *mockNotifier @@ -223,16 +140,9 @@ func newTestWatcher( t.Helper() deps := &testDeps{ - resolver: &mockResolver{ - nsRecords: make(map[string][]string), - allRecords: make(map[string]map[string]map[string][]string), - ipAddresses: make(map[string][]string), - }, - portChecker: &mockPortChecker{ - results: make(map[string]bool), - }, + portChecker: &mockPortChecker{}, tlsChecker: &mockTLSChecker{ - certs: make(map[string]*tlscheck.CertificateInfo), + notAfter: time.Now().Add(90 * 24 * time.Hour), }, notifier: &mockNotifier{}, config: cfg, @@ -243,7 +153,7 @@ func newTestWatcher( w := watcher.NewForTest( deps.config, deps.state, - deps.resolver, + resolver.NewFromLogger(slog.Default()), deps.portChecker, deps.tlsChecker, deps.notifier, @@ -263,6 +173,122 @@ func defaultTestConfig(t *testing.T) *config.Config { } } +// checkOnce runs the watcher's checks once and returns an error when a +// configured name has no hostname state saved by this check, or that +// state holds no address. Either live DNS gave no answer for the name, +// or the watcher saved no fresh result for it. +func checkOnce( + ctx context.Context, + w *watcher.Watcher, + deps *testDeps, +) error { + started := time.Now() + + w.RunOnce(ctx) + + names := slices.Concat(deps.config.Domains, deps.config.Hostnames) + + for _, name := range names { + hs, ok := deps.state.GetHostnameState(name) + if !ok || hs.LastChecked.Before(started) || + len(addresses(hs)) == 0 { + return fmt.Errorf( + "%s: %w, or the watcher saved no fresh "+ + "result for it", + name, livedns.ErrNoAnswer, + ) + } + } + + return nil +} + +// runChecks builds a watcher, lets prepare set up the saved state and +// stand-ins it starts from, and runs its checks once against live DNS. +// If change is not nil, change then alters the saved state or stand-ins +// and the checks run a second time. When either check finds no fresh +// address for a name (see checkOnce), the watcher is thrown away and +// all of this runs again on a new one, so a failed attempt leaves +// nothing behind in the saved state, the stand-ins or the notifications. +func runChecks( + t *testing.T, + cfg *config.Config, + prepare, change func(deps *testDeps), +) *testDeps { + t.Helper() + + var deps *testDeps + + livedns.Retry(t, "watcher checks", func(ctx context.Context) error { + var w *watcher.Watcher + + w, deps = newTestWatcher(t, cfg) + + if prepare != nil { + prepare(deps) + } + + err := checkOnce(ctx, w, deps) + if err != nil || change == nil { + return err + } + + change(deps) + + return checkOnce(ctx, w, deps) + }) + + return deps +} + +// addresses returns the A and AAAA values saved for a hostname. +func addresses(hs *state.HostnameState) []string { + var ips []string + + for _, nsState := range hs.RecordsByNameserver { + ips = append(ips, nsState.Records["A"]...) + ips = append(ips, nsState.Records["AAAA"]...) + } + + return ips +} + +// assertNotified checks that a notification with this title and +// priority was sent. +func assertNotified( + t *testing.T, + deps *testDeps, + title, priority string, +) { + t.Helper() + + notifications := deps.notifier.getNotifications() + + for _, n := range notifications { + if n.Title == title && n.Priority == priority { + return + } + } + + t.Errorf( + "expected %s notification %q, got: %v", + priority, title, notifications, + ) +} + +// countNotifications counts the notifications sent with this title. +func countNotifications(deps *testDeps, title string) int { + count := 0 + + for _, n := range deps.notifier.getNotifications() { + if n.Title == title { + count++ + } + } + + return count +} + func TestFirstRunBaseline(t *testing.T) { t.Parallel() @@ -270,51 +296,12 @@ func TestFirstRunBaseline(t *testing.T) { cfg.Domains = []string{testDomain} cfg.Hostnames = []string{testHost} - w, deps := newTestWatcher(t, cfg) - setupBaselineMocks(deps) - - w.RunOnce(t.Context()) + deps := runChecks(t, cfg, nil, nil) assertNoNotifications(t, deps) assertStatePopulated(t, deps) } -func setupBaselineMocks(deps *testDeps) { - deps.resolver.nsRecords[testDomain] = []string{ - testNS1, - testNS2, - } - deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ - testNS1: {"A": {testIPv4}}, - testNS2: {"A": {testIPv4}}, - } - deps.resolver.allRecords[testHost] = map[string]map[string][]string{ - testNS1: {"A": {testIPv4}}, - testNS2: {"A": {testIPv4}}, - } - deps.resolver.ipAddresses[testHost] = []string{ - testIPv4, - } - deps.portChecker.results["93.184.216.34:80"] = true - deps.portChecker.results["93.184.216.34:443"] = true - deps.tlsChecker.certs["93.184.216.34:www.example.com"] = &tlscheck.CertificateInfo{ - CommonName: testHost, - Issuer: testIssuer, - NotAfter: time.Now().Add(90 * 24 * time.Hour), - SubjectAlternativeNames: []string{ - testHost, - }, - } - deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{ - CommonName: testDomain, - Issuer: testIssuer, - NotAfter: time.Now().Add(90 * 24 * time.Hour), - SubjectAlternativeNames: []string{ - testDomain, - }, - } -} - func assertNoNotifications( t *testing.T, deps *testDeps, @@ -361,26 +348,7 @@ func TestDomainPortAndTLSChecks(t *testing.T) { cfg := defaultTestConfig(t) cfg.Domains = []string{testDomain} - w, deps := newTestWatcher(t, cfg) - - deps.resolver.nsRecords[testDomain] = []string{ - testNS1, - } - deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ - testNS1: {"A": {testIPv4}}, - } - deps.portChecker.results["93.184.216.34:80"] = true - deps.portChecker.results["93.184.216.34:443"] = true - deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{ - CommonName: testDomain, - Issuer: testIssuer, - NotAfter: time.Now().Add(90 * 24 * time.Hour), - SubjectAlternativeNames: []string{ - testDomain, - }, - } - - w.RunOnce(t.Context()) + deps := runChecks(t, cfg, nil, nil) snap := deps.state.GetSnapshot() @@ -419,50 +387,18 @@ func TestNSChangeDetection(t *testing.T) { cfg := defaultTestConfig(t) cfg.Domains = []string{testDomain} - w, deps := newTestWatcher(t, cfg) + // The saved state lists nameservers that live DNS does not. + deps := runChecks(t, cfg, func(deps *testDeps) { + deps.state.SetDomainState(testDomain, &state.DomainState{ + Nameservers: []string{oldNS1, oldNS2}, + }) + }, nil) - deps.resolver.nsRecords[testDomain] = []string{ - testNS1, - testNS2, - } - deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ - testNS1: {"A": {testIP}}, - testNS2: {"A": {testIP}}, - } - deps.portChecker.results["1.2.3.4:80"] = false - deps.portChecker.results["1.2.3.4:443"] = false + assertNotified(t, deps, "NS Change: "+testDomain, "warning") - ctx := t.Context() - w.RunOnce(ctx) - - deps.resolver.mu.Lock() - deps.resolver.nsRecords[testDomain] = []string{ - testNS1, - "ns3.example.com.", - } - deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ - testNS1: {"A": {testIP}}, - "ns3.example.com.": {"A": {testIP}}, - } - deps.resolver.mu.Unlock() - - w.RunOnce(ctx) - - notifications := deps.notifier.getNotifications() - if len(notifications) == 0 { - t.Error("expected notification for NS change") - } - - found := false - - for _, n := range notifications { - if n.Priority == "warning" { - found = true - } - } - - if !found { - t.Error("expected warning-priority NS change notification") + ds, _ := deps.state.GetDomainState(testDomain) + if slices.Contains(ds.Nameservers, oldNS1) { + t.Errorf("saved nameservers not updated: %v", ds.Nameservers) } } @@ -472,40 +408,18 @@ func TestRecordChangeDetection(t *testing.T) { cfg := defaultTestConfig(t) cfg.Hostnames = []string{testHost} - w, deps := newTestWatcher(t, cfg) + // Between the checks, save for every nameserver an address live DNS + // never returns. + deps := runChecks(t, cfg, nil, func(deps *testDeps) { + hs, _ := deps.state.GetHostnameState(testHost) + for _, nsState := range hs.RecordsByNameserver { + nsState.Records = map[string][]string{"A": {oldIP}} + } - deps.resolver.allRecords[testHost] = map[string]map[string][]string{ - testNS1: {"A": {testIPv4}}, - } - deps.resolver.ipAddresses[testHost] = []string{ - testIPv4, - } - deps.portChecker.results["93.184.216.34:80"] = false - deps.portChecker.results["93.184.216.34:443"] = false + deps.state.SetHostnameState(testHost, hs) + }) - ctx := t.Context() - w.RunOnce(ctx) - - deps.resolver.mu.Lock() - deps.resolver.allRecords[testHost] = map[string]map[string][]string{ - testNS1: {"A": {"93.184.216.35"}}, - } - deps.resolver.ipAddresses[testHost] = []string{ - "93.184.216.35", - } - deps.resolver.mu.Unlock() - - deps.portChecker.mu.Lock() - deps.portChecker.results["93.184.216.35:80"] = false - deps.portChecker.results["93.184.216.35:443"] = false - deps.portChecker.mu.Unlock() - - w.RunOnce(ctx) - - notifications := deps.notifier.getNotifications() - if len(notifications) == 0 { - t.Error("expected notification for record change") - } + assertNotified(t, deps, "Record Change: "+testHost, "warning") } func TestPortStateChange(t *testing.T) { @@ -514,38 +428,23 @@ func TestPortStateChange(t *testing.T) { cfg := defaultTestConfig(t) cfg.Hostnames = []string{testHost} - w, deps := newTestWatcher(t, cfg) + // Between the checks, every port closes. + deps := runChecks(t, cfg, nil, func(deps *testDeps) { + deps.portChecker.mu.Lock() + deps.portChecker.closed = true + deps.portChecker.mu.Unlock() + }) - deps.resolver.allRecords[testHost] = map[string]map[string][]string{ - testNS1: {"A": {testIP}}, - } - deps.resolver.ipAddresses[testHost] = []string{ - testIP, - } - deps.portChecker.results["1.2.3.4:80"] = true - deps.portChecker.results["1.2.3.4:443"] = true - deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ - CommonName: testHost, - Issuer: testIssuer, - NotAfter: time.Now().Add(90 * 24 * time.Hour), - SubjectAlternativeNames: []string{ - testHost, - }, - } + hs, _ := deps.state.GetHostnameState(testHost) + assertNotified( + t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning", + ) +} - ctx := t.Context() - w.RunOnce(ctx) - - deps.portChecker.mu.Lock() - deps.portChecker.results["1.2.3.4:443"] = false - deps.portChecker.mu.Unlock() - - w.RunOnce(ctx) - - notifications := deps.notifier.getNotifications() - if len(notifications) == 0 { - t.Error("expected notification for port state change") - } +// expiresInThreeDays makes the TLS checker return certificates that +// expire within the seven-day warning period. +func expiresInThreeDays(deps *testDeps) { + deps.tlsChecker.notAfter = time.Now().Add(3 * 24 * time.Hour) } func TestTLSExpiryWarning(t *testing.T) { @@ -554,49 +453,9 @@ func TestTLSExpiryWarning(t *testing.T) { cfg := defaultTestConfig(t) cfg.Hostnames = []string{testHost} - w, deps := newTestWatcher(t, cfg) + deps := runChecks(t, cfg, expiresInThreeDays, nil) - deps.resolver.allRecords[testHost] = map[string]map[string][]string{ - testNS1: {"A": {testIP}}, - } - deps.resolver.ipAddresses[testHost] = []string{ - testIP, - } - deps.portChecker.results["1.2.3.4:80"] = true - deps.portChecker.results["1.2.3.4:443"] = true - deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ - CommonName: testHost, - Issuer: testIssuer, - NotAfter: time.Now().Add(3 * 24 * time.Hour), - SubjectAlternativeNames: []string{ - testHost, - }, - } - - ctx := t.Context() - - // First run = baseline - w.RunOnce(ctx) - - // Second run should warn about expiry - w.RunOnce(ctx) - - notifications := deps.notifier.getNotifications() - - found := false - - for _, n := range notifications { - if n.Priority == "warning" { - found = true - } - } - - if !found { - t.Errorf( - "expected expiry warning, got: %v", - notifications, - ) - } + assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning") } func TestTLSExpiryWarningDedup(t *testing.T) { @@ -606,50 +465,25 @@ func TestTLSExpiryWarningDedup(t *testing.T) { cfg.Hostnames = []string{testHost} cfg.TLSInterval = 24 * time.Hour - w, deps := newTestWatcher(t, cfg) + title := "TLS Expiry Warning: " + testHost - deps.resolver.allRecords[testHost] = map[string]map[string][]string{ - testNS1: {"A": {testIP}}, - } - deps.resolver.ipAddresses[testHost] = []string{ - testIP, - } - deps.portChecker.results["1.2.3.4:80"] = true - deps.portChecker.results["1.2.3.4:443"] = true - deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ - CommonName: testHost, - Issuer: testIssuer, - NotAfter: time.Now().Add(3 * 24 * time.Hour), - SubjectAlternativeNames: []string{ - testHost, - }, + // The second check comes within the TLS interval of the first, + // so it must not warn again. + var warnings int + + deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) { + warnings = countNotifications(deps, title) + }) + + if warnings == 0 { + t.Fatal("expected expiry warnings from the first check") } - ctx := t.Context() - - // First run = baseline, no notifications - w.RunOnce(ctx) - - // Second run should fire one expiry warning - w.RunOnce(ctx) - - // Third run should NOT fire another warning (dedup) - w.RunOnce(ctx) - - notifications := deps.notifier.getNotifications() - - expiryCount := 0 - - for _, n := range notifications { - if n.Title == "TLS Expiry Warning: www.example.com" { - expiryCount++ - } - } - - if expiryCount != 1 { + got := countNotifications(deps, title) + if got != warnings { t.Errorf( - "expected exactly 1 expiry warning (dedup), got %d", - expiryCount, + "expected %d expiry warnings (dedup), got %d", + warnings, got, ) } } @@ -657,21 +491,12 @@ func TestTLSExpiryWarningDedup(t *testing.T) { func TestGracefulShutdown(t *testing.T) { t.Parallel() + // No domains or hostnames: stopping does not involve DNS. cfg := defaultTestConfig(t) - cfg.Domains = []string{testDomain} cfg.DNSInterval = 100 * time.Millisecond cfg.TLSInterval = 100 * time.Millisecond - w, deps := newTestWatcher(t, cfg) - - deps.resolver.nsRecords[testDomain] = []string{ - testNS1, - } - deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ - testNS1: {"A": {testIP}}, - } - deps.portChecker.results["1.2.3.4:80"] = false - deps.portChecker.results["1.2.3.4:443"] = false + w, _ := newTestWatcher(t, cfg) ctx, cancel := context.WithCancel(t.Context()) @@ -693,90 +518,52 @@ func TestGracefulShutdown(t *testing.T) { } } -func setupHostnameIP( - deps *testDeps, - hostname, ip string, -) { - deps.resolver.allRecords[hostname] = map[string]map[string][]string{ - testNS1: {"A": {ip}}, - } - deps.portChecker.results[ip+":80"] = true - deps.portChecker.results[ip+":443"] = true - deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{ - CommonName: hostname, - Issuer: testIssuer, - NotAfter: time.Now().Add(90 * 24 * time.Hour), - SubjectAlternativeNames: []string{hostname}, - } -} - -func updateHostnameIP(deps *testDeps, hostname, ip string) { - deps.resolver.mu.Lock() - deps.resolver.allRecords[hostname] = map[string]map[string][]string{ - testNS1: {"A": {ip}}, - } - deps.resolver.mu.Unlock() - - deps.portChecker.mu.Lock() - deps.portChecker.results[ip+":80"] = true - deps.portChecker.results[ip+":443"] = true - deps.portChecker.mu.Unlock() - - deps.tlsChecker.mu.Lock() - deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{ - CommonName: hostname, - Issuer: testIssuer, - NotAfter: time.Now().Add(90 * 24 * time.Hour), - SubjectAlternativeNames: []string{hostname}, - } - deps.tlsChecker.mu.Unlock() -} - func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) cfg.Hostnames = []string{testHost} - w, deps := newTestWatcher(t, cfg) - - setupHostnameIP(deps, testHost, "10.0.0.1") - - ctx := t.Context() - w.RunOnce(ctx) + // The saved state says the last check found testHost at oldIP. + deps := runChecks(t, cfg, func(deps *testDeps) { + deps.state.SetHostnameState(testHost, &state.HostnameState{ + RecordsByNameserver: map[string]*state.NameserverRecordState{ + oldNS1: { + Records: map[string][]string{"A": {oldIP}}, + Status: "ok", + }, + }, + }) + }, nil) snap := deps.state.GetSnapshot() - if _, ok := snap.Ports["10.0.0.1:80"]; !ok { - t.Fatal("expected port state for 10.0.0.1:80") + + if _, ok := snap.Ports[oldIP+":80"]; ok { + t.Error("port check used stale DNS: found " + oldIP + ":80") } - // DNS changes to a new IP; port and TLS must pick it up. - updateHostnameIP(deps, testHost, "10.0.0.2") + // Port and TLS checks must use the addresses this check found. + for _, ip := range addresses(snap.Hostnames[testHost]) { + if _, ok := snap.Ports[ip+":80"]; !ok { + t.Error("port check used stale DNS: missing " + ip + ":80") + } - w.RunOnce(ctx) - - snap = deps.state.GetSnapshot() - - if _, ok := snap.Ports["10.0.0.2:80"]; !ok { - t.Error("port check used stale DNS: missing 10.0.0.2:80") - } - - certKey := "10.0.0.2:443:www.example.com" - if _, ok := snap.Certificates[certKey]; !ok { - t.Error("TLS check used stale DNS: missing " + certKey) + certKey := ip + ":443:" + testHost + if _, ok := snap.Certificates[certKey]; !ok { + t.Error("TLS check used stale DNS: missing " + certKey) + } } } func TestSendTestNotification_Enabled(t *testing.T) { t.Parallel() + // No domains or hostnames: the startup notification does not + // involve DNS. cfg := defaultTestConfig(t) - cfg.Domains = []string{testDomain} - cfg.Hostnames = []string{testHost} cfg.SendTestNotification = true w, deps := newTestWatcher(t, cfg) - setupBaselineMocks(deps) w.RunOnce(t.Context()) @@ -797,14 +584,11 @@ func TestSendTestNotification_ViaRun(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{testDomain} - cfg.Hostnames = []string{testHost} cfg.SendTestNotification = true cfg.DNSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour w, deps := newTestWatcher(t, cfg) - setupBaselineMocks(deps) ctx, cancel := context.WithCancel(t.Context()) @@ -844,14 +628,11 @@ func TestSendTestNotification_Disabled(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{testDomain} - cfg.Hostnames = []string{testHost} cfg.SendTestNotification = false cfg.DNSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour w, deps := newTestWatcher(t, cfg) - setupBaselineMocks(deps) ctx, cancel := context.WithCancel(t.Context()) @@ -884,32 +665,22 @@ func TestNSFailureAndRecovery(t *testing.T) { cfg := defaultTestConfig(t) cfg.Hostnames = []string{testHost} - w, deps := newTestWatcher(t, cfg) + // Between the checks, save every nameserver the first check found + // as failed, and add, as answering, one that live DNS does not list. + deps := runChecks(t, cfg, nil, func(deps *testDeps) { + hs, _ := deps.state.GetHostnameState(testHost) + for _, nsState := range hs.RecordsByNameserver { + nsState.Status = "error" + } - deps.resolver.allRecords[testHost] = map[string]map[string][]string{ - testNS1: {"A": {testIP}}, - testNS2: {"A": {testIP}}, - } - deps.resolver.ipAddresses[testHost] = []string{ - testIP, - } - deps.portChecker.results["1.2.3.4:80"] = false - deps.portChecker.results["1.2.3.4:443"] = false + hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{ + Records: map[string][]string{"A": {oldIP}}, + Status: "ok", + } - ctx := t.Context() + deps.state.SetHostnameState(testHost, hs) + }) - w.RunOnce(ctx) - - deps.resolver.mu.Lock() - deps.resolver.allRecords[testHost] = map[string]map[string][]string{ - testNS1: {"A": {testIP}}, - } - deps.resolver.mu.Unlock() - - w.RunOnce(ctx) - - notifications := deps.notifier.getNotifications() - if len(notifications) == 0 { - t.Error("expected notification for NS disappearance") - } + assertNotified(t, deps, "NS Failure: "+testHost, "error") + assertNotified(t, deps, "NS Recovery: "+testHost, "success") } -- 2.54.0 From 93c1fe15e30a66d2b0d23f03256246c3ccb5890d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 10:27:07 +0200 Subject: [PATCH 18/92] golangci: re-vendor the org config with gomodguard_v2 (closes #123) The org .golangci.yml now uses gomodguard_v2 in place of the deprecated gomodguard, which made every lint run print a deprecation warning. The file is copied unchanged from sneak/prompts. It also turns on depguard with the org test-support rule, which rejects net/http/httptest except in test files and in files under a directory whose name ends in test. This repo's previous copy had no deny entries of its own, so there were none to carry forward. Model: opus-5-5 --- .golangci.yml | 68 +++++++++++++++++++++++++++++++++++++++++++++++++-- TODO.md | 6 +++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 26b1610..a7a74c2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,14 +10,20 @@ run: linters: default: all + enable: + # Successor to the deprecated gomodguard. Named explicitly, rather than + # left to `default: all`, because it carries the module policy below. + - gomodguard_v2 disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields - - depguard # Dependency allow/block lists - godot # Requires comments to end with periods - - wsl # Deprecated, replaced by wsl_v5 - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go + # Deprecated: the warning is attached to the old name, so it is + # silenced by disabling that name, not by enabling the successor. + - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 settings: lll: line-length: 88 @@ -28,6 +34,64 @@ linters: max-complexity: 15 dupl: threshold: 100 + depguard: + # Test-support code must not be compiled into the shipped binary. A + # test-support package exists to hand a test privileges the program + # itself must never have, so a file that is not a test must not import + # one. Test files, and the files inside a package whose directory name + # ends in `test`, are where that code belongs, and are exempt. + # + # The deny list below is the one part of this file a repository is + # expected to extend, and the only part it may. depguard matches an + # import path against a list of prefixes, so it cannot be told "any path + # whose last segment ends in test"; a repository's own test-support + # packages have to be named here one at a time, by full import path, + # under a module path that differs from repository to repository. Add + # them; change nothing else. + rules: + test-support: + list-mode: lax + files: + - "$all" + - "!$test" + - "!**/*test/**" + deny: + - pkg: net/http/httptest + desc: >- + Test-support code belongs in test files and in packages whose + directory name ends in test, not in the shipped binary. + # Only decisions already recorded in the Go package defaults are + # listed here. Every entry matches the module path exactly. + gomodguard_v2: + blocked: + - module: github.com/rs/zerolog + recommendations: + - log/slog + reason: "Structured logging is stdlib log/slog." + # One entry per pre-fork module path, because the later releases + # are separate paths. A prefix match would be shorter but would + # also reach github.com/go-redis/redismock, the test double for + # the successor these entries recommend. + - module: github.com/go-redis/redis + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v7 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v8 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/sergi/go-diff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "No unified diff output; use go-udiff." + - module: github.com/hexops/gotextdiff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "Unmaintained fork; use go-udiff." issues: max-issues-per-linter: 0 diff --git a/TODO.md b/TODO.md index c03615b..1c50752 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,12 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-29: `.golangci.yml` re-fetched unchanged from `sneak/prompts`. It + replaces the deprecated `gomodguard` with `gomodguard_v2`, so `make lint` no + longer warns about it, and turns on `depguard` with the org `test-support` + rule, which rejects `net/http/httptest` except in test files and in files + under a directory whose name ends in `test`. This repo had no `deny` entries + of its own to carry forward (closes #123). - 2026-09-29: nothing stands in for DNS any more. Watcher tests that look something up in DNS use the real resolver against live DNS servers and test record and nameserver changes by preparing the saved state a check starts -- 2.54.0 From 651429137f726fdfc1180370257bf980a988878b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 19:52:48 +0200 Subject: [PATCH 19/92] tests: rename internal/livedns to livednstest and deny it outside tests (closes #164) The live-DNS retry and concurrency limit is only for tests, but nothing stopped program code from importing it and compiling it into the binary. Its directory name now ends in test, and its import path is on the test-support deny list in .golangci.yml, so make lint fails when program code imports it. Every import and mention is updated to the new name. Model: opus-5-5 --- .golangci.yml | 4 +++ README.md | 2 +- TESTING.md | 2 +- TODO.md | 7 +++-- .../livedns.go => livednstest/livednstest.go} | 4 +-- .../livednstest_test.go} | 18 ++++++------ internal/resolver/livedns_test.go | 28 +++++++++---------- internal/resolver/resolver_test.go | 2 +- internal/watcher/watcher_test.go | 6 ++-- 9 files changed, 40 insertions(+), 33 deletions(-) rename internal/{livedns/livedns.go => livednstest/livednstest.go} (97%) rename internal/{livedns/livedns_test.go => livednstest/livednstest_test.go} (75%) diff --git a/.golangci.yml b/.golangci.yml index a7a74c2..35b5a7d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -60,6 +60,10 @@ linters: desc: >- Test-support code belongs in test files and in packages whose directory name ends in test, not in the shipped binary. + - pkg: sneak.berlin/go/dnswatcher/internal/livednstest + desc: >- + Live-DNS test support belongs in test files and in packages + whose directory name ends in test, not in the shipped binary. # Only decisions already recorded in the Go package defaults are # listed here. Every entry matches the module path exactly. gomodguard_v2: diff --git a/README.md b/README.md index 4704b23..1e6294b 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ internal/ tlscheck/tlscheck.go TLS certificate inspector notify/notify.go Notification service (Slack, Mattermost, ntfy) watcher/watcher.go Main monitoring orchestrator and scheduler - livedns/livedns.go Retry and concurrency limit for tests + livednstest/livednstest.go Retry and concurrency limit for tests against live DNS (imported only by tests) ``` diff --git a/TESTING.md b/TESTING.md index 70e3899..6b4f3d8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -25,7 +25,7 @@ real servers ensures the resolver works correctly in production. - Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) plus processing margin - Root server fan-out is limited to reduce parallel query load -- Live lookups that expect an answer go through `internal/livedns`, +- Live lookups that expect an answer go through `internal/livednstest`, which limits how many run at once in a test binary and retries a lookup that got none - Flaky failures from transient network issues are acceptable and diff --git a/TODO.md b/TODO.md index 1c50752..5f654fe 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,9 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-09-29: the live-DNS test package is renamed `internal/livednstest` and + added to the `test-support` `deny` list in `.golangci.yml`, so `make lint` + fails when program code imports it (closes #164). - 2026-09-29: `.golangci.yml` re-fetched unchanged from `sneak/prompts`. It replaces the deprecated `gomodguard` with `gomodguard_v2`, so `make lint` no longer warns about it, and turns on `depguard` with the org `test-support` @@ -30,8 +33,8 @@ Rationale, Design, TODO, License, Author) if any are still missing. record and nameserver changes by preparing the saved state a check starts from; the resolver timeout test queries an address that never answers, and `NewFromLoggerWithClient`, used only by its stand-in client, is gone. The - live-DNS retry and concurrency limit moved to `internal/livedns`, which both - test packages use. `TESTING.md` states the README's rule (closes #159). + live-DNS retry and concurrency limit moved to `internal/livednstest`, which + both test packages use. `TESTING.md` states the README's rule (closes #159). - 2026-09-28: the inconsistency alert is sent once, on the check where two nameservers start to disagree or where a nameserver that disagrees first appears, instead of on every check while they disagree, and not again after diff --git a/internal/livedns/livedns.go b/internal/livednstest/livednstest.go similarity index 97% rename from internal/livedns/livedns.go rename to internal/livednstest/livednstest.go index b86794b..1554596 100644 --- a/internal/livedns/livedns.go +++ b/internal/livednstest/livednstest.go @@ -1,4 +1,4 @@ -// Package livedns runs the live DNS operations of tests. Tests that +// Package livednstest runs the live DNS operations of tests. Tests that // look something up in DNS query live DNS servers, never a stand-in — // see TESTING.md. Nothing here mocks, fakes, stubs, records or replays // DNS, and nothing here skips a test: it only changes *how* the live @@ -22,7 +22,7 @@ // first attempt. A fault in the code under test that leaves // nothing to check looks the same as live DNS not answering, and // fails only after the last attempt. -package livedns +package livednstest import ( "context" diff --git a/internal/livedns/livedns_test.go b/internal/livednstest/livednstest_test.go similarity index 75% rename from internal/livedns/livedns_test.go rename to internal/livednstest/livednstest_test.go index 1c0f5fd..d7f2376 100644 --- a/internal/livedns/livedns_test.go +++ b/internal/livednstest/livednstest_test.go @@ -1,4 +1,4 @@ -package livedns_test +package livednstest_test import ( "context" @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" - "sneak.berlin/go/dnswatcher/internal/livedns" + "sneak.berlin/go/dnswatcher/internal/livednstest" ) // Tests for the retry and the concurrency limit themselves. They @@ -21,11 +21,11 @@ func TestRetryRecoversFromTransientFailure(t *testing.T) { attempts := 0 - livedns.Retry(t, "transient", func(_ context.Context) error { + livednstest.Retry(t, "transient", func(_ context.Context) error { attempts++ if attempts < wantAttempts { - return livedns.ErrNoAnswer + return livednstest.ErrNoAnswer } return nil @@ -37,19 +37,19 @@ func TestRetryRecoversFromTransientFailure(t *testing.T) { func TestRetryGivesEachAttemptADeadline(t *testing.T) { t.Parallel() - livedns.Retry(t, "deadline", func(ctx context.Context) error { + livednstest.Retry(t, "deadline", func(ctx context.Context) error { deadline, ok := ctx.Deadline() assert.True(t, ok, "attempt should carry a deadline") remaining := time.Until(deadline) - assert.LessOrEqual(t, remaining, livedns.AttemptTimeout) + assert.LessOrEqual(t, remaining, livednstest.AttemptTimeout) // Lower bound too: without one this passes for a // deadline far shorter than intended, which would // silently turn every live attempt into an instant // timeout. - assert.Greater(t, remaining, livedns.AttemptTimeout/2) + assert.Greater(t, remaining, livednstest.AttemptTimeout/2) return nil }) @@ -73,7 +73,7 @@ func TestRunBoundsConcurrency(t *testing.T) { go func() { defer wg.Done() - _ = livedns.Run(func(_ context.Context) error { + _ = livednstest.Run(func(_ context.Context) error { mu.Lock() inFlight++ @@ -97,7 +97,7 @@ func TestRunBoundsConcurrency(t *testing.T) { assert.Positive(t, maxSeen) assert.LessOrEqual( - t, maxSeen, livedns.Concurrency, + t, maxSeen, livednstest.Concurrency, "live queries must stay under the package-wide gate", ) } diff --git a/internal/resolver/livedns_test.go b/internal/resolver/livedns_test.go index b7d1be2..fb425a8 100644 --- a/internal/resolver/livedns_test.go +++ b/internal/resolver/livedns_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "sneak.berlin/go/dnswatcher/internal/livedns" + "sneak.berlin/go/dnswatcher/internal/livednstest" "sneak.berlin/go/dnswatcher/internal/resolver" ) @@ -20,9 +20,9 @@ import ( // Tests that look something up in DNS query live DNS servers, never a // stand-in; logic that works on record data may be tested on that // data with no lookup (see TESTING.md). Each live operation below goes -// through livedns.Retry, which bounds how many resolutions are in +// through livednstest.Retry, which bounds how many resolutions are in // flight at once and retries an operation that got no answer (see -// package livedns). +// package livednstest). // // Where an assertion spans several independent nameservers, a quorum // is enough: a strict majority answering as expected. A server that @@ -162,7 +162,7 @@ func liveFindAuthoritative( var out []string - livedns.Retry( + livednstest.Retry( t, "FindAuthoritativeNameservers("+domain+")", func(ctx context.Context) error { @@ -174,7 +174,7 @@ func liveFindAuthoritative( if len(ns) == 0 { return fmt.Errorf( "%w: %s has no nameservers", - livedns.ErrNoAnswer, domain, + livednstest.ErrNoAnswer, domain, ) } @@ -198,7 +198,7 @@ func liveLookupNS( var out []string - livedns.Retry( + livednstest.Retry( t, "LookupNS("+domain+")", func(ctx context.Context) error { @@ -210,7 +210,7 @@ func liveLookupNS( if len(ns) == 0 { return fmt.Errorf( "%w: %s has no nameservers", - livedns.ErrNoAnswer, domain, + livednstest.ErrNoAnswer, domain, ) } @@ -240,7 +240,7 @@ func liveQueryNameserver( var out *resolver.NameserverResponse - livedns.Retry( + livednstest.Retry( t, what, func(ctx context.Context) error { @@ -255,7 +255,7 @@ func liveQueryNameserver( resp.Status == resolver.StatusError { return fmt.Errorf( "%w: %s returned %s: %s", - livedns.ErrNoAnswer, nameserver, + livednstest.ErrNoAnswer, nameserver, resp.Status, resp.Error, ) } @@ -282,7 +282,7 @@ func liveQueryAllNameservers( var out map[string]*resolver.NameserverResponse - livedns.Retry( + livednstest.Retry( t, "QueryAllNameservers("+hostname+")", func(ctx context.Context) error { @@ -294,7 +294,7 @@ func liveQueryAllNameservers( if len(results) == 0 { return fmt.Errorf( "%w: no nameservers queried for %s", - livedns.ErrNoAnswer, hostname, + livednstest.ErrNoAnswer, hostname, ) } @@ -327,7 +327,7 @@ func liveResolveIPs( var out []string - livedns.Retry( + livednstest.Retry( t, "ResolveIPAddresses("+hostname+")", func(ctx context.Context) error { @@ -339,7 +339,7 @@ func liveResolveIPs( if len(ips) == 0 { return fmt.Errorf( "%w: no addresses for %s", - livedns.ErrNoAnswer, hostname, + livednstest.ErrNoAnswer, hostname, ) } @@ -366,7 +366,7 @@ func liveResolveIPsAllowingEmpty( var out []string - livedns.Retry( + livednstest.Retry( t, "ResolveIPAddresses("+hostname+")", func(ctx context.Context) error { diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 4755245..28544f7 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -33,7 +33,7 @@ func newTestResolver(t *testing.T) *resolver.Resolver { // findOneNSForDomain picks one authoritative nameserver to aim a // test at. Quorum handling lives in livedns_test.go, and the live-DNS -// retry and concurrency limit in package livedns. +// retry and concurrency limit in package livednstest. func findOneNSForDomain( t *testing.T, r *resolver.Resolver, diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 5ad6030..8ff670e 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -10,7 +10,7 @@ import ( "time" "sneak.berlin/go/dnswatcher/internal/config" - "sneak.berlin/go/dnswatcher/internal/livedns" + "sneak.berlin/go/dnswatcher/internal/livednstest" "sneak.berlin/go/dnswatcher/internal/portcheck" "sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/state" @@ -195,7 +195,7 @@ func checkOnce( return fmt.Errorf( "%s: %w, or the watcher saved no fresh "+ "result for it", - name, livedns.ErrNoAnswer, + name, livednstest.ErrNoAnswer, ) } } @@ -219,7 +219,7 @@ func runChecks( var deps *testDeps - livedns.Retry(t, "watcher checks", func(ctx context.Context) error { + livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { var w *watcher.Watcher w, deps = newTestWatcher(t, cfg) -- 2.54.0 From 5493e28480fccfd349a2914ad0f8f0076c40e9b4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 20:42:21 +0200 Subject: [PATCH 20/92] notify: make the shutdown tests fail with the right message (closes #116) drainSlack stood for three things: the deadline given to a drain that should finish early, the watchdog on a drain that should time out, and the wait for a delivery to reach the test server. It is now three constants, each commented with what it bounds and why it is 2s; no value changed. The idle-drain failure printed that deadline instead of idleDrainBound, the bound it checks. The cancelled-context test now also requires the drain to return within idleDrainBound and to log its debug line, so a drain that logs nothing no longer passes; newLoggingService records debug level for this. Model: opus-5-5 --- TODO.md | 2 + internal/notify/shutdown_test.go | 111 +++++++++++++++++++++++-------- 2 files changed, 85 insertions(+), 28 deletions(-) diff --git a/TODO.md b/TODO.md index 5f654fe..5e7cad9 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-10-01: notify shutdown tests use one timing constant per meaning, name + the bound they check, and require the drain's debug line (closes #116). - 2026-09-29: the live-DNS test package is renamed `internal/livednstest` and added to the `test-support` `deny` list in `.golangci.yml`, so `make lint` fails when program code imports it (closes #164). diff --git a/internal/notify/shutdown_test.go b/internal/notify/shutdown_test.go index f938b64..224741d 100644 --- a/internal/notify/shutdown_test.go +++ b/internal/notify/shutdown_test.go @@ -33,10 +33,29 @@ const ( // out. drainDeadline = 50 * time.Millisecond - // drainSlack is the upper bound on how long a bounded - // drain may take; generous enough for a loaded CI box, - // still far below the 20s test ceiling. - drainSlack = 2 * time.Second + // timeoutDrainBound is how long a drain given drainDeadline + // may take to return before the test gives up on it. At + // forty times drainDeadline it leaves ample room for + // scheduling delay on a loaded box under -race, yet it is far + // below the test binary's -timeout, so a drain that its + // deadline does not bound fails that one test instead of + // hanging the package. + timeoutDrainBound = 2 * time.Second + + // longDrainDeadline is the deadline given to a drain that is + // expected to finish well before it: when the in-flight + // delivery completes after inFlightHold, or at once when + // nothing is in flight. It is far above inFlightHold, so + // those drains never reach it, and four times + // idleDrainBound, so an idle drain that waited for its + // deadline instead of returning fails that bound. + longDrainDeadline = 2 * time.Second + + // reachEndpointTimeout is how long a submitted delivery may + // take to reach the test server. That normally takes a few + // milliseconds; the margin is for a loaded box under -race, + // and only a failing run ever waits this long. + reachEndpointTimeout = 2 * time.Second // settleDelay is how long to wait before asserting that // something did *not* happen. @@ -46,10 +65,11 @@ const ( // nothing in flight. It is deliberately far above the cost // of the goroutine hop through inFlight.Wait() — which // reached 57ms on a loaded box under -race with the package's - // parallel tests — and far below drainSlack, the deadline - // such a drain is given. A drain that blocked until its - // deadline instead of returning on the WaitGroup therefore - // still fails this bound, but scheduling delay alone cannot. + // parallel tests — and far below longDrainDeadline, the + // deadline such a drain is given. A drain that blocked until + // its deadline instead of returning on the WaitGroup + // therefore still fails this bound, but scheduling delay + // alone cannot. idleDrainBound = 500 * time.Millisecond ) @@ -75,12 +95,14 @@ func (sb *syncBuffer) String() string { } // newLoggingService returns a Service writing JSON logs into -// the returned buffer. +// the returned buffer, debug level included. func newLoggingService( transport http.RoundTripper, ) (*notify.Service, *syncBuffer) { logs := &syncBuffer{} - handler := slog.NewJSONHandler(logs, nil) + handler := slog.NewJSONHandler( + logs, &slog.HandlerOptions{Level: slog.LevelDebug}, + ) return notify.NewTestServiceWithLogger(transport, handler), logs @@ -134,7 +156,7 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) { // drain begins. select { case <-entered: - case <-time.After(drainSlack): + case <-time.After(reachEndpointTimeout): t.Fatal("delivery never reached the endpoint") } @@ -151,7 +173,7 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) { defer timer.Stop() ctx, cancel := context.WithTimeout( - context.Background(), drainSlack, + context.Background(), longDrainDeadline, ) defer cancel() @@ -257,11 +279,11 @@ func TestDrainBoundedByContextDeadline(t *testing.T) { select { case <-returned: - case <-time.After(drainSlack): + case <-time.After(timeoutDrainBound): t.Fatalf( "drain did not return within %v; its %v deadline "+ "did not bound it", - drainSlack, drainDeadline, + timeoutDrainBound, drainDeadline, ) } @@ -333,7 +355,7 @@ func TestDrainRefusesNewDeliveries(t *testing.T) { svc.SetMattermostWebhookURL(target) ctx, cancel := context.WithTimeout( - context.Background(), drainSlack, + context.Background(), longDrainDeadline, ) defer cancel() @@ -446,7 +468,7 @@ func TestNewRegistersDrainingStopHook(t *testing.T) { select { case <-entered: - case <-time.After(drainSlack): + case <-time.After(reachEndpointTimeout): t.Fatal("delivery never reached the endpoint") } @@ -456,7 +478,7 @@ func TestNewRegistersDrainingStopHook(t *testing.T) { defer timer.Stop() ctx, cancel := context.WithTimeout( - context.Background(), drainSlack, + context.Background(), longDrainDeadline, ) defer cancel() @@ -487,7 +509,7 @@ func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) { start := time.Now() ctx, cancel := context.WithTimeout( - context.Background(), drainSlack, + context.Background(), longDrainDeadline, ) defer cancel() @@ -495,9 +517,9 @@ func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) { if elapsed := time.Since(start); elapsed > idleDrainBound { t.Errorf( - "drain of an idle service took %v, want well "+ - "under its %v deadline", - elapsed, drainSlack, + "drain of an idle service took %v, want at most "+ + "%v; its deadline was %v", + elapsed, idleDrainBound, longDrainDeadline, ) } } @@ -505,10 +527,11 @@ func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) { // TestDrainWithCancelledContextDoesNotWarn verifies that an // OnStop context that is already dead on entry does not produce // an "abandoning them" warning when there was nothing in flight -// to abandon. The expired context wins the select immediately, -// so only the outstanding count can tell the difference between -// a genuine timeout and a shutdown that had simply already run -// out of time with no work left. +// to abandon, and that the drain returns and says at debug level +// that nothing was in flight. The expired context wins the +// select immediately, so only the outstanding count can tell the +// difference between a genuine timeout and a shutdown that had +// simply already run out of time with no work left. func TestDrainWithCancelledContextDoesNotWarn(t *testing.T) { t.Parallel() @@ -517,11 +540,43 @@ func TestDrainWithCancelledContextDoesNotWarn(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - svc.Drain(ctx) + // A watchdog, as in TestDrainBoundedByContextDeadline, so + // that a drain which never returns fails here instead of + // hanging the package. + returned := make(chan struct{}) - if output := logs.String(); strings.Contains( - output, `"level":"WARN"`, + go func() { + defer close(returned) + + svc.Drain(ctx) + }() + + select { + case <-returned: + case <-time.After(idleDrainBound): + t.Fatalf( + "drain with nothing in flight and a cancelled "+ + "context did not return within %v", + idleDrainBound, + ) + } + + output := logs.String() + + // The absence of a warning alone would also pass if the drain + // logged nothing at all, so require the debug line it writes + // when it finds nothing outstanding. + if !strings.Contains( + output, "all in-flight notifications completed", ) { + t.Errorf( + "drain did not log that nothing was in flight; "+ + "log output: %s", + output, + ) + } + + if strings.Contains(output, `"level":"WARN"`) { t.Errorf( "drain with nothing in flight warned about "+ "abandoned deliveries; log output: %s", -- 2.54.0 From db94c903dffd4150b66e382c38b1df4e52176e6a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 21:03:03 +0200 Subject: [PATCH 21/92] tests: move the test-only constructors into export_test.go (closes #111) state.NewForTest, state.NewForTestWithDataDir and watcher.NewForTest were compiled into and exported by the production packages internal/state and internal/watcher. NewForTestWithDataDir and watcher.NewForTest now live in their package's export_test.go. Tests in other packages cannot see those files, so the watcher and middleware tests build their State with state.New and a temporary data directory; the watcher tests no longer try to save to /state.json. state.NewForTest, whose State saved to /, is deleted: the state tests that used it pass t.TempDir() to NewForTestWithDataDir, and the test of the helper itself is gone. Model: opus-5-5 --- TODO.md | 2 ++ internal/middleware/middleware_test.go | 10 +++++- internal/state/export_test.go | 23 ++++++++++++++ internal/state/state_test.go | 43 +++++--------------------- internal/state/state_test_helper.go | 38 ----------------------- internal/watcher/export_test.go | 25 +++++++++++++++ internal/watcher/watcher.go | 22 ------------- internal/watcher/watcher_test.go | 23 +++++++++++++- 8 files changed, 88 insertions(+), 98 deletions(-) create mode 100644 internal/state/export_test.go delete mode 100644 internal/state/state_test_helper.go diff --git a/TODO.md b/TODO.md index 5e7cad9..a7c1ce8 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-10-01: `internal/state` and `internal/watcher` no longer export test-only + constructors: two moved to `export_test.go`, one is deleted (closes #111). - 2026-10-01: notify shutdown tests use one timing constant per meaning, name the bound they check, and require the drain's debug line (closes #116). - 2026-09-29: the live-DNS test package is renamed `internal/livednstest` and diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 598f476..da51423 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -276,10 +276,18 @@ func newTestHandlers(t *testing.T) *handlers.Handlers { t.Fatalf("notify.New: %v", err) } + st, err := state.New(fxtest.NewLifecycle(t), state.Params{ + Logger: log, + Config: &config.Config{DataDir: t.TempDir()}, + }) + if err != nil { + t.Fatalf("state.New: %v", err) + } + hnd, err := handlers.New(nil, handlers.Params{ Logger: log, Globals: glob, - State: state.NewForTest(), + State: st, Notify: notifier, }) if err != nil { diff --git a/internal/state/export_test.go b/internal/state/export_test.go new file mode 100644 index 0000000..8defb0c --- /dev/null +++ b/internal/state/export_test.go @@ -0,0 +1,23 @@ +package state + +import ( + "log/slog" + + "sneak.berlin/go/dnswatcher/internal/config" +) + +// NewForTestWithDataDir creates an empty State that saves to dataDir, +// without the fx lifecycle. +func NewForTestWithDataDir(dataDir string) *State { + return &State{ + log: slog.Default(), + snapshot: &Snapshot{ + Version: stateVersion, + Domains: make(map[string]*DomainState), + Hostnames: make(map[string]*HostnameState), + Ports: make(map[string]*PortState), + Certificates: make(map[string]*CertificateState), + }, + config: &config.Config{DataDir: dataDir}, + } +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 3fbca93..fabc142 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -739,7 +739,7 @@ func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) { func TestGetSnapshot_ReturnsCopy(t *testing.T) { t.Parallel() - s := state.NewForTest() + s := state.NewForTestWithDataDir(t.TempDir()) populateState(t, s) @@ -761,7 +761,7 @@ func TestGetSnapshot_ReturnsCopy(t *testing.T) { func TestDomainState_GetSet(t *testing.T) { t.Parallel() - s := state.NewForTest() + s := state.NewForTestWithDataDir(t.TempDir()) // Get on missing key returns false. _, ok := s.GetDomainState("nonexistent.com") @@ -812,7 +812,7 @@ func TestDomainState_GetSet(t *testing.T) { func TestHostnameState_GetSet(t *testing.T) { t.Parallel() - s := state.NewForTest() + s := state.NewForTestWithDataDir(t.TempDir()) _, ok := s.GetHostnameState("missing.example.com") if ok { @@ -857,7 +857,7 @@ func TestHostnameState_GetSet(t *testing.T) { func TestPortState_GetSetDelete(t *testing.T) { t.Parallel() - s := state.NewForTest() + s := state.NewForTestWithDataDir(t.TempDir()) _, ok := s.GetPortState("1.2.3.4:80") if ok { @@ -895,7 +895,7 @@ func TestPortState_GetSetDelete(t *testing.T) { func TestGetAllPortKeys(t *testing.T) { t.Parallel() - s := state.NewForTest() + s := state.NewForTestWithDataDir(t.TempDir()) keys := s.GetAllPortKeys() if len(keys) != 0 { @@ -937,7 +937,7 @@ func TestGetAllPortKeys(t *testing.T) { func TestCertificateState_GetSet(t *testing.T) { t.Parallel() - s := state.NewForTest() + s := state.NewForTestWithDataDir(t.TempDir()) _, ok := s.GetCertificateState("1.2.3.4:443:www.example.com") if ok { @@ -1158,7 +1158,7 @@ func TestLoadPreservesExistingStateOnMissingFile(t *testing.T) { func TestConcurrentGetSet(t *testing.T) { t.Parallel() - s := state.NewForTest() + s := state.NewForTestWithDataDir(t.TempDir()) const goroutines = 20 @@ -1368,35 +1368,6 @@ func TestMultipleSavesOverwrite(t *testing.T) { } } -// TestNewForTest verifies the test helper creates a valid empty state. -func TestNewForTest(t *testing.T) { - t.Parallel() - - s := state.NewForTest() - - snap := s.GetSnapshot() - - if snap.Version != 1 { - t.Errorf("version: got %d, want 1", snap.Version) - } - - if snap.Domains == nil { - t.Error("Domains map should be initialized") - } - - if snap.Hostnames == nil { - t.Error("Hostnames map should be initialized") - } - - if snap.Ports == nil { - t.Error("Ports map should be initialized") - } - - if snap.Certificates == nil { - t.Error("Certificates map should be initialized") - } -} - // TestSaveFilePermissions verifies the saved file has restricted permissions. func TestSaveFilePermissions(t *testing.T) { t.Parallel() diff --git a/internal/state/state_test_helper.go b/internal/state/state_test_helper.go deleted file mode 100644 index 7e9aac7..0000000 --- a/internal/state/state_test_helper.go +++ /dev/null @@ -1,38 +0,0 @@ -package state - -import ( - "log/slog" - - "sneak.berlin/go/dnswatcher/internal/config" -) - -// NewForTest creates a State for unit testing with no persistence. -func NewForTest() *State { - return &State{ - log: slog.Default(), - snapshot: &Snapshot{ - Version: stateVersion, - Domains: make(map[string]*DomainState), - Hostnames: make(map[string]*HostnameState), - Ports: make(map[string]*PortState), - Certificates: make(map[string]*CertificateState), - }, - config: &config.Config{DataDir: ""}, - } -} - -// NewForTestWithDataDir creates a State backed by the given directory -// for tests that need file persistence. -func NewForTestWithDataDir(dataDir string) *State { - return &State{ - log: slog.Default(), - snapshot: &Snapshot{ - Version: stateVersion, - Domains: make(map[string]*DomainState), - Hostnames: make(map[string]*HostnameState), - Ports: make(map[string]*PortState), - Certificates: make(map[string]*CertificateState), - }, - config: &config.Config{DataDir: dataDir}, - } -} diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 04a36d5..c647a6e 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -2,10 +2,35 @@ package watcher import ( "context" + "log/slog" + "time" + "sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/state" ) +// NewForTest creates a Watcher without fx for unit testing. +func NewForTest( + cfg *config.Config, + st *state.State, + res DNSResolver, + pc PortChecker, + tc TLSChecker, + n Notifier, +) *Watcher { + return &Watcher{ + log: slog.Default(), + config: cfg, + state: st, + resolver: res, + portCheck: pc, + tlsCheck: tc, + notify: n, + firstRun: true, + expiryNotified: make(map[string]time.Time), + } +} + // NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing. func NewlyDisagreeingPairs( prev *state.HostnameState, diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 1af5709..c3d59f0 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -102,28 +102,6 @@ func New( return w, nil } -// NewForTest creates a Watcher without fx for unit testing. -func NewForTest( - cfg *config.Config, - st *state.State, - res DNSResolver, - pc PortChecker, - tc TLSChecker, - n Notifier, -) *Watcher { - return &Watcher{ - log: slog.Default(), - config: cfg, - state: st, - resolver: res, - portCheck: pc, - tlsCheck: tc, - notify: n, - firstRun: true, - expiryNotified: make(map[string]time.Time), - } -} - // Run starts the monitoring loop with periodic scheduling. func (w *Watcher) Run(ctx context.Context) { w.log.Info( diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 8ff670e..0620f8a 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -9,8 +9,12 @@ import ( "testing" "time" + "go.uber.org/fx/fxtest" + "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/globals" "sneak.berlin/go/dnswatcher/internal/livednstest" + "sneak.berlin/go/dnswatcher/internal/logger" "sneak.berlin/go/dnswatcher/internal/portcheck" "sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/state" @@ -148,7 +152,24 @@ func newTestWatcher( config: cfg, } - deps.state = state.NewForTest() + g, err := globals.New(nil) + if err != nil { + t.Fatalf("globals.New: %v", err) + } + + log, err := logger.New(nil, logger.Params{Globals: g}) + if err != nil { + t.Fatalf("logger.New: %v", err) + } + + // The watcher saves state after every check, into cfg.DataDir. + deps.state, err = state.New(fxtest.NewLifecycle(t), state.Params{ + Logger: log, + Config: cfg, + }) + if err != nil { + t.Fatalf("state.New: %v", err) + } w := watcher.NewForTest( deps.config, -- 2.54.0 From f7cc6b42e0ec21dc7a4fef4692bd5d870375ce97 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 21:05:43 +0200 Subject: [PATCH 22/92] server: limit wildcard CORS to the public routes (closes #100) The CORS wildcard was global, so it also covered the Basic-Auth protected /metrics, which REPO_POLICIES.md forbids, and it allowed POST, PUT and DELETE, which no route serves, plus the Authorization and X-CSRF-Token headers. CORS now sits on a router holding only the public routes and allows GET and OPTIONS with the Accept and Content-Type headers. /metrics gets no CORS at all. Both are mounted routers rather than a Group: chi answers OPTIONS on a Group's route with 405 before its middleware runs, and any method /metrics does not register would otherwise fall through to the public router. So every method on /metrics now meets Basic Auth first, and /metrics/ is served like /metrics. Model: opus-5-5 --- TODO.md | 2 + internal/middleware/middleware.go | 15 +- internal/server/routes.go | 37 +++-- internal/server/routes_test.go | 221 ++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 internal/server/routes_test.go diff --git a/TODO.md b/TODO.md index a7c1ce8..23a1f8f 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ Rationale, Design, TODO, License, Author) if any are still missing. # Completed Steps +- 2026-10-01: wildcard CORS now applies only to the public routes, not to + `/metrics`, and allows only the methods they serve (closes #100). - 2026-10-01: `internal/state` and `internal/watcher` no longer export test-only constructors: two moved to `export_test.go`, one is deleted (closes #111). - 2026-10-01: notify shutdown tests use one timing constant per meaning, name diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 03f435e..4ba05a8 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -223,17 +223,14 @@ func realIP(r *http.Request) string { return addr } -// CORS returns CORS middleware. +// CORS returns middleware that lets any origin read a response. It is +// for the public, read-only routes only, so it allows only the +// methods those routes serve and no Authorization header. func (m *Middleware) CORS() func(http.Handler) http.Handler { return cors.Handler(cors.Options{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{ - "GET", "POST", "PUT", "DELETE", "OPTIONS", - }, - AllowedHeaders: []string{ - "Accept", "Authorization", - "Content-Type", "X-CSRF-Token", - }, + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Content-Type"}, ExposedHeaders: []string{"Link"}, AllowCredentials: false, MaxAge: corsMaxAge, diff --git a/internal/server/routes.go b/internal/server/routes.go index 5c71d84..6305e1d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -23,14 +23,20 @@ func (s *Server) SetupRoutes() { s.router.Use(chimw.RequestID) s.router.Use(s.mw.SecurityHeaders()) s.router.Use(s.mw.Logging()) - s.router.Use(s.mw.CORS()) s.router.Use(chimw.Timeout(requestTimeout)) + // Public, unauthenticated, read-only routes, the only ones + // REPO_POLICIES.md allows wildcard CORS on. CORS is middleware of + // this whole router, not of a Group, so that it also answers + // OPTIONS preflight requests, which no route here registers. + public := chi.NewRouter() + public.Use(s.mw.CORS()) + // Dashboard (read-only web UI) - s.router.Get("/", s.handlers.HandleDashboard()) + public.Get("/", s.handlers.HandleDashboard()) // Static assets (embedded CSS/JS) - s.router.Mount( + public.Mount( "/s", http.StripPrefix( "/s", @@ -39,27 +45,30 @@ func (s *Server) SetupRoutes() { ) // Health check (standard well-known path) - s.router.Get( + public.Get( "/.well-known/healthcheck", s.handlers.HandleHealthCheck(), ) // Legacy health check (keep for backward compatibility) - s.router.Get("/health", s.handlers.HandleHealthCheck()) + public.Get("/health", s.handlers.HandleHealthCheck()) // API v1 routes - s.router.Route("/api/v1", func(r chi.Router) { + public.Route("/api/v1", func(r chi.Router) { r.Get("/status", s.handlers.HandleStatus()) }) - // Metrics endpoint (optional, with basic auth) + s.router.Mount("/", public) + + // Metrics endpoint (optional, with basic auth) and no CORS: a + // Prometheus scraper is not a browser. It is mounted rather than + // added with Get so that every method on /metrics, OPTIONS + // included, ends here instead of falling through to the public + // router and its CORS. if s.params.Config.MetricsUsername != "" { - s.router.Group(func(r chi.Router) { - r.Use(s.mw.MetricsAuth()) - r.Get( - "/metrics", - promhttp.Handler().ServeHTTP, - ) - }) + metrics := chi.NewRouter() + metrics.Use(s.mw.MetricsAuth()) + metrics.Get("/", promhttp.Handler().ServeHTTP) + s.router.Mount("/metrics", metrics) } } diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go new file mode 100644 index 0000000..a16c762 --- /dev/null +++ b/internal/server/routes_test.go @@ -0,0 +1,221 @@ +package server_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/viper" + + "sneak.berlin/go/dnswatcher/internal/server" +) + +// Credentials for /metrics, which is only routed when a username is set. +const ( + metricsUsername = "scraper" + metricsPassword = "scrape-secret" +) + +// The tests below set env vars and touch viper global state, so like +// the config tests they cannot use t.Parallel. + +// routedServer builds the server with its routes set up, ready to serve +// test requests. The caller must first configure viper. +func routedServer(t *testing.T) *server.Server { + t.Helper() + + srv := buildServer(t) + srv.SetupRoutes() + + return srv +} + +// crossOriginRequest builds a request as a browser sends it from a page +// on another site. +func crossOriginRequest( + t *testing.T, + method string, + target string, +) *http.Request { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), method, target, nil) + req.Header.Set("Origin", "https://example.net") + + return req +} + +// preflightRequest builds the OPTIONS request a browser sends before a +// cross-origin request with the given method and request headers. +func preflightRequest( + t *testing.T, + target string, + method string, + headers string, +) *http.Request { + t.Helper() + + req := crossOriginRequest(t, http.MethodOptions, target) + req.Header.Set("Access-Control-Request-Method", method) + + if headers != "" { + req.Header.Set("Access-Control-Request-Headers", headers) + } + + return req +} + +func serve( + srv *server.Server, + req *http.Request, +) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + return rec +} + +// publicPaths returns one path on each public route. +func publicPaths() []string { + return []string{ + "/", + "/s/css/tailwind.min.css", + "/api/v1/status", + "/health", + "/.well-known/healthcheck", + } +} + +// TestPublicRoutesAllowAnyOrigin checks that every public route answers +// a cross-origin GET with the CORS wildcard. +func TestPublicRoutesAllowAnyOrigin(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_METRICS_USERNAME", metricsUsername) + t.Setenv("DNSWATCHER_METRICS_PASSWORD", metricsPassword) + + srv := routedServer(t) + + for _, path := range publicPaths() { + rec := serve(srv, crossOriginRequest(t, http.MethodGet, path)) + + if rec.Code != http.StatusOK { + t.Errorf("GET %s: status = %d, want 200", path, rec.Code) + } + + got := rec.Header().Get("Access-Control-Allow-Origin") + if got != "*" { + t.Errorf( + "GET %s: Access-Control-Allow-Origin = %q, want %q", + path, got, "*", + ) + } + } +} + +// TestMetricsHasNoCORS checks that no request to the Basic-Auth +// protected /metrics, preflight included, gets a CORS header. +func TestMetricsHasNoCORS(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_METRICS_USERNAME", metricsUsername) + t.Setenv("DNSWATCHER_METRICS_PASSWORD", metricsPassword) + + srv := routedServer(t) + + authenticated := crossOriginRequest(t, http.MethodGet, "/metrics") + authenticated.SetBasicAuth(metricsUsername, metricsPassword) + + tests := []struct { + name string + req *http.Request + wantStatus int + }{ + { + "authenticated GET", + authenticated, + http.StatusOK, + }, + { + "unauthenticated GET", + crossOriginRequest(t, http.MethodGet, "/metrics"), + http.StatusUnauthorized, + }, + { + "preflight", + preflightRequest(t, "/metrics", http.MethodGet, ""), + http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + rec := serve(srv, tt.req) + + if rec.Code != tt.wantStatus { + t.Errorf( + "%s: status = %d, want %d", + tt.name, rec.Code, tt.wantStatus, + ) + } + + got := rec.Header().Get("Access-Control-Allow-Origin") + if got != "" { + t.Errorf( + "%s: Access-Control-Allow-Origin = %q, want none", + tt.name, got, + ) + } + } +} + +// TestPreflightAllowsOnlyWhatPublicRoutesServe checks what each public +// route agrees to in a CORS preflight: GET, but not POST, PUT or +// DELETE, which no route serves, and not the Authorization or +// X-CSRF-Token headers, which no public route reads. It checks every +// public route because one added with Get, such as /health, answers a +// preflight only while CORS is middleware of a whole router; in a +// Group, chi would answer it with 405 and no CORS headers. +func TestPreflightAllowsOnlyWhatPublicRoutesServe(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_METRICS_USERNAME", metricsUsername) + t.Setenv("DNSWATCHER_METRICS_PASSWORD", metricsPassword) + + srv := routedServer(t) + + tests := []struct { + method string + headers string + allowed bool + }{ + {http.MethodGet, "", true}, + {http.MethodGet, "Content-Type", true}, + {http.MethodPost, "", false}, + {http.MethodPut, "", false}, + {http.MethodDelete, "", false}, + {http.MethodGet, "Authorization", false}, + {http.MethodGet, "X-CSRF-Token", false}, + } + + for _, path := range publicPaths() { + for _, tt := range tests { + rec := serve(srv, preflightRequest( + t, path, tt.method, tt.headers, + )) + + want := "" + if tt.allowed { + want = tt.method + } + + got := rec.Header().Get("Access-Control-Allow-Methods") + if got != want { + t.Errorf( + "preflight to %s for %s with headers %q: "+ + "Access-Control-Allow-Methods = %q, want %q", + path, tt.method, tt.headers, got, want, + ) + } + } + } +} -- 2.54.0 From 3390d7065e857e280e6415d6bfec55ff106e0f1f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 21:15:02 +0200 Subject: [PATCH 23/92] docker: set up the data directory in an entrypoint (closes #166) The runtime image no longer sets USER. Its new entrypoint, deploy/docker-entrypoint.sh, runs as root: it creates the data directory if needed, gives it and everything in it to the dnswatcher user (uid 10001) with mode 700 on the directory, then runs dnswatcher as that user with su-exec. A bind-mounted host directory, whether empty and root-owned or holding a state file left by another uid, no longer has to be chowned first, and the README's upaas section now says only which path to mount. The startup check that the data directory is writable stays. Model: opus-5-5 --- Dockerfile | 18 ++++++++---------- README.md | 12 +----------- TODO.md | 2 ++ deploy/docker-entrypoint.sh | 17 +++++++++++++++++ 4 files changed, 28 insertions(+), 21 deletions(-) create mode 100755 deploy/docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index b4f7273..711588c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,18 +41,15 @@ RUN make build # alpine 3.21, 2026-02-28 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 -RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache ca-certificates tzdata su-exec COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher +COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -# Run as an unprivileged user that owns the data directory. A fresh named -# volume inherits this ownership; a bind-mounted host directory must be -# owned by uid 10001 (see "Running under upaas" in README.md), or startup -# fails. +# dnswatcher runs as this unprivileged user. The entrypoint creates the +# data directory and gives it to this user on every start. RUN addgroup -S -g 10001 dnswatcher \ - && adduser -S -G dnswatcher -u 10001 dnswatcher \ - && mkdir -p /var/lib/dnswatcher \ - && chown dnswatcher:dnswatcher /var/lib/dnswatcher + && adduser -S -G dnswatcher -u 10001 dnswatcher ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher @@ -62,7 +59,8 @@ ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher # data directory, or the binary's directory, the working directory. WORKDIR / -USER dnswatcher +# No USER: the entrypoint must start as root to set up the data +# directory; it then runs dnswatcher as the dnswatcher user. EXPOSE 8080 @@ -72,4 +70,4 @@ EXPOSE 8080 HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \ CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" || exit 1 -ENTRYPOINT ["/usr/local/bin/dnswatcher"] +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 1e6294b..f971096 100644 --- a/README.md +++ b/README.md @@ -533,17 +533,7 @@ repository's `Dockerfile` and runs it. The app needs: - **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to `prod` pull request is a deploy. - **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where - the state file lives. upaas bind-mounts the host path it is given and - does not create it. The container runs as uid 10001 and does not start - unless it can write there. Create the directory before the first - deploy: - - ```sh - mkdir -p /path/to/data - chown 10001:10001 /path/to/data - chmod 700 /path/to/data - ``` - + the state file lives. - **Network and port:** the dashboard is unauthenticated and shows every watched name and recent alert, and upaas publishes every mapped port on all interfaces of the host diff --git a/TODO.md b/TODO.md index 23a1f8f..7462510 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,8 @@ Rationale, Design, TODO, License, Author) if any are still missing. constructors: two moved to `export_test.go`, one is deleted (closes #111). - 2026-10-01: notify shutdown tests use one timing constant per meaning, name the bound they check, and require the drain's debug line (closes #116). +- 2026-09-29: the entrypoint chowns the data directory to `dnswatcher` and runs + dnswatcher as that user, so a host bind mount needs no chown (closes #166). - 2026-09-29: the live-DNS test package is renamed `internal/livednstest` and added to the `test-support` `deny` list in `.golangci.yml`, so `make lint` fails when program code imports it (closes #164). diff --git a/deploy/docker-entrypoint.sh b/deploy/docker-entrypoint.sh new file mode 100755 index 0000000..62b95cd --- /dev/null +++ b/deploy/docker-entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# deploy/docker-entrypoint.sh: the Docker image's ENTRYPOINT. It runs as +# root only to give the data directory to the dnswatcher user: a host +# directory bind-mounted there keeps its host owner, often root, and may +# hold a state file left by another uid, which dnswatcher could neither +# read nor replace. dnswatcher itself always runs as the dnswatcher user. +set -eu + +main() { + dir="${DNSWATCHER_DATA_DIR:-/var/lib/dnswatcher}" + mkdir -p "$dir" + chown -R dnswatcher:dnswatcher "$dir" + chmod 700 "$dir" + exec su-exec dnswatcher /usr/local/bin/dnswatcher "$@" +} + +main "$@" -- 2.54.0 From 60703566760e73ac48660606d8a9da0b31e3985f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 21:32:49 +0200 Subject: [PATCH 24/92] docs: bring TODO.md up to date (closes #146) Next Step and Future Steps list the open issues by full URL, in the order of the review on https://git.eeqj.de/sneak/dnswatcher/issues/144; issues the review does not name sit next to the entries they relate to, and https://git.eeqj.de/sneak/dnswatcher/issues/144 itself comes last. Left out: https://git.eeqj.de/sneak/dnswatcher/issues/146, which this change closes; https://git.eeqj.de/sneak/dnswatcher/issues/59, DNSSEC, ruled post-1.0; issues assigned to sneak, which wait on the owner. Shipped work, the dropped domains and hostnames endpoints and the line about mocked resolver tests are gone. Every Completed Steps entry is at most two lines; none was dropped. Workflow branches from `next` and opens PRs against it. Wrapped by hand at 80 columns: `make fmt` does not format Markdown yet (https://git.eeqj.de/sneak/dnswatcher/issues/119). The old Next Step is now https://git.eeqj.de/sneak/dnswatcher/issues/173. Model: opus-5-5 --- TODO.md | 321 +++++++++++++------------------------------------------- 1 file changed, 74 insertions(+), 247 deletions(-) diff --git a/TODO.md b/TODO.md index 7462510..8af2f80 100644 --- a/TODO.md +++ b/TODO.md @@ -1,24 +1,27 @@ # Workflow -* branch (from `main`) +* branch (from `next`) * do the work in Next Step * move Next Step to the top of Completed Steps * move the top item of Future Steps into Next Step * commit (`TODO.md` changes in the same commit as the work) -* merge to `main` if the branch is not protected, otherwise open a PR * push +* open a PR against `next` # Status -pre-1.0. No git tags. +pre-1.0. No git tags. Work lands on `next` by PR. Open work for 1.0 is tracked +on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Next Step -Add the README sections required by policy (Description, Getting Started, -Rationale, Design, TODO, License, Author) if any are still missing. +NS failure and NS recovery notifications: +https://git.eeqj.de/sneak/dnswatcher/issues/104 # Completed Steps +- 2026-10-01: `TODO.md` brought up to date: open issues listed by URL, every + Completed Steps entry cut to at most two lines (closes #146). - 2026-10-01: wildcard CORS now applies only to the public routes, not to `/metrics`, and allows only the methods they serve (closes #100). - 2026-10-01: `internal/state` and `internal/watcher` no longer export test-only @@ -27,259 +30,83 @@ Rationale, Design, TODO, License, Author) if any are still missing. the bound they check, and require the drain's debug line (closes #116). - 2026-09-29: the entrypoint chowns the data directory to `dnswatcher` and runs dnswatcher as that user, so a host bind mount needs no chown (closes #166). -- 2026-09-29: the live-DNS test package is renamed `internal/livednstest` and - added to the `test-support` `deny` list in `.golangci.yml`, so `make lint` - fails when program code imports it (closes #164). -- 2026-09-29: `.golangci.yml` re-fetched unchanged from `sneak/prompts`. It - replaces the deprecated `gomodguard` with `gomodguard_v2`, so `make lint` no - longer warns about it, and turns on `depguard` with the org `test-support` - rule, which rejects `net/http/httptest` except in test files and in files - under a directory whose name ends in `test`. This repo had no `deny` entries - of its own to carry forward (closes #123). -- 2026-09-29: nothing stands in for DNS any more. Watcher tests that look - something up in DNS use the real resolver against live DNS servers and test - record and nameserver changes by preparing the saved state a check starts - from; the resolver timeout test queries an address that never answers, and - `NewFromLoggerWithClient`, used only by its stand-in client, is gone. The - live-DNS retry and concurrency limit moved to `internal/livednstest`, which - both test packages use. `TESTING.md` states the README's rule (closes #159). -- 2026-09-28: the inconsistency alert is sent once, on the check where two - nameservers start to disagree or where a nameserver that disagrees first - appears, instead of on every check while they disagree, and not again after - a restart. Every pair of nameservers is compared, not only neighbours in - sorted order of name (closes #158). +- 2026-09-29: the live-DNS test package is renamed `internal/livednstest`; + `make lint` fails when program code imports it (closes #164). +- 2026-09-29: `.golangci.yml` re-fetched from `sneak/prompts`, with + `gomodguard_v2` and the org `depguard` `test-support` rule (closes #123). +- 2026-09-29: watcher and resolver tests that look something up in DNS use the + real resolver against live DNS servers (closes #159). +- 2026-09-28: the inconsistency alert is sent once, when two nameservers start + to disagree; every pair of nameservers is compared (closes #158). - 2026-09-28: DNS names in record values (CNAME, MX, SRV and NS targets) are - lower-cased, so nameservers that answer in different letter case no longer - count as inconsistent or as a record change (closes #157). -- 2026-09-28: `script/cibuild` and `script/docker` now pass - `--no-cache-filter=lint,builder` so lint and tests run every build (closes - #115). -- 2026-09-28: the server timeout test now drives `Run` and checks the - `http.Server` it serves carries the timeouts; corrected the `ReadTimeout` - note in that test (closes #120). -- 2026-09-28: upaas deploy readiness — runtime image runs as unprivileged - `dnswatcher`, Docker `HEALTHCHECK`, startup fails when the data directory is - not writable, README "Running under upaas" (closes #147). + lower-cased, so letter case alone is not a change (closes #157). +- 2026-09-28: lint and tests run on every build: `script/cibuild` and + `script/docker` pass `--no-cache-filter=lint,builder` (closes #115). +- 2026-09-28: the server timeout test drives `Run` and checks the timeouts on + the `http.Server` it serves (closes #120). +- 2026-09-28: upaas deploy readiness: the image runs as user `dnswatcher` with a + `HEALTHCHECK`; README "Running under upaas" (closes #147). - 2026-09-21: added behavioural tests for `internal/globals`, `internal/healthcheck`, and `internal/logger` (closes #110). - 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync` `// indirect` line so `script/bootstrap` leaves a clean tree (#132) -- 2026-08-10: comment-only corrections to `script/bootstrap`, - `script/cibuild`, and `Dockerfile.lint`. The `goimports` pin in - `script/bootstrap` was justified by a claim that `script/fmt-check` - runs it on the host; it does not (it runs `gofmt -l .` only), so the - header now credits `script/fmt` alone. `script/cibuild` still claimed - the `Dockerfile` runs `make check`, which stopped being true when - linting moved to its own stage; it now describes the lint stage - (`make fmt-check` plus `golangci-lint`) and the builder stage - (`make test`, `make build`). The `docker`-missing warning in - `script/bootstrap` reads as one sentence instead of three fragments - each re-prefixed with `bootstrap:`. `Dockerfile.lint` now records the - residual risk of omitting `golangci-lint config verify`: unknown - top-level keys in `.golangci.yml` are silently ignored, so a mistyped - key lints clean while applying nothing. No behaviour changed -- 2026-08-10: MIT `LICENSE` added at the repository root, closing the - last gap in `REPO_POLICIES.md`'s required-minimum file list and - removing the all-rights-reserved default that would otherwise have - shipped with a 1.0 tag. The licence choice is the standing org policy - (any public repo lacking a licence gets MIT; a private repo with no - licence is already all-rights-reserved), and this repo is public. The - file holds the canonical MIT text byte-for-byte with only the - copyright line filled in (`Copyright (c) 2026 sneak`); no clauses were - added, removed, or reflowed. `README.md`'s first line now names the - licence, as the Description requirement demands, and the License - section states MIT and points at the file instead of saying the choice - is pending. `make fmt` covers only Go sources (`gofmt -s`, - `goimports`), so it cannot reflow `LICENSE` -- 2026-08-10: the policy scaffold (`REPO_POLICIES.md`, `.editorconfig`, - `.dockerignore`, `.gitea/workflows/check.yml`, and the `fmt-check`, - `docker`, and hooks Makefile targets) is present; it landed piecemeal - across the scripts-to-rule-them-all and policy commits rather than as - the single commit this file once planned -- 2026-08-10: Go's test cache disabled for `script/test` via `-count=1`, - so every invocation actually executes. A cached pass replays an - earlier run's output without querying DNS at all, which in this repo - means the suite's entire premise goes unexercised while the run - reports green in under a second. The conditional verbose rerun that - `REPO_POLICIES.md` mandates was added at the same time (the primary - run had been unconditionally `-v`): quiet first, `-v` only on - failure, `-count=1` on both, and exit 1 forced regardless of the - rerun's result so a flake passing the second time cannot turn the - build green. `-timeout 90s` left alone as the deliberate backstop - above the 60s hard cap. Uncached suite runs ~4s, well inside the 20s - target -- 2026-08-10: live-DNS test flakiness addressed by robustness rather - than gating, per the owner's ruling on #93: new - `internal/resolver/livedns_test.go` adds a package-wide concurrency - gate (so parallel tests stop bursting at the first root server), - retry with exponential backoff on transport failures only, and - quorum instead of unanimity for multi-nameserver assertions. Quorum - tolerates silence only: every per-nameserver status must be in a - closed allowlist (`ok`/`timeout`/`error`, or - `nxdomain`/`timeout`/`error`), so a wrong answer from a minority — - `nodata` today, any status added later — fails the test instead of - sliding through under the majority. The - `make test` cap moved to the new org-wide 60s hard cap / 20s target - with a 90s `-timeout` backstop; `REPO_POLICIES.md` re-vendored - byte-identical from `sneak/prompts`. No mocks, no `-short`, no build - tags, no skips, and no change to production resolver behaviour -- 2026-08-10: all linting moved into Docker: new root `Dockerfile.lint` - on the digest-pinned `golangci/golangci-lint:v2.12.2` image, - `script/lint` reduced to a thin wrapper that builds it with - `--no-cache-filter=lint` so the linter actually executes every run, - golangci-lint install dropped from `script/bootstrap` (goimports - stays, `script/fmt` needs it on the host), and the root `Dockerfile` - given its own lint stage so its build no longer recurses through - `make check` into `script/lint`. `golangci-lint config verify` is - deliberately omitted: it fetches its schema over an unpinned live - HTTPS call -- 2026-08-09: in-flight notification deliveries are now drained at - shutdown (#106): `notify.New` registers an fx `OnStop` hook that waits - on a `sync.WaitGroup` of tracked delivery goroutines, bounded by the - `OnStop` context; on expiry the outstanding count is logged at warn - level and parked retry backoffs are released instead of being dropped - silently, and deliveries submitted after the drain begins are refused - so shutdown cannot be extended indefinitely; an `OnStop` context that - is already expired on entry with nothing outstanding drains quietly - rather than warning about deliveries that were never abandoned -- 2026-08-09: `http.Server` now sets all four socket-level timeouts - (`ReadTimeout` 15s, `ReadHeaderTimeout` 10s, `WriteTimeout` 75s, - `IdleTimeout` 120s) as named constants in `internal/server/server.go`, - closing the slowloris / unreaped-keep-alive exposure required by - `REPO_POLICIES.md` before 1.0; `WriteTimeout` is deliberately greater - than the 60s `chimw.Timeout` handler budget so that budget stays - reachable, and tests in `internal/server` pin both the non-zero - values and that relationship (#99) -- 2026-08-09: security response headers middleware - (`SecurityHeaders()` in `internal/middleware/middleware.go`) - registered globally in `internal/server/routes.go`, so HSTS, CSP, - `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and - `Permissions-Policy` are set on every response including `/s/...` and - `/metrics`; the CSP needs no `unsafe-inline`/`unsafe-eval` because the - dashboard ships no JavaScript and no inline styles; HSTS is emitted - unconditionally per policy (TLS-terminating proxy in front). Remaining - 1.0 hardening items — `http.Server` timeouts, request body limits, - rate limiting, CORS scoping — are tracked separately -- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs - in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the - org-standard v2-schema config used across the org's repos - (owner-authorized; same file is being landed as canonical via prompts - PR #24), with settings under `linters.settings` so the - lll/funlen/cyclop/dupl thresholds apply; fixed the resulting - `goconst`, `dupl`, and `lll` findings; the informational `gomodguard` - deprecation warning under this config is accepted -- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - Makefile shims, README Entrypoints section -- 2026-02-20: iterative DNS resolver implemented; tests made hermetic - with mocked DNS (origin/feature/resolver, unmerged) -- 2026-02-20: CI actions and go install refs pinned to commit SHAs; - Gitea Actions workflow for make check (origin/ci/make-check, unmerged) +- 2026-08-10: comment-only corrections to `script/bootstrap`, `script/cibuild` + and `Dockerfile.lint`; no behaviour changed. +- 2026-08-10: MIT `LICENSE` added at the repository root; the README's first + line and License section name the licence. +- 2026-08-10: policy scaffold present: `REPO_POLICIES.md`, `.editorconfig`, + `.dockerignore`, CI workflow, `make fmt-check`, `make docker`, `make hooks`. +- 2026-08-10: Go's test cache disabled in `script/test` (`-count=1`), so every + run queries live DNS; a failed run is rerun with `-v`. +- 2026-08-10: live-DNS tests made robust rather than gated (#93): a limit on + concurrent lookups, retries, and a quorum across nameservers. +- 2026-08-10: all linting moved into Docker: `script/lint` builds + `Dockerfile.lint`, and the root `Dockerfile` has its own lint stage. +- 2026-08-09: in-flight notification deliveries are drained at shutdown, bounded + by the shutdown deadline (#106). +- 2026-08-09: `http.Server` sets all four socket timeouts; `WriteTimeout` stays + above the 60s handler timeout (#99). +- 2026-08-09: `SecurityHeaders()` middleware sets HSTS, CSP and the other + security headers `REPO_POLICIES.md` requires on every response. +- 2026-08-07: golangci-lint bumped to v2.12.2 and `.golangci.yml` set to the org + config; fixed the resulting `goconst`, `dupl` and `lll` findings. +- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile + shims, README Entrypoints section +- 2026-02-20: iterative DNS resolver implemented +- 2026-02-20: CI actions and go install refs pinned to commit SHAs; Gitea + Actions workflow added - 2026-02-20: watcher monitoring orchestrator merged to main (#8) - 2026-02-20: DOMAINS/HOSTNAMES unified into single TARGETS config (#11) - 2026-02-19: TCP port connectivity checker, made concurrent with port validation; gosec G704 SSRF findings fixed without suppression - (feature branches, unmerged) -- 2026-02-19: TLS certificate inspector with no-peer-certificates error - path and IP SANs (feature branch, unmerged) +- 2026-02-19: TLS certificate inspector with no-peer-certificates error path and + IP SANs - 2026-02-19: gosec SSRF and formatting fixes on main - 2026-02-19: initial scaffold with per-nameserver DNS monitoring model # Future Steps -Compliance: - -- Pin Dockerfile base images by sha256 and ensure the Docker build runs - make check - -Branch reconciliation: - -- Sync local checkout with origin: local main is 8 commits behind - origin/main; local feature/resolver has diverged from - origin/feature/resolver, which already implements the resolver -- Merge in-flight branches to main once green: feature/resolver, - ci/make-check, feature/portcheck-implementation, - feature/tlscheck-implementation - -Resolver (plan from untracked TODO.md; largely implemented on -origin/feature/resolver, verify each item before closing): - -- Add github.com/miekg/dns dependency -- roots.go: hardcoded IANA root server list (a through m, IPv4/IPv6), - rootServers() returning ip:53 strings -- query.go: low-level query(ctx, server, name, qtype): UDP with TCP - fallback on truncation, RD=0, context respected, 5s per-query timeout, - returns raw *dns.Msg -- trace.go: iterative delegation chasing from roots: referral detection - (NOERROR, empty answer, NS in authority), glue extraction with - bailiwick check, out-of-bailiwick NS resolved with recursion guard, - delegation depth limit (20), retry across nameservers on failure, do - not chase CNAMEs inside trace -- FindAuthoritativeNameservers: NS set via trace, sorted, FQDN - normalized, trailing dot handled; must pass its 9 tests -- QueryNameserver: resolve NS host to IPs, query A/AAAA/CNAME/MX/TXT/ - SRV/CAA/NS, build NameserverResponse with status mapping (OK, - NXDomain, NoData, Error), documented record formatting, sorted values, - lame delegation detection; must pass its 16 tests -- QueryAllNameservers: find NS set for parent domain (public suffix - list), query all NS in parallel with bounded concurrency, return map - even when all fail, context cancellation; must pass its 4 tests -- LookupNS: thin wrapper over FindAuthoritativeNameservers, sorted, - identical results; must pass its 3 tests -- ResolveIPAddresses: collect A/AAAA from all NS, follow CNAME chains - with MaxCNAMEDepth, dedupe, sort, NXDOMAIN returns empty slice with - nil error; must pass its 9 tests -- All 39 resolver tests pass, make check green, merge to main - -Watcher (internal/watcher/watcher.go): - -- Scheduling loop in Run(ctx): initial check on startup, separate - tickers for DNS/port and TLS intervals, persist state via state.Save() - after each cycle, clean shutdown on context cancel -- Domain check: LookupNS, compare to stored state, store silently on - first run, notify with old/new NS lists on change -- Hostname check: QueryAllNameservers, compare per-NS records; notify on - record changes, NS failure, NS recovery, inconsistency detected, - inconsistency resolved, empty response; store silently on first run -- Port check: ResolveIPAddresses, check ports 80 and 443 per IP, notify - on open/closed transitions, handle new and disappeared IPs -- TLS check: for each open IP:443, CheckCertificate; notify on expiry - warning, certificate change (CN/issuer/SANs), TLS failure/recovery - -Port checker (internal/portcheck/portcheck.go): - -- Tests against known-open ports and RFC documentation IPs -- CheckPort: net.DialTimeout (5s), context respected; (true, nil) open, - (false, nil) closed/timeout/refused, error only for unexpected - failures - -TLS checker (internal/tlscheck/tlscheck.go): - -- Tests against known public HTTPS servers, verify fields populated -- CheckCertificate: tls.Dial to specific IP:443 with hostname as SNI; - extract subject CN, issuer CN and org, NotAfter, SANs; error on - handshake failure - -Notification service (internal/notify/notify.go, Slack/Mattermost/ntfy -backends exist): - -- Structured notification types: DNS change, port change, TLS expiry, - TLS change, NS failure, NS recovery, NS inconsistency -- Per-backend formatting: Slack/Mattermost attachment colors (red - failures/expiry, yellow warnings, green recoveries, blue info); ntfy - priorities (urgent failures, high warnings, default changes, low - recoveries); include hostname, nameserver, old/new values, timestamps - -HTTP API handlers: - -- Wire *state.State and *watcher.Watcher into handler params -- GET /api/v1/status: full state snapshot as JSON -- GET /api/v1/domains: domain states with NS records and last-checked -- GET /api/v1/hostnames: hostname states with per-NS record data - -Infrastructure notes (from untracked TODO.md): - -- Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de - remote intentionally; do not "fix" it -- Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix -- DNS is never mocked; tests that look something up in DNS query live DNS - servers (README, "No DNS mocking. Ever.") +- nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 +- `DNSWATCHER_SENTRY_DSN` does nothing: + https://git.eeqj.de/sneak/dnswatcher/issues/107 +- invalid DNS or TLS interval silently replaced by the default: + https://git.eeqj.de/sneak/dnswatcher/issues/177 +- rate limit on `/metrics` Basic Auth: + https://git.eeqj.de/sneak/dnswatcher/issues/101 +- images report version `dev`: https://git.eeqj.de/sneak/dnswatcher/issues/109 +- trial run of the finished image: + https://git.eeqj.de/sneak/dnswatcher/issues/149 +- 1.0 readiness: run it with a real config and read the logs: + https://git.eeqj.de/sneak/dnswatcher/issues/66 +- `goimports` in `make fmt-check`, Markdown formatting: + https://git.eeqj.de/sneak/dnswatcher/issues/119 +- final state save at shutdown: https://git.eeqj.de/sneak/dnswatcher/issues/114 +- `internal/notify` shutdown tests hang when a drain returns early: + https://git.eeqj.de/sneak/dnswatcher/issues/176 +- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 +- README sections required by policy: + https://git.eeqj.de/sneak/dnswatcher/issues/173 +- `script/install-precommit` in a linked worktree: + https://git.eeqj.de/sneak/dnswatcher/issues/129 +- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138 +- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144 -- 2.54.0 From bde047f2a3bbb5f9fba5a67a8b5b1f06b299e3bb Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 21:45:57 +0200 Subject: [PATCH 25/92] script/install-precommit: work where .git is a file (closes #129) The script wrote the hook to .git/hooks, which fails when .git is a file rather than a directory, as in a clone made with --separate-git-dir. It now asks git for the repository's own git directory with `git rev-parse --git-common-dir`, creates its hooks directory if missing, and writes the hook there. In an ordinary clone that is .git/hooks, so nothing moves. Before writing anything it stops with an error when its top directory is not the top of the checkout git finds, so a copy inside another repository cannot replace that repository's hook. git's core.hooksPath setting is not followed; where it is in force, git does not run the installed hook, as before. Model: opus-5-5 --- TODO.md | 4 ++-- script/install-precommit | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 8af2f80..614432b 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 # Completed Steps +- 2026-10-01: `script/install-precommit` asks git for the repository's git + directory, so `make hooks` also works where `.git` is a file (closes #129). - 2026-10-01: `TODO.md` brought up to date: open issues listed by URL, every Completed Steps entry cut to at most two lines (closes #146). - 2026-10-01: wildcard CORS now applies only to the public routes, not to @@ -106,7 +108,5 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 - README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 - README sections required by policy: https://git.eeqj.de/sneak/dnswatcher/issues/173 -- `script/install-precommit` in a linked worktree: - https://git.eeqj.de/sneak/dnswatcher/issues/129 - fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138 - review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144 diff --git a/script/install-precommit b/script/install-precommit index 723bae1..c005c5e 100755 --- a/script/install-precommit +++ b/script/install-precommit @@ -7,7 +7,20 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - hook=".git/hooks/pre-commit" + # Stop if this directory is not the top of its own git checkout, for + # example a copy inside another repository, whose hook must not be + # replaced. + if [ "$(git rev-parse --show-toplevel)" != "$ROOT" ]; then + echo "install-precommit: $ROOT is not the top of a git checkout" >&2 + exit 1 + fi + # Ask git for the repository's own git directory: .git is a file, not + # a directory, in some checkouts (for example a clone made with + # --separate-git-dir). core.hooksPath is deliberately not followed, so + # the hook is never written outside this repository. + hooks="$(git rev-parse --git-common-dir)/hooks" + mkdir -p "$hooks" + hook="$hooks/pre-commit" printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook" chmod +x "$hook" echo "pre-commit hook installed: runs script/precommit" -- 2.54.0 From e93c2664b883c9858368a364109eaec9e640873d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 21:58:58 +0200 Subject: [PATCH 26/92] notify: release held deliveries so shutdown tests fail, not hang (closes #176) Two shutdown tests hold a delivery inside the test server's handler and release it from a timer. The deferred timer stop ran before the server was closed, so a drain that returned early left the handler blocked and the server's close waited on it until the package timed out. Each test now defers a release, guarded so the timer and the defer can both call it, ahead of closing the server. The watchdog comment no longer names a 30-second timeout the test script does not use. Model: opus-5-5 --- TODO.md | 4 ++-- internal/notify/shutdown_test.go | 21 ++++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/TODO.md b/TODO.md index 614432b..e0c114a 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 # Completed Steps +- 2026-10-01: two notify shutdown tests always release the delivery they hold, + so a drain that returns early fails them instead of hanging (closes #176). - 2026-10-01: `script/install-precommit` asks git for the repository's git directory, so `make hooks` also works where `.git` is a file (closes #129). - 2026-10-01: `TODO.md` brought up to date: open issues listed by URL, every @@ -103,8 +105,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 - `goimports` in `make fmt-check`, Markdown formatting: https://git.eeqj.de/sneak/dnswatcher/issues/119 - final state save at shutdown: https://git.eeqj.de/sneak/dnswatcher/issues/114 -- `internal/notify` shutdown tests hang when a drain returns early: - https://git.eeqj.de/sneak/dnswatcher/issues/176 - README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 - README sections required by policy: https://git.eeqj.de/sneak/dnswatcher/issues/173 diff --git a/internal/notify/shutdown_test.go b/internal/notify/shutdown_test.go index 224741d..afc1048 100644 --- a/internal/notify/shutdown_test.go +++ b/internal/notify/shutdown_test.go @@ -143,6 +143,12 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) { srv := blockingNtfyServer(entered, release, &served) defer srv.Close() + // srv.Close waits for the handler, so release it however the + // test ends; otherwise a drain that returns early hangs the + // package instead of failing this test. + releaseHandler := sync.OnceFunc(func() { close(release) }) + defer releaseHandler() + topicURL, _ := url.Parse(srv.URL) svc := notify.NewTestService(http.DefaultTransport) @@ -167,9 +173,7 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) { // delay alone. start := time.Now() - timer := time.AfterFunc(inFlightHold, func() { - close(release) - }) + timer := time.AfterFunc(inFlightHold, releaseHandler) defer timer.Stop() ctx, cancel := context.WithTimeout( @@ -268,7 +272,7 @@ func TestDrainBoundedByContextDeadline(t *testing.T) { // all never returns here (the delivery is parked in a backoff // that never fires), so an unbounded drain must fail this // test promptly instead of hanging the package until the test - // binary's 30s timeout. + // binary's -timeout. returned := make(chan struct{}) go func() { @@ -447,6 +451,11 @@ func TestNewRegistersDrainingStopHook(t *testing.T) { srv := blockingNtfyServer(entered, release, &served) defer srv.Close() + // As in TestDrainWaitsForInFlightDelivery: release the handler + // however the test ends, before srv.Close waits for it. + releaseHandler := sync.OnceFunc(func() { close(release) }) + defer releaseHandler() + lifecycle := &recordingLifecycle{} svc := newNotifyService(t, lifecycle, srv.URL) @@ -472,9 +481,7 @@ func TestNewRegistersDrainingStopHook(t *testing.T) { t.Fatal("delivery never reached the endpoint") } - timer := time.AfterFunc(inFlightHold, func() { - close(release) - }) + timer := time.AfterFunc(inFlightHold, releaseHandler) defer timer.Stop() ctx, cancel := context.WithTimeout( -- 2.54.0 From bea9a3b2f2d21420dab015c4e4837597fe261ac1 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 22:01:43 +0200 Subject: [PATCH 27/92] docker: report the real version in the image (closes #109) The Dockerfile builder stage takes ARG VERSION (default `dev`) and passes it to `make build` on the command line, which overrides the Makefile's `git describe` default. script/docker computes the version from `git describe` on the host and passes it as --build-arg VERSION, because .dockerignore leaves .git out of the build context and `git describe` inside the build only ever produced `dev`. A build that passes no argument, such as script/cibuild, still reports `dev`. `logger.Identify`, which logs `starting` with the version, was never called; `main` now calls it first, so the version is in the startup log. Model: opus-5-5 --- Dockerfile | 7 +++++-- Makefile | 2 ++ README.md | 14 +++++++++----- TODO.md | 3 ++- cmd/dnswatcher/main.go | 1 + script/docker | 10 +++++++++- 6 files changed, 28 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 711588c..a76cbb6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,8 +34,11 @@ COPY . . # Run the tests - build fails if any test fails RUN make test -# Build the binary -RUN make build +# Build the binary. .dockerignore leaves out .git, so `git describe` in +# the Makefile cannot find the version here: script/docker passes it as +# --build-arg VERSION, and a build that passes none reports `dev`. +ARG VERSION=dev +RUN make build VERSION="${VERSION}" # Runtime stage # alpine 3.21, 2026-02-28 diff --git a/Makefile b/Makefile index 7b2447f..5d36bc7 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ .PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker BINARY := dnswatcher +# `make build VERSION=...` overrides this; the Dockerfile does so, as the +# image has no .git to describe. VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") LDFLAGS := -X main.Version=$(VERSION) diff --git a/README.md b/README.md index f971096..a074dbe 100644 --- a/README.md +++ b/README.md @@ -480,7 +480,8 @@ them. We provide: - `script/check` — run test, lint, and fmt-check - `script/docker` — build the Docker image tagged via `script/projectname`, with `--no-cache-filter=lint,builder` so the lint stage and the builder stage, - which runs the tests, run on every invocation + which runs the tests, run on every invocation, and with the version from + `git describe` passed as `--build-arg VERSION` - `script/cibuild` — CI entrypoint: `docker build` with `--no-cache-filter=lint,builder`, so the lint stage and the builder stage, which runs the tests, run on every invocation, because a cached build lints @@ -502,11 +503,14 @@ make clean # Remove build artifacts ### Build-Time Variables -Version is injected via `-ldflags`: +`make build` sets the version with `-ldflags "-X main.Version=..."`, taking +it from `git describe --tags --always --dirty`, or from `VERSION` when given +on the command line (`make build VERSION=1.2.3`). The version appears in the +startup log and in the health check response. -```sh -go build -ldflags "-X main.Version=$(git describe --tags --always)" ./cmd/dnswatcher -``` +The Docker image has no `.git`, so the `Dockerfile` takes the version as +`--build-arg VERSION`. `make docker` passes it; a plain `docker build` +passes none, and that image reports `dev`. --- diff --git a/TODO.md b/TODO.md index e0c114a..1e32630 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 # Completed Steps +- 2026-10-01: the image built by `make docker` reports the `git describe` + version, not `dev`, and the startup log now shows it (closes #109). - 2026-10-01: two notify shutdown tests always release the delivery they hold, so a drain that returns early fails them instead of hanging (closes #176). - 2026-10-01: `script/install-precommit` asks git for the repository's git @@ -97,7 +99,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 https://git.eeqj.de/sneak/dnswatcher/issues/177 - rate limit on `/metrics` Basic Auth: https://git.eeqj.de/sneak/dnswatcher/issues/101 -- images report version `dev`: https://git.eeqj.de/sneak/dnswatcher/issues/109 - trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: diff --git a/cmd/dnswatcher/main.go b/cmd/dnswatcher/main.go index baff13b..11e6780 100644 --- a/cmd/dnswatcher/main.go +++ b/cmd/dnswatcher/main.go @@ -63,6 +63,7 @@ func main() { return n }, ), + fx.Invoke(func(l *logger.Logger) { l.Identify() }), fx.Invoke(func(*server.Server, *watcher.Watcher) {}), ).Run() } diff --git a/script/docker b/script/docker index 4f1fd14..93d2408 100755 --- a/script/docker +++ b/script/docker @@ -12,7 +12,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build --no-cache-filter=lint,builder -t "$("$SCRIPT_DIR/projectname")" . + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. VERSION is computed here because .dockerignore + # excludes .git, so `git describe` in a build stage cannot find it. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache-filter=lint,builder \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" -- 2.54.0 From ed0f56f144ed821bd1bf8a26e4a1e060a5adfc52 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 22:09:14 +0200 Subject: [PATCH 28/92] metrics: rate limit /metrics per client address before Basic Auth (closes #101) /metrics is behind a password, and REPO_POLICIES.md requires rate limiting on password logins. Each client address may now send it 30 requests a minute, counted by httprate before Basic Auth, so failed logins use up the allowance and a request over it gets 429 without the password being checked. The address is the one the existing trusted-proxy logic in internal/middleware works out, with IPv6 addresses grouped by /64; an IPv4 address a proxy reports in IPv6-mapped form counts as the plain IPv4 address. A Prometheus server scraping every 15 seconds sends 4 requests a minute. Model: opus-5-5 --- README.md | 11 +- TODO.md | 4 +- go.mod | 3 + go.sum | 8 ++ internal/middleware/export_test.go | 10 ++ internal/middleware/middleware.go | 39 +++++++ internal/middleware/middleware_test.go | 142 +++++++++++++++++++++++++ internal/server/routes.go | 5 +- internal/server/routes_test.go | 69 ++++++++++++ 9 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 internal/middleware/export_test.go diff --git a/README.md b/README.md index a074dbe..ba79f23 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ internal/ logger/logger.go slog structured logging (TTY detection) healthcheck/healthcheck.go Health check service middleware/middleware.go HTTP middleware (logging, CORS, security - headers, metrics auth) + headers, metrics auth and rate limit) handlers/handlers.go HTTP request handlers server/ server.go HTTP server lifecycle @@ -335,6 +335,15 @@ is a misconfiguration, so dnswatcher fails fast with a clear error message rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated list of DNS names before starting. +**`/metrics` is rate limited.** Each client address may send it 30 requests a +minute, failed logins included; beyond that it answers `429 Too Many Requests` +without checking the password. A Prometheus server scraping every 15 seconds +sends 4 a minute. IPv6 addresses in one /64 count as one client. When the +request comes from a private or loopback address, such as a reverse proxy's, +the client address is taken from the `X-Real-IP` or `X-Forwarded-For` header +the proxy sets; a proxy that sets neither makes all its clients share one +allowance. + ### Example `.env` ```sh diff --git a/TODO.md b/TODO.md index 1e32630..0b048af 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 # Completed Steps +- 2026-10-01: `/metrics` allows each client address 30 requests a minute, + counted before Basic Auth, and answers 429 beyond that (closes #101). - 2026-10-01: the image built by `make docker` reports the `git describe` version, not `dev`, and the startup log now shows it (closes #109). - 2026-10-01: two notify shutdown tests always release the delivery they hold, @@ -97,8 +99,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 https://git.eeqj.de/sneak/dnswatcher/issues/107 - invalid DNS or TLS interval silently replaced by the default: https://git.eeqj.de/sneak/dnswatcher/issues/177 -- rate limit on `/metrics` Basic Auth: - https://git.eeqj.de/sneak/dnswatcher/issues/101 - trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: diff --git a/go.mod b/go.mod index 0078dcf..a9012dd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 + github.com/go-chi/httprate v0.16.0 github.com/joho/godotenv v1.5.1 github.com/miekg/dns v1.1.72 github.com/prometheus/client_golang v1.23.2 @@ -22,6 +23,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -34,6 +36,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect go.uber.org/dig v1.19.0 // indirect go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.26.0 // indirect diff --git a/go.sum b/go.sum index 720b18f..1570cf1 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= +github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -22,6 +24,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -62,6 +66,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= diff --git a/internal/middleware/export_test.go b/internal/middleware/export_test.go new file mode 100644 index 0000000..1abde0f --- /dev/null +++ b/internal/middleware/export_test.go @@ -0,0 +1,10 @@ +package middleware + +import "time" + +// The /metrics rate limit, exported so the tests can count requests +// against it. +const ( + MetricsRequestLimit = metricsRequestLimit + MetricsRequestWindow time.Duration = metricsRequestWindow +) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 4ba05a8..f65f63f 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -5,12 +5,14 @@ import ( "log/slog" "net" "net/http" + "net/netip" "strings" "time" "github.com/99designs/basicauth-go" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "github.com/go-chi/httprate" "go.uber.org/fx" "sneak.berlin/go/dnswatcher/internal/config" @@ -21,6 +23,17 @@ import ( // corsMaxAge is the maximum age for CORS preflight responses. const corsMaxAge = 300 +// Rate limit for /metrics: each client address may send +// metricsRequestLimit requests per metricsRequestWindow. Every request +// counts, so password guessing gets at most 30 tries a minute per +// address. One Prometheus server scraping every 15 seconds sends 4 +// requests a minute, and two scraping every 5 seconds from one address +// send 24, so normal scraping stays under the limit. +const ( + metricsRequestLimit = 30 + metricsRequestWindow = time.Minute +) + // Security response header values applied to every response. // // The CSP is as strict as the dashboard allows: the template ships no @@ -268,6 +281,32 @@ func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler { } } +// MetricsRateLimit returns middleware for /metrics that answers 429 +// Too Many Requests to a client address over the rate limit. The +// address is the one realIP works out, so a client that is not a +// trusted proxy cannot get a fresh allowance by sending its own +// X-Real-IP or X-Forwarded-For. CanonicalizeIP counts all IPv6 +// addresses in one /64 as one client, since a client usually holds a +// whole /64. An IPv4 address a proxy reports in IPv6-mapped form +// (::ffff:203.0.113.1) is turned back into plain IPv4 first, as every +// such address is in the same /64. +func (m *Middleware) MetricsRateLimit() func(http.Handler) http.Handler { + return httprate.LimitBy( + metricsRequestLimit, + metricsRequestWindow, + func(request *http.Request) (string, error) { + ip := realIP(request) + + addr, err := netip.ParseAddr(ip) + if err == nil { + ip = addr.Unmap().String() + } + + return httprate.CanonicalizeIP(ip), nil + }, + ) +} + // MetricsAuth returns basic auth middleware for /metrics. func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler { if m.params.Config.MetricsUsername == "" { diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index da51423..c7c1a8c 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/go-chi/chi/v5" "go.uber.org/fx/fxtest" @@ -340,3 +341,144 @@ func TestDashboardRendersWithSecurityHeaders(t *testing.T) { t.Errorf("CSP would block %q: %q", stylesheetPath, csp) } } + +// Addresses for the rate limit tests: a client connecting directly, a +// trusted proxy, and a client behind that proxy as its X-Real-IP +// header names it. +const ( + directClient = "198.51.100.1:4000" + trustedProxy = "10.0.0.1:4000" + proxiedClient = "203.0.113.1" +) + +// statusFrom sends a GET through handler as if from remoteAddr, with +// an X-Real-IP header when xRealIP is not empty, and returns the +// response status. +func statusFrom( + t *testing.T, + handler http.Handler, + remoteAddr string, + xRealIP string, +) int { + t.Helper() + + req := httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/metrics", nil, + ) + req.RemoteAddr = remoteAddr + + if xRealIP != "" { + req.Header.Set("X-Real-IP", xRealIP) + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + return rec.Code +} + +// TestMetricsRateLimitAllowsScraping checks that one address can send, +// within one window, what two Prometheus servers scraping every 5 +// seconds send in that time, without being turned away. +func TestMetricsRateLimitAllowsScraping(t *testing.T) { + t.Parallel() + + const scrapeInterval = 5 * time.Second + + scrapes := 2 * int(middleware.MetricsRequestWindow/scrapeInterval) + limited := newTestMiddleware(t).MetricsRateLimit()(okHandler()) + + for i := range scrapes { + got := statusFrom(t, limited, directClient, "") + if got != http.StatusOK { + t.Fatalf( + "scrape %d of %d: status = %d, want 200", + i+1, scrapes, got, + ) + } + } +} + +// TestMetricsRateLimitKeysOnClientAddress checks which requests share +// an allowance. Each case uses up the allowance of one client, then +// sends one more request. +func TestMetricsRateLimitKeysOnClientAddress(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + usedRemoteAddr string + usedXRealIP string + nextRemoteAddr string + nextXRealIP string + want int + }{ + { + "same address", + directClient, "", + directClient, "", + http.StatusTooManyRequests, + }, + { + "another address", + directClient, "", + "198.51.100.2:4000", "", + http.StatusOK, + }, + { + "own X-Real-IP from an untrusted address", + directClient, "", + directClient, "203.0.113.9", + http.StatusTooManyRequests, + }, + { + "same client behind the proxy", + trustedProxy, proxiedClient, + trustedProxy, proxiedClient, + http.StatusTooManyRequests, + }, + { + "another client behind the proxy", + trustedProxy, proxiedClient, + trustedProxy, "203.0.113.2", + http.StatusOK, + }, + { + "another client behind the proxy, IPv6-mapped", + trustedProxy, "::ffff:203.0.113.1", + trustedProxy, "::ffff:203.0.113.2", + http.StatusOK, + }, + { + "same IPv6 /64", + "[2001:db8::1]:4000", "", + "[2001:db8::2]:4000", "", + http.StatusTooManyRequests, + }, + { + "another IPv6 /64", + "[2001:db8::1]:4000", "", + "[2001:db8:0:1::1]:4000", "", + http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + limited := newTestMiddleware(t).MetricsRateLimit()(okHandler()) + + for range middleware.MetricsRequestLimit { + statusFrom(t, limited, tt.usedRemoteAddr, tt.usedXRealIP) + } + + got := statusFrom( + t, limited, tt.nextRemoteAddr, tt.nextXRealIP, + ) + if got != tt.want { + t.Errorf("status = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 6305e1d..0a8e8ed 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -64,9 +64,12 @@ func (s *Server) SetupRoutes() { // Prometheus scraper is not a browser. It is mounted rather than // added with Get so that every method on /metrics, OPTIONS // included, ends here instead of falling through to the public - // router and its CORS. + // router and its CORS. The rate limit comes before Basic Auth, so + // failed logins count against it and a request over the limit + // never reaches the password check. if s.params.Config.MetricsUsername != "" { metrics := chi.NewRouter() + metrics.Use(s.mw.MetricsRateLimit()) metrics.Use(s.mw.MetricsAuth()) metrics.Get("/", promhttp.Handler().ServeHTTP) s.router.Mount("/metrics", metrics) diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index a16c762..f8f9c47 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -219,3 +219,72 @@ func TestPreflightAllowsOnlyWhatPublicRoutesServe(t *testing.T) { } } } + +// metricsRequest builds a GET for /metrics from remoteAddr that logs +// in with the given password. +func metricsRequest( + t *testing.T, + remoteAddr string, + password string, +) *http.Request { + t.Helper() + + req := httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/metrics", nil, + ) + req.RemoteAddr = remoteAddr + req.SetBasicAuth(metricsUsername, password) + + return req +} + +// TestMetricsRateLimitComesBeforeAuth checks that failed logins to +// /metrics count against the rate limit; that once an address is over +// it, even the right password gets 429, with the same body as a wrong +// one; and that another address still gets in. +func TestMetricsRateLimitComesBeforeAuth(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_METRICS_USERNAME", metricsUsername) + t.Setenv("DNSWATCHER_METRICS_PASSWORD", metricsPassword) + + const ( + guesser = "198.51.100.1:4000" + other = "198.51.100.2:4000" + + // Far more guesses than the rate limit allows. + maxGuesses = 1000 + ) + + srv := routedServer(t) + + var guess *httptest.ResponseRecorder + + for range maxGuesses { + guess = serve(srv, metricsRequest(t, guesser, "wrong")) + if guess.Code != http.StatusUnauthorized { + break + } + } + + if guess.Code != http.StatusTooManyRequests { + t.Fatalf("wrong password: status = %d, want 429", guess.Code) + } + + right := serve(srv, metricsRequest(t, guesser, metricsPassword)) + if right.Code != http.StatusTooManyRequests { + t.Errorf("right password: status = %d, want 429", right.Code) + } + + if right.Body.String() != guess.Body.String() { + t.Errorf( + "429 body with right password = %q, with wrong one = %q", + right.Body.String(), guess.Body.String(), + ) + } + + rec := serve(srv, metricsRequest(t, other, metricsPassword)) + if rec.Code != http.StatusOK { + t.Errorf("another address: status = %d, want 200", rec.Code) + } +} -- 2.54.0 From fcd4f7e2c22f17778b16b9400724e00b9f6511c1 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 22:19:04 +0200 Subject: [PATCH 29/92] config: stop startup on an invalid DNS or TLS interval (closes #177) DNSWATCHER_DNS_INTERVAL and DNSWATCHER_TLS_INTERVAL were parsed with time.ParseDuration and silently replaced by the default when that failed, so a value like 5 or 1d gave hourly checks with no hint why, and zero or negative values were accepted. Both now go through parseInterval, which returns an error naming the variable and the value, and startup stops the same way it does for invalid targets. An unset or empty variable still gets its default from setupViper. The three tests that pinned the old fallback are replaced. The README says what a valid value looks like. Model: opus-5-5 --- README.md | 12 ++++++-- TODO.md | 4 +-- internal/config/config.go | 35 +++++++++++++++++------ internal/config/config_test.go | 52 ++++++++++++++++++++-------------- 4 files changed, 69 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index ba79f23..9b4ade1 100644 --- a/README.md +++ b/README.md @@ -320,8 +320,8 @@ the following precedence (highest to lowest): | `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` | | `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` | | `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` | -| `DNSWATCHER_DNS_INTERVAL` | DNS check interval | `1h` | -| `DNSWATCHER_TLS_INTERVAL` | TLS check interval | `12h` | +| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` | +| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` | | `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` | | `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | | `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` | @@ -344,6 +344,14 @@ the client address is taken from the `X-Real-IP` or `X-Forwarded-For` header the proxy sets; a proxy that sets neither makes all its clients share one allowance. +**`DNSWATCHER_DNS_INTERVAL` and `DNSWATCHER_TLS_INTERVAL`** take a positive +duration: a number followed by a unit such as `s`, `m` or `h`, for example +`90s`, `30m`, `1h` or `1h30m`. There is no unit for days; write `24h`. An +unset or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If +either is set to anything else, including a bare number or a zero or negative +duration, dnswatcher refuses to start with an error naming the variable and +the value. + ### Example `.env` ```sh diff --git a/TODO.md b/TODO.md index 0b048af..2d00cb7 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 # Completed Steps +- 2026-10-01: a `DNSWATCHER_DNS_INTERVAL` or `DNSWATCHER_TLS_INTERVAL` that is + not a positive duration stops startup; empty means the default (closes #177). - 2026-10-01: `/metrics` allows each client address 30 requests a minute, counted before Basic Auth, and answers 429 beyond that (closes #101). - 2026-10-01: the image built by `make docker` reports the `git describe` @@ -97,8 +99,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 - nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 - `DNSWATCHER_SENTRY_DSN` does nothing: https://git.eeqj.de/sneak/dnswatcher/issues/107 -- invalid DNS or TLS interval silently replaced by the default: - https://git.eeqj.de/sneak/dnswatcher/issues/177 - trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: diff --git a/internal/config/config.go b/internal/config/config.go index 264b90b..8d58433 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,13 @@ var ErrNoTargets = errors.New( "no monitoring targets configured: set DNSWATCHER_TARGETS environment variable", ) +// ErrInvalidInterval is returned when DNSWATCHER_DNS_INTERVAL or +// DNSWATCHER_TLS_INTERVAL is set but is not a positive duration. An empty +// value counts as unset and means the default. +var ErrInvalidInterval = errors.New( + "interval must be a positive duration such as 30m or 1h", +) + // Params contains dependencies for Config. type Params struct { fx.In @@ -125,18 +132,14 @@ func buildConfig( } } - dnsInterval, err := time.ParseDuration( - viper.GetString("DNS_INTERVAL"), - ) + dnsInterval, err := parseInterval("DNS_INTERVAL") if err != nil { - dnsInterval = defaultDNSInterval + return nil, err } - tlsInterval, err := time.ParseDuration( - viper.GetString("TLS_INTERVAL"), - ) + tlsInterval, err := parseInterval("TLS_INTERVAL") if err != nil { - tlsInterval = defaultTLSInterval + return nil, err } domains, hostnames, err := parseAndValidateTargets() @@ -168,6 +171,22 @@ func buildConfig( return cfg, nil } +// parseInterval reads the DNSWATCHER_-prefixed setting key as a duration. A +// value that does not parse, or is zero or negative, is an error naming the +// variable and the value; an unset variable has its default from setupViper. +func parseInterval(key string) (time.Duration, error) { + value := viper.GetString(key) + + interval, err := time.ParseDuration(value) + if err != nil || interval <= 0 { + return 0, fmt.Errorf( + "invalid DNSWATCHER_%s %q: %w", key, value, ErrInvalidInterval, + ) + } + + return interval, nil +} + func parseAndValidateTargets() ([]string, []string, error) { domains, hostnames, err := ClassifyTargets( parseCSV(viper.GetString("TARGETS")), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2917818..c4471ff 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config_test import ( + "strconv" "testing" "time" @@ -113,33 +114,40 @@ func TestNew_OnlyEmptyCSVSegments(t *testing.T) { assert.ErrorIs(t, err, config.ErrNoTargets) } -func TestNew_InvalidDNSInterval_FallsBackToDefault(t *testing.T) { - viper.Reset() - t.Setenv("DNSWATCHER_TARGETS", "example.com") - t.Setenv("DNSWATCHER_DNS_INTERVAL", "banana") +// TestNew_InvalidIntervalStopsStartup checks values that must stop startup; +// TestNew_DefaultValues and TestNew_EmptyIntervalMeansDefault check that an +// unset or empty interval means the default. +func TestNew_InvalidIntervalStopsStartup(t *testing.T) { + variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"} + values := []string{ + "banana", // not a duration + "5", // no unit + "1d", // days are not a unit time.ParseDuration knows + "0", // zero + "-1h", // negative + } - cfg, err := config.New(nil, newTestParams(t)) - require.NoError(t, err) - assert.Equal(t, time.Hour, cfg.DNSInterval, - "invalid DNS interval should fall back to 1h default") + for _, variable := range variables { + for _, value := range values { + t.Run(variable+"="+value, func(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv(variable, value) + + _, err := config.New(nil, newTestParams(t)) + require.ErrorIs(t, err, config.ErrInvalidInterval) + require.ErrorContains(t, err, variable) + require.ErrorContains(t, err, strconv.Quote(value)) + }) + } + } } -func TestNew_InvalidTLSInterval_FallsBackToDefault(t *testing.T) { +func TestNew_EmptyIntervalMeansDefault(t *testing.T) { viper.Reset() t.Setenv("DNSWATCHER_TARGETS", "example.com") - t.Setenv("DNSWATCHER_TLS_INTERVAL", "notaduration") - - cfg, err := config.New(nil, newTestParams(t)) - require.NoError(t, err) - assert.Equal(t, 12*time.Hour, cfg.TLSInterval, - "invalid TLS interval should fall back to 12h default") -} - -func TestNew_BothIntervalsInvalid(t *testing.T) { - viper.Reset() - t.Setenv("DNSWATCHER_TARGETS", "example.com") - t.Setenv("DNSWATCHER_DNS_INTERVAL", "xyz") - t.Setenv("DNSWATCHER_TLS_INTERVAL", "abc") + t.Setenv("DNSWATCHER_DNS_INTERVAL", "") + t.Setenv("DNSWATCHER_TLS_INTERVAL", "") cfg, err := config.New(nil, newTestParams(t)) require.NoError(t, err) -- 2.54.0 From 8f11ef0038df318f73679761c326596de8e82261 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 22:23:33 +0200 Subject: [PATCH 30/92] watcher: notify NS query failure and recovery (closes #104) LookupAllRecords now returns each nameserver's response, so the watcher saves its status: ok when it answered, NXDOMAIN and no records included, and error with the reason when it timed out, answered SERVFAIL or REFUSED, or could not be reached. A nameserver that starts failing sends NS Failure and one that answers again sends NS Recovery. A failing nameserver is left out of the record change and inconsistency comparisons. The resolver used to report REFUSED and network errors as an answer with no records; they are now errors. A lookup cut short by its context now returns an error instead of a failure of the nameserver it was querying. Model: opus-5-5 --- README.md | 29 ++- TODO.md | 6 +- internal/resolver/export_test.go | 15 +- internal/resolver/iterative.go | 36 +-- internal/resolver/resolver_test.go | 66 ++++- internal/watcher/export_test.go | 15 +- internal/watcher/inconsistency_test.go | 11 +- internal/watcher/interfaces.go | 5 +- internal/watcher/nsfailure_test.go | 333 +++++++++++++++++++++++++ internal/watcher/watcher.go | 145 +++++++---- internal/watcher/watcher_test.go | 13 +- 11 files changed, 583 insertions(+), 91 deletions(-) create mode 100644 internal/watcher/nsfailure_test.go diff --git a/README.md b/README.md index 9b4ade1..0365448 100644 --- a/README.md +++ b/README.md @@ -71,18 +71,25 @@ rejected. did on the previous check (additions, removals, value changes). - **NS query failure**: A nameserver that previously responded becomes unreachable (timeout, SERVFAIL, REFUSED, network error). - This is distinct from "responded with no records." + This is distinct from "responded with no records": a nameserver + that answers NXDOMAIN or with no records has responded. The alert + is sent once, on the check where it starts failing. A failing + nameserver gives no records, so it is not reported as a record + change or compared for inconsistency. A nameserver that is already + failing on the first check that sees it is recorded silently. - **NS recovery**: A previously-unreachable nameserver starts - responding again. + responding again. Its records are not compared with those from + before it failed, so a change made while it was failing is not + reported as a record change. - **Inconsistency detected**: Two nameservers return different record sets for the same hostname and did not already differ on the previous check. Every pair of nameservers is compared. The alert is sent once for each such pair, on the check where they start to disagree, and not again while they keep disagreeing, including after a restart. A nameserver that was not in the previous check (newly added, or back - after dropping out) and answers differently is reported on the check - where it appears. If a pair agrees again and later disagrees, the - alert is sent again. + after dropping out), or failed on it, and answers differently is + reported on the check where it answers. If a pair agrees again and + later disagrees, the alert is sent again. ### TCP Port Monitoring @@ -458,10 +465,14 @@ not as a merged view, to enable inconsistency detection. The `status` field for each per-nameserver entry and certificate entry tracks reachability: -| Status | Meaning | -|-------------|-------------------------------------------------| -| `ok` | Query succeeded, records are current | -| `error` | Query failed (timeout, SERVFAIL, network error) | +| Status | Meaning | +|-------------|------------------------------------------------------------| +| `ok` | Query succeeded, records are current | +| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) | + +A nameserver that answers NXDOMAIN or with no records has status `ok` and +empty `records`. A nameserver whose query failed has status `error`, empty +`records`, and the reason in `error`. --- diff --git a/TODO.md b/TODO.md index 2d00cb7..3fae221 100644 --- a/TODO.md +++ b/TODO.md @@ -15,11 +15,12 @@ on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Next Step -NS failure and NS recovery notifications: -https://git.eeqj.de/sneak/dnswatcher/issues/104 +nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 # Completed Steps +- 2026-10-01: a nameserver that does not answer is saved as `error` with the + reason, and NS failure and NS recovery are notified (closes #104). - 2026-10-01: a `DNSWATCHER_DNS_INTERVAL` or `DNSWATCHER_TLS_INTERVAL` that is not a positive duration stops startup; empty means the default (closes #177). - 2026-10-01: `/metrics` allows each client address 30 requests a minute, @@ -96,7 +97,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104 # Future Steps -- nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 - `DNSWATCHER_SENTRY_DSN` does nothing: https://git.eeqj.de/sneak/dnswatcher/issues/107 - trial run of the finished image: diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 6f4f9f1..d8e382b 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -1,8 +1,21 @@ package resolver -import "github.com/miekg/dns" +import ( + "context" + + "github.com/miekg/dns" +) // ExtractRecordValue exports extractRecordValue for testing. func ExtractRecordValue(rr dns.RR) string { return extractRecordValue(rr) } + +// QueryEachNS exports queryEachNS for testing. +func (r *Resolver) QueryEachNS( + ctx context.Context, + nameservers []string, + hostname string, +) (map[string]*NameserverResponse, error) { + return r.queryEachNS(ctx, nameservers, hostname) +} diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index f89d73d..1e0b494 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -504,7 +504,9 @@ func (r *Resolver) queryAllTypes( type queryState struct { gotNXDomain bool gotSERVFAIL bool + gotRefused bool gotTimeout bool + netErr error hasRecords bool } @@ -542,8 +544,13 @@ func (r *Resolver) querySingleType( ) { msg, err := r.queryDNS(ctx, nsIP, hostname, qtype) if err != nil { - if isTimeout(err) { + switch { + case isTimeout(err): state.gotTimeout = true + case errors.Is(err, ErrRefused): + state.gotRefused = true + default: + state.netErr = err } return @@ -603,6 +610,12 @@ func classifyResponse(resp *NameserverResponse, state queryState) { case state.gotSERVFAIL && !state.hasRecords: resp.Status = StatusError resp.Error = "server returned SERVFAIL" + case state.gotRefused && !state.hasRecords: + resp.Status = StatusError + resp.Error = "server returned REFUSED" + case state.netErr != nil && !state.hasRecords: + resp.Status = StatusError + resp.Error = "network error: " + state.netErr.Error() case !state.hasRecords && !state.gotNXDomain: resp.Status = StatusNoData } @@ -682,11 +695,14 @@ func (r *Resolver) queryEachNS( results := make(map[string]*NameserverResponse) for _, ns := range nameservers { + resp, err := r.QueryNameserver(ctx, ns, hostname) + + // A query the context cut short says nothing about the + // nameserver, so it must not be returned as its failure. if checkCtx(ctx) != nil { return nil, ErrContextCanceled } - resp, err := r.QueryNameserver(ctx, ns, hostname) if err != nil { results[ns] = &NameserverResponse{ Nameserver: ns, @@ -714,21 +730,13 @@ func (r *Resolver) LookupNS( // LookupAllRecords performs iterative resolution to find all DNS // records for the given hostname, keyed by authoritative nameserver. +// Each nameserver's response carries its status and error with its +// records. func (r *Resolver) LookupAllRecords( ctx context.Context, hostname string, -) (map[string]map[string][]string, error) { - results, err := r.QueryAllNameservers(ctx, hostname) - if err != nil { - return nil, err - } - - out := make(map[string]map[string][]string, len(results)) - for ns, resp := range results { - out[ns] = resp.Records - } - - return out, nil +) (map[string]*NameserverResponse, error) { + return r.QueryAllNameservers(ctx, hostname) } // ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 28544f7..2946519 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -2,6 +2,7 @@ package resolver_test import ( "context" + "fmt" "log/slog" "net" "os" @@ -13,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/dnswatcher/internal/livednstest" "sneak.berlin/go/dnswatcher/internal/resolver" ) @@ -231,6 +233,45 @@ func TestQueryNameserver_NXDomain(t *testing.T) { assert.Equal(t, resolver.StatusNXDomain, resp.Status) } +// TestQueryNameserver_Refused asks a google.com nameserver about +// cloudflare.com, a zone it does not serve, which it refuses. Refusing +// is a failure to answer, not an answer with no records. +func TestQueryNameserver_Refused(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ns := findOneNSForDomain(t, r, "google.com") + + var resp *resolver.NameserverResponse + + livednstest.Retry( + t, + "QueryNameserver("+ns+", cloudflare.com)", + func(ctx context.Context) error { + var err error + + resp, err = r.QueryNameserver(ctx, ns, "cloudflare.com") + if err != nil { + return err + } + + // A timeout or a network error is no reply at all. + if resp.Status == resolver.StatusTimeout || + strings.HasPrefix(resp.Error, "network error") { + return fmt.Errorf( + "%w: %s: %s", + livednstest.ErrNoAnswer, ns, resp.Error, + ) + } + + return nil + }, + ) + + assert.Equal(t, resolver.StatusError, resp.Status) + assert.Equal(t, "server returned REFUSED", resp.Error) +} + func TestQueryNameserver_RecordsSorted(t *testing.T) { t.Parallel() @@ -518,6 +559,29 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) { assert.Error(t, err) } +// TestQueryEachNS_CanceledDuringQuery cancels the context while a +// nameserver is being queried, as shutdown does. A lookup cut short +// says nothing about the nameserver, so it must return an error, not a +// failed response for it. +func TestQueryEachNS_CanceledDuringQuery(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + // Finding the nameserver's address alone starts at the root + // servers and takes several round trips, so a cancel a few + // milliseconds in lands during the query. + time.AfterFunc(5*time.Millisecond, cancel) + + results, err := r.QueryEachNS( + ctx, []string{"ns1.google.com."}, "google.com", + ) + require.ErrorIs(t, err, resolver.ErrContextCanceled) + assert.Nil(t, results) +} + // ---------------------------------------------------------------- // Timeout tests // ---------------------------------------------------------------- @@ -530,7 +594,7 @@ func TestQueryNameserverIP_Timeout(t *testing.T) { // Nothing answers at 192.0.2.1, a documentation address. The // resolver tries each query twice, and the first try gives up // after two seconds. A deadline that ends during the first try - // makes the status vary from run to run between nodata and + // makes the status vary from run to run between error and // timeout, so the deadline must outlast the first try. ctx, cancel := context.WithTimeout( context.Background(), 3*time.Second, diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index c647a6e..5bebe13 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -6,6 +6,7 @@ import ( "time" "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/state" ) @@ -33,8 +34,7 @@ func NewForTest( // NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing. func NewlyDisagreeingPairs( - prev *state.HostnameState, - current map[string]map[string][]string, + prev, current *state.HostnameState, ) [][2]string { return newlyDisagreeingPairs(prev, current) } @@ -43,8 +43,15 @@ func NewlyDisagreeingPairs( func (w *Watcher) DetectHostnameChanges( ctx context.Context, hostname string, - prev *state.HostnameState, - current map[string]map[string][]string, + prev, current *state.HostnameState, ) { w.detectHostnameChanges(ctx, hostname, prev, current) } + +// BuildHostnameState exports buildHostnameState for testing. +func BuildHostnameState( + results map[string]*resolver.NameserverResponse, + now time.Time, +) *state.HostnameState { + return buildHostnameState(results, now) +} diff --git a/internal/watcher/inconsistency_test.go b/internal/watcher/inconsistency_test.go index 9128426..494ec2b 100644 --- a/internal/watcher/inconsistency_test.go +++ b/internal/watcher/inconsistency_test.go @@ -105,7 +105,9 @@ func TestNewlyDisagreeingPairs(t *testing.T) { prev := hostnameState(tt.loaded) - for i, current := range tt.checks { + for i, records := range tt.checks { + current := hostnameState(records) + got := watcher.NewlyDisagreeingPairs(prev, current) if !slices.Equal(got, tt.want[i]) { t.Errorf( @@ -114,7 +116,7 @@ func TestNewlyDisagreeingPairs(t *testing.T) { ) } - prev = hostnameState(current) + prev = current } }) } @@ -162,8 +164,9 @@ func TestInconsistencyAlert(t *testing.T) { prev := hostnameState(tt.loaded) for range 3 { - w.DetectHostnameChanges(t.Context(), host, prev, disagree) - prev = hostnameState(disagree) + current := hostnameState(disagree) + w.DetectHostnameChanges(t.Context(), host, prev, current) + prev = current } got := 0 diff --git a/internal/watcher/interfaces.go b/internal/watcher/interfaces.go index dd68017..41f53bc 100644 --- a/internal/watcher/interfaces.go +++ b/internal/watcher/interfaces.go @@ -5,6 +5,7 @@ import ( "context" "sneak.berlin/go/dnswatcher/internal/portcheck" + "sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/tlscheck" ) @@ -17,11 +18,11 @@ type DNSResolver interface { ) ([]string, error) // LookupAllRecords queries all record types for a hostname, - // returning results keyed by nameserver then record type. + // returning each nameserver's response keyed by nameserver. LookupAllRecords( ctx context.Context, hostname string, - ) (map[string]map[string][]string, error) + ) (map[string]*resolver.NameserverResponse, error) // ResolveIPAddresses resolves a hostname to all IP addresses. ResolveIPAddresses( diff --git a/internal/watcher/nsfailure_test.go b/internal/watcher/nsfailure_test.go new file mode 100644 index 0000000..91a5580 --- /dev/null +++ b/internal/watcher/nsfailure_test.go @@ -0,0 +1,333 @@ +package watcher_test + +import ( + "context" + "fmt" + "log/slog" + "strings" + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/livednstest" + "sneak.berlin/go/dnswatcher/internal/resolver" + "sneak.berlin/go/dnswatcher/internal/state" + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +// answered is what a check saves for a nameserver that answered with +// these records. +func answered(records map[string][]string) *state.NameserverRecordState { + return &state.NameserverRecordState{Records: records, Status: "ok"} +} + +// failed is what a check saves for a nameserver that did not answer. +func failed() *state.NameserverRecordState { + return &state.NameserverRecordState{ + Records: map[string][]string{}, + Status: "error", + Error: "all queries timed out", + } +} + +// saved builds the hostname state a check saves. +func saved( + byNameserver map[string]*state.NameserverRecordState, +) *state.HostnameState { + return &state.HostnameState{RecordsByNameserver: byNameserver} +} + +// alertCounts counts the hostname alerts sent, by kind. +type alertCounts struct { + failures, recoveries, recordChanges, inconsistencies int +} + +// countAlerts runs the hostname change detection from the state loaded +// at startup through each check in turn, and counts the alerts sent. +func countAlerts( + t *testing.T, + loaded *state.HostnameState, + checks []*state.HostnameState, +) alertCounts { + t.Helper() + + // The hostname change detection uses only the notifier. + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + prev := loaded + + for _, current := range checks { + w.DetectHostnameChanges(t.Context(), host, prev, current) + prev = current + } + + var got alertCounts + + for _, n := range notifier.getNotifications() { + kind, _, _ := strings.Cut(n.Title, ":") + + switch kind { + case "NS Failure": + got.failures++ + case "NS Recovery": + got.recoveries++ + case "Record Change": + got.recordChanges++ + case "Inconsistency": + got.inconsistencies++ + } + } + + return got +} + +func TestNSFailureAndRecoveryAlerts(t *testing.T) { + t.Parallel() + + records := map[string][]string{"A": {ip1}} + + bothAnswer := saved(map[string]*state.NameserverRecordState{ + nsA: answered(records), nsB: answered(records), + }) + bFails := saved(map[string]*state.NameserverRecordState{ + nsA: answered(records), nsB: failed(), + }) + onlyA := saved(map[string]*state.NameserverRecordState{ + nsA: answered(records), + }) + bAnswersNoRecords := saved(map[string]*state.NameserverRecordState{ + nsA: answered(records), nsB: answered(map[string][]string{}), + }) + bAnswersDifferently := saved(map[string]*state.NameserverRecordState{ + nsA: answered(records), nsB: answered(map[string][]string{"A": {ip2}}), + }) + + // Each case starts from the state loaded at startup and runs the + // checks in order. + tests := []struct { + name string + loaded *state.HostnameState + checks []*state.HostnameState + want alertCounts + }{ + { + "failure lasting several checks alerts once", + bothAnswer, []*state.HostnameState{bFails, bFails, bFails}, + alertCounts{failures: 1}, + }, + { + "recovery alerts once", + bFails, []*state.HostnameState{bothAnswer, bothAnswer}, + alertCounts{recoveries: 1}, + }, + { + "failing again after recovering alerts again", + bothAnswer, []*state.HostnameState{bFails, bothAnswer, bFails}, + alertCounts{failures: 2, recoveries: 1}, + }, + { + "nameserver failing when first seen does not alert", + onlyA, []*state.HostnameState{bFails, bFails}, + alertCounts{}, + }, + { + "answer with no records is a record change, not a failure", + bothAnswer, []*state.HostnameState{bAnswersNoRecords}, + alertCounts{recordChanges: 1, inconsistencies: 1}, + }, + { + "recovered nameserver that answers differently disagrees", + bFails, []*state.HostnameState{bAnswersDifferently}, + alertCounts{recoveries: 1, inconsistencies: 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := countAlerts(t, tt.loaded, tt.checks) + if got != tt.want { + t.Errorf("sent %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) { + t.Parallel() + + records := map[string][]string{"A": {ip1}} + + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + w.DetectHostnameChanges( + t.Context(), host, + saved(map[string]*state.NameserverRecordState{nsA: answered(records)}), + saved(map[string]*state.NameserverRecordState{nsA: failed()}), + ) + + notifications := notifier.getNotifications() + if len(notifications) != 1 { + t.Fatalf("sent %v, want one NS Failure", notifications) + } + + msg := notifications[0].Message + if !strings.Contains(msg, host) || !strings.Contains(msg, nsA) || + !strings.Contains(msg, failed().Error) { + t.Errorf( + "message %q does not name %s, %s and the reason", + msg, host, nsA, + ) + } +} + +// TestNameserverThatNeverAnswers asks a nameserver address where +// nothing answers, 192.0.2.1, and checks what the watcher saves for it. +// The deadline outlasts the resolver's first two-second try, as in the +// resolver's timeout test. +func TestNameserverThatNeverAnswers(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + t.Cleanup(cancel) + + res := resolver.NewFromLogger(slog.Default()) + + resp, err := res.QueryNameserverIP(ctx, nsA, "192.0.2.1", host) + if err != nil { + t.Fatal(err) + } + + hs := watcher.BuildHostnameState( + map[string]*resolver.NameserverResponse{nsA: resp}, time.Now(), + ) + + got := hs.RecordsByNameserver[nsA] + if got.Status != "error" || got.Error == "" { + t.Errorf( + "saved status %q, error %q; want status error with a reason", + got.Status, got.Error, + ) + } +} + +// TestNameserverThatAnswersNXDOMAIN asks a real nameserver about a name +// that does not exist and checks what the watcher saves for it: NXDOMAIN +// is an answer, so the nameserver is saved as ok with no error. +func TestNameserverThatAnswersNXDOMAIN(t *testing.T) { + t.Parallel() + + res := resolver.NewFromLogger(slog.Default()) + name := "this-surely-does-not-exist-xyz." + testDomain + + var ( + ns string + resp *resolver.NameserverResponse + ) + + livednstest.Retry(t, "QueryNameserver("+name+")", func(ctx context.Context) error { + nameservers, err := res.LookupNS(ctx, testDomain) + if err != nil { + return err + } + + ns = nameservers[0] + + resp, err = res.QueryNameserver(ctx, ns, name) + if err != nil { + return err + } + + // A timeout or a failure is no answer to check. + if resp.Status == resolver.StatusTimeout || + resp.Status == resolver.StatusError { + return fmt.Errorf( + "%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error, + ) + } + + return nil + }) + + if resp.Status != resolver.StatusNXDomain { + t.Fatalf("%s answered %q for %s, want NXDOMAIN", ns, resp.Status, name) + } + + hs := watcher.BuildHostnameState( + map[string]*resolver.NameserverResponse{ns: resp}, time.Now(), + ) + + got := hs.RecordsByNameserver[ns] + if got.Status != "ok" || got.Error != "" { + t.Errorf( + "saved status %q, error %q; want status ok with no error", + got.Status, got.Error, + ) + } +} + +// TestNameserverThatRefuses asks a google.com nameserver about +// cloudflare.com, a zone it does not serve, which it refuses, and checks +// what the watcher saves for it: REFUSED is no answer, so the nameserver +// is saved as error with the reason. +func TestNameserverThatRefuses(t *testing.T) { + t.Parallel() + + const reason = "server returned REFUSED" + + res := resolver.NewFromLogger(slog.Default()) + + var ( + ns string + resp *resolver.NameserverResponse + ) + + livednstest.Retry( + t, + "QueryNameserver(cloudflare.com)", + func(ctx context.Context) error { + nameservers, err := res.LookupNS(ctx, testDomain) + if err != nil { + return err + } + + ns = nameservers[0] + + resp, err = res.QueryNameserver(ctx, ns, "cloudflare.com") + if err != nil { + return err + } + + // A timeout or a network error is no reply at all. + if resp.Status == resolver.StatusTimeout || + strings.HasPrefix(resp.Error, "network error") { + return fmt.Errorf( + "%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error, + ) + } + + return nil + }, + ) + + if resp.Error != reason { + t.Fatalf( + "%s answered %q (%s) for cloudflare.com, want REFUSED", + ns, resp.Status, resp.Error, + ) + } + + hs := watcher.BuildHostnameState( + map[string]*resolver.NameserverResponse{ns: resp}, time.Now(), + ) + + got := hs.RecordsByNameserver[ns] + if got.Status != failed().Status || got.Error != reason { + t.Errorf( + "saved status %q, error %q; want status %q, error %q", + got.Status, got.Error, failed().Status, reason, + ) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index c3d59f0..2d18f85 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -13,6 +13,7 @@ import ( "sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/logger" + "sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/tlscheck" ) @@ -227,7 +228,7 @@ func (w *Watcher) checkDomain( // Also look up A/AAAA records for the apex domain so that // port and TLS checks (which read HostnameState) can find // the domain's IP addresses. - records, err := w.resolver.LookupAllRecords(ctx, domain) + results, err := w.resolver.LookupAllRecords(ctx, domain) if err != nil { w.log.Error( "failed to lookup records for domain", @@ -238,12 +239,13 @@ func (w *Watcher) checkDomain( return } + newState := buildHostnameState(results, now) + prevHS, hasPrevHS := w.state.GetHostnameState(domain) if hasPrevHS && !w.firstRun { - w.detectHostnameChanges(ctx, domain, prevHS, records) + w.detectHostnameChanges(ctx, domain, prevHS, newState) } - newState := buildHostnameState(records, now) w.state.SetHostnameState(domain, newState) } @@ -292,7 +294,7 @@ func (w *Watcher) checkHostname( ctx context.Context, hostname string, ) { - records, err := w.resolver.LookupAllRecords(ctx, hostname) + results, err := w.resolver.LookupAllRecords(ctx, hostname) if err != nil { w.log.Error( "failed to lookup records", @@ -303,19 +305,22 @@ func (w *Watcher) checkHostname( return } - now := time.Now().UTC() - prev, hasPrev := w.state.GetHostnameState(hostname) + newState := buildHostnameState(results, time.Now().UTC()) + prev, hasPrev := w.state.GetHostnameState(hostname) if hasPrev && !w.firstRun { - w.detectHostnameChanges(ctx, hostname, prev, records) + w.detectHostnameChanges(ctx, hostname, prev, newState) } - newState := buildHostnameState(records, now) w.state.SetHostnameState(hostname, newState) } +// buildHostnameState saves each nameserver's response. A nameserver +// that answered, even with NXDOMAIN or no records, is saved as ok; one +// that timed out or failed is saved as error with the reason, and its +// empty record set is not an answer. func buildHostnameState( - records map[string]map[string][]string, + results map[string]*resolver.NameserverResponse, now time.Time, ) *state.HostnameState { hs := &state.HostnameState{ @@ -325,12 +330,20 @@ func buildHostnameState( LastChecked: now, } - for ns, recs := range records { - hs.RecordsByNameserver[ns] = &state.NameserverRecordState{ - Records: recs, + for ns, resp := range results { + nsState := &state.NameserverRecordState{ + Records: resp.Records, Status: statusOK, LastChecked: now, } + + if resp.Status == resolver.StatusTimeout || + resp.Status == resolver.StatusError { + nsState.Status = statusError + nsState.Error = resp.Error + } + + hs.RecordsByNameserver[ns] = nsState } return hs @@ -339,27 +352,29 @@ func buildHostnameState( func (w *Watcher) detectHostnameChanges( ctx context.Context, hostname string, - prev *state.HostnameState, - current map[string]map[string][]string, + prev, current *state.HostnameState, ) { w.detectRecordChanges(ctx, hostname, prev, current) w.detectNSDisappearances(ctx, hostname, prev, current) + w.detectNSFailures(ctx, hostname, prev, current) w.detectInconsistencies(ctx, hostname, prev, current) } +// detectRecordChanges compares each nameserver's records with those of +// the previous check. Only answers are compared: a nameserver that +// failed on either check has no records to compare. func (w *Watcher) detectRecordChanges( ctx context.Context, hostname string, - prev *state.HostnameState, - current map[string]map[string][]string, + prev, current *state.HostnameState, ) { - for ns, recs := range current { + for ns, cur := range current.RecordsByNameserver { prevNS, ok := prev.RecordsByNameserver[ns] - if !ok { + if !ok || prevNS.Status != statusOK || cur.Status != statusOK { continue } - if recordsEqual(prevNS.Records, recs) { + if recordsEqual(prevNS.Records, cur.Records) { continue } @@ -367,7 +382,7 @@ func (w *Watcher) detectRecordChanges( "Hostname: %s\nNameserver: %s\n"+ "Old: %v\nNew: %v", hostname, ns, - prevNS.Records, recs, + prevNS.Records, cur.Records, ) w.notify.SendNotification( @@ -382,11 +397,10 @@ func (w *Watcher) detectRecordChanges( func (w *Watcher) detectNSDisappearances( ctx context.Context, hostname string, - prev *state.HostnameState, - current map[string]map[string][]string, + prev, current *state.HostnameState, ) { for ns, prevNS := range prev.RecordsByNameserver { - if _, ok := current[ns]; ok || prevNS.Status != statusOK { + if _, ok := current.RecordsByNameserver[ns]; ok || prevNS.Status != statusOK { continue } @@ -402,32 +416,55 @@ func (w *Watcher) detectNSDisappearances( "error", ) } +} - for ns := range current { +// detectNSFailures notifies when a nameserver that answered on the +// previous check fails, and when one that failed answers again. A +// nameserver missing from the previous check is not compared. +func (w *Watcher) detectNSFailures( + ctx context.Context, + hostname string, + prev, current *state.HostnameState, +) { + for ns, cur := range current.RecordsByNameserver { prevNS, ok := prev.RecordsByNameserver[ns] - if !ok || prevNS.Status != statusError { + if !ok { continue } - msg := fmt.Sprintf( - "Hostname: %s\nNameserver: %s recovered", - hostname, ns, - ) + switch { + case prevNS.Status == statusOK && cur.Status == statusError: + msg := fmt.Sprintf( + "Hostname: %s\nNameserver: %s\nError: %s", + hostname, ns, cur.Error, + ) - w.notify.SendNotification( - ctx, - "NS Recovery: "+hostname, - msg, - "success", - ) + w.notify.SendNotification( + ctx, + "NS Failure: "+hostname, + msg, + "error", + ) + case prevNS.Status == statusError && cur.Status == statusOK: + msg := fmt.Sprintf( + "Hostname: %s\nNameserver: %s recovered", + hostname, ns, + ) + + w.notify.SendNotification( + ctx, + "NS Recovery: "+hostname, + msg, + "success", + ) + } } } func (w *Watcher) detectInconsistencies( ctx context.Context, hostname string, - prev *state.HostnameState, - current map[string]map[string][]string, + prev, current *state.HostnameState, ) { for _, pair := range newlyDisagreeingPairs(prev, current) { ns1, ns2 := pair[0], pair[1] @@ -435,8 +472,8 @@ func (w *Watcher) detectInconsistencies( msg := fmt.Sprintf( "Hostname: %s\n%s: %v\n%s: %v", hostname, - ns1, current[ns1], - ns2, current[ns2], + ns1, current.RecordsByNameserver[ns1].Records, + ns2, current.RecordsByNameserver[ns2].Records, ) w.notify.SendNotification( @@ -448,17 +485,20 @@ func (w *Watcher) detectInconsistencies( } } -// newlyDisagreeingPairs returns every pair of nameservers whose records -// differ in current, in sorted order of name, except pairs where both -// nameservers were in prev and already differed there. A nameserver -// missing from prev is paired with every nameserver it differs from. +// newlyDisagreeingPairs returns every pair of nameservers that answered +// in current and whose records differ there, in sorted order of name, +// except pairs where both nameservers answered in prev and already +// differed there. A nameserver missing from prev, or that failed there, +// is paired with every nameserver it differs from. A nameserver that +// failed in current has no records to compare and is in no pair. func newlyDisagreeingPairs( - prev *state.HostnameState, - current map[string]map[string][]string, + prev, current *state.HostnameState, ) [][2]string { - nameservers := make([]string, 0, len(current)) - for ns := range current { - nameservers = append(nameservers, ns) + nameservers := make([]string, 0, len(current.RecordsByNameserver)) + for ns, cur := range current.RecordsByNameserver { + if cur.Status == statusOK { + nameservers = append(nameservers, ns) + } } sort.Strings(nameservers) @@ -467,14 +507,19 @@ func newlyDisagreeingPairs( for i, ns1 := range nameservers { for _, ns2 := range nameservers[i+1:] { - if recordsEqual(current[ns1], current[ns2]) { + if recordsEqual( + current.RecordsByNameserver[ns1].Records, + current.RecordsByNameserver[ns2].Records, + ) { continue } prev1, ok1 := prev.RecordsByNameserver[ns1] prev2, ok2 := prev.RecordsByNameserver[ns2] - if ok1 && ok2 && !recordsEqual(prev1.Records, prev2.Records) { + if ok1 && ok2 && + prev1.Status == statusOK && prev2.Status == statusOK && + !recordsEqual(prev1.Records, prev2.Records) { continue } diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 0620f8a..5f23678 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -687,11 +687,12 @@ func TestNSFailureAndRecovery(t *testing.T) { cfg.Hostnames = []string{testHost} // Between the checks, save every nameserver the first check found - // as failed, and add, as answering, one that live DNS does not list. + // as one that did not answer, and add, as answering, one that live + // DNS does not list, which then disappears. deps := runChecks(t, cfg, nil, func(deps *testDeps) { hs, _ := deps.state.GetHostnameState(testHost) - for _, nsState := range hs.RecordsByNameserver { - nsState.Status = "error" + for ns := range hs.RecordsByNameserver { + hs.RecordsByNameserver[ns] = failed() } hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{ @@ -704,4 +705,10 @@ func TestNSFailureAndRecovery(t *testing.T) { assertNotified(t, deps, "NS Failure: "+testHost, "error") assertNotified(t, deps, "NS Recovery: "+testHost, "success") + + // A nameserver that did not answer has no records to compare, so + // its recovery is not also a record change. + if n := countNotifications(deps, "Record Change: "+testHost); n != 0 { + t.Errorf("sent %d record changes on recovery, want 0", n) + } } -- 2.54.0 From a8f9a646001cffe65ea58c4133709ead2ef54808 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 22:35:17 +0200 Subject: [PATCH 31/92] middleware: take the client address from the right of X-Forwarded-For (closes #181) realIP took the first X-Forwarded-For entry, which the client itself can write, so behind a proxy that appends to the header a client chose the address dnswatcher logs and the /metrics rate limit counts. It now walks the entries from the right past trusted proxies, using the existing trusted-proxy check, and takes the first that is not one; the leftmost when all are. All X-Forwarded-For header lines are read as one list, since a proxy may add its own line instead of appending to the client's. An empty entry where the client address belongs falls back to the peer address, as an empty first entry did before. X-Real-IP is unchanged. Model: opus-5-5 --- README.md | 6 +- TODO.md | 2 + internal/middleware/export_test.go | 11 +++- internal/middleware/middleware.go | 30 ++++++--- internal/middleware/middleware_test.go | 87 +++++++++++++++++++++++++- 5 files changed, 122 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0365448..de6445b 100644 --- a/README.md +++ b/README.md @@ -347,9 +347,9 @@ minute, failed logins included; beyond that it answers `429 Too Many Requests` without checking the password. A Prometheus server scraping every 15 seconds sends 4 a minute. IPv6 addresses in one /64 count as one client. When the request comes from a private or loopback address, such as a reverse proxy's, -the client address is taken from the `X-Real-IP` or `X-Forwarded-For` header -the proxy sets; a proxy that sets neither makes all its clients share one -allowance. +the client address is taken from the `X-Real-IP` header the proxy sets, or else +from `X-Forwarded-For`, as the last address in it that is not private or +loopback. A proxy that sets neither makes all its clients share one allowance. **`DNSWATCHER_DNS_INTERVAL` and `DNSWATCHER_TLS_INTERVAL`** take a positive duration: a number followed by a unit such as `s`, `m` or `h`, for example diff --git a/TODO.md b/TODO.md index 3fae221..c38b103 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 # Completed Steps +- 2026-10-01: the client address from `X-Forwarded-For` is the last entry that + is not a trusted proxy, not the first, which the client sets (closes #181). - 2026-10-01: a nameserver that does not answer is saved as `error` with the reason, and NS failure and NS recovery are notified (closes #104). - 2026-10-01: a `DNSWATCHER_DNS_INTERVAL` or `DNSWATCHER_TLS_INTERVAL` that is diff --git a/internal/middleware/export_test.go b/internal/middleware/export_test.go index 1abde0f..93680e8 100644 --- a/internal/middleware/export_test.go +++ b/internal/middleware/export_test.go @@ -1,6 +1,9 @@ package middleware -import "time" +import ( + "net/http" + "time" +) // The /metrics rate limit, exported so the tests can count requests // against it. @@ -8,3 +11,9 @@ const ( MetricsRequestLimit = metricsRequestLimit MetricsRequestWindow time.Duration = metricsRequestWindow ) + +// RealIP is realIP, exported so the tests can check which address it +// takes as the client's. +func RealIP(r *http.Request) string { + return realIP(r) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index f65f63f..e558b0d 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -209,6 +209,12 @@ func isTrustedProxy(ip net.IP) bool { // realIP extracts the client's real IP address from the request. // Proxy headers are only trusted from RFC1918/loopback addresses. +// +// Each proxy adds to the end of X-Forwarded-For the address it got the +// request from, so the client can write every entry before the one the +// first trusted proxy added. The client address is therefore the +// rightmost entry that is not a trusted proxy, or the leftmost entry +// when they all are. func realIP(r *http.Request) string { addr := ipFromHostPort(r.RemoteAddr) remoteIP := net.ParseIP(addr) @@ -223,16 +229,26 @@ func realIP(r *http.Request) string { return ip } - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - if parts := strings.SplitN( - xff, ",", 2, //nolint:mnd - ); len(parts) > 0 { - if ip := strings.TrimSpace(parts[0]); ip != "" { - return ip - } + // A proxy may add its entry as a header line of its own instead of + // appending to the line the client sent, so all lines form one list. + entries := strings.Split( + strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",", + ) + client := strings.TrimSpace(entries[0]) + + for i := len(entries) - 1; i > 0; i-- { + entry := strings.TrimSpace(entries[i]) + if !isTrustedProxy(net.ParseIP(entry)) { + client = entry + + break } } + if client != "" { + return client + } + return addr } diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index c7c1a8c..9e306a9 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -342,9 +342,9 @@ func TestDashboardRendersWithSecurityHeaders(t *testing.T) { } } -// Addresses for the rate limit tests: a client connecting directly, a -// trusted proxy, and a client behind that proxy as its X-Real-IP -// header names it. +// Addresses for the rate limit and realIP tests: a client connecting +// directly, a trusted proxy, and a client behind that proxy as the +// proxy's X-Real-IP or X-Forwarded-For header names it. const ( directClient = "198.51.100.1:4000" trustedProxy = "10.0.0.1:4000" @@ -482,3 +482,84 @@ func TestMetricsRateLimitKeysOnClientAddress(t *testing.T) { }) } } + +// TestRealIP checks which address realIP takes as the client's. Each +// element of forwardedFor is sent as an X-Forwarded-For header line of +// its own, and 198.51.100.9 is always an entry the client wrote itself. +func TestRealIP(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + remoteAddr string + xRealIP string + forwardedFor []string + want string + }{ + { + "untrusted peer, both headers ignored", + directClient, proxiedClient, []string{"198.51.100.9"}, + "198.51.100.1", + }, + { + "X-Real-IP from a trusted proxy wins", + trustedProxy, proxiedClient, []string{"203.0.113.8"}, + proxiedClient, + }, + { + "client's own entry, then the one the proxy added", + trustedProxy, "", []string{"198.51.100.9, 203.0.113.1"}, + proxiedClient, + }, + { + "several trusted proxies", + trustedProxy, "", + []string{"198.51.100.9, 203.0.113.1, 10.0.0.3, 10.0.0.2"}, + proxiedClient, + }, + { + "proxy adds a header line of its own", + trustedProxy, "", []string{"198.51.100.9", proxiedClient}, + proxiedClient, + }, + { + "every entry a trusted proxy", + trustedProxy, "", []string{"10.0.0.3, 10.0.0.2"}, + "10.0.0.3", + }, + { + "empty where the client address belongs", + trustedProxy, "", []string{"203.0.113.1, , 10.0.0.2"}, + "10.0.0.1", + }, + { + "no headers from a trusted proxy", + trustedProxy, "", nil, + "10.0.0.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/", nil, + ) + req.RemoteAddr = tt.remoteAddr + + if tt.xRealIP != "" { + req.Header.Set("X-Real-IP", tt.xRealIP) + } + + for _, line := range tt.forwardedFor { + req.Header.Add("X-Forwarded-For", line) + } + + got := middleware.RealIP(req) + if got != tt.want { + t.Errorf("realIP = %q, want %q", got, tt.want) + } + }) + } +} -- 2.54.0 From fe01cdda1e0c4cc68cecef98243eb06f56c1a1fc Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 22:49:45 +0200 Subject: [PATCH 32/92] watcher: save and notify nothing for a cut-short port or TLS check (closes #185) When shutdown cancels a check that is under way, the rest of the check still runs with the cancelled context. The resolver already drops a lookup the context cut short, but a cancelled connection attempt was saved as a closed port or a failed certificate check and notified as Port Change or TLS Failure. The watcher now drops a port or TLS check result when its context was cancelled, the same way. The test runs a check with the context already cancelled, using the real resolver and the real port and TLS checkers; no query is sent and no connection is made. Model: opus-5-5 --- README.md | 3 +- TODO.md | 2 + internal/watcher/cancelled_test.go | 81 ++++++++++++++++++++++++++++++ internal/watcher/watcher.go | 14 ++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 internal/watcher/cancelled_test.go diff --git a/README.md b/README.md index de6445b..f73b339 100644 --- a/README.md +++ b/README.md @@ -618,7 +618,8 @@ repository's `Dockerfile` and runs it. The app needs: abandoned, and the number abandoned is logged at warn level rather than dropped silently. Notifications generated after shutdown has begun are refused and logged, so a late burst cannot extend the - shutdown. + shutdown. A DNS lookup, port check or TLS check that shutdown cuts + short saves nothing and sends no notification. --- diff --git a/TODO.md b/TODO.md index c38b103..8dc1484 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 # Completed Steps +- 2026-10-01: a port or TLS check that shutdown cuts short saves nothing and + sends no notification, as a cut-short DNS lookup already did (closes #185). - 2026-10-01: the client address from `X-Forwarded-For` is the last entry that is not a trusted proxy, not the first, which the client sets (closes #181). - 2026-10-01: a nameserver that does not answer is saved as `error` with the diff --git a/internal/watcher/cancelled_test.go b/internal/watcher/cancelled_test.go new file mode 100644 index 0000000..7d600d2 --- /dev/null +++ b/internal/watcher/cancelled_test.go @@ -0,0 +1,81 @@ +package watcher_test + +import ( + "context" + "log/slog" + "reflect" + "testing" + + "sneak.berlin/go/dnswatcher/internal/portcheck" + "sneak.berlin/go/dnswatcher/internal/resolver" + "sneak.berlin/go/dnswatcher/internal/state" + "sneak.berlin/go/dnswatcher/internal/tlscheck" + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +// TestCancelledCheckSavesNothing runs a check with its context already +// cancelled, which is how the rest of a check runs once shutdown cuts it +// short. The real resolver drops the DNS lookup without sending a query, +// and the real port and TLS checkers fail without connecting. The port +// and certificate state the last check saved must stay as it was, and +// nothing may be notified. +func TestCancelledCheckSavesNothing(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{host} + + // newTestWatcher's watcher has stand-in checkers. This one, on the + // same state and notifier, has the real ones. + _, deps := newTestWatcher(t, cfg) + w := watcher.NewForTest( + cfg, + deps.state, + resolver.NewFromLogger(slog.Default()), + portcheck.NewStandalone(), + tlscheck.NewStandalone(), + deps.notifier, + ) + + // The last check found host at a local address, with both ports + // open and a good certificate. + const localIP = "127.0.0.1" + + deps.state.SetHostnameState(host, hostnameState( + map[string]map[string][]string{nsA: {"A": {localIP}}}, + )) + + ports := map[string]*state.PortState{ + localIP + ":80": {Open: true, Hostnames: []string{host}}, + localIP + ":443": {Open: true, Hostnames: []string{host}}, + } + for key, ps := range ports { + deps.state.SetPortState(key, ps) + } + + certKey := localIP + ":443:" + host + cert := &state.CertificateState{CommonName: host, Status: "ok"} + deps.state.SetCertificateState(certKey, cert) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + w.RunOnce(ctx) + + for key, want := range ports { + got, _ := deps.state.GetPortState(key) + if !reflect.DeepEqual(got, want) { + t.Errorf("port %s saved as %+v, want %+v", key, got, want) + } + } + + got, _ := deps.state.GetCertificateState(certKey) + if !reflect.DeepEqual(got, cert) { + t.Errorf("certificate saved as %+v, want %+v", got, cert) + } + + notifications := deps.notifier.getNotifications() + if len(notifications) != 0 { + t.Errorf("sent %v, want no notifications", notifications) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2d18f85..546c3bd 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -658,6 +658,13 @@ func (w *Watcher) checkSinglePort( hostnames []string, ) { result, err := w.portCheck.CheckPort(ctx, ip, port) + + // A check the context cut short says nothing about the port, so it + // is neither saved nor notified. + if ctx.Err() != nil { + return + } + if err != nil { w.log.Error( "port check failed", @@ -733,6 +740,13 @@ func (w *Watcher) checkTLSCert( hostname string, ) { cert, err := w.tlsCheck.CheckCertificate(ctx, ip, hostname) + + // A check the context cut short says nothing about the certificate, + // so it is neither saved nor notified. + if ctx.Err() != nil { + return + } + certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname) now := time.Now().UTC() prev, hasPrev := w.state.GetCertificateState(certKey) -- 2.54.0 From c0ea9b96f2d482ac2d2bd1857bb017559b3ad2f6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 23:09:39 +0200 Subject: [PATCH 33/92] server: report HTTP handler panics to Sentry (closes #107) DNSWATCHER_SENTRY_DSN was read but never used. This ports the Sentry integration from gohttpserver with sentry-go v0.49.0. The server's start hook calls sentry.Init when the DSN is set; a DSN Sentry cannot parse fails the hook, so startup stops with the parse error. sentryhttp, with Repanic, reports handler panics and passes them on to chi's Recoverer. Shutdown sends queued reports once the HTTP server has stopped. The client uses the older transport (DisableTelemetryBuffer): with the default one, Flush can return before sending a report made just before it. Client reports are off, so only panics are sent. The DSN is checked at server start, not in config, so the config test is unchanged. sentry-go raises several golang.org/x modules and moves go-spew and go-difflib to untagged commits. Model: opus-5-5 --- README.md | 7 ++ TODO.md | 4 +- go.mod | 17 +-- go.sum | 44 ++++---- internal/server/export_test.go | 14 +++ internal/server/routes.go | 11 ++ internal/server/sentry_test.go | 190 +++++++++++++++++++++++++++++++++ internal/server/server.go | 75 +++++++++++-- internal/server/server_test.go | 31 +++--- 9 files changed, 343 insertions(+), 50 deletions(-) create mode 100644 internal/server/sentry_test.go diff --git a/README.md b/README.md index f73b339..35454d2 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,13 @@ either is set to anything else, including a bare number or a zero or negative duration, dnswatcher refuses to start with an error naming the variable and the value. +**`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it +is set, a panic in an HTTP request handler is sent to Sentry, and the request +still gets a `500 Internal Server Error` answer. Nothing else is sent to Sentry: +DNS, port and TLS problems are reported as notifications. A value Sentry cannot +parse stops dnswatcher at startup. At shutdown, reports not yet sent are sent, +waiting at most 2 seconds. + ### Example `.env` ```sh diff --git a/TODO.md b/TODO.md index 8dc1484..6043a58 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 # Completed Steps +- 2026-10-01: `DNSWATCHER_SENTRY_DSN` reports panics in HTTP handlers to Sentry, + and a DSN Sentry cannot parse stops startup (closes #107). - 2026-10-01: a port or TLS check that shutdown cuts short saves nothing and sends no notification, as a cut-short DNS lookup already did (closes #185). - 2026-10-01: the client address from `X-Forwarded-For` is the last entry that @@ -101,8 +103,6 @@ nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 # Future Steps -- `DNSWATCHER_SENTRY_DSN` does nothing: - https://git.eeqj.de/sneak/dnswatcher/issues/107 - trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: diff --git a/go.mod b/go.mod index a9012dd..da8e0b7 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.5 require ( github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 + github.com/getsentry/sentry-go v0.49.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.16.0 @@ -13,20 +14,20 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 go.uber.org/fx v1.24.0 - golang.org/x/net v0.50.0 - golang.org/x/sync v0.19.0 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.21.0 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect @@ -42,10 +43,10 @@ require ( go.uber.org/zap v1.26.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1570cf1..eb081e2 100644 --- a/go.sum +++ b/go.sum @@ -4,18 +4,22 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s= +github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -38,8 +42,12 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -48,8 +56,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -84,18 +92,18 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/server/export_test.go b/internal/server/export_test.go index 5a1bec7..acd4d8c 100644 --- a/internal/server/export_test.go +++ b/internal/server/export_test.go @@ -3,6 +3,8 @@ package server import ( "net/http" "time" + + "github.com/go-chi/chi/v5" ) // RequestTimeout exports the handler execution budget applied by @@ -22,3 +24,15 @@ func SetListenPort(s *Server, port int) { func HTTPServerOf(s *Server) *http.Server { return s.httpServer } + +// EnableSentry runs the Sentry setup that the start hook runs, without +// starting the HTTP server. +func EnableSentry(s *Server) error { + return s.enableSentry() +} + +// RouterOf returns the router SetupRoutes built, so a test can add a +// route that panics. +func RouterOf(s *Server) *chi.Mux { + return s.router +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 0a8e8ed..805ff21 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + sentryhttp "github.com/getsentry/sentry-go/http" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -25,6 +26,16 @@ func (s *Server) SetupRoutes() { s.router.Use(s.mw.Logging()) s.router.Use(chimw.Timeout(requestTimeout)) + // Report panics in handlers to Sentry when DNSWATCHER_SENTRY_DSN is + // set. Repanic passes each panic on to chimw.Recoverer above, which + // still answers the request. + if s.sentryEnabled { + sentryHandler := sentryhttp.New(sentryhttp.Options{ + Repanic: true, + }) + s.router.Use(sentryHandler.Handle) + } + // Public, unauthenticated, read-only routes, the only ones // REPO_POLICIES.md allows wildcard CORS on. CORS is middleware of // this whole router, not of a Group, so that it also answers diff --git a/internal/server/sentry_test.go b/internal/server/sentry_test.go new file mode 100644 index 0000000..1525d3f --- /dev/null +++ b/internal/server/sentry_test.go @@ -0,0 +1,190 @@ +package server_test + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/getsentry/sentry-go" + "github.com/spf13/viper" + "go.uber.org/fx" + + "sneak.berlin/go/dnswatcher/internal/server" +) + +// The tests below set env vars and touch the global state of viper and +// of Sentry, so they cannot use t.Parallel. + +// standInDelay is how long the Sentry stand-in takes to answer. It +// records a report only then, so a report that Shutdown did not wait +// for has not been recorded yet when Shutdown returns. +const standInDelay = 100 * time.Millisecond + +// sentryStandIn is a local HTTP server in place of Sentry's, so that +// nothing a test reports leaves the host. It keeps the body of every +// request it receives. +type sentryStandIn struct { + server *httptest.Server + mu sync.Mutex + bodies []string +} + +func newSentryStandIn(t *testing.T) *sentryStandIn { + t.Helper() + + standIn := &sentryStandIn{} + standIn.server = httptest.NewServer(http.HandlerFunc( + func(_ http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("reading request to the Sentry stand-in: %v", err) + } + + time.Sleep(standInDelay) + + standIn.mu.Lock() + defer standIn.mu.Unlock() + + standIn.bodies = append(standIn.bodies, string(body)) + }, + )) + t.Cleanup(standIn.server.Close) + + return standIn +} + +// dsn returns a DSN that points Sentry at the stand-in. +func (s *sentryStandIn) dsn(t *testing.T) string { + t.Helper() + + dsn, err := url.Parse(s.server.URL) + if err != nil { + t.Fatalf("parsing the stand-in URL: %v", err) + } + + dsn.User = url.User("public-key") + dsn.Path = "/1" + + return dsn.String() +} + +// received reports whether a request to the stand-in contained text. +func (s *sentryStandIn) received(text string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + for _, body := range s.bodies { + if strings.Contains(body, text) { + return true + } + } + + return false +} + +func TestSentryUnsetDoesNothing(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_SENTRY_DSN", "") + + srv := buildServer(t) + + err := server.EnableSentry(srv) + if err != nil { + t.Fatalf("Sentry setup with no DSN: %v", err) + } + + if sentry.CurrentHub().Client() != nil { + t.Error("Sentry was set up with no DSN configured") + } +} + +// TestSentryReportsHandlerPanic checks that with a valid DSN a panic in +// a handler is reported to Sentry, still reaches chimw.Recoverer, and +// has been sent by the time Shutdown returns, and that nothing else is +// sent to Sentry. +func TestSentryReportsHandlerPanic(t *testing.T) { + standIn := newSentryStandIn(t) + + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_SENTRY_DSN", standIn.dsn(t)) + + srv := buildServer(t) + + err := server.EnableSentry(srv) + if err != nil { + t.Fatalf("Sentry setup with a valid DSN: %v", err) + } + + // Sentry's client is global: close it so later tests find none. + t.Cleanup(func() { + sentry.CurrentHub().Client().Close() + sentry.CurrentHub().BindClient(nil) + }) + + const panicMessage = "handler panic in the Sentry test" + + srv.SetupRoutes() + server.RouterOf(srv).Get( + "/panic", + func(http.ResponseWriter, *http.Request) { + panic(panicMessage) + }, + ) + + // An ordinary request first: with client reports on, Sentry would + // add a count of its dropped transaction to the panic report. + serve(srv, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/.well-known/healthcheck", nil, + )) + + rec := serve(srv, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/panic", nil, + )) + if rec.Code != http.StatusInternalServerError { + t.Errorf( + "status %d, want %d from the recoverer", + rec.Code, http.StatusInternalServerError, + ) + } + + err = srv.Shutdown(t.Context()) + if err != nil { + t.Fatalf("Shutdown: %v", err) + } + + if !standIn.received(panicMessage) { + t.Error("the panic had not been sent to Sentry when Shutdown returned") + } + + if standIn.received("client_report") { + t.Error("Sentry was sent a client report, not only the panic") + } +} + +func TestSentryInvalidDSNStopsStartup(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir()) + // Sentry cannot parse this: it has no public key before the host. + t.Setenv("DNSWATCHER_SENTRY_DSN", "https://sentry.test/1") + + app := newServerApp(fx.Invoke(func(*server.Server) {})) + + err := app.Start(t.Context()) + if err == nil { + _ = app.Stop(t.Context()) + + t.Fatal("startup succeeded with an invalid DSN") + } + + if !strings.Contains(err.Error(), "invalid DNSWATCHER_SENTRY_DSN") { + t.Errorf("startup error does not name the setting: %v", err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index f9385eb..e18a4d3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,6 +9,7 @@ import ( "net/http" "time" + "github.com/getsentry/sentry-go" "github.com/go-chi/chi/v5" "go.uber.org/fx" @@ -33,6 +34,10 @@ type Params struct { // shutdownTimeout is how long to wait for graceful shutdown. const shutdownTimeout = 30 * time.Second +// sentryFlushTimeout is how long shutdown waits for Sentry to send the +// error reports it still holds. +const sentryFlushTimeout = 2 * time.Second + // Socket-level timeouts for the HTTP server. // // These bound time spent on the connection itself and are a distinct @@ -82,14 +87,15 @@ const ( // Server is the HTTP server. type Server struct { - startupTime time.Time - port int - log *slog.Logger - router *chi.Mux - httpServer *http.Server - params Params - mw *middleware.Middleware - handlers *handlers.Handlers + startupTime time.Time + port int + sentryEnabled bool + log *slog.Logger + router *chi.Mux + httpServer *http.Server + params Params + mw *middleware.Middleware + handlers *handlers.Handlers } // New creates a new Server instance. @@ -108,6 +114,12 @@ func New( lifecycle.Append(fx.Hook{ OnStart: func(_ context.Context) error { srv.startupTime = time.Now() + + err := srv.enableSentry() + if err != nil { + return err + } + go srv.Run() return nil @@ -152,8 +164,11 @@ func (s *Server) Run() { } } -// Shutdown gracefully shuts down the server. +// Shutdown gracefully shuts down the server, then sends the error +// reports Sentry still holds. func (s *Server) Shutdown(ctx context.Context) error { + defer s.flushSentry() + if s.httpServer == nil { return nil } @@ -184,3 +199,45 @@ func (s *Server) ServeHTTP( ) { s.router.ServeHTTP(writer, request) } + +// enableSentry turns on Sentry error reporting when +// DNSWATCHER_SENTRY_DSN is set, and does nothing when it is not. A DSN +// that Sentry cannot parse is an error, so that startup stops instead +// of running without the error reporting the operator asked for. +func (s *Server) enableSentry() error { + if s.params.Config.SentryDSN == "" { + return nil + } + + err := sentry.Init(sentry.ClientOptions{ + Dsn: s.params.Config.SentryDSN, + Release: s.params.Globals.Appname + "-" + s.params.Globals.Version, + // Use the transport that queues each report as it is made. With + // the default one, Flush can return before sending a report made + // just before it, such as one from the last request at shutdown. + DisableTelemetryBuffer: true, + // Send panic reports only, not Sentry's counts of what it dropped, + // such as the transaction it starts for every request. + DisableClientReports: true, + }) + if err != nil { + return fmt.Errorf("invalid DNSWATCHER_SENTRY_DSN: %w", err) + } + + s.log.Info("sentry error reporting activated") + s.sentryEnabled = true + + return nil +} + +// flushSentry sends the error reports Sentry still holds, waiting at +// most sentryFlushTimeout. +func (s *Server) flushSentry() { + if !s.sentryEnabled { + return + } + + if !sentry.Flush(sentryFlushTimeout) { + s.log.Warn("sentry flush timed out; some error reports were not sent") + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6cd036c..8aa5f2d 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -17,18 +17,13 @@ import ( "sneak.berlin/go/dnswatcher/internal/state" ) -// buildServer wires a *server.Server exactly as cmd/dnswatcher does, -// minus the watcher/resolver subtree that would touch live DNS. fx -// builds the object graph but the lifecycle is never started, so no -// OnStart hook runs and nothing listens or resolves. The caller must -// first configure viper (config.New reads it), which is also why the -// caller cannot run in parallel. -func buildServer(t *testing.T) *server.Server { - t.Helper() - - var srv *server.Server - - app := fx.New( +// newServerApp builds an fx app holding a *server.Server wired exactly +// as cmd/dnswatcher wires it, minus the watcher/resolver subtree that +// would touch live DNS, plus the given option. config.New reads viper, +// so the caller must first configure it, which is also why the caller +// cannot run in parallel. +func newServerApp(option fx.Option) *fx.App { + return fx.New( fx.NopLogger, fx.Provide( globals.New, @@ -41,8 +36,18 @@ func buildServer(t *testing.T) *server.Server { handlers.New, server.New, ), - fx.Populate(&srv), + option, ) +} + +// buildServer builds the server without starting the app's lifecycle, +// so no OnStart hook runs and nothing listens or resolves. +func buildServer(t *testing.T) *server.Server { + t.Helper() + + var srv *server.Server + + app := newServerApp(fx.Populate(&srv)) err := app.Err() if err != nil { -- 2.54.0 From f6567df2d0eba710553f69b0b97fd2c80a3065fd Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 23:14:17 +0200 Subject: [PATCH 34/92] watcher: save state when it stops and wait for that save (closes #114) The final save at shutdown came from the state's own stop hook, while the watcher's stop hook only cancelled its run loop, so a check under way could change state after that save or be cut off at exit. Run now saves state as it returns, and the watcher's stop hook waits for Run, bounded by the shutdown deadline. The state's own save stays; Save holds the state lock for the whole write, so the two cannot overlap. The start hook derives the watcher's context with WithoutCancel, so the linter needs no exception. A new test stops a watcher built by New and reads the change back from the state file, with no DNS. Model: opus-5-5 --- README.md | 7 +- TODO.md | 3 +- internal/watcher/watcher.go | 44 ++++++++---- internal/watcher/watcher_test.go | 113 +++++++++++++++++++++++++++---- 4 files changed, 138 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 35454d2..9439183 100644 --- a/README.md +++ b/README.md @@ -618,9 +618,10 @@ repository's `Dockerfile` and runs it. The app needs: from a previous cycle. 4. **On change detection**: Send notifications to all configured endpoints, update in-memory state, persist to disk. -5. **Shutdown**: Persist final state to disk, wait for in-flight - notification deliveries to complete, stop gracefully. The wait is - bounded by the fx shutdown timeout (15s by default): deliveries still +5. **Shutdown**: The watcher stops checking and saves the final state + to disk, and shutdown waits for that save before it goes on. Then it + waits for in-flight notification deliveries to complete. Both waits + share the fx shutdown timeout (15s by default): deliveries still retrying against an unreachable endpoint when that expires are abandoned, and the number abandoned is logged at warn level rather than dropped silently. Notifications generated after shutdown has diff --git a/TODO.md b/TODO.md index 6043a58..20871f3 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 # Completed Steps +- 2026-10-01: the watcher saves state when it stops, and shutdown waits for that + save, so it no longer relies on the state's own stop hook (closes #114). - 2026-10-01: `DNSWATCHER_SENTRY_DSN` reports panics in HTTP handlers to Sentry, and a DSN Sentry cannot parse stops startup (closes #107). - 2026-10-01: a port or TLS check that shutdown cuts short saves nothing and @@ -109,7 +111,6 @@ nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 https://git.eeqj.de/sneak/dnswatcher/issues/66 - `goimports` in `make fmt-check`, Markdown formatting: https://git.eeqj.de/sneak/dnswatcher/issues/119 -- final state save at shutdown: https://git.eeqj.de/sneak/dnswatcher/issues/114 - README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 - README sections required by policy: https://git.eeqj.de/sneak/dnswatcher/issues/173 diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 546c3bd..239351b 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -56,6 +56,7 @@ type Watcher struct { tlsCheck TLSChecker notify Notifier cancel context.CancelFunc + done chan struct{} // closed when Run returns firstRun bool expiryNotifiedMu sync.Mutex expiryNotified map[string]time.Time @@ -79,31 +80,47 @@ func New( } lifecycle.Append(fx.Hook{ - OnStart: func(_ context.Context) error { - // Use context.Background() — the fx startup context - // expires after startup completes, so deriving from it - // would cancel the watcher immediately. The watcher's - // lifetime is controlled by w.cancel in OnStop. - ctx, cancel := context.WithCancel(context.Background()) + OnStart: func(startCtx context.Context) error { + // The fx startup context expires after startup + // completes, so the watcher's context drops its + // cancellation. The watcher's lifetime is controlled + // by w.cancel in OnStop. + ctx, cancel := context.WithCancel( + context.WithoutCancel(startCtx), + ) w.cancel = cancel + w.done = make(chan struct{}) - go w.Run(ctx) //nolint:contextcheck // intentionally not derived from startCtx + go func() { + defer close(w.done) + + w.Run(ctx) + }() return nil }, - OnStop: func(_ context.Context) error { - if w.cancel != nil { - w.cancel() - } + OnStop: func(ctx context.Context) error { + w.cancel() - return nil + // Run saves state as it returns. Waiting for it here + // means the save is done before shutdown goes on. + select { + case <-w.done: + return nil + case <-ctx.Done(): + return fmt.Errorf( + "waiting for the watcher to stop: %w", + ctx.Err(), + ) + } }, }) return w, nil } -// Run starts the monitoring loop with periodic scheduling. +// Run starts the monitoring loop with periodic scheduling. When ctx +// is cancelled, it saves state and returns. func (w *Watcher) Run(ctx context.Context) { w.log.Info( "watcher starting", @@ -125,6 +142,7 @@ func (w *Watcher) Run(ctx context.Context) { for { select { case <-ctx.Done(): + w.saveState() w.log.Info("watcher stopped") return diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 5f23678..c95a550 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "os" "slices" "sync" "testing" @@ -135,6 +136,7 @@ type testDeps struct { notifier *mockNotifier state *state.State config *config.Config + log *logger.Logger } func newTestWatcher( @@ -143,6 +145,23 @@ func newTestWatcher( ) (*watcher.Watcher, *testDeps) { t.Helper() + deps := newTestDeps(t, cfg) + + w := watcher.NewForTest( + deps.config, + deps.state, + resolver.NewFromLogger(slog.Default()), + deps.portChecker, + deps.tlsChecker, + deps.notifier, + ) + + return w, deps +} + +func newTestDeps(t *testing.T, cfg *config.Config) *testDeps { + t.Helper() + deps := &testDeps{ portChecker: &mockPortChecker{}, tlsChecker: &mockTLSChecker{ @@ -157,30 +176,21 @@ func newTestWatcher( t.Fatalf("globals.New: %v", err) } - log, err := logger.New(nil, logger.Params{Globals: g}) + deps.log, err = logger.New(nil, logger.Params{Globals: g}) if err != nil { t.Fatalf("logger.New: %v", err) } // The watcher saves state after every check, into cfg.DataDir. deps.state, err = state.New(fxtest.NewLifecycle(t), state.Params{ - Logger: log, + Logger: deps.log, Config: cfg, }) if err != nil { t.Fatalf("state.New: %v", err) } - w := watcher.NewForTest( - deps.config, - deps.state, - resolver.NewFromLogger(slog.Default()), - deps.portChecker, - deps.tlsChecker, - deps.notifier, - ) - - return w, deps + return deps } func defaultTestConfig(t *testing.T) *config.Config { @@ -539,6 +549,85 @@ func TestGracefulShutdown(t *testing.T) { } } +// TestStopSavesState stops a watcher built by New the way fx stops it, +// and checks that a change made to the state after the last check is in +// the state file afterwards. The state's own stop hook never runs here, +// so only the watcher can have saved it. Nothing is configured to +// check, so no DNS is involved. +func TestStopSavesState(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + deps := newTestDeps(t, cfg) + lc := fxtest.NewLifecycle(t) + + _, err := watcher.New(lc, watcher.Params{ + Logger: deps.log, + Config: cfg, + State: deps.state, + Resolver: resolver.NewFromLogger(slog.Default()), + PortCheck: deps.portChecker, + TLSCheck: deps.tlsChecker, + Notify: deps.notifier, + }) + if err != nil { + t.Fatalf("watcher.New: %v", err) + } + + lc.RequireStart() + + // The first check saves state once. Wait for that save before + // changing the state, so the change can reach the file only + // through the save made at stop. + deadline := time.Now().Add(5 * time.Second) + + for { + _, err = os.Stat(cfg.StatePath()) + if err == nil { + break + } + + if time.Now().After(deadline) { + t.Fatalf("the first check saved no state: %v", err) + } + + time.Sleep(10 * time.Millisecond) + } + + deps.state.SetDomainState(testDomain, &state.DomainState{ + Nameservers: []string{oldNS1}, + }) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + err = lc.Stop(ctx) + if err != nil { + t.Fatalf("stopping the watcher: %v", err) + } + + saved, err := state.New(fxtest.NewLifecycle(t), state.Params{ + Logger: deps.log, + Config: cfg, + }) + if err != nil { + t.Fatalf("state.New: %v", err) + } + + err = saved.Load() + if err != nil { + t.Fatalf("loading the state file: %v", err) + } + + ds, ok := saved.GetDomainState(testDomain) + if !ok || !slices.Equal(ds.Nameservers, []string{oldNS1}) { + t.Errorf( + "state file after stop has %+v for %s, want nameservers %v", + ds, testDomain, []string{oldNS1}, + ) + } +} + func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { t.Parallel() -- 2.54.0 From c247f6bcf50bed90ca7948e2a6d9d82030fe902e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 23:23:26 +0200 Subject: [PATCH 35/92] watcher: notify nameserver address changes (closes #105) Each domain check now looks up the addresses every nameserver's name resolves to, with the resolver's ResolveIPAddresses, and saves them sorted in the domain's state. A nameserver that stays in the delegation and resolves to different addresses sends one NS Address Change notification naming the domain, the nameserver and the old and new addresses. Added or removed nameservers get only the NS change notification. A failed or empty lookup keeps the previous addresses, because the resolver returns no address without an error when every server it asks times out. State files without the field load, and the next check fills it in silently. Watcher tests that run domain checks use example.com, which has two nameservers, to stay within the per-attempt limit. Model: opus-5-5 --- README.md | 19 +++- TODO.md | 7 +- internal/state/state.go | 8 +- internal/state/state_test.go | 63 ++++++++++++ internal/watcher/export_test.go | 19 ++++ internal/watcher/nsaddress_test.go | 151 +++++++++++++++++++++++++++++ internal/watcher/watcher.go | 83 +++++++++++++++- internal/watcher/watcher_test.go | 150 +++++++++++++++++++++++++--- 8 files changed, 479 insertions(+), 21 deletions(-) create mode 100644 internal/watcher/nsaddress_test.go diff --git a/README.md b/README.md index 9439183..78c14c6 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,15 @@ rejected. - Every **1 hour**, performs a full iterative trace from root servers to discover all authoritative nameservers (NS records) for each domain. - Queries **every** discovered authoritative nameserver independently. -- Stores the NS record set as observed by the delegation chain. +- Stores the NS record set as observed by the delegation chain, and the + IPv4 and IPv6 addresses each nameserver's name resolves to. - Any change triggers a notification: - NS added to or removed from the delegation. - - NS IP address changed (glue record change). + - NS address change: a nameserver that stays in the delegation + resolves to different addresses than on the previous check. A + nameserver added or removed gets only the NS change notification. + When the lookup of a nameserver's addresses fails or finds none, + its previous addresses are kept and nothing is sent. ### DNS Hostname Monitoring (Subdomains) @@ -139,6 +144,8 @@ includes: - **DNS record changes**: Which hostname, which nameserver, what record type, old values, new values. - **DNS NS changes**: Which domain, which nameservers were added/removed. +- **NS address changes**: Which domain, which nameserver, its old and + new addresses. - **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL, REFUSED, network error), which hostname/domain affected. - **NS recoveries**: Which nameserver recovered, which hostname/domain. @@ -418,6 +425,10 @@ not as a merged view, to enable inconsistency detection. "domains": { "example.com": { "nameservers": ["ns1.example.com.", "ns2.example.com."], + "nameserverAddresses": { + "ns1.example.com.": ["192.0.2.53", "2001:db8::53"], + "ns2.example.com.": ["198.51.100.53"] + }, "lastChecked": "2026-02-19T12:00:00Z" } }, @@ -481,6 +492,10 @@ A nameserver that answers NXDOMAIN or with no records has status `ok` and empty `records`. A nameserver whose query failed has status `error`, empty `records`, and the reason in `error`. +`nameserverAddresses` lists, by nameserver, the sorted addresses its name +resolves to. A state file without it loads, and the next check fills it in +without a notification. + --- ## Entrypoints diff --git a/TODO.md b/TODO.md index 20871f3..14cf343 100644 --- a/TODO.md +++ b/TODO.md @@ -15,10 +15,13 @@ on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Next Step -nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 +trial run of the finished image: +https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: each nameserver's addresses are saved with its domain, and a + change while it stays in the delegation is notified (closes #105). - 2026-10-01: the watcher saves state when it stops, and shutdown waits for that save, so it no longer relies on the state's own stop hook (closes #114). - 2026-10-01: `DNSWATCHER_SENTRY_DSN` reports panics in HTTP handlers to Sentry, @@ -105,8 +108,6 @@ nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 # Future Steps -- trial run of the finished image: - https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: https://git.eeqj.de/sneak/dnswatcher/issues/66 - `goimports` in `make fmt-check`, Markdown formatting: diff --git a/internal/state/state.go b/internal/state/state.go index 417a4d7..a8af3f9 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -35,9 +35,13 @@ type Params struct { } // DomainState holds the monitoring state for an apex domain. +// NameserverAddresses holds the sorted addresses each nameserver's name +// resolves to, by nameserver name. A state file written before it +// existed loads with it nil. type DomainState struct { - Nameservers []string `json:"nameservers"` - LastChecked time.Time `json:"lastChecked"` + Nameservers []string `json:"nameservers"` + NameserverAddresses map[string][]string `json:"nameserverAddresses"` + LastChecked time.Time `json:"lastChecked"` } // NameserverRecordState holds one NS's response for a hostname. diff --git a/internal/state/state_test.go b/internal/state/state_test.go index fabc142..16f709d 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "sync" "testing" @@ -37,6 +38,10 @@ func populateState(t *testing.T, s *state.State) { s.SetDomainState("example.com", &state.DomainState{ Nameservers: []string{testNS1, testNS2}, + NameserverAddresses: map[string][]string{ + testNS1: {testIP, testIPv4}, + testNS2: {testIPv4}, + }, LastChecked: now, }) @@ -123,6 +128,64 @@ func TestSaveLoadRoundTrip_Domains(t *testing.T) { if len(dom.Nameservers) != 2 { t.Errorf("expected 2 nameservers, got %d", len(dom.Nameservers)) } + + want := map[string][]string{ + testNS1: {testIP, testIPv4}, + testNS2: {testIPv4}, + } + if !reflect.DeepEqual(dom.NameserverAddresses, want) { + t.Errorf( + "nameserver addresses: got %v, want %v", + dom.NameserverAddresses, want, + ) + } +} + +// TestLoadStateFromBeforeNameserverAddresses loads a state file written +// before nameserver addresses were saved. +func TestLoadStateFromBeforeNameserverAddresses(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + data := []byte(`{ + "version": 1, + "lastUpdated": "2026-02-19T12:00:00Z", + "domains": { + "example.com": { + "nameservers": ["ns1.example.com.", "ns2.example.com."], + "lastChecked": "2026-02-19T12:00:00Z" + } + } + }`) + + err := os.WriteFile(filepath.Join(dir, "state.json"), data, 0o600) + if err != nil { + t.Fatalf("writing state file: %v", err) + } + + s := state.NewForTestWithDataDir(dir) + + err = s.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + dom, ok := s.GetDomainState("example.com") + if !ok { + t.Fatal("missing domain example.com") + } + + if !reflect.DeepEqual(dom.Nameservers, []string{testNS1, testNS2}) { + t.Errorf("nameservers: got %v", dom.Nameservers) + } + + if dom.NameserverAddresses != nil { + t.Errorf( + "nameserver addresses: got %v, want none", + dom.NameserverAddresses, + ) + } } // TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle. diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 5bebe13..772253f 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -48,6 +48,25 @@ func (w *Watcher) DetectHostnameChanges( w.detectHostnameChanges(ctx, hostname, prev, current) } +// ResolveNameserverAddresses exports resolveNameserverAddresses for +// testing. +func (w *Watcher) ResolveNameserverAddresses( + ctx context.Context, + nameservers []string, + prev map[string][]string, +) map[string][]string { + return w.resolveNameserverAddresses(ctx, nameservers, prev) +} + +// DetectNSAddressChanges exports detectNSAddressChanges for testing. +func (w *Watcher) DetectNSAddressChanges( + ctx context.Context, + domain string, + prev, current map[string][]string, +) { + w.detectNSAddressChanges(ctx, domain, prev, current) +} + // BuildHostnameState exports buildHostnameState for testing. func BuildHostnameState( results map[string]*resolver.NameserverResponse, diff --git a/internal/watcher/nsaddress_test.go b/internal/watcher/nsaddress_test.go new file mode 100644 index 0000000..6eaf3de --- /dev/null +++ b/internal/watcher/nsaddress_test.go @@ -0,0 +1,151 @@ +package watcher_test + +import ( + "context" + "log/slog" + "reflect" + "testing" + + "sneak.berlin/go/dnswatcher/internal/livednstest" + "sneak.berlin/go/dnswatcher/internal/resolver" + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +const domain = "example.net" + +func TestNSAddressChangeAlerts(t *testing.T) { + t.Parallel() + + // Each case is the nameserver addresses saved by the previous check + // and by the current one. + tests := []struct { + name string + prev, current map[string][]string + want int + }{ + { + "same addresses", + map[string][]string{nsA: {ip1, ip2}}, + map[string][]string{nsA: {ip1, ip2}}, + 0, + }, + { + "same addresses in another order", + map[string][]string{nsA: {ip2, ip1}}, + map[string][]string{nsA: {ip1, ip2}}, + 0, + }, + { + "address replaced", + map[string][]string{nsA: {ip1}}, + map[string][]string{nsA: {ip2}}, + 1, + }, + { + "address added", + map[string][]string{nsA: {ip1}}, + map[string][]string{nsA: {ip1, ip2}}, + 1, + }, + { + "two nameservers changed", + map[string][]string{nsA: {ip1}, nsB: {ip2}}, + map[string][]string{nsA: {ip3}, nsB: {ip3}}, + 2, + }, + { + "nameserver added", + map[string][]string{nsA: {ip1}}, + map[string][]string{nsA: {ip1}, nsB: {ip2}}, + 0, + }, + { + "nameserver removed", + map[string][]string{nsA: {ip1}, nsB: {ip2}}, + map[string][]string{nsA: {ip1}}, + 0, + }, + { + "state file from before addresses were saved", + nil, + map[string][]string{nsA: {ip1}, nsB: {ip2}}, + 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + w.DetectNSAddressChanges(t.Context(), domain, tt.prev, tt.current) + + got := len(notifier.getNotifications()) + if got != tt.want { + t.Errorf("sent %d address changes, want %d", got, tt.want) + } + }) + } +} + +func TestNSAddressChangeAlertNamesDomainNameserverAndAddresses( + t *testing.T, +) { + t.Parallel() + + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + w.DetectNSAddressChanges( + t.Context(), domain, + map[string][]string{nsA: {ip1}}, + map[string][]string{nsA: {ip2, ip3}}, + ) + + want := notification{ + Title: "NS Address Change: " + domain, + Message: "Domain: " + domain + "\nNameserver: " + nsA + + "\nOld: " + ip1 + "\nNew: " + ip2 + ", " + ip3, + Priority: "warning", + } + + got := notifier.getNotifications() + if len(got) != 1 || got[0] != want { + t.Errorf("sent %v, want %v", got, want) + } +} + +// TestNameserverWithNoAddressKeepsPrevious looks up nameserver names +// with no address: two under .invalid, whose lookup fails with an +// error, and one that does not exist under a real zone, which live DNS +// answers with no address and no error. Each one with addresses saved +// by the previous check keeps them; the one without gets none. +func TestNameserverWithNoAddressKeepsPrevious(t *testing.T) { + t.Parallel() + + w := watcher.NewForTest( + nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil, + ) + + nonexistentNS := "this-surely-does-not-exist-xyz." + testSmallDomain + "." + + prev := map[string][]string{oldNS1: {oldIP}, nonexistentNS: {oldIP}} + + var got map[string][]string + + // The result is the same whether or not live DNS answers, so the + // lookup is not retried. + _ = livednstest.Run(func(ctx context.Context) error { + got = w.ResolveNameserverAddresses( + ctx, []string{oldNS1, oldNS2, nonexistentNS}, prev, + ) + + return nil + }) + + if !reflect.DeepEqual(got, prev) { + t.Errorf("saved %v, want %v", got, prev) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 239351b..2a2c523 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -234,13 +234,25 @@ func (w *Watcher) checkDomain( now := time.Now().UTC() prev, hasPrev := w.state.GetDomainState(domain) + + var prevAddresses map[string][]string + if hasPrev { + prevAddresses = prev.NameserverAddresses + } + + addresses := w.resolveNameserverAddresses( + ctx, nameservers, prevAddresses, + ) + if hasPrev && !w.firstRun { w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers) + w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses) } w.state.SetDomainState(domain, &state.DomainState{ - Nameservers: nameservers, - LastChecked: now, + Nameservers: nameservers, + NameserverAddresses: addresses, + LastChecked: now, }) // Also look up A/AAAA records for the apex domain so that @@ -308,6 +320,73 @@ func (w *Watcher) detectNSChanges( ) } +// resolveNameserverAddresses returns the sorted addresses each +// nameserver's name resolves to. A nameserver whose lookup fails or +// finds no address keeps its addresses from prev: the resolver finds no +// address, without an error, when every server it asks times out, and +// that is not an address change. +func (w *Watcher) resolveNameserverAddresses( + ctx context.Context, + nameservers []string, + prev map[string][]string, +) map[string][]string { + addresses := make(map[string][]string, len(nameservers)) + + for _, ns := range nameservers { + ips, err := w.resolver.ResolveIPAddresses(ctx, ns) + if err == nil && len(ips) > 0 { + sort.Strings(ips) + addresses[ns] = ips + + continue + } + + w.log.Error( + "no addresses found for nameserver", + "nameserver", ns, + "error", err, + ) + + if prevIPs, ok := prev[ns]; ok { + addresses[ns] = prevIPs + } + } + + return addresses +} + +// detectNSAddressChanges notifies when a nameserver in both checks +// resolves to different addresses. A nameserver added or removed is +// reported by detectNSChanges alone, and one with no addresses saved by +// the previous check, as in a state file from before they were saved, +// is not compared. +func (w *Watcher) detectNSAddressChanges( + ctx context.Context, + domain string, + prev, current map[string][]string, +) { + for ns, cur := range current { + old, ok := prev[ns] + if !ok || sliceEqual(old, cur) { + continue + } + + msg := fmt.Sprintf( + "Domain: %s\nNameserver: %s\nOld: %s\nNew: %s", + domain, ns, + strings.Join(old, ", "), + strings.Join(cur, ", "), + ) + + w.notify.SendNotification( + ctx, + "NS Address Change: "+domain, + msg, + "warning", + ) + } +} + func (w *Watcher) checkHostname( ctx context.Context, hostname string, diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index c95a550..c166a3e 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "slices" + "strings" "sync" "testing" "time" @@ -27,11 +28,17 @@ import ( // so tests assert on what the watcher does with the answers, never on // the records these zones publish. testHost's nameservers and addresses // stay the same from one check to the next, which the tests that check -// it twice rely on. +// it twice rely on, and testSmallDomain's nameservers stay the same +// between a test looking them up and its check. A domain check looks up +// each nameserver's addresses, about a second per nameserver, so the +// tests that check a domain use testSmallDomain, which has two +// nameservers, and check it once. The tests that query testDomain's +// nameservers directly do no domain check. const ( - testDomain = "google.com" - testHost = "cloudflare.com" - testIssuer = "DigiCert" + testDomain = "google.com" + testSmallDomain = "example.com" + testHost = "cloudflare.com" + testIssuer = "DigiCert" ) // Saved-state values that live DNS never returns: nameserver names @@ -206,8 +213,10 @@ func defaultTestConfig(t *testing.T) *config.Config { // checkOnce runs the watcher's checks once and returns an error when a // configured name has no hostname state saved by this check, or that -// state holds no address. Either live DNS gave no answer for the name, -// or the watcher saved no fresh result for it. +// state holds no address, or a configured domain's nameserver has no +// address saved or still has oldIP, which the tests save and live DNS +// never returns. Either live DNS gave no answer for the name, or the +// watcher saved no fresh result for it. func checkOnce( ctx context.Context, w *watcher.Watcher, @@ -231,6 +240,20 @@ func checkOnce( } } + for _, name := range deps.config.Domains { + ds, _ := deps.state.GetDomainState(name) + for _, ns := range ds.Nameservers { + ips := ds.NameserverAddresses[ns] + if len(ips) == 0 || slices.Contains(ips, oldIP) { + return fmt.Errorf( + "%s: nameserver %s: %w, or the watcher saved "+ + "no fresh addresses for it", + name, ns, livednstest.ErrNoAnswer, + ) + } + } + } + return nil } @@ -272,6 +295,26 @@ func runChecks( return deps } +// lookupNameservers returns the nameservers live DNS lists for domain, +// for a test to save in the state its check starts from. +func lookupNameservers(t *testing.T, domain string) []string { + t.Helper() + + res := resolver.NewFromLogger(slog.Default()) + + var nameservers []string + + livednstest.Retry(t, "LookupNS("+domain+")", func(ctx context.Context) error { + var err error + + nameservers, err = res.LookupNS(ctx, domain) + + return err + }) + + return nameservers +} + // addresses returns the A and AAAA values saved for a hostname. func addresses(hs *state.HostnameState) []string { var ips []string @@ -324,7 +367,7 @@ func TestFirstRunBaseline(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{testDomain} + cfg.Domains = []string{testSmallDomain} cfg.Hostnames = []string{testHost} deps := runChecks(t, cfg, nil, nil) @@ -377,7 +420,7 @@ func TestDomainPortAndTLSChecks(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{testDomain} + cfg.Domains = []string{testSmallDomain} deps := runChecks(t, cfg, nil, nil) @@ -416,23 +459,106 @@ func TestNSChangeDetection(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{testDomain} + cfg.Domains = []string{testSmallDomain} // The saved state lists nameservers that live DNS does not. deps := runChecks(t, cfg, func(deps *testDeps) { - deps.state.SetDomainState(testDomain, &state.DomainState{ + deps.state.SetDomainState(testSmallDomain, &state.DomainState{ Nameservers: []string{oldNS1, oldNS2}, }) }, nil) - assertNotified(t, deps, "NS Change: "+testDomain, "warning") + assertNotified(t, deps, "NS Change: "+testSmallDomain, "warning") - ds, _ := deps.state.GetDomainState(testDomain) + ds, _ := deps.state.GetDomainState(testSmallDomain) if slices.Contains(ds.Nameservers, oldNS1) { t.Errorf("saved nameservers not updated: %v", ds.Nameservers) } } +func TestNSAddressChangeDetection(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{testSmallDomain} + + nameservers := lookupNameservers(t, testSmallDomain) + + // The saved state lists the nameservers live DNS lists, each at an + // address live DNS never returns. + deps := runChecks(t, cfg, func(deps *testDeps) { + nsAddresses := make(map[string][]string, len(nameservers)) + for _, ns := range nameservers { + nsAddresses[ns] = []string{oldIP} + } + + deps.state.SetDomainState(testSmallDomain, &state.DomainState{ + Nameservers: nameservers, + NameserverAddresses: nsAddresses, + }) + }, nil) + + title := "NS Address Change: " + testSmallDomain + ds, _ := deps.state.GetDomainState(testSmallDomain) + + // One alert per nameserver, naming it and the address it had. + for _, ns := range ds.Nameservers { + prefix := "Domain: " + testSmallDomain + "\nNameserver: " + ns + + "\nOld: " + oldIP + "\nNew: " + + sent := 0 + + for _, n := range deps.notifier.getNotifications() { + if n.Title == title && strings.HasPrefix(n.Message, prefix) { + sent++ + } + } + + if sent != 1 { + t.Errorf("sent %d address changes for %s, want 1", sent, ns) + } + } + + if n := countNotifications(deps, title); n != len(ds.Nameservers) { + t.Errorf( + "sent %d address changes for %d nameservers", + n, len(ds.Nameservers), + ) + } + + if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 0 { + t.Errorf("sent %d NS changes, want 0", n) + } +} + +func TestNSAddedAndRemovedIsNoAddressChange(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{testSmallDomain} + + nameservers := lookupNameservers(t, testSmallDomain) + + // The saved state lists oldNS1, which live DNS does not, in place of + // the first nameserver live DNS lists, so that the check finds that + // one added and oldNS1 removed. Only oldNS1 has addresses saved. + deps := runChecks(t, cfg, func(deps *testDeps) { + deps.state.SetDomainState(testSmallDomain, &state.DomainState{ + Nameservers: append([]string{oldNS1}, nameservers[1:]...), + NameserverAddresses: map[string][]string{oldNS1: {oldIP}}, + }) + }, nil) + + if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 1 { + t.Errorf("sent %d NS changes, want 1", n) + } + + title := "NS Address Change: " + testSmallDomain + if n := countNotifications(deps, title); n != 0 { + t.Errorf("sent %d address changes, want 0", n) + } +} + func TestRecordChangeDetection(t *testing.T) { t.Parallel() -- 2.54.0 From 797c936c48744e44aa06a2990c85c1a46bcf134b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 23:34:35 +0200 Subject: [PATCH 36/92] resolver: query a hostname at its own zone's servers (closes #189) A hostname's nameservers came from its last two labels, so a name under co.uk was asked at the co.uk servers and a name in a delegated subdomain at the parent's servers; both only refer onward. The hostname now goes through FindAuthoritativeNameservers, which follows delegations for the name and walks up its labels until it finds the zone it is in. followDelegation now stops at an authoritative reply: that server holds the zone, so its reply is not a referral. Without this, a CNAME answer that also lists the zone's NS records in its authority section, as many servers send, was followed as a referral until the delegation limit. Model: opus-5-5 --- README.md | 4 +- TODO.md | 2 + internal/resolver/iterative.go | 36 ++++++------- internal/resolver/resolver_test.go | 84 +++++++++++++++++++----------- 4 files changed, 73 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 78c14c6..058cbe3 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,9 @@ rejected. - Accepts a list of DNS hostnames (subdomains, distinguished from apex domains via the Public Suffix List). - Every **1 hour**, performs a full iterative trace to discover the - authoritative nameservers for the hostname's parent domain. + authoritative nameservers of the zone the hostname is in, which is not + always its last two labels (a name under `co.uk`, or in a delegated + subdomain). - Queries **each** authoritative nameserver independently for **all** record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. - Stores results **per nameserver**. The state for a hostname is not a diff --git a/TODO.md b/TODO.md index 14cf343..928baeb 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: a hostname is queried at the servers of the zone it is in, found + by following delegations for the name, not its last two labels (closes #189). - 2026-10-01: each nameserver's addresses are saved with its domain, and a change while it stays in the delegation is notified (closes #105). - 2026-10-01: the watcher saves state when it stops, and shutdown waits for that diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 1e0b494..93f6b9b 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -17,7 +17,6 @@ const ( maxRetries = 2 maxDelegation = 20 timeoutMultiplier = 2 - minDomainLabels = 2 ) // ErrRefused is returned when a DNS server refuses a query. @@ -225,6 +224,15 @@ func (r *Resolver) followDelegation( return ansNS, nil } + // An authoritative reply comes from the servers of the zone + // domain is in; it is not a referral, even when its authority + // section lists that zone's NS records. Without NS records in + // the answer, domain is not the zone's apex and has no + // nameservers of its own. + if resp.Authoritative { + return nil, ErrNoNameservers + } + authNS := extractNSSet(resp.Ns) if len(authNS) == 0 { return r.resolveNSIterative(ctx, domain) @@ -407,7 +415,9 @@ func (r *Resolver) resolveARecord( // FindAuthoritativeNameservers traces the delegation chain from // root servers to discover all authoritative nameservers for the -// given domain. Walks up the label hierarchy for subdomains. +// given domain. For a name that is not a zone apex it tries each +// parent name in turn, so it returns the nameservers of the zone the +// name is in. func (r *Resolver) FindAuthoritativeNameservers( ctx context.Context, domain string, @@ -653,22 +663,8 @@ func extractRecordValue(rr dns.RR) string { } } -// parentDomain returns the registerable parent domain. -func parentDomain(hostname string) string { - hostname = dns.Fqdn(strings.ToLower(hostname)) - labels := dns.SplitDomainName(hostname) - - if len(labels) <= minDomainLabels { - return strings.Join(labels, ".") + "." - } - - return strings.Join( - labels[len(labels)-minDomainLabels:], ".", - ) + "." -} - -// QueryAllNameservers discovers auth NSes for the hostname's -// parent domain, then queries each one independently. +// QueryAllNameservers discovers the auth NSes of the zone the +// hostname is in, then queries each one independently. func (r *Resolver) QueryAllNameservers( ctx context.Context, hostname string, @@ -677,9 +673,7 @@ func (r *Resolver) QueryAllNameservers( return nil, ErrContextCanceled } - parent := parentDomain(hostname) - - nameservers, err := r.FindAuthoritativeNameservers(ctx, parent) + nameservers, err := r.FindAuthoritativeNameservers(ctx, hostname) if err != nil { return nil, err } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 2946519..15a18c5 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -79,9 +79,10 @@ func TestFindAuthoritativeNameservers_Subdomain( t.Parallel() r := newTestResolver(t) - nameservers := liveFindAuthoritative(t, r, "www.google.com") + fromHost := liveFindAuthoritative(t, r, "www.google.com") + fromZone := liveFindAuthoritative(t, r, "google.com") - assert.NotEmpty(t, nameservers) + assert.Equal(t, fromZone, fromHost) } func TestFindAuthoritativeNameservers_ReturnsSorted( @@ -350,37 +351,58 @@ func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) { func TestQueryAllNameservers_AllReturnOK(t *testing.T) { t.Parallel() - r := newTestResolver(t) - results := liveQueryAllNameservers(t, r, "google.com") + // The last two names are in zones other than their last two + // labels: google.co.uk, under the two-label suffix co.uk, and + // compute-1.amazonaws.com, which amazonaws.com delegates to other + // servers and which has a host name for each of its addresses. + // Servers above a name's zone only refer onward, which gives + // nodata, so ok shows the name was asked at its own zone's + // servers. + hostnames := []string{ + "google.com", + "www.google.co.uk", + "ec2-3-80-0-1.compute-1.amazonaws.com", + } - // A quorum, not unanimity: one authoritative server being - // slow or rate-limiting us is a property of the live - // internet, not a resolver defect. - assert.GreaterOrEqual( - t, - countStatus(results, resolver.StatusOK), - liveQuorum(len(results)), - "a quorum of nameservers should answer OK: %s", - describeStatuses(results), - ) + for _, hostname := range hostnames { + t.Run(hostname, func(t *testing.T) { + t.Parallel() - // Quorum tolerates SILENCE only. Every individual result must - // be either the expected answer or a non-answer: ok, timeout - // or error, and nothing else. Stated as a closed allowlist so - // that a wrong answer no one thought to ban — nxdomain and - // nodata today, any status added later — fails here rather - // than sliding through under the quorum. - assert.Empty( - t, - unsanctionedStatuses( - results, - resolver.StatusOK, - resolver.StatusTimeout, - resolver.StatusError, - ), - "every nameserver must answer OK or not answer at all: %s", - describeStatuses(results), - ) + r := newTestResolver(t) + results := liveQueryAllNameservers(t, r, hostname) + + // A quorum, not unanimity: one authoritative server + // being slow or rate-limiting us is a property of the + // live internet, not a resolver defect. + assert.GreaterOrEqual( + t, + countStatus(results, resolver.StatusOK), + liveQuorum(len(results)), + "a quorum of nameservers should answer OK: %s", + describeStatuses(results), + ) + + // Quorum tolerates SILENCE only. Every individual + // result must be either the expected answer or a + // non-answer: ok, timeout or error, and nothing else. + // Stated as a closed allowlist so that a wrong answer + // no one thought to ban — nxdomain and nodata today, + // any status added later — fails here rather than + // sliding through under the quorum. + assert.Empty( + t, + unsanctionedStatuses( + results, + resolver.StatusOK, + resolver.StatusTimeout, + resolver.StatusError, + ), + "every nameserver must answer OK or not answer "+ + "at all: %s", + describeStatuses(results), + ) + }) + } } func TestQueryAllNameservers_NXDomainFromAllNS( -- 2.54.0 From b5814b2451684a9d19867828d28ef7c1a7ba31e3 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 23:51:22 +0200 Subject: [PATCH 37/92] fmt: check goimports in fmt-check, run it at its pinned commit (#119) script/fmt-check now runs goimports in list mode and fails naming any file it would change, and checks gofmt with -s, as script/fmt applies it. Both scripts run goimports with `go run` at the commit that script/bootstrap used to install, so a goimports on PATH is never used and bootstrap no longer installs it. The pin is written in both scripts; change them together. The first run on a machine, and every Dockerfile lint stage run, downloads and builds goimports. The Markdown half of the issue (prettier) is not done here: it needs node in the lint image or a separate build, a decision for the owner. Model: opus-5-5 --- README.md | 11 ++++++----- TODO.md | 4 +++- script/bootstrap | 13 ++----------- script/fmt | 8 +++++++- script/fmt-check | 15 ++++++++++++--- 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 058cbe3..4e8c13d 100644 --- a/README.md +++ b/README.md @@ -508,9 +508,8 @@ standard: normalized scripts in `script/` are the entrypoints for the development workflow, and the Makefile targets are thin shims that call them. We provide: -- `script/bootstrap` — install all dependencies (go, pinned goimports, - `go mod download`). It does not install golangci-lint: see - `script/lint` below. +- `script/bootstrap` — install all dependencies (go, `go mod download`). + It does not install golangci-lint: see `script/lint` below. - `script/setup` — make a fresh clone ready for development: bootstrap plus the git pre-commit hook - `script/projectname` — print the project name (used for the Docker @@ -527,8 +526,10 @@ them. We provide: host, and Docker is the only prerequisite. Caching is waived for linting: the lint stage is forced to execute on every run with `--no-cache-filter`, because a cached build lints nothing. -- `script/fmt` — format all code (gofmt -s, goimports) -- `script/fmt-check` — check formatting (read-only) +- `script/fmt` — format all code (gofmt -s, goimports). goimports runs + with `go run` at a pinned commit, never from your `PATH`. +- `script/fmt-check` — check formatting (read-only) with the same tools, + failing on any file `script/fmt` would change - `script/check` — run test, lint, and fmt-check - `script/docker` — build the Docker image tagged via `script/projectname`, with `--no-cache-filter=lint,builder` so the lint stage and the builder stage, diff --git a/TODO.md b/TODO.md index 928baeb..897bda6 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: `make fmt-check` fails on a file `goimports` would change; both + format scripts run `goimports` at its pinned commit, not from `PATH` (#119). - 2026-10-01: a hostname is queried at the servers of the zone it is in, found by following delegations for the name, not its last two labels (closes #189). - 2026-10-01: each nameserver's addresses are saved with its domain, and a @@ -112,7 +114,7 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: https://git.eeqj.de/sneak/dnswatcher/issues/66 -- `goimports` in `make fmt-check`, Markdown formatting: +- Markdown formatting with prettier: https://git.eeqj.de/sneak/dnswatcher/issues/119 - README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 - README sections required by policy: diff --git a/script/bootstrap b/script/bootstrap index 06da5d0..a463d0a 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,9 +3,8 @@ # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes nothing is present. -# goimports is installed via `go install` at a pinned commit (never -# "latest") because script/fmt runs it on the host; script/fmt-check -# does not (it runs gofmt only). +# goimports is not installed here: script/fmt and script/fmt-check run +# it with `go run` at a pinned commit. # The linter is NOT installed here: golangci-lint runs via docker only # (script/lint), pinned by image digest, so its only prerequisite is a # working docker. @@ -13,10 +12,6 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# Pinned version, 2026-08-07 (same pin as the Dockerfile) -# goimports v0.42.0 -GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" - PKGMGR="" SUDO="" APT_UPDATED="" @@ -71,10 +66,6 @@ main() { if missing make; then pkg_install gnumake make make make; fi if missing go; then pkg_install go golang go go; fi - # Format tools, pinned via go install (installs into - # "$(go env GOPATH)/bin"; ensure that is on your PATH). - if missing goimports; then go install "$GOIMPORTS_REF"; fi - # Linting runs via docker only (script/lint). Warn, don't fail: # everything except `make lint` works without it. if missing docker; then diff --git a/script/fmt b/script/fmt index b3235a8..a0b28e6 100755 --- a/script/fmt +++ b/script/fmt @@ -1,13 +1,19 @@ #!/bin/sh # script/fmt: format all files (writes). +# +# goimports runs with `go run` at a pinned commit, never from PATH, so +# every machine formats with the same version and nothing installs it. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# goimports v0.42.0, 2026-08-07. Must match script/fmt-check. +GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" + main() { cd "$ROOT" gofmt -s -w . - goimports -w . + go run "$GOIMPORTS_REF" -w . } main "$@" diff --git a/script/fmt-check b/script/fmt-check index b7438ef..40911dd 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -1,18 +1,27 @@ #!/bin/sh -# script/fmt-check: check formatting (read-only). Same scope as -# script/fmt, but fails instead of writing. +# script/fmt-check: check formatting (read-only). Same tools and scope +# as script/fmt, but fails instead of writing. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# goimports v0.42.0, 2026-08-07. Must match script/fmt. +GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" + main() { cd "$ROOT" - files="$(gofmt -l .)" + files="$(gofmt -s -l .)" if [ -n "$files" ]; then echo "gofmt: files not formatted:" >&2 echo "$files" >&2 exit 1 fi + files="$(go run "$GOIMPORTS_REF" -l .)" + if [ -n "$files" ]; then + echo "goimports: files not formatted:" >&2 + echo "$files" >&2 + exit 1 + fi } main "$@" -- 2.54.0 From 4c2932d6d67a553e46f0435b9953487efa9d4bbb Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 00:19:30 +0200 Subject: [PATCH 38/92] fmt: format and check Markdown with prettier in a container (closes #119) make fmt and make fmt-check now cover every Markdown file with prettier (4-space tabs, proseWrap always), as template-app-go does: prettier is pinned by package.json and yarn.lock and runs in a docker build on a digest-pinned node image, never on the host. The check is forced to run with --no-cache-filter, as script/lint is. script/fmt-check is split into a Go half and a Markdown half because the Dockerfile lint stage cannot run docker: that stage now runs the Go half and script/cibuild runs the Markdown half after the build. *.md leaves .dockerignore so documents reach the build context. README.md, TESTING.md and TODO.md are reformatted by make fmt; apart from the README Entrypoints entries, its make fmt line under Building and the TODO entry, that diff is mechanical. Model: opus-5-5 --- .dockerignore | 5 +- .gitignore | 1 + .prettierignore | 5 + .prettierrc | 4 + Dockerfile | 6 +- Dockerfile.fmt | 55 +++ README.md | 692 +++++++++++++++++++------------------- TESTING.md | 53 ++- TODO.md | 21 +- package.json | 9 + script/bootstrap | 14 +- script/cibuild | 13 +- script/fmt | 50 ++- script/fmt-check | 23 +- script/fmt-check-go | 31 ++ script/fmt-check-markdown | 29 ++ yarn.lock | 8 + 17 files changed, 602 insertions(+), 417 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 Dockerfile.fmt create mode 100644 package.json create mode 100755 script/fmt-check-go create mode 100755 script/fmt-check-markdown create mode 100644 yarn.lock diff --git a/.dockerignore b/.dockerignore index bae18ae..0e93e56 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,9 @@ .git/ bin/ -*.md +node_modules/ +# No .md may be excluded: Dockerfile.fmt checks every document with +# prettier, and an exclusion here would drop a file from that check while +# prettier still reports every file it was handed clean. LICENSE .editorconfig .gitignore diff --git a/.gitignore b/.gitignore index 9c22cd8..d0077e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin/ +node_modules/ vendor/ data/ .env diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c8d365b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +bin/ +data/ +node_modules/ +.claude/ +static/css/tailwind.min.css diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/Dockerfile b/Dockerfile index a76cbb6..c289c07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ # Lint stage - fast feedback on lint issues, before the build starts. # The linter is invoked directly rather than through `make lint`: that # target shells out to `docker build -f Dockerfile.lint`, and there is -# no docker daemon inside a docker build. +# no docker daemon inside a docker build. For the same reason this stage +# runs only the Go half of `make fmt-check`; script/cibuild runs the +# markdown half after this build. # script/cibuild and script/docker name this stage in --no-cache-filter. # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10 FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint @@ -12,7 +14,7 @@ RUN go mod download COPY . . -RUN make fmt-check +RUN script/fmt-check-go RUN golangci-lint run --config .golangci.yml ./... # Build stage diff --git a/Dockerfile.fmt b/Dockerfile.fmt new file mode 100644 index 0000000..be7d0ba --- /dev/null +++ b/Dockerfile.fmt @@ -0,0 +1,55 @@ +# prettier over the markdown, in a container, so it is never installed +# on the host. script/fmt-check-markdown builds the fmt-check stage; +# script/fmt builds fmt-out and takes the formatted files back. + +# node:22-bookworm-slim, 2026-09-05 +FROM node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 AS nodedeps + +# prettier lives outside /src so that a `COPY . .` of the repo cannot +# overwrite it, and so that node_modules never appears in the tree +# prettier is about to walk. +WORKDIR /tools + +# package.json pins the version and yarn.lock pins the bytes: +# --frozen-lockfile installs exactly the lockfile's resolution and fails +# if package.json disagrees with it, so the tool cannot float between +# runs. yarn is the one in the image above. +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --non-interactive --no-progress + +ENV PATH="/tools/node_modules/.bin:${PATH}" + +WORKDIR /src + +# Read-only markdown check. Must match $stage in +# script/fmt-check-markdown. +FROM nodedeps AS fmt-check + +COPY . . + +# --config, not discovery: a .prettierrc that failed to arrive would +# otherwise leave prettier on its defaults, where proseWrap is "preserve" +# and every wrap this check exists to enforce passes. Missing the file is +# a hard error instead. --no-editorconfig for the same reason in reverse: +# .editorconfig is not in the build context, so honouring it here and on +# a developer's machine would be two different answers. +RUN prettier --config .prettierrc --no-editorconfig --check "**/*.md" + +# Write path. Not a check: script/fmt builds this and takes the files. +FROM nodedeps AS fmt + +COPY . . + +RUN prettier --config .prettierrc --no-editorconfig --write "**/*.md" + +# Only the markdown leaves, with its paths intact, so that the export +# below cannot put anything else back over the caller's working tree. +RUN mkdir -p /out && cd /src && \ + find . -name '*.md' -type f -exec cp --parents '{}' /out/ ';' + +# Export target: `docker build --target fmt-out --output type=local` +# writes /out's tree into a directory on the client, which is how +# script/fmt gets formatted markdown back without a bind mount. +# Must match $stage in script/fmt. +FROM scratch AS fmt-out +COPY --from=fmt /out/ / diff --git a/README.md b/README.md index 4e8c13d..c5461fb 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,20 @@ # dnswatcher -dnswatcher is an MIT-licensed, pre-1.0 Go daemon by [@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, and TLS certificates, delivering real-time change notifications via Slack, Mattermost, and ntfy webhooks. +dnswatcher is an MIT-licensed, pre-1.0 Go daemon by +[@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, +and TLS certificates, delivering real-time change notifications via Slack, +Mattermost, and ntfy webhooks. -> ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice. +> ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without +> notice. -dnswatcher watches configured DNS domains and hostnames for changes, monitors TCP -port availability, tracks TLS certificate expiry, and delivers real-time +dnswatcher watches configured DNS domains and hostnames for changes, monitors +TCP port availability, tracks TLS certificate expiry, and delivers real-time notifications via Slack, Mattermost, and/or ntfy webhooks. It performs all DNS resolution itself via iterative (non-recursive) queries, -tracing from root nameservers to authoritative servers directly—never relying -on upstream recursive resolvers. +tracing from root nameservers to authoritative servers directly—never relying on +upstream recursive resolvers. State is persisted to a local JSON file so that monitoring survives restarts without requiring an external database. @@ -19,21 +23,20 @@ without requiring an external database. ## No DNS mocking. Ever. -**DNS is never mocked in this project — not in tests, not anywhere else.** -No mock resolvers, no fake DNS servers, no stubbed lookups. +**DNS is never mocked in this project — not in tests, not anywhere else.** No +mock resolvers, no fake DNS servers, no stubbed lookups. -dnswatcher's entire purpose is correct behavior against the real DNS. -Tests exercise real iterative resolution against live nameservers by -design; a test suite that passes against a mock proves nothing about the -one thing this program exists to do. +dnswatcher's entire purpose is correct behavior against the real DNS. Tests +exercise real iterative resolution against live nameservers by design; a test +suite that passes against a mock proves nothing about the one thing this program +exists to do. -When live tests are flaky, that is a robustness problem, and it gets -fixed with robustness: retries with backoff, querying multiple -independent nameservers, longer timeouts — or explicit opt-in gating -decided by the project owner. Never with mocks. +When live tests are flaky, that is a robustness problem, and it gets fixed with +robustness: retries with backoff, querying multiple independent nameservers, +longer timeouts — or explicit opt-in gating decided by the project owner. Never +with mocks. -Contributions that introduce mocked, faked, or stubbed DNS will be -rejected. +Contributions that introduce mocked, faked, or stubbed DNS will be rejected. --- @@ -46,126 +49,123 @@ rejected. - Every **1 hour**, performs a full iterative trace from root servers to discover all authoritative nameservers (NS records) for each domain. - Queries **every** discovered authoritative nameserver independently. -- Stores the NS record set as observed by the delegation chain, and the - IPv4 and IPv6 addresses each nameserver's name resolves to. +- Stores the NS record set as observed by the delegation chain, and the IPv4 and + IPv6 addresses each nameserver's name resolves to. - Any change triggers a notification: - - NS added to or removed from the delegation. - - NS address change: a nameserver that stays in the delegation - resolves to different addresses than on the previous check. A - nameserver added or removed gets only the NS change notification. - When the lookup of a nameserver's addresses fails or finds none, - its previous addresses are kept and nothing is sent. + - NS added to or removed from the delegation. + - NS address change: a nameserver that stays in the delegation resolves to + different addresses than on the previous check. A nameserver added or + removed gets only the NS change notification. When the lookup of a + nameserver's addresses fails or finds none, its previous addresses are + kept and nothing is sent. ### DNS Hostname Monitoring (Subdomains) -- Accepts a list of DNS hostnames (subdomains, distinguished from apex - domains via the Public Suffix List). +- Accepts a list of DNS hostnames (subdomains, distinguished from apex domains + via the Public Suffix List). - Every **1 hour**, performs a full iterative trace to discover the - authoritative nameservers of the zone the hostname is in, which is not - always its last two labels (a name under `co.uk`, or in a delegated - subdomain). -- Queries **each** authoritative nameserver independently for **all** - record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. -- Stores results **per nameserver**. The state for a hostname is not a - merged view — it is a map from nameserver to record set. -- DNS names inside record values (CNAME, MX, SRV and NS targets) are - stored in lower case, because names are case-insensitive and - nameservers may answer in any letter case. TXT and CAA values keep - their letter case; they are not lower-cased. -- Any observable change in any nameserver's response triggers a - notification. This includes: - - **Record change**: A nameserver returns different records than it - did on the previous check (additions, removals, value changes). - - **NS query failure**: A nameserver that previously responded - becomes unreachable (timeout, SERVFAIL, REFUSED, network error). - This is distinct from "responded with no records": a nameserver - that answers NXDOMAIN or with no records has responded. The alert - is sent once, on the check where it starts failing. A failing - nameserver gives no records, so it is not reported as a record - change or compared for inconsistency. A nameserver that is already - failing on the first check that sees it is recorded silently. - - **NS recovery**: A previously-unreachable nameserver starts - responding again. Its records are not compared with those from - before it failed, so a change made while it was failing is not - reported as a record change. - - **Inconsistency detected**: Two nameservers return different record - sets for the same hostname and did not already differ on the previous - check. Every pair of nameservers is compared. The alert is sent once - for each such pair, on the check where they start to disagree, and not - again while they keep disagreeing, including after a restart. A - nameserver that was not in the previous check (newly added, or back - after dropping out), or failed on it, and answers differently is - reported on the check where it answers. If a pair agrees again and - later disagrees, the alert is sent again. + authoritative nameservers of the zone the hostname is in, which is not always + its last two labels (a name under `co.uk`, or in a delegated subdomain). +- Queries **each** authoritative nameserver independently for **all** record + types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. +- Stores results **per nameserver**. The state for a hostname is not a merged + view — it is a map from nameserver to record set. +- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in + lower case, because names are case-insensitive and nameservers may answer in + any letter case. TXT and CAA values keep their letter case; they are not + lower-cased. +- Any observable change in any nameserver's response triggers a notification. + This includes: + - **Record change**: A nameserver returns different records than it did on + the previous check (additions, removals, value changes). + - **NS query failure**: A nameserver that previously responded becomes + unreachable (timeout, SERVFAIL, REFUSED, network error). This is distinct + from "responded with no records": a nameserver that answers NXDOMAIN or + with no records has responded. The alert is sent once, on the check where + it starts failing. A failing nameserver gives no records, so it is not + reported as a record change or compared for inconsistency. A nameserver + that is already failing on the first check that sees it is recorded + silently. + - **NS recovery**: A previously-unreachable nameserver starts responding + again. Its records are not compared with those from before it failed, so a + change made while it was failing is not reported as a record change. + - **Inconsistency detected**: Two nameservers return different record sets + for the same hostname and did not already differ on the previous check. + Every pair of nameservers is compared. The alert is sent once for each + such pair, on the check where they start to disagree, and not again while + they keep disagreeing, including after a restart. A nameserver that was + not in the previous check (newly added, or back after dropping out), or + failed on it, and answers differently is reported on the check where it + answers. If a pair agrees again and later disagrees, the alert is sent + again. ### TCP Port Monitoring -- For every configured domain and hostname, constructs a deduplicated list - of all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain - resolution across all authoritative nameservers. +- For every configured domain and hostname, constructs a deduplicated list of + all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain resolution + across all authoritative nameservers. - Checks TCP connectivity on ports **80** and **443** for each IP address. - Every **1 hour**, re-checks all ports. - Any change in port availability triggers a notification: - - Port transitioned from open to closed (or vice versa). - - New IP appeared (from DNS change) and its port state was recorded. - - IP disappeared (from DNS change) — noted in the DNS change - notification; port state for that IP is removed. + - Port transitioned from open to closed (or vice versa). + - New IP appeared (from DNS change) and its port state was recorded. + - IP disappeared (from DNS change) — noted in the DNS change notification; + port state for that IP is removed. ### TLS Certificate Monitoring -- Every **12 hours**, for each IP address listening on port 443, connects - via TLS using the correct SNI hostname. +- Every **12 hours**, for each IP address listening on port 443, connects via + TLS using the correct SNI hostname. - Records the certificate's Subject CN, SANs, issuer, and expiry date. - Any change triggers a notification: - - Certificate is expiring within **7 days** (warning, repeated each - check until renewed or expired). - - Certificate CN, issuer, or SANs changed (replacement detected, - reports old and new values). - - TLS connection failure to a previously-reachable IP:443 (handshake - error, timeout, connection refused after previously succeeding). - - TLS recovery: a previously-failing IP:443 now completes a - handshake again. + - Certificate is expiring within **7 days** (warning, repeated each check + until renewed or expired). + - Certificate CN, issuer, or SANs changed (replacement detected, reports old + and new values). + - TLS connection failure to a previously-reachable IP:443 (handshake error, + timeout, connection refused after previously succeeding). + - TLS recovery: a previously-failing IP:443 now completes a handshake again. ### Notifications **Every observable state change produces a notification.** dnswatcher is -designed as a real-time change feed — degradations, failures, recoveries, -and routine changes are all reported equally. +designed as a real-time change feed — degradations, failures, recoveries, and +routine changes are all reported equally. Supported notification backends: -| Backend | Configuration | Payload Format | -|----------------|--------------------------|------------------------------| -| **Slack** | Incoming Webhook URL | Attachments with color | -| **Mattermost** | Incoming Webhook URL | Slack-compatible attachments | -| **ntfy** | Topic URL (e.g. `https://ntfy.sh/mytopic`) | Title + body + priority | +| Backend | Configuration | Payload Format | +| -------------- | ------------------------------------------ | ---------------------------- | +| **Slack** | Incoming Webhook URL | Attachments with color | +| **Mattermost** | Incoming Webhook URL | Slack-compatible attachments | +| **ntfy** | Topic URL (e.g. `https://ntfy.sh/mytopic`) | Title + body + priority | All configured endpoints receive every notification. Notification content includes: -- **DNS record changes**: Which hostname, which nameserver, what record - type, old values, new values. +- **DNS record changes**: Which hostname, which nameserver, what record type, + old values, new values. - **DNS NS changes**: Which domain, which nameservers were added/removed. -- **NS address changes**: Which domain, which nameserver, its old and - new addresses. -- **NS query failures**: Which nameserver failed, error type (timeout, - SERVFAIL, REFUSED, network error), which hostname/domain affected. +- **NS address changes**: Which domain, which nameserver, its old and new + addresses. +- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL, + REFUSED, network error), which hostname/domain affected. - **NS recoveries**: Which nameserver recovered, which hostname/domain. -- **NS inconsistencies**: Which nameservers disagree, what each one - returned, which hostname affected. +- **NS inconsistencies**: Which nameservers disagree, what each one returned, + which hostname affected. - **Port changes**: Which IP:port, old state, new state, all associated hostnames. -- **TLS expiry warnings**: Which certificate, days remaining, CN, - issuer, associated hostname and IP. -- **TLS certificate changes**: Old and new CN/issuer/SANs, associated - hostname and IP. +- **TLS expiry warnings**: Which certificate, days remaining, CN, issuer, + associated hostname and IP. +- **TLS certificate changes**: Old and new CN/issuer/SANs, associated hostname + and IP. - **TLS connection failures/recoveries**: Which IP:port, error details, associated hostname. ### State Management -- All monitoring state is kept in memory and persisted to a JSON file on - disk (`DATA_DIR/state.json`). +- All monitoring state is kept in memory and persisted to a JSON file on disk + (`DATA_DIR/state.json`). - State is loaded on startup to resume monitoring without triggering false-positive change notifications. - State is written atomically (write to temp file, then rename) to prevent @@ -173,41 +173,39 @@ includes: ### Web Dashboard -dnswatcher includes an unauthenticated, read-only web dashboard at the -root URL (`/`). It displays: +dnswatcher includes an unauthenticated, read-only web dashboard at the root URL +(`/`). It displays: -- **Summary counts** for monitored domains, hostnames, ports, and - certificates. +- **Summary counts** for monitored domains, hostnames, ports, and certificates. - **Domains** with their discovered nameservers. - **Hostnames** with per-nameserver DNS records and status. - **Ports** with open/closed state and associated hostnames. - **TLS certificates** with CN, issuer, expiry, and status. -- **Recent alerts** (last 100 notifications sent since the process - started), displayed in reverse chronological order. +- **Recent alerts** (last 100 notifications sent since the process started), + displayed in reverse chronological order. -Every data point shows its age (e.g. "5m ago") so you can tell at a -glance how fresh the information is. The page auto-refreshes every 30 -seconds. +Every data point shows its age (e.g. "5m ago") so you can tell at a glance how +fresh the information is. The page auto-refreshes every 30 seconds. -The dashboard intentionally does not expose any configuration details -such as webhook URLs, notification endpoints, or API tokens. +The dashboard intentionally does not expose any configuration details such as +webhook URLs, notification endpoints, or API tokens. -All assets (CSS) are embedded in the binary and served from the -application itself. The dashboard makes zero external HTTP requests — -no CDN dependencies or third-party resources are loaded at runtime. +All assets (CSS) are embedded in the binary and served from the application +itself. The dashboard makes zero external HTTP requests — no CDN dependencies or +third-party resources are loaded at runtime. ### HTTP API dnswatcher exposes a lightweight HTTP API for operational visibility: -| Endpoint | Description | -|---------------------------------------|--------------------------------| -| `GET /` | Web dashboard (HTML) | -| `GET /s/...` | Static assets (embedded CSS) | -| `GET /.well-known/healthcheck` | Health check (JSON) | -| `GET /health` | Health check (JSON, legacy) | -| `GET /api/v1/status` | Current monitoring state | -| `GET /metrics` | Prometheus metrics (optional) | +| Endpoint | Description | +| ------------------------------ | ----------------------------- | +| `GET /` | Web dashboard (HTML) | +| `GET /s/...` | Static assets (embedded CSS) | +| `GET /.well-known/healthcheck` | Health check (JSON) | +| `GET /health` | Health check (JSON, legacy) | +| `GET /api/v1/status` | Current monitoring state | +| `GET /metrics` | Prometheus metrics (optional) | #### Server timeouts @@ -216,7 +214,7 @@ constants in `internal/server/server.go`, not configurable via environment variables. | Timeout | Value | Purpose | -|---------------------|-------|-----------------------------------------------| +| ------------------- | ----- | --------------------------------------------- | | `ReadHeaderTimeout` | 10s | Bounds the request header read (slowloris) | | `ReadTimeout` | 15s | Bounds the whole request read, headers + body | | `WriteTimeout` | 75s | Bounds handler execution plus response flush | @@ -224,20 +222,20 @@ variables. These are distinct from the 60s per-request handler budget applied by `chimw.Timeout` in `internal/server/routes.go`, which cancels the request -context but does not touch the socket. `WriteTimeout` is deliberately -larger than that budget: the write deadline is armed once request headers -are read, so a smaller value would sever the connection before a handler -using its full budget could respond. `IdleTimeout` exceeds common -Prometheus scrape intervals so the scraper reuses its connection. +context but does not touch the socket. `WriteTimeout` is deliberately larger +than that budget: the write deadline is armed once request headers are read, so +a smaller value would sever the connection before a handler using its full +budget could respond. `IdleTimeout` exceeds common Prometheus scrape intervals +so the scraper reuses its connection. ### Security Headers Every response — the dashboard, the static assets under `/s/...`, the -healthchecks, the JSON API, and `/metrics` — carries the following -headers, set by a global middleware: +healthchecks, the JSON API, and `/metrics` — carries the following headers, set +by a global middleware: | Header | Value | -|-----------------------------|---------------------------------------| +| --------------------------- | ------------------------------------- | | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | | `Content-Security-Policy` | see below | | `X-Frame-Options` | `DENY` | @@ -254,21 +252,21 @@ form-action 'none'; frame-ancestors 'none' ``` The dashboard ships no JavaScript (the 30-second refresh is a -``), no inline styles, no inline event -handlers, and no images; its only subresource is the embedded stylesheet -at `/s/css/tailwind.min.css`, which `style-src 'self'` permits. The -policy therefore needs neither `unsafe-inline` nor `unsafe-eval`. +``), no inline styles, no inline event handlers, and +no images; its only subresource is the embedded stylesheet at +`/s/css/tailwind.min.css`, which `style-src 'self'` permits. The policy +therefore needs neither `unsafe-inline` nor `unsafe-eval`. `frame-ancestors 'none'` is the primary anti-framing control, with `X-Frame-Options: DENY` retained as the legacy fallback. HSTS is emitted unconditionally, including over plain HTTP. dnswatcher is -expected to run behind a TLS-terminating reverse proxy, and the browser -must still be told to enforce HTTPS end to end, so the header is never -gated on whether the request itself arrived over TLS. +expected to run behind a TLS-terminating reverse proxy, and the browser must +still be told to enforce HTTPS end to end, so the header is never gated on +whether the request itself arrived over TLS. `Referrer-Policy: no-referrer` is stricter than the -`strict-origin-when-cross-origin` baseline: the dashboard has no -cross-origin navigation needs, and its URL may name internal hosts. +`strict-origin-when-cross-origin` baseline: the dashboard has no cross-origin +navigation needs, and its URL may name internal hosts. --- @@ -301,23 +299,23 @@ internal/ ### Design Principles - **No recursive resolvers**: All DNS resolution is performed iteratively, - tracing from root nameservers through the delegation chain to - authoritative servers. + tracing from root nameservers through the delegation chain to authoritative + servers. - **No external database**: State is persisted as a single JSON file. - **Dependency injection**: All components are wired via [uber/fx](https://github.com/uber-go/fx). -- **Structured logging**: All logs use `log/slog` with JSON output in - production (TTY detection for development). -- **Graceful shutdown**: All background goroutines respect context - cancellation and the fx lifecycle. In-flight notification deliveries - are drained on shutdown, bounded by the shutdown timeout. +- **Structured logging**: All logs use `log/slog` with JSON output in production + (TTY detection for development). +- **Graceful shutdown**: All background goroutines respect context cancellation + and the fx lifecycle. In-flight notification deliveries are drained on + shutdown, bounded by the shutdown timeout. --- ## Configuration -Configuration is loaded via [Viper](https://github.com/spf13/viper) with -the following precedence (highest to lowest): +Configuration is loaded via [Viper](https://github.com/spf13/viper) with the +following precedence (highest to lowest): 1. Environment variables (prefixed with `DNSWATCHER_`) 2. `.env` file (loaded via godotenv) @@ -327,46 +325,45 @@ the following precedence (highest to lowest): ### Environment Variables -| Variable | Description | Default | -|---------------------------------|--------------------------------------------|-------------| -| `PORT` | HTTP listen port | `8080` | -| `DNSWATCHER_DEBUG` | Enable debug logging | `false` | -| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` | -| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` | -| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` | -| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` | -| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` | -| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` | -| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` | -| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` | -| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | -| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` | -| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` | -| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | -| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | +| Variable | Description | Default | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------- | +| `PORT` | HTTP listen port | `8080` | +| `DNSWATCHER_DEBUG` | Enable debug logging | `false` | +| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` | +| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` | +| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` | +| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` | +| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` | +| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` | +| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` | +| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` | +| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | +| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` | +| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` | +| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | +| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | **`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no monitoring targets are configured. A monitoring daemon with nothing to monitor is a misconfiguration, so dnswatcher fails fast with a clear error message -rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated -list of DNS names before starting. +rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated list +of DNS names before starting. **`/metrics` is rate limited.** Each client address may send it 30 requests a minute, failed logins included; beyond that it answers `429 Too Many Requests` without checking the password. A Prometheus server scraping every 15 seconds sends 4 a minute. IPv6 addresses in one /64 count as one client. When the -request comes from a private or loopback address, such as a reverse proxy's, -the client address is taken from the `X-Real-IP` header the proxy sets, or else -from `X-Forwarded-For`, as the last address in it that is not private or -loopback. A proxy that sets neither makes all its clients share one allowance. +request comes from a private or loopback address, such as a reverse proxy's, the +client address is taken from the `X-Real-IP` header the proxy sets, or else from +`X-Forwarded-For`, as the last address in it that is not private or loopback. A +proxy that sets neither makes all its clients share one allowance. **`DNSWATCHER_DNS_INTERVAL` and `DNSWATCHER_TLS_INTERVAL`** take a positive duration: a number followed by a unit such as `s`, `m` or `h`, for example -`90s`, `30m`, `1h` or `1h30m`. There is no unit for days; write `24h`. An -unset or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If -either is set to anything else, including a bare number or a zero or negative -duration, dnswatcher refuses to start with an error naming the variable and -the value. +`90s`, `30m`, `1h` or `1h30m`. There is no unit for days; write `24h`. An unset +or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If either is +set to anything else, including a bare number or a zero or negative duration, +dnswatcher refuses to start with an error naming the variable and the value. **`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it is set, a panic in an HTTP request handler is sent to Sentry, and the request @@ -392,107 +389,107 @@ DNSWATCHER_SEND_TEST_NOTIFICATION=true ## DNS Resolution Strategy -dnswatcher never uses the system's configured recursive resolver. Instead, -it performs full iterative resolution: +dnswatcher never uses the system's configured recursive resolver. Instead, it +performs full iterative resolution: -1. **Root servers**: Starts from the IANA root nameserver list (hardcoded, - with periodic refresh). +1. **Root servers**: Starts from the IANA root nameserver list (hardcoded, with + periodic refresh). 2. **TLD delegation**: Queries root servers for the TLD NS records. -3. **Domain delegation**: Queries TLD nameservers for the domain's NS - records. -4. **Authoritative query**: Queries all discovered authoritative - nameservers directly for the requested records. +3. **Domain delegation**: Queries TLD nameservers for the domain's NS records. +4. **Authoritative query**: Queries all discovered authoritative nameservers + directly for the requested records. This approach ensures: + - Independence from any upstream resolver's cache or filtering. -- Ability to detect split-horizon or inconsistent responses across - authoritative servers. +- Ability to detect split-horizon or inconsistent responses across authoritative + servers. - Visibility into the full delegation chain. -For hostname monitoring, the resolver follows CNAME chains (with a -depth limit to prevent loops) before collecting terminal A/AAAA records. +For hostname monitoring, the resolver follows CNAME chains (with a depth limit +to prevent loops) before collecting terminal A/AAAA records. --- ## State File Format The state file (`DATA_DIR/state.json`) contains the complete monitoring -snapshot. Hostname records are stored **per authoritative nameserver**, -not as a merged view, to enable inconsistency detection. +snapshot. Hostname records are stored **per authoritative nameserver**, not as a +merged view, to enable inconsistency detection. ```json { - "version": 1, - "lastUpdated": "2026-02-19T12:00:00Z", - "domains": { - "example.com": { - "nameservers": ["ns1.example.com.", "ns2.example.com."], - "nameserverAddresses": { - "ns1.example.com.": ["192.0.2.53", "2001:db8::53"], - "ns2.example.com.": ["198.51.100.53"] - }, - "lastChecked": "2026-02-19T12:00:00Z" - } - }, - "hostnames": { - "www.example.com": { - "recordsByNameserver": { - "ns1.example.com.": { - "records": { - "A": ["93.184.216.34"], - "AAAA": ["2606:2800:220:1:248:1893:25c8:1946"] - }, - "status": "ok", - "lastChecked": "2026-02-19T12:00:00Z" - }, - "ns2.example.com.": { - "records": { - "A": ["93.184.216.34"], - "AAAA": ["2606:2800:220:1:248:1893:25c8:1946"] - }, - "status": "ok", - "lastChecked": "2026-02-19T12:00:00Z" + "version": 1, + "lastUpdated": "2026-02-19T12:00:00Z", + "domains": { + "example.com": { + "nameservers": ["ns1.example.com.", "ns2.example.com."], + "nameserverAddresses": { + "ns1.example.com.": ["192.0.2.53", "2001:db8::53"], + "ns2.example.com.": ["198.51.100.53"] + }, + "lastChecked": "2026-02-19T12:00:00Z" } - }, - "lastChecked": "2026-02-19T12:00:00Z" - } - }, - "ports": { - "93.184.216.34:80": { - "open": true, - "hostnames": ["www.example.com"], - "lastChecked": "2026-02-19T12:00:00Z" }, - "93.184.216.34:443": { - "open": true, - "hostnames": ["www.example.com"], - "lastChecked": "2026-02-19T12:00:00Z" + "hostnames": { + "www.example.com": { + "recordsByNameserver": { + "ns1.example.com.": { + "records": { + "A": ["93.184.216.34"], + "AAAA": ["2606:2800:220:1:248:1893:25c8:1946"] + }, + "status": "ok", + "lastChecked": "2026-02-19T12:00:00Z" + }, + "ns2.example.com.": { + "records": { + "A": ["93.184.216.34"], + "AAAA": ["2606:2800:220:1:248:1893:25c8:1946"] + }, + "status": "ok", + "lastChecked": "2026-02-19T12:00:00Z" + } + }, + "lastChecked": "2026-02-19T12:00:00Z" + } + }, + "ports": { + "93.184.216.34:80": { + "open": true, + "hostnames": ["www.example.com"], + "lastChecked": "2026-02-19T12:00:00Z" + }, + "93.184.216.34:443": { + "open": true, + "hostnames": ["www.example.com"], + "lastChecked": "2026-02-19T12:00:00Z" + } + }, + "certificates": { + "93.184.216.34:443:www.example.com": { + "commonName": "www.example.com", + "issuer": "DigiCert TLS RSA SHA256 2020 CA1", + "notAfter": "2027-01-15T23:59:59Z", + "subjectAlternativeNames": ["www.example.com"], + "status": "ok", + "lastChecked": "2026-02-19T06:00:00Z" + } } - }, - "certificates": { - "93.184.216.34:443:www.example.com": { - "commonName": "www.example.com", - "issuer": "DigiCert TLS RSA SHA256 2020 CA1", - "notAfter": "2027-01-15T23:59:59Z", - "subjectAlternativeNames": ["www.example.com"], - "status": "ok", - "lastChecked": "2026-02-19T06:00:00Z" - } - } } ``` -The `status` field for each per-nameserver entry and certificate entry -tracks reachability: +The `status` field for each per-nameserver entry and certificate entry tracks +reachability: -| Status | Meaning | -|-------------|------------------------------------------------------------| -| `ok` | Query succeeded, records are current | -| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) | +| Status | Meaning | +| ------- | -------------------------------------------------------- | +| `ok` | Query succeeded, records are current | +| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) | -A nameserver that answers NXDOMAIN or with no records has status `ok` and -empty `records`. A nameserver whose query failed has status `error`, empty -`records`, and the reason in `error`. +A nameserver that answers NXDOMAIN or with no records has status `ok` and empty +`records`. A nameserver whose query failed has status `error`, empty `records`, +and the reason in `error`. `nameserverAddresses` lists, by nameserver, the sorted addresses its name resolves to. A state file without it loads, and the next check fills it in @@ -505,31 +502,38 @@ without a notification. This repository adheres to the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) standard: normalized scripts in `script/` are the entrypoints for the -development workflow, and the Makefile targets are thin shims that call -them. We provide: +development workflow, and the Makefile targets are thin shims that call them. We +provide: -- `script/bootstrap` — install all dependencies (go, `go mod download`). - It does not install golangci-lint: see `script/lint` below. -- `script/setup` — make a fresh clone ready for development: bootstrap - plus the git pre-commit hook -- `script/projectname` — print the project name (used for the Docker - image tag) -- `script/test` — run the test suite (race detector, coverage). Caching - is waived for testing, exactly as it is for linting: `-count=1` - forces every invocation to execute, because the suite queries live - DNS and a cached pass queries nothing. Failures are rerun with `-v` - automatically, and the build fails even if that rerun passes. +- `script/bootstrap` — install all dependencies (go, `go mod download`). It does + not install golangci-lint or prettier: both run in Docker, see `script/lint` + and `script/fmt` below. +- `script/setup` — make a fresh clone ready for development: bootstrap plus the + git pre-commit hook +- `script/projectname` — print the project name (used for the Docker image tag) +- `script/test` — run the test suite (race detector, coverage). Caching is + waived for testing, exactly as it is for linting: `-count=1` forces every + invocation to execute, because the suite queries live DNS and a cached pass + queries nothing. Failures are rerun with `-v` automatically, and the build + fails even if that rerun passes. - `script/lint` — run golangci-lint, always inside Docker: it builds - `Dockerfile.lint`, which COPYs the repo into the digest-pinned - `golangci-lint` image and lints as a build step, so a successful - build is a clean lint. The linter is never installed or run on the - host, and Docker is the only prerequisite. Caching is waived for - linting: the lint stage is forced to execute on every run with - `--no-cache-filter`, because a cached build lints nothing. -- `script/fmt` — format all code (gofmt -s, goimports). goimports runs - with `go run` at a pinned commit, never from your `PATH`. -- `script/fmt-check` — check formatting (read-only) with the same tools, - failing on any file `script/fmt` would change + `Dockerfile.lint`, which COPYs the repo into the digest-pinned `golangci-lint` + image and lints as a build step, so a successful build is a clean lint. The + linter is never installed or run on the host, and Docker is the only + prerequisite. Caching is waived for linting: the lint stage is forced to + execute on every run with `--no-cache-filter`, because a cached build lints + nothing. +- `script/fmt` — format all code (gofmt -s, goimports) and all Markdown + (prettier). goimports runs with `go run` at a pinned commit, never from your + `PATH`. prettier runs inside Docker, built from `Dockerfile.fmt` on a + digest-pinned node image, at the version pinned by `package.json` and + `yarn.lock`; it is never installed on the host. +- `script/fmt-check` — check formatting (read-only) with the same tools, failing + on any file `script/fmt` would change. It runs the two scripts below. +- `script/fmt-check-go` — the gofmt and goimports half, on the host. The + `Dockerfile` lint stage runs it. +- `script/fmt-check-markdown` — the prettier half, inside Docker, forced to + execute on every run with `--no-cache-filter` - `script/check` — run test, lint, and fmt-check - `script/docker` — build the Docker image tagged via `script/projectname`, with `--no-cache-filter=lint,builder` so the lint stage and the builder stage, @@ -538,9 +542,9 @@ them. We provide: - `script/cibuild` — CI entrypoint: `docker build` with `--no-cache-filter=lint,builder`, so the lint stage and the builder stage, which runs the tests, run on every invocation, because a cached build lints - nothing and queries no DNS -- `script/precommit` — run by the git pre-commit hook; `go mod tidy` - guard, then `script/check` + nothing and queries no DNS; then `script/fmt-check-markdown` +- `script/precommit` — run by the git pre-commit hook; `go mod tidy` guard, then + `script/check` - `script/install-precommit` — install the git pre-commit hook ## Building @@ -549,21 +553,21 @@ them. We provide: make build # Build binary to bin/dnswatcher make test # Run tests with race detector make lint # Run golangci-lint in Docker (requires docker) -make fmt # Format code +make fmt # Format code and Markdown (requires docker) make check # Run all checks (test, lint, fmt-check) make clean # Remove build artifacts ``` ### Build-Time Variables -`make build` sets the version with `-ldflags "-X main.Version=..."`, taking -it from `git describe --tags --always --dirty`, or from `VERSION` when given -on the command line (`make build VERSION=1.2.3`). The version appears in the -startup log and in the health check response. +`make build` sets the version with `-ldflags "-X main.Version=..."`, taking it +from `git describe --tags --always --dirty`, or from `VERSION` when given on the +command line (`make build VERSION=1.2.3`). The version appears in the startup +log and in the health check response. The Docker image has no `.git`, so the `Dockerfile` takes the version as -`--build-arg VERSION`. `make docker` passes it; a plain `docker build` -passes none, and that image reports `dev`. +`--build-arg VERSION`. `make docker` passes it; a plain `docker build` passes +none, and that image reports `dev`. --- @@ -584,84 +588,80 @@ docker run -d \ ## Running under upaas -[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this -repository's `Dockerfile` and runs it. The app needs: +[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this repository's +`Dockerfile` and runs it. The app needs: -- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to - `prod` pull request is a deploy. -- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where - the state file lives. -- **Network and port:** the dashboard is unauthenticated and shows every - watched name and recent alert, and upaas publishes every mapped port on - all interfaces of the host - ([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)). Add a - port mapping to container port `8080` only if the dashboard should be - public. Otherwise add none: set the app's Docker network in upaas to - your reverse proxy's Docker network, and the proxy reaches the app at - `upaas-` followed by the app name, port `8080`. -- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list - of the domains and hostnames to watch. dnswatcher refuses to start - without it. +- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to `prod` + pull request is a deploy. +- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where the + state file lives. +- **Network and port:** the dashboard is unauthenticated and shows every watched + name and recent alert, and upaas publishes every mapped port on all interfaces + of the host ([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)). + Add a port mapping to container port `8080` only if the dashboard should be + public. Otherwise add none: set the app's Docker network in upaas to your + reverse proxy's Docker network, and the proxy reaches the app at `upaas-` + followed by the app name, port `8080`. +- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list of the + domains and hostnames to watch. dnswatcher refuses to start without it. - **Recommended environment:** at least one notification endpoint (`DNSWATCHER_SLACK_WEBHOOK`, `DNSWATCHER_MATTERMOST_WEBHOOK`, - `DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the - dashboard. `DNSWATCHER_METRICS_USERNAME` and - `DNSWATCHER_METRICS_PASSWORD` serve `/metrics` behind basic auth. + `DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the dashboard. + `DNSWATCHER_METRICS_USERNAME` and `DNSWATCHER_METRICS_PASSWORD` serve + `/metrics` behind basic auth. - **Leave unset:** `DNSWATCHER_DATA_DIR`, which the image sets to - `/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every - setting comes from the environment; the image holds no config file. -- **Health check:** the image's own, which requests - `/.well-known/healthcheck` every 10 seconds. upaas reads the - container's health 60 seconds after a deploy and marks the deploy - failed unless it is `healthy`. + `/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every setting + comes from the environment; the image holds no config file. +- **Health check:** the image's own, which requests `/.well-known/healthcheck` + every 10 seconds. upaas reads the container's health 60 seconds after a deploy + and marks the deploy failed unless it is `healthy`. --- ## Monitoring Lifecycle -1. **Startup**: Check that the data directory can be written, and exit - with an error naming it if not. Load state from disk. If no state - file exists, start with empty state (first check will establish - baseline without triggering change notifications). -2. **Initial check**: Immediately perform all DNS, port, and TLS checks - on startup. +1. **Startup**: Check that the data directory can be written, and exit with an + error naming it if not. Load state from disk. If no state file exists, start + with empty state (first check will establish baseline without triggering + change notifications). +2. **Initial check**: Immediately perform all DNS, port, and TLS checks on + startup. 3. **Periodic checks** (DNS always runs first): - - DNS checks: every `DNSWATCHER_DNS_INTERVAL` (default 1h). Also - re-run before every TLS check cycle to ensure fresh IPs. - - Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes. - - TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after - DNS completes. - - Port and TLS checks always use freshly resolved IP addresses from - the DNS phase that immediately precedes them — never stale IPs - from a previous cycle. -4. **On change detection**: Send notifications to all configured - endpoints, update in-memory state, persist to disk. -5. **Shutdown**: The watcher stops checking and saves the final state - to disk, and shutdown waits for that save before it goes on. Then it - waits for in-flight notification deliveries to complete. Both waits - share the fx shutdown timeout (15s by default): deliveries still - retrying against an unreachable endpoint when that expires are - abandoned, and the number abandoned is logged at warn level rather - than dropped silently. Notifications generated after shutdown has - begun are refused and logged, so a late burst cannot extend the - shutdown. A DNS lookup, port check or TLS check that shutdown cuts - short saves nothing and sends no notification. + - DNS checks: every `DNSWATCHER_DNS_INTERVAL` (default 1h). Also re-run + before every TLS check cycle to ensure fresh IPs. + - Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes. + - TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after DNS + completes. + - Port and TLS checks always use freshly resolved IP addresses from the DNS + phase that immediately precedes them — never stale IPs from a previous + cycle. +4. **On change detection**: Send notifications to all configured endpoints, + update in-memory state, persist to disk. +5. **Shutdown**: The watcher stops checking and saves the final state to disk, + and shutdown waits for that save before it goes on. Then it waits for + in-flight notification deliveries to complete. Both waits share the fx + shutdown timeout (15s by default): deliveries still retrying against an + unreachable endpoint when that expires are abandoned, and the number + abandoned is logged at warn level rather than dropped silently. Notifications + generated after shutdown has begun are refused and logged, so a late burst + cannot extend the shutdown. A DNS lookup, port check or TLS check that + shutdown cuts short saves nothing and sends no notification. --- ## Planned Future Features (Post-1.0) -- **DNSSEC validation**: Validate the DNSSEC chain of trust during - iterative resolution and report DNSSEC failures as notifications. +- **DNSSEC validation**: Validate the DNSSEC chain of trust during iterative + resolution and report DNSSEC failures as notifications. --- ## Project Structure Follows the conventions defined in `REPO_POLICIES.md`, adapted from the -[upaas](https://git.eeqj.de/sneak/upaas) project template. Uses uber/fx -for dependency injection, go-chi for HTTP routing, slog for logging, and -Viper for configuration. +[upaas](https://git.eeqj.de/sneak/upaas) project template. Uses uber/fx for +dependency injection, go-chi for HTTP routing, slog for logging, and Viper for +configuration. --- diff --git a/TESTING.md b/TESTING.md index 6b4f3d8..3971309 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2,44 +2,43 @@ ## DNS Resolution Tests -DNS is never mocked in this project, not in tests and not anywhere -else; see the README section "No DNS mocking. Ever." Every test that -looks something up in DNS **MUST** query live DNS servers, never a -stand-in. Logic that works on record data, such as comparing or -formatting records, may be tested on that data directly with no -lookup. +DNS is never mocked in this project, not in tests and not anywhere else; see the +README section "No DNS mocking. Ever." Every test that looks something up in DNS +**MUST** query live DNS servers, never a stand-in. Logic that works on record +data, such as comparing or formatting records, may be tested on that data +directly with no lookup. ### Rationale -The resolver performs iterative resolution from root nameservers through -the full delegation chain. Mocked responses cannot faithfully represent -the variety of real-world DNS behavior (truncation, referrals, glue -records, DNSSEC, varied response times, EDNS, etc.). Testing against -real servers ensures the resolver works correctly in production. +The resolver performs iterative resolution from root nameservers through the +full delegation chain. Mocked responses cannot faithfully represent the variety +of real-world DNS behavior (truncation, referrals, glue records, DNSSEC, varied +response times, EDNS, etc.). Testing against real servers ensures the resolver +works correctly in production. ### Constraints - Tests hit real DNS infrastructure and require network access -- Test duration depends on network conditions; timeout tuning keeps - the suite within the 60-second target -- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) - plus processing margin +- Test duration depends on network conditions; timeout tuning keeps the suite + within the 60-second target +- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) plus + processing margin - Root server fan-out is limited to reduce parallel query load -- Live lookups that expect an answer go through `internal/livednstest`, - which limits how many run at once in a test binary and retries a - lookup that got none -- Flaky failures from transient network issues are acceptable and - should be investigated as potential resolver bugs, not papered over - with mocks or skip flags +- Live lookups that expect an answer go through `internal/livednstest`, which + limits how many run at once in a test binary and retries a lookup that got + none +- Flaky failures from transient network issues are acceptable and should be + investigated as potential resolver bugs, not papered over with mocks or skip + flags ### What NOT to do -- **Do not mock, fake or stub DNS** anywhere: no stand-in `DNSClient`, - no stand-in for the watcher's `DNSResolver`, no fake DNS server, no - canned responses +- **Do not mock, fake or stub DNS** anywhere: no stand-in `DNSClient`, no + stand-in for the watcher's `DNSResolver`, no fake DNS server, no canned + responses - **Do not add `-short` flags** to skip slow tests - **Do not increase `-timeout`** to hide hanging queries -- **Do not remove `-count=1` from `script/test`** — Go's test cache - replays a previous run's output without querying anything, so a - cached pass is not evidence that live resolution works +- **Do not remove `-count=1` from `script/test`** — Go's test cache replays a + previous run's output without querying anything, so a cached pass is not + evidence that live resolution works - **Do not modify linter configuration** to suppress findings diff --git a/TODO.md b/TODO.md index 897bda6..a7e12ce 100644 --- a/TODO.md +++ b/TODO.md @@ -1,12 +1,12 @@ # Workflow -* branch (from `next`) -* do the work in Next Step -* move Next Step to the top of Completed Steps -* move the top item of Future Steps into Next Step -* commit (`TODO.md` changes in the same commit as the work) -* push -* open a PR against `next` +- branch (from `next`) +- do the work in Next Step +- move Next Step to the top of Completed Steps +- move the top item of Future Steps into Next Step +- commit (`TODO.md` changes in the same commit as the work) +- push +- open a PR against `next` # Status @@ -15,11 +15,12 @@ on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Next Step -trial run of the finished image: -https://git.eeqj.de/sneak/dnswatcher/issues/149 +trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: `make fmt` and `make fmt-check` cover Markdown with prettier, run + in Docker at the version pinned by `yarn.lock` (closes #119). - 2026-10-01: `make fmt-check` fails on a file `goimports` would change; both format scripts run `goimports` at its pinned commit, not from `PATH` (#119). - 2026-10-01: a hostname is queried at the servers of the zone it is in, found @@ -114,8 +115,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: https://git.eeqj.de/sneak/dnswatcher/issues/66 -- Markdown formatting with prettier: - https://git.eeqj.de/sneak/dnswatcher/issues/119 - README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 - README sections required by policy: https://git.eeqj.de/sneak/dnswatcher/issues/173 diff --git a/package.json b/package.json new file mode 100644 index 0000000..53eca1f --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "dnswatcher-tooling", + "version": "0.0.0", + "private": true, + "description": "Pins the prettier that script/fmt and script/fmt-check run against this repo's markdown. Not a JavaScript project; nothing here is imported, published, or shipped.", + "devDependencies": { + "prettier": "3.9.6" + } +} diff --git a/script/bootstrap b/script/bootstrap index a463d0a..dd76db4 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,11 +3,12 @@ # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes nothing is present. -# goimports is not installed here: script/fmt and script/fmt-check run -# it with `go run` at a pinned commit. +# goimports is not installed here: script/fmt and script/fmt-check-go +# run it with `go run` at a pinned commit. # The linter is NOT installed here: golangci-lint runs via docker only # (script/lint), pinned by image digest, so its only prerequisite is a -# working docker. +# working docker. Nor is prettier: script/fmt and +# script/fmt-check-markdown run it in a container from Dockerfile.fmt. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -66,11 +67,12 @@ main() { if missing make; then pkg_install gnumake make make make; fi if missing go; then pkg_install go golang go go; fi - # Linting runs via docker only (script/lint). Warn, don't fail: - # everything except `make lint` works without it. + # Linting and the markdown formatter run via docker only. Warn, + # don't fail: building and testing work without it. if missing docker; then echo "bootstrap: WARNING: docker not found; install it to" \ - "run make lint and make docker." >&2 + "run make lint, make fmt, make fmt-check, make check" \ + "and make docker." >&2 fi go mod download diff --git a/script/cibuild b/script/cibuild index 29bea03..ad4ed76 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,18 +1,23 @@ #!/bin/sh # script/cibuild: run the CI build. The Dockerfile's lint stage runs -# make fmt-check and golangci-lint; its builder stage runs make test -# and make build. +# the Go half of make fmt-check and golangci-lint; its builder stage +# runs make test and make build. The markdown half of make fmt-check +# runs after that build, as its own build of Dockerfile.fmt, because +# there is no docker inside a docker build. # # --no-cache-filter=lint,builder runs both stages on every invocation; # otherwise an unchanged tree is served from the layer cache and passes -# without linting or querying live DNS. +# without linting or querying live DNS. script/fmt-check-markdown busts +# its own cache the same way. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" docker build --no-cache-filter=lint,builder . + "$SCRIPT_DIR/fmt-check-markdown" } main "$@" diff --git a/script/fmt b/script/fmt index a0b28e6..d64453a 100755 --- a/script/fmt +++ b/script/fmt @@ -1,19 +1,65 @@ #!/bin/sh -# script/fmt: format all files (writes). +# script/fmt: format all files (writes). Go with gofmt and goimports on +# the host, markdown with the prettier pinned by Dockerfile.fmt. # # goimports runs with `go run` at a pinned commit, never from PATH, so # every machine formats with the same version and nothing installs it. +# +# The markdown pass is a `docker build --output type=local` rather than a +# `docker run -v`, so it needs no bind mount and behaves the same against +# a remote daemon; the formatted documents come back out of the build and +# are copied over the tree here. +# +# Unlike script/fmt-check-markdown this does not bust the cache: it is +# not a gate, and any edit to a document changes the COPY layer above the +# prettier step, so a cached result is a result over this exact tree. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# goimports v0.42.0, 2026-08-07. Must match script/fmt-check. +# goimports v0.42.0, 2026-08-07. Must match script/fmt-check-go. GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" +# Must match the export stage name in Dockerfile.fmt. +stage=fmt-out + +die() { + echo "script/fmt: $*" >&2 + exit 1 +} + main() { cd "$ROOT" gofmt -s -w . go run "$GOIMPORTS_REF" -w . + + tmp="$(mktemp -d "${TMPDIR:-/tmp}/dnswatcher-fmt.XXXXXX")" + trap 'rm -rf "$tmp"' EXIT INT TERM + + docker build \ + --target "$stage" \ + --output "type=local,dest=$tmp/out" \ + -f Dockerfile.fmt . + + # An empty export means prettier was handed nothing, which must not + # read as "already formatted". + (cd "$tmp/out" && find . -type f -name '*.md') | + sed 's|^\./||' | LC_ALL=C sort >"$tmp/files" + + [ -s "$tmp/files" ] || + die "the formatting build produced no markdown; the build" \ + "context reached prettier empty" + + # Copied only where the bytes differ, so an already-formatted tree + # keeps its timestamps and says nothing. + while IFS= read -r f; do + [ -n "$f" ] || continue + if [ -f "$f" ] && cmp -s "$tmp/out/$f" "$f"; then + continue + fi + cp "$tmp/out/$f" "$f" + echo "prettier: reformatted $f" + done <"$tmp/files" } main "$@" diff --git a/script/fmt-check b/script/fmt-check index 40911dd..c5fb94f 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -1,27 +1,14 @@ #!/bin/sh # script/fmt-check: check formatting (read-only). Same tools and scope -# as script/fmt, but fails instead of writing. +# as script/fmt, but fails instead of writing: the Go on the host, the +# markdown with prettier in a container. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -# goimports v0.42.0, 2026-08-07. Must match script/fmt. -GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" main() { - cd "$ROOT" - files="$(gofmt -s -l .)" - if [ -n "$files" ]; then - echo "gofmt: files not formatted:" >&2 - echo "$files" >&2 - exit 1 - fi - files="$(go run "$GOIMPORTS_REF" -l .)" - if [ -n "$files" ]; then - echo "goimports: files not formatted:" >&2 - echo "$files" >&2 - exit 1 - fi + "$SCRIPT_DIR/fmt-check-go" + "$SCRIPT_DIR/fmt-check-markdown" } main "$@" diff --git a/script/fmt-check-go b/script/fmt-check-go new file mode 100755 index 0000000..fa21fc5 --- /dev/null +++ b/script/fmt-check-go @@ -0,0 +1,31 @@ +#!/bin/sh +# script/fmt-check-go: fail unless every Go source is formatted the way +# script/fmt would leave it, and name the files that are not. Read-only. +# +# Its own script because the Dockerfile's lint stage runs this half +# alone: there is no docker inside a docker build to run the markdown +# half in. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# goimports v0.42.0, 2026-08-07. Must match script/fmt. +GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" + +main() { + cd "$ROOT" + files="$(gofmt -s -l .)" + if [ -n "$files" ]; then + echo "gofmt: files not formatted:" >&2 + echo "$files" >&2 + exit 1 + fi + files="$(go run "$GOIMPORTS_REF" -l .)" + if [ -n "$files" ]; then + echo "goimports: files not formatted:" >&2 + echo "$files" >&2 + exit 1 + fi +} + +main "$@" diff --git a/script/fmt-check-markdown b/script/fmt-check-markdown new file mode 100755 index 0000000..e849b59 --- /dev/null +++ b/script/fmt-check-markdown @@ -0,0 +1,29 @@ +#!/bin/sh +# script/fmt-check-markdown: fail unless every .md is formatted the way +# script/fmt would leave it. Read-only. +# +# prettier is never installed on the host: it runs in a container built +# from Dockerfile.fmt, pinned by package.json and yarn.lock. +# --no-cache-filter is here for the reason script/lint gives: a cached +# build checks nothing. +# +# Its own script because script/cibuild runs this half alone, after the +# Dockerfile's lint stage has checked the Go. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Must match the markdown check stage name in Dockerfile.fmt. +stage=fmt-check + +main() { + cd "$ROOT" + docker build \ + --progress=plain \ + --no-cache-filter="$stage" \ + --target "$stage" \ + -f Dockerfile.fmt \ + . +} + +main "$@" diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..8f3c21a --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +prettier@3.9.6: + version "3.9.6" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6" + integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g== -- 2.54.0 From d2f154b2cfb87eb21fadcfe8afc826fbe151ded1 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 00:28:53 +0200 Subject: [PATCH 39/92] resolver: error from ResolveIPAddresses when no nameserver answered (closes #190) ResolveIPAddresses now returns an error, not no addresses, when no nameserver of the name's zone answered. A nameserver with status timeout or error is not an answer; one answer, even NXDOMAIN, is enough for an empty result without an error. When every server of a zone fails, FindAuthoritativeNameservers moves on to the parent name, whose servers only refer the query onward. Such a referral now has status error, so it is no answer either, and a hostname's saved records show it as error. The only caller, the nameserver address lookup, already keeps the previous addresses on an error; its comment no longer says the resolver hides this case. Model: opus-5-5 --- README.md | 4 +- TODO.md | 2 + internal/resolver/errors.go | 5 ++ internal/resolver/export_test.go | 7 +++ internal/resolver/iterative.go | 45 +++++++++++++++-- internal/resolver/iterative_test.go | 32 ++++++++++++ internal/resolver/resolver_test.go | 76 +++++++++++++++++++++++++++++ internal/watcher/watcher.go | 7 ++- 8 files changed, 168 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c5461fb..b25ef86 100644 --- a/README.md +++ b/README.md @@ -488,8 +488,8 @@ reachability: | `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) | A nameserver that answers NXDOMAIN or with no records has status `ok` and empty -`records`. A nameserver whose query failed has status `error`, empty `records`, -and the reason in `error`. +`records`. A nameserver whose query failed, or that only referred it to other +nameservers, has status `error`, empty `records`, and the reason in `error`. `nameserverAddresses` lists, by nameserver, the sorted addresses its name resolves to. A state file without it loads, and the next check fills it in diff --git a/TODO.md b/TODO.md index a7e12ce..10f64bb 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no + nameserver of the name's zone answered (closes #190). - 2026-10-01: `make fmt` and `make fmt-check` cover Markdown with prettier, run in Docker at the version pinned by `yarn.lock` (closes #119). - 2026-10-01: `make fmt-check` fails on a file `goimports` would change; both diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index 3f203d4..ab05678 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -10,6 +10,11 @@ var ( "no authoritative nameservers found", ) + // ErrNoNameserverAnswered is returned when every nameserver + // asked about a name timed out, failed or returned a referral, + // so whether the name has addresses is unknown. + ErrNoNameserverAnswered = errors.New("no nameserver answered") + // ErrCNAMEDepthExceeded is returned when a CNAME chain // exceeds MaxCNAMEDepth. ErrCNAMEDepthExceeded = errors.New( diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index d8e382b..9569a3a 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -11,6 +11,13 @@ func ExtractRecordValue(rr dns.RR) string { return extractRecordValue(rr) } +// CollectIPs exports collectIPs for testing. +func CollectIPs( + results map[string]*NameserverResponse, +) ([]string, string, error) { + return collectIPs(results) +} + // QueryEachNS exports queryEachNS for testing. func (r *Resolver) QueryEachNS( ctx context.Context, diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 93f6b9b..a4c88ab 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -516,6 +516,7 @@ type queryState struct { gotSERVFAIL bool gotRefused bool gotTimeout bool + gotReferral bool netErr error hasRecords bool } @@ -578,6 +579,18 @@ func (r *Resolver) querySingleType( return } + // A reply with no answer that lists other nameservers, from a server + // that does not hold the name's zone, is a referral and says nothing + // about the name's records. A parent zone's servers send one when + // every server of the name's own zone failed and + // FindAuthoritativeNameservers moved on to the parent name. + if !msg.Authoritative && len(msg.Answer) == 0 && + len(extractNSSet(msg.Ns)) > 0 { + state.gotReferral = true + + return + } + collectAnswerRecords(msg, resp, state) } @@ -626,6 +639,9 @@ func classifyResponse(resp *NameserverResponse, state queryState) { case state.netErr != nil && !state.hasRecords: resp.Status = StatusError resp.Error = "network error: " + state.netErr.Error() + case state.gotReferral && !state.hasRecords: + resp.Status = StatusError + resp.Error = "server returned a referral" case !state.hasRecords && !state.gotNXDomain: resp.Status = StatusNoData } @@ -734,7 +750,9 @@ func (r *Resolver) LookupAllRecords( } // ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 -// addresses, following CNAME chains up to MaxCNAMEDepth. +// addresses, following CNAME chains up to MaxCNAMEDepth. When no +// nameserver of the name's zone answered, it returns an error rather +// than no addresses. func (r *Resolver) ResolveIPAddresses( ctx context.Context, hostname string, @@ -760,7 +778,10 @@ func (r *Resolver) resolveIPWithCNAME( return nil, err } - ips, cnameTarget := collectIPs(results) + ips, cnameTarget, err := collectIPs(results) + if err != nil { + return nil, fmt.Errorf("resolving %s: %w", hostname, err) + } if len(ips) == 0 && cnameTarget != "" { return r.resolveIPWithCNAME(ctx, cnameTarget, depth+1) @@ -771,16 +792,28 @@ func (r *Resolver) resolveIPWithCNAME( return ips, nil } +// collectIPs returns the addresses in the nameservers' answers and the +// first CNAME target among them. It returns ErrNoNameserverAnswered when +// every nameserver timed out, failed or returned a referral: that is not +// a name with no addresses. func collectIPs( results map[string]*NameserverResponse, -) ([]string, string) { +) ([]string, string, error) { seen := make(map[string]bool) var ips []string var cnameTarget string + answered := false + for _, resp := range results { + if resp.Status == StatusTimeout || resp.Status == StatusError { + continue + } + + answered = true + if resp.Status == StatusNXDomain { continue } @@ -804,5 +837,9 @@ func collectIPs( } } - return ips, cnameTarget + if !answered { + return nil, "", ErrNoNameserverAnswered + } + + return ips, cnameTarget, nil } diff --git a/internal/resolver/iterative_test.go b/internal/resolver/iterative_test.go index 781e90b..f3440e7 100644 --- a/internal/resolver/iterative_test.go +++ b/internal/resolver/iterative_test.go @@ -5,10 +5,42 @@ import ( "github.com/miekg/dns" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "sneak.berlin/go/dnswatcher/internal/resolver" ) +// TestCollectIPs_OneAnswerIsEnough checks that one nameserver answering +// NXDOMAIN says the name has no addresses, though the other timed out. +func TestCollectIPs_OneAnswerIsEnough(t *testing.T) { + t.Parallel() + + ips, _, err := resolver.CollectIPs( + map[string]*resolver.NameserverResponse{ + "ns1.example.": {Status: resolver.StatusTimeout}, + "ns2.example.": {Status: resolver.StatusNXDomain}, + }, + ) + require.NoError(t, err) + assert.Empty(t, ips) +} + +// TestCollectIPs_FailedIsNoAnswer checks that nameservers that all have +// status error, from a refusal, a server failure, a network error or a +// referral, are no answer rather than a name with no addresses. +func TestCollectIPs_FailedIsNoAnswer(t *testing.T) { + t.Parallel() + + ips, _, err := resolver.CollectIPs( + map[string]*resolver.NameserverResponse{ + "ns1.example.": {Status: resolver.StatusError}, + "ns2.example.": {Status: resolver.StatusError}, + }, + ) + require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered) + assert.Empty(t, ips) +} + func TestExtractRecordValue_LetterCase(t *testing.T) { t.Parallel() diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 15a18c5..dd585c8 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -633,6 +633,82 @@ func TestQueryNameserverIP_Timeout(t *testing.T) { assert.NotEmpty(t, resp.Error) } +// TestCollectIPs_NoNameserverAnswered takes the response of a +// nameserver at 192.0.2.1, where nothing answers, as +// TestQueryNameserverIP_Timeout does. Addresses collected from +// nameservers that all failed to answer are an error, not none. +func TestCollectIPs_NoNameserverAnswered(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + // The deadline outlasts the first try, as in + // TestQueryNameserverIP_Timeout. + ctx, cancel := context.WithTimeout( + context.Background(), 3*time.Second, + ) + t.Cleanup(cancel) + + resp, err := r.QueryNameserverIP( + ctx, "unreachable.test.", "192.0.2.1", + "example.com", + ) + require.NoError(t, err) + + ips, _, err := resolver.CollectIPs( + map[string]*resolver.NameserverResponse{resp.Nameserver: resp}, + ) + require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered) + assert.Empty(t, ips) +} + +// TestCollectIPs_ReferralIsNoAnswer asks a root server about +// example.com, which the root zone does not hold, so it only refers the +// query to the com servers. That reply is no answer, as is a parent +// zone's when every server of the name's own zone failed. +func TestCollectIPs_ReferralIsNoAnswer(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + var resp *resolver.NameserverResponse + + livednstest.Retry( + t, + "QueryNameserverIP(a.root-servers.net, example.com)", + func(ctx context.Context) error { + var err error + + resp, err = r.QueryNameserverIP( + ctx, "a.root-servers.net.", "198.41.0.4", + "example.com", + ) + if err != nil { + return err + } + + // A timeout or a network error is no reply at all. + if resp.Status == resolver.StatusTimeout || + strings.HasPrefix(resp.Error, "network error") { + return fmt.Errorf( + "%w: %s", livednstest.ErrNoAnswer, resp.Error, + ) + } + + return nil + }, + ) + + assert.Equal(t, resolver.StatusError, resp.Status) + assert.Equal(t, "server returned a referral", resp.Error) + + ips, _, err := resolver.CollectIPs( + map[string]*resolver.NameserverResponse{resp.Nameserver: resp}, + ) + require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered) + assert.Empty(t, ips) +} + func TestResolveIPAddresses_ContextCanceled(t *testing.T) { t.Parallel() diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2a2c523..98f0c68 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -321,10 +321,9 @@ func (w *Watcher) detectNSChanges( } // resolveNameserverAddresses returns the sorted addresses each -// nameserver's name resolves to. A nameserver whose lookup fails or -// finds no address keeps its addresses from prev: the resolver finds no -// address, without an error, when every server it asks times out, and -// that is not an address change. +// nameserver's name resolves to. A nameserver whose lookup fails, as it +// does when no nameserver of the name's zone answers, or finds no +// address keeps its addresses from prev and is not an address change. func (w *Watcher) resolveNameserverAddresses( ctx context.Context, nameservers []string, -- 2.54.0 From 97c8138c8544268c09b4ce06e30e98c73c7c8680 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 00:44:49 +0200 Subject: [PATCH 40/92] watcher: keep port state when no nameserver of a name answered (closes #193) The port check removed the saved port state of every address no configured name resolves to. A name whose nameservers all timed out or failed is saved with no records, so its addresses looked gone and lost their port state; when the nameservers answered again it was recorded afresh, and a port that opened or closed meanwhile was not notified. An entry is now kept when one of the names saved on it is configured and none of its nameservers answered on its last check, and such a name stays on the entry when the port is checked again for another name. A name whose nameservers answer with no addresses still loses it, and so does a name no longer configured. Model: opus-5-5 --- README.md | 4 +- TODO.md | 2 + internal/watcher/export_test.go | 5 ++ internal/watcher/nsfailure_test.go | 115 +++++++++++++++++++++++++++++ internal/watcher/watcher.go | 55 +++++++++++++- 5 files changed, 176 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b25ef86..0636e9b 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,9 @@ Contributions that introduce mocked, faked, or stubbed DNS will be rejected. - Port transitioned from open to closed (or vice versa). - New IP appeared (from DNS change) and its port state was recorded. - IP disappeared (from DNS change) — noted in the DNS change notification; - port state for that IP is removed. + port state for that IP is removed. When none of a name's nameservers + answered, its addresses are not known, so the port state saved for them is + kept. ### TLS Certificate Monitoring diff --git a/TODO.md b/TODO.md index 10f64bb..dc9e0a3 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: when none of a configured name's nameservers answered, the port + state saved for its addresses is kept, not removed (closes #193). - 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no nameserver of the name's zone answered (closes #190). - 2026-10-01: `make fmt` and `make fmt-check` cover Markdown with prettier, run diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 772253f..557e787 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -67,6 +67,11 @@ func (w *Watcher) DetectNSAddressChanges( w.detectNSAddressChanges(ctx, domain, prev, current) } +// CheckAllPorts exports checkAllPorts for testing. +func (w *Watcher) CheckAllPorts(ctx context.Context) { + w.checkAllPorts(ctx) +} + // BuildHostnameState exports buildHostnameState for testing. func BuildHostnameState( results map[string]*resolver.NameserverResponse, diff --git a/internal/watcher/nsfailure_test.go b/internal/watcher/nsfailure_test.go index 91a5580..afae908 100644 --- a/internal/watcher/nsfailure_test.go +++ b/internal/watcher/nsfailure_test.go @@ -331,3 +331,118 @@ func TestNameserverThatRefuses(t *testing.T) { ) } } + +// TestPortStateWhenNoNameserverAnswered runs the port checks on +// hostname state built here, which gives the name no address. The port +// state saved for its old address is kept only when the name is a +// configured hostname or domain and none of its nameservers answered. +func TestPortStateWhenNoNameserverAnswered(t *testing.T) { + t.Parallel() + + noneAnswered := saved(map[string]*state.NameserverRecordState{ + nsA: failed(), nsB: failed(), + }) + oneAnsweredNoAddress := saved(map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{}), nsB: failed(), + }) + + configured := []string{host} + + tests := []struct { + name string + hostname *state.HostnameState + hostnames []string + domains []string + wantKept bool + }{ + {"no nameserver answered", noneAnswered, configured, nil, true}, + { + "no nameserver answered, configured as a domain", + noneAnswered, nil, configured, true, + }, + { + "one answered with no address", + oneAnsweredNoAddress, configured, nil, false, + }, + {"no nameserver answered, not configured", noneAnswered, nil, nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = tt.hostnames + cfg.Domains = tt.domains + + // The port checks read the saved hostname state and look + // nothing up, so the watcher has no resolver. + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + + key := ip1 + ":443" + + deps.state.SetHostnameState(host, tt.hostname) + deps.state.SetPortState(key, &state.PortState{ + Open: true, Hostnames: []string{host}, + }) + + w.CheckAllPorts(t.Context()) + + _, kept := deps.state.GetPortState(key) + if kept != tt.wantKept { + t.Errorf("port state %s kept: %v, want %v", key, kept, tt.wantKept) + } + }) + } +} + +// TestPortStateWhenNoNameserverAnsweredAndOtherNameMovesAway saves the +// port state of an address two configured hostnames resolve to. While +// none of the first one's nameservers answer, the port checks run with +// the other one still at that address, then after it moved away; the +// port state is kept both times. +func TestPortStateWhenNoNameserverAnsweredAndOtherNameMovesAway( + t *testing.T, +) { + t.Parallel() + + const other = "mail.example.net" + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{host, other} + + // The port checks read the saved hostname state and look nothing + // up, so the watcher has no resolver. + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + + key := ip1 + ":443" + + deps.state.SetPortState(key, &state.PortState{ + Open: true, Hostnames: []string{host, other}, + }) + deps.state.SetHostnameState(host, saved( + map[string]*state.NameserverRecordState{nsA: failed(), nsB: failed()}, + )) + + for _, otherIP := range []string{ip1, ip2} { + deps.state.SetHostnameState(other, saved( + map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {otherIP}}), + }, + )) + + w.CheckAllPorts(t.Context()) + + if _, kept := deps.state.GetPortState(key); !kept { + t.Fatalf("port state %s removed with %s at %s", key, other, otherIP) + } + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 98f0c68..2df33f4 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "slices" "sort" "strings" "sync" @@ -708,15 +709,46 @@ func parsePortKey(key string) (string, int) { } // cleanupStalePorts removes port state entries that are no -// longer referenced by any hostname in the current DNS data. +// longer referenced by any hostname in the current DNS data. An +// entry saved for a configured name none of whose nameservers +// answered is kept: that name's addresses are not known, not gone. func (w *Watcher) cleanupStalePorts( currentAssociations map[string][]string, ) { for _, key := range w.state.GetAllPortKeys() { - if _, exists := currentAssociations[key]; !exists { - w.state.DeletePortState(key) + if _, exists := currentAssociations[key]; exists { + continue + } + + ps, ok := w.state.GetPortState(key) + if ok && slices.ContainsFunc(ps.Hostnames, w.noNameserverAnswered) { + continue + } + + w.state.DeletePortState(key) + } +} + +// noNameserverAnswered reports whether name is a configured domain or +// hostname and none of its nameservers answered on its last check. +func (w *Watcher) noNameserverAnswered(name string) bool { + if !slices.Contains(w.config.Hostnames, name) && + !slices.Contains(w.config.Domains, name) { + return false + } + + hs, ok := w.state.GetHostnameState(name) + if !ok { + return false + } + + for _, nsState := range hs.RecordsByNameserver { + if nsState.Status == statusOK { + return false } } + + return true } func (w *Watcher) collectIPs(hostname string) []string { @@ -795,9 +827,24 @@ func (w *Watcher) checkSinglePort( ) } + // A configured name on the saved list none of whose nameservers + // answered stays on it, so the entry is kept when the other names + // stop resolving to this address. + savedHostnames := slices.Clone(hostnames) + + if hasPrev { + for _, name := range prev.Hostnames { + if !slices.Contains(hostnames, name) && w.noNameserverAnswered(name) { + savedHostnames = append(savedHostnames, name) + } + } + + sort.Strings(savedHostnames) + } + w.state.SetPortState(key, &state.PortState{ Open: result.Open, - Hostnames: hostnames, + Hostnames: savedHostnames, LastChecked: now, }) } -- 2.54.0 From d09822562d6a188cf0b6286ba4190ee667c72fd9 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 01:17:37 +0200 Subject: [PATCH 41/92] resolver: pass over a server that answers SERVFAIL or refers no closer (closes #197) When the resolver walks from the root servers towards a name, a server that answered SERVFAIL, or referred the query back to its own zone, up or sideways, ended the step, so finding a zone's servers gave up on the zone though its other servers would answer. Such a reply is now passed over for the zone's next server, as a timeout or a refusal already was. To tell a referral that leads closer to the name from one that does not, each walk keeps the zone of the servers it is asking. Other error replies, such as FORMERR, are passed over too. The walk that finds a nameserver's address shares the same server loop, so it changes too. Model: opus-5-5 --- TODO.md | 2 + internal/resolver/errors.go | 7 +++ internal/resolver/export_test.go | 5 ++ internal/resolver/iterative.go | 56 +++++++++++++++++- internal/resolver/iterative_test.go | 91 +++++++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index dc9e0a3..8972d49 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: a zone's server that answers SERVFAIL or a referral leading no + closer is passed over for the next, as one that times out is (closes #197). - 2026-10-01: when none of a configured name's nameservers answered, the port state saved for its addresses is kept, not removed (closes #193). - 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index ab05678..91eea16 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -15,6 +15,13 @@ var ( // so whether the name has addresses is unknown. ErrNoNameserverAnswered = errors.New("no nameserver answered") + // ErrUnusableReply is returned when a server replied with an + // error such as SERVFAIL, or with a referral that leads no + // closer to the name asked about. + ErrUnusableReply = errors.New( + "reply is an error or a referral that leads no closer", + ) + // ErrCNAMEDepthExceeded is returned when a CNAME chain // exceeds MaxCNAMEDepth. ErrCNAMEDepthExceeded = errors.New( diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 9569a3a..73c3a33 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -11,6 +11,11 @@ func ExtractRecordValue(rr dns.RR) string { return extractRecordValue(rr) } +// UsableReply exports usableReply for testing. +func UsableReply(resp *dns.Msg, zone string, name string) bool { + return usableReply(resp, zone, name) +} + // CollectIPs exports collectIPs for testing. func CollectIPs( results map[string]*NameserverResponse, diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index a4c88ab..7c5cfa7 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -207,13 +207,16 @@ func (r *Resolver) followDelegation( domain string, servers []string, ) ([]string, error) { + // servers are the root servers, the servers of zone ".". + zone := "." + for range maxDelegation { if checkCtx(ctx) != nil { return nil, ErrContextCanceled } resp, err := r.queryServers( - ctx, servers, domain, dns.TypeNS, + ctx, servers, zone, domain, dns.TypeNS, ) if err != nil { return nil, err @@ -250,14 +253,19 @@ func (r *Resolver) followDelegation( } servers = nextServers + zone = referralZone(resp) } return nil, ErrNoNameservers } +// queryServers asks servers, the servers of zone, about name until one +// gives a usable reply. A server that times out, refuses or gives a +// reply that is not usable is passed over for the next. func (r *Resolver) queryServers( ctx context.Context, servers []string, + zone string, name string, qtype uint16, ) (*dns.Msg, error) { @@ -269,6 +277,12 @@ func (r *Resolver) queryServers( } resp, err := r.queryDNS(ctx, ip, name, qtype) + if err == nil && !usableReply(resp, zone, name) { + err = fmt.Errorf( + "query %s @%s: %w", name, ip, ErrUnusableReply, + ) + } + if err == nil { return resp, nil } @@ -279,6 +293,38 @@ func (r *Resolver) queryServers( return nil, fmt.Errorf("all servers failed: %w", lastErr) } +// usableReply reports whether resp, a reply from one of the servers of +// zone to a query about name, is usable. An error reply such as SERVFAIL +// is not. Nor is a referral, unless it refers the query to a zone below +// zone that name is in: a server that refers it back to zone, up or +// sideways does not serve zone as it should. +func usableReply(resp *dns.Msg, zone string, name string) bool { + if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError { + return false + } + + child := referralZone(resp) + if resp.Authoritative || len(resp.Answer) > 0 || child == "" { + return true + } + + return child != zone && dns.IsSubDomain(zone, child) && + dns.IsSubDomain(child, name) +} + +// referralZone returns the zone a referral refers the query to: the +// owner name of the NS records in resp's authority section, or "" when +// there are none. +func referralZone(resp *dns.Msg) string { + for _, rr := range resp.Ns { + if ns, ok := rr.(*dns.NS); ok { + return strings.ToLower(ns.Hdr.Name) + } + } + + return "" +} + func (r *Resolver) resolveNSIPs( ctx context.Context, nsNames []string, @@ -312,6 +358,7 @@ func (r *Resolver) resolveNSIterative( domain = dns.Fqdn(domain) servers := rootServerList() + zone := "." for range maxDelegation { if checkCtx(ctx) != nil { @@ -319,7 +366,7 @@ func (r *Resolver) resolveNSIterative( } resp, err := r.queryServers( - ctx, servers, domain, dns.TypeNS, + ctx, servers, zone, domain, dns.TypeNS, ) if err != nil { return nil, err @@ -344,6 +391,7 @@ func (r *Resolver) resolveNSIterative( } servers = nextServers + zone = referralZone(resp) } return nil, ErrNoNameservers @@ -361,6 +409,7 @@ func (r *Resolver) resolveARecord( hostname = dns.Fqdn(hostname) servers := rootServerList() + zone := "." for range maxDelegation { if checkCtx(ctx) != nil { @@ -368,7 +417,7 @@ func (r *Resolver) resolveARecord( } resp, err := r.queryServers( - ctx, servers, hostname, dns.TypeA, + ctx, servers, zone, hostname, dns.TypeA, ) if err != nil { return nil, fmt.Errorf( @@ -406,6 +455,7 @@ func (r *Resolver) resolveARecord( } servers = nextServers + zone = referralZone(resp) } return nil, fmt.Errorf( diff --git a/internal/resolver/iterative_test.go b/internal/resolver/iterative_test.go index f3440e7..cc1b30b 100644 --- a/internal/resolver/iterative_test.go +++ b/internal/resolver/iterative_test.go @@ -41,6 +41,97 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) { assert.Empty(t, ips) } +// exampleCom is the zone most cases of TestUsableReply are about. +const exampleCom = "example.com." + +// nsRecord builds an NS record that names a server of zone. +func nsRecord(zone string) *dns.NS { + return &dns.NS{ + Hdr: dns.RR_Header{ + Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, + }, + Ns: "ns1.example.net.", + } +} + +// referralTo builds a reply that refers the query to the servers of +// zone. +func referralTo(zone string) *dns.Msg { + msg := new(dns.Msg) + msg.Ns = []dns.RR{nsRecord(zone)} + + return msg +} + +// TestUsableReply checks which replies from one of a zone's servers are +// used. A reply that is not usable moves the query on to the zone's +// next server. +func TestUsableReply(t *testing.T) { + t.Parallel() + + servfail := new(dns.Msg) + servfail.Rcode = dns.RcodeServerFailure + + answer := new(dns.Msg) + answer.Authoritative = true + answer.Answer = []dns.RR{nsRecord(exampleCom)} + + nxdomain := new(dns.Msg) + nxdomain.Authoritative = true + nxdomain.Rcode = dns.RcodeNameError + + tests := []struct { + name string + resp *dns.Msg + zone string + query string + want bool + }{ + { + name: "SERVFAIL", resp: servfail, + zone: exampleCom, query: exampleCom, want: false, + }, + { + name: "answer", resp: answer, + zone: exampleCom, query: exampleCom, want: true, + }, + { + name: "NXDOMAIN", resp: nxdomain, + zone: ".", query: exampleCom, want: true, + }, + { + name: "root refers to com", resp: referralTo("com."), + zone: ".", query: exampleCom, want: true, + }, + { + name: "com refers to example.com", resp: referralTo(exampleCom), + zone: "com.", query: "www.example.com.", want: true, + }, + { + name: "referral back to the zone", resp: referralTo(exampleCom), + zone: exampleCom, query: exampleCom, want: false, + }, + { + name: "referral up to the root", resp: referralTo("."), + zone: exampleCom, query: exampleCom, want: false, + }, + { + name: "referral sideways", resp: referralTo("net."), + zone: ".", query: exampleCom, want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, + resolver.UsableReply(tt.resp, tt.zone, tt.query), + ) + }) + } +} + func TestExtractRecordValue_LetterCase(t *testing.T) { t.Parallel() -- 2.54.0 From 11ce1b249b8d20dc65059383cc9d8f360e5c536d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 01:25:13 +0200 Subject: [PATCH 42/92] docs: add the README sections policy requires (closes #173) REPO_POLICIES.md requires Getting Started, Rationale, Design and TODO sections in the README, and it had none of them. Getting Started clones the repository, builds the image and runs it watching example.com and www.example.com; DNSWATCHER_TARGETS is the only setting it requires. Rationale is drawn from what the README already says. The Architecture section moves below Entrypoints and is renamed Design, its text unchanged, so the required sections come in policy order. TODO points to TODO.md and the 1.0 milestone instead of copying the list. Model: opus-5-5 --- README.md | 127 ++++++++++++++++++++++++++++++++++++------------------ TODO.md | 4 +- 2 files changed, 87 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 0636e9b..d1ec4db 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,29 @@ Contributions that introduce mocked, faked, or stubbed DNS will be rejected. --- +## Getting Started + +You need git and Docker. This builds the image and runs dnswatcher watching +`example.com` and `www.example.com`: + +```sh +git clone https://git.eeqj.de/sneak/dnswatcher.git +cd dnswatcher +docker build -t dnswatcher . +docker run -d --name dnswatcher \ + -p 8080:8080 \ + -v dnswatcher-data:/var/lib/dnswatcher \ + -e DNSWATCHER_TARGETS=example.com,www.example.com \ + dnswatcher +``` + +The build also runs the linter and the test suite, which queries live DNS. Once +the container is running, the dashboard is at . With no +notification endpoint set, changes show only on the dashboard; see +[Configuration](#configuration) to add one. + +--- + ## Features ### DNS Domain Monitoring (Apex Domains) @@ -272,48 +295,6 @@ navigation needs, and its URL may name internal hosts. --- -## Architecture - -``` -cmd/dnswatcher/main.go Entry point (uber/fx bootstrap) - -internal/ - config/config.go Viper-based configuration - globals/globals.go Build-time variables (version) - logger/logger.go slog structured logging (TTY detection) - healthcheck/healthcheck.go Health check service - middleware/middleware.go HTTP middleware (logging, CORS, security - headers, metrics auth and rate limit) - handlers/handlers.go HTTP request handlers - server/ - server.go HTTP server lifecycle - routes.go Route definitions - state/state.go JSON file state persistence - resolver/resolver.go Iterative DNS resolution engine - portcheck/portcheck.go TCP port connectivity checker - tlscheck/tlscheck.go TLS certificate inspector - notify/notify.go Notification service (Slack, Mattermost, ntfy) - watcher/watcher.go Main monitoring orchestrator and scheduler - livednstest/livednstest.go Retry and concurrency limit for tests - against live DNS (imported only by tests) -``` - -### Design Principles - -- **No recursive resolvers**: All DNS resolution is performed iteratively, - tracing from root nameservers through the delegation chain to authoritative - servers. -- **No external database**: State is persisted as a single JSON file. -- **Dependency injection**: All components are wired via - [uber/fx](https://github.com/uber-go/fx). -- **Structured logging**: All logs use `log/slog` with JSON output in production - (TTY detection for development). -- **Graceful shutdown**: All background goroutines respect context cancellation - and the fx lifecycle. In-flight notification deliveries are drained on - shutdown, bounded by the shutdown timeout. - ---- - ## Configuration Configuration is loaded via [Viper](https://github.com/spf13/viper) with the @@ -667,6 +648,68 @@ configuration. --- +## Rationale + +dnswatcher exists to report changes to the DNS records, TCP port availability +and TLS certificates of its configured domains and hostnames, failures and +recoveries included: it is designed as a real-time change feed. It queries the +authoritative nameservers directly, tracing from the root, instead of a +recursive resolver, so no resolver's cache or filtering hides a change and +nameservers that disagree with each other are seen. Its state is a single JSON +file, so it survives a restart without an external database. + +--- + +## Design + +``` +cmd/dnswatcher/main.go Entry point (uber/fx bootstrap) + +internal/ + config/config.go Viper-based configuration + globals/globals.go Build-time variables (version) + logger/logger.go slog structured logging (TTY detection) + healthcheck/healthcheck.go Health check service + middleware/middleware.go HTTP middleware (logging, CORS, security + headers, metrics auth and rate limit) + handlers/handlers.go HTTP request handlers + server/ + server.go HTTP server lifecycle + routes.go Route definitions + state/state.go JSON file state persistence + resolver/resolver.go Iterative DNS resolution engine + portcheck/portcheck.go TCP port connectivity checker + tlscheck/tlscheck.go TLS certificate inspector + notify/notify.go Notification service (Slack, Mattermost, ntfy) + watcher/watcher.go Main monitoring orchestrator and scheduler + livednstest/livednstest.go Retry and concurrency limit for tests + against live DNS (imported only by tests) +``` + +### Design Principles + +- **No recursive resolvers**: All DNS resolution is performed iteratively, + tracing from root nameservers through the delegation chain to authoritative + servers. +- **No external database**: State is persisted as a single JSON file. +- **Dependency injection**: All components are wired via + [uber/fx](https://github.com/uber-go/fx). +- **Structured logging**: All logs use `log/slog` with JSON output in production + (TTY detection for development). +- **Graceful shutdown**: All background goroutines respect context cancellation + and the fx lifecycle. In-flight notification deliveries are drained on + shutdown, bounded by the shutdown timeout. + +--- + +## TODO + +[`TODO.md`](./TODO.md) names the next step and the steps planned after it. The +work for 1.0 is tracked as issues on the +[1.0 milestone](https://git.eeqj.de/sneak/dnswatcher/milestone/7). + +--- + ## License dnswatcher is released under the MIT License, Copyright (c) 2026 diff --git a/TODO.md b/TODO.md index 8972d49..596779e 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: README has Getting Started, Rationale and TODO sections, and its + Architecture section is now Design, in the order policy sets (closes #173). - 2026-10-01: a zone's server that answers SERVFAIL or a referral leading no closer is passed over for the next, as one that times out is (closes #197). - 2026-10-01: when none of a configured name's nameservers answered, the port @@ -122,7 +124,5 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: https://git.eeqj.de/sneak/dnswatcher/issues/66 - README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 -- README sections required by policy: - https://git.eeqj.de/sneak/dnswatcher/issues/173 - fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138 - review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144 -- 2.54.0 From 1f1640d4cd75bcfebf35d9ab4be233d15b2c7480 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 01:48:54 +0200 Subject: [PATCH 43/92] resolver: take a domain's NS set from its delegation (closes #200) A domain's NS set was taken from whichever of its own servers answered first, so when they disagree (during a move between DNS providers, or with a stale secondary) the set could change between checks and send an NS change notification with nothing changed. The walk now stops at the referral to the domain from its parent zone's servers and returns that delegation, which those servers all hold alike; the domain's own servers are no longer asked for it. The NS records in an answer are still used where no such referral comes first, as from a server that holds both the parent zone and the domain. Hostnames get their zone's servers the same way. Model: opus-5-5 --- TODO.md | 2 + internal/resolver/export_test.go | 5 +++ internal/resolver/iterative.go | 33 ++++++++++++---- internal/resolver/iterative_test.go | 60 +++++++++++++++++++++++++++-- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/TODO.md b/TODO.md index 596779e..aa422dc 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: a domain's NS set is its delegation from the parent zone's + servers, not whichever of its own servers answered first (closes #200). - 2026-10-01: README has Getting Started, Rationale and TODO sections, and its Architecture section is now Design, in the order policy sets (closes #173). - 2026-10-01: a zone's server that answers SERVFAIL or a referral leading no diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 73c3a33..5c59228 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -16,6 +16,11 @@ func UsableReply(resp *dns.Msg, zone string, name string) bool { return usableReply(resp, zone, name) } +// NSSetFrom exports nsSetFrom for testing. +func NSSetFrom(resp *dns.Msg, domain string) []string { + return nsSetFrom(resp, domain) +} + // CollectIPs exports collectIPs for testing. func CollectIPs( results map[string]*NameserverResponse, diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 7c5cfa7..d495193 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -222,9 +222,9 @@ func (r *Resolver) followDelegation( return nil, err } - ansNS := extractNSSet(resp.Answer) - if len(ansNS) > 0 { - return ansNS, nil + nsSet := nsSetFrom(resp, domain) + if len(nsSet) > 0 { + return nsSet, nil } // An authoritative reply comes from the servers of the zone @@ -325,6 +325,21 @@ func referralZone(resp *dns.Msg) string { return "" } +// nsSetFrom returns the NS set of domain that resp, a reply to a query +// for domain's NS records, gives: the delegation in a referral to domain +// itself, or else the NS records in the answer; empty when it gives +// neither. A referral to domain comes from its parent zone's servers, +// which all hold the same delegation, so the set does not depend on +// which of them answered. domain's own servers, which can disagree about +// their NS records, are then not asked. +func nsSetFrom(resp *dns.Msg, domain string) []string { + if referralZone(resp) == domain { + return extractNSSet(resp.Ns) + } + + return extractNSSet(resp.Answer) +} + func (r *Resolver) resolveNSIPs( ctx context.Context, nsNames []string, @@ -372,7 +387,7 @@ func (r *Resolver) resolveNSIterative( return nil, err } - nsNames := extractNSSet(resp.Answer) + nsNames := nsSetFrom(resp, domain) if len(nsNames) > 0 { return nsNames, nil } @@ -465,7 +480,8 @@ func (r *Resolver) resolveARecord( // FindAuthoritativeNameservers traces the delegation chain from // root servers to discover all authoritative nameservers for the -// given domain. For a name that is not a zone apex it tries each +// given domain, as the delegation from its parent zone's servers lists +// them. For a name that is not a zone apex it tries each // parent name in turn, so it returns the nameservers of the zone the // name is in. func (r *Resolver) FindAuthoritativeNameservers( @@ -631,9 +647,10 @@ func (r *Resolver) querySingleType( // A reply with no answer that lists other nameservers, from a server // that does not hold the name's zone, is a referral and says nothing - // about the name's records. A parent zone's servers send one when - // every server of the name's own zone failed and - // FindAuthoritativeNameservers moved on to the parent name. + // about the name's records. A server named in the delegation that + // does not hold the zone may send one, as do a parent zone's servers + // when FindAuthoritativeNameservers found no delegation for the + // name's zone and moved on to a parent name. if !msg.Authoritative && len(msg.Answer) == 0 && len(extractNSSet(msg.Ns)) > 0 { state.gotReferral = true diff --git a/internal/resolver/iterative_test.go b/internal/resolver/iterative_test.go index cc1b30b..413cc71 100644 --- a/internal/resolver/iterative_test.go +++ b/internal/resolver/iterative_test.go @@ -41,8 +41,15 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) { assert.Empty(t, ips) } -// exampleCom is the zone most cases of TestUsableReply are about. -const exampleCom = "example.com." +const ( + // exampleCom is the zone most cases of TestUsableReply and + // TestNSSetFrom are about, and wwwExampleCom a name in it. + exampleCom = "example.com." + wwwExampleCom = "www.example.com." + + // exampleNS is the server the NS records nsRecord builds name. + exampleNS = "ns1.example.net." +) // nsRecord builds an NS record that names a server of zone. func nsRecord(zone string) *dns.NS { @@ -50,7 +57,7 @@ func nsRecord(zone string) *dns.NS { Hdr: dns.RR_Header{ Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, }, - Ns: "ns1.example.net.", + Ns: exampleNS, } } @@ -105,7 +112,7 @@ func TestUsableReply(t *testing.T) { }, { name: "com refers to example.com", resp: referralTo(exampleCom), - zone: "com.", query: "www.example.com.", want: true, + zone: "com.", query: wwwExampleCom, want: true, }, { name: "referral back to the zone", resp: referralTo(exampleCom), @@ -132,6 +139,51 @@ func TestUsableReply(t *testing.T) { } } +// TestNSSetFrom checks which NS set a reply gives for a domain; a set +// that is not empty ends the walk. The referral to example.com that +// com's servers all send alike gives its delegation, so the set is the +// same whichever of them answered, and example.com's own servers, which +// can disagree, are not asked. +func TestNSSetFrom(t *testing.T) { + t.Parallel() + + answer := new(dns.Msg) + answer.Authoritative = true + answer.Answer = []dns.RR{nsRecord(exampleCom)} + + tests := []struct { + name string + resp *dns.Msg + domain string + want []string + }{ + { + name: "com refers to example.com", resp: referralTo(exampleCom), + domain: exampleCom, want: []string{exampleNS}, + }, + { + name: "com refers on, for www.example.com", + resp: referralTo(exampleCom), domain: wwwExampleCom, + want: nil, + }, + { + name: "answer from a server that holds example.com", + resp: answer, domain: exampleCom, + want: []string{exampleNS}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.ElementsMatch(t, tt.want, + resolver.NSSetFrom(tt.resp, tt.domain), + ) + }) + } +} + func TestExtractRecordValue_LetterCase(t *testing.T) { t.Parallel() -- 2.54.0 From c9510a986c77b95e23d048f9d907ebedd579d3b6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 01:58:28 +0200 Subject: [PATCH 44/92] watcher: warn of an expiring certificate on every TLS check (closes #204) An expiry warning was skipped when the last one for that hostname and address was sent less than DNSWATCHER_TLS_INTERVAL ago. Each TLS check runs after a DNS pass of varying length, so two checks can be less than the interval apart, and a certificate about to expire was warned about on every check or every other check, at random. TLS checks already start once per interval, so the in-memory record of when each warning was sent is removed and every check warns, as the README says. The test that expected the second check to stay silent is replaced by one that runs TLS checks on state built in the test, with no DNS. Model: opus-5-5 --- TODO.md | 2 ++ internal/watcher/export_test.go | 22 +++++++----- internal/watcher/watcher.go | 56 ++++++++++------------------- internal/watcher/watcher_test.go | 61 +++++++++++++++++++++----------- 4 files changed, 74 insertions(+), 67 deletions(-) diff --git a/TODO.md b/TODO.md index aa422dc..6b45c81 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: a certificate within the expiry warning period is warned about on + every TLS check, where some checks used to skip it at random (closes #204). - 2026-10-01: a domain's NS set is its delegation from the parent zone's servers, not whichever of its own servers answered first (closes #200). - 2026-10-01: README has Getting Started, Rationale and TODO sections, and its diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 557e787..59f0319 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -20,15 +20,14 @@ func NewForTest( n Notifier, ) *Watcher { return &Watcher{ - log: slog.Default(), - config: cfg, - state: st, - resolver: res, - portCheck: pc, - tlsCheck: tc, - notify: n, - firstRun: true, - expiryNotified: make(map[string]time.Time), + log: slog.Default(), + config: cfg, + state: st, + resolver: res, + portCheck: pc, + tlsCheck: tc, + notify: n, + firstRun: true, } } @@ -72,6 +71,11 @@ func (w *Watcher) CheckAllPorts(ctx context.Context) { w.checkAllPorts(ctx) } +// RunTLSChecks exports runTLSChecks for testing. +func (w *Watcher) RunTLSChecks(ctx context.Context) { + w.runTLSChecks(ctx) +} + // BuildHostnameState exports buildHostnameState for testing. func BuildHostnameState( results map[string]*resolver.NameserverResponse, diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2df33f4..bcee203 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -7,7 +7,6 @@ import ( "slices" "sort" "strings" - "sync" "time" "go.uber.org/fx" @@ -49,18 +48,16 @@ type Params struct { // Watcher orchestrates all monitoring checks on a schedule. type Watcher struct { - log *slog.Logger - config *config.Config - state *state.State - resolver DNSResolver - portCheck PortChecker - tlsCheck TLSChecker - notify Notifier - cancel context.CancelFunc - done chan struct{} // closed when Run returns - firstRun bool - expiryNotifiedMu sync.Mutex - expiryNotified map[string]time.Time + log *slog.Logger + config *config.Config + state *state.State + resolver DNSResolver + portCheck PortChecker + tlsCheck TLSChecker + notify Notifier + cancel context.CancelFunc + done chan struct{} // closed when Run returns + firstRun bool } // New creates a new Watcher instance wired into the fx lifecycle. @@ -69,15 +66,14 @@ func New( params Params, ) (*Watcher, error) { w := &Watcher{ - log: params.Logger.Get(), - config: params.Config, - state: params.State, - resolver: params.Resolver, - portCheck: params.PortCheck, - tlsCheck: params.TLSCheck, - notify: params.Notify, - firstRun: true, - expiryNotified: make(map[string]time.Time), + log: params.Logger.Get(), + config: params.Config, + state: params.State, + resolver: params.Resolver, + portCheck: params.PortCheck, + tlsCheck: params.TLSCheck, + notify: params.Notify, + firstRun: true, } lifecycle.Append(fx.Hook{ @@ -1028,22 +1024,6 @@ func (w *Watcher) checkTLSExpiry( return } - // Deduplicate expiry warnings: don't re-notify for the same - // hostname within the TLS check interval. - dedupKey := fmt.Sprintf("expiry:%s:%s", hostname, ip) - - w.expiryNotifiedMu.Lock() - - lastNotified, seen := w.expiryNotified[dedupKey] - if seen && time.Since(lastNotified) < w.config.TLSInterval { - w.expiryNotifiedMu.Unlock() - - return - } - - w.expiryNotified[dedupKey] = time.Now() - w.expiryNotifiedMu.Unlock() - msg := fmt.Sprintf( "Host: %s\nIP: %s\nCN: %s\n"+ "Expires: %s (%.0f days)", diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index c166a3e..9918fc8 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -615,33 +615,54 @@ func TestTLSExpiryWarning(t *testing.T) { assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning") } -func TestTLSExpiryWarningDedup(t *testing.T) { +// TestTLSExpiryWarningEachCheck runs the TLS checks three times in a +// row on hostname and port state built here, for a certificate that +// expires within the warning period. Each check warns once, whether the +// TLS interval is a nanosecond, shorter than the time between two +// checks, or a day, longer than it. +func TestTLSExpiryWarningEachCheck(t *testing.T) { t.Parallel() - cfg := defaultTestConfig(t) - cfg.Hostnames = []string{testHost} - cfg.TLSInterval = 24 * time.Hour + title := "TLS Expiry Warning: " + host - title := "TLS Expiry Warning: " + testHost + for _, interval := range []time.Duration{time.Nanosecond, 24 * time.Hour} { + t.Run(interval.String(), func(t *testing.T) { + t.Parallel() - // The second check comes within the TLS interval of the first, - // so it must not warn again. - var warnings int + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{host} + cfg.TLSInterval = interval - deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) { - warnings = countNotifications(deps, title) - }) + // The TLS checks read the saved hostname and port state and + // look nothing up, so the watcher has no resolver. + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) - if warnings == 0 { - t.Fatal("expected expiry warnings from the first check") - } + expiresInThreeDays(deps) + deps.state.SetHostnameState(host, saved( + map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip1}}), + }, + )) + deps.state.SetPortState(ip1+":443", &state.PortState{ + Open: true, Hostnames: []string{host}, + }) - got := countNotifications(deps, title) - if got != warnings { - t.Errorf( - "expected %d expiry warnings (dedup), got %d", - warnings, got, - ) + for check := 1; check <= 3; check++ { + w.RunTLSChecks(t.Context()) + + got := countNotifications(deps, title) + if got != check { + t.Fatalf( + "after check %d: %d expiry warnings, want %d", + check, got, check, + ) + } + } + }) } } -- 2.54.0 From 56c4395a394c48b42eea5ebf6e8e49127d9f29b4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 02:56:11 +0200 Subject: [PATCH 45/92] config: watch a target listed twice only once (closes #207) ClassifyTargets kept a name every time it appeared in DNSWATCHER_TARGETS, so example.com,Example.com. put example.com in the domain list twice and every check looked it up and checked its certificates twice, sending two expiry warnings per TLS check (port checks were already grouped by address and port). It now skips a name it has already kept, comparing after the lower-casing and trailing-dot removal it already did; the list keeps the order of first appearance. Model: opus-5-5 --- README.md | 3 ++- TODO.md | 2 ++ internal/config/classify.go | 9 +++++++-- internal/config/classify_test.go | 24 ++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d1ec4db..c195381 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,8 @@ following precedence (highest to lowest): monitoring targets are configured. A monitoring daemon with nothing to monitor is a misconfiguration, so dnswatcher fails fast with a clear error message rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated list -of DNS names before starting. +of DNS names before starting. A name listed more than once, in any letter case +or with a trailing dot, is watched once. **`/metrics` is rate limited.** Each client address may send it 30 requests a minute, failed logins included; beyond that it answers `429 Too Many Requests` diff --git a/TODO.md b/TODO.md index 6b45c81..f08c72d 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any + letter case or with a trailing dot, is watched once (closes #207). - 2026-10-01: a certificate within the expiry warning period is warned about on every TLS check, where some checks used to skip it at random (closes #204). - 2026-10-01: a domain's NS set is its delegation from the parent zone's diff --git a/internal/config/classify.go b/internal/config/classify.go index 1076215..ef10036 100644 --- a/internal/config/classify.go +++ b/internal/config/classify.go @@ -57,17 +57,22 @@ func ClassifyDNSName(name string) (DNSNameType, error) { // ClassifyTargets splits a list of DNS names into apex domains and // hostnames using the Public Suffix List. It returns an error if any -// name cannot be classified. +// name cannot be classified. A name given more than once, in any letter +// case or with a trailing dot, is kept once. func ClassifyTargets(targets []string) ([]string, []string, error) { var domains, hostnames []string + seen := make(map[string]bool) + for _, t := range targets { normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(t), ".")) - if normalized == "" { + if normalized == "" || seen[normalized] { continue } + seen[normalized] = true + typ, classErr := ClassifyDNSName(normalized) if classErr != nil { return nil, nil, classErr diff --git a/internal/config/classify_test.go b/internal/config/classify_test.go index a9c03af..a82c853 100644 --- a/internal/config/classify_test.go +++ b/internal/config/classify_test.go @@ -1,6 +1,7 @@ package config_test import ( + "slices" "testing" "sneak.berlin/go/dnswatcher/internal/config" @@ -93,6 +94,29 @@ func TestClassifyTargets(t *testing.T) { } } +func TestClassifyTargetsKeepsEachNameOnce(t *testing.T) { + t.Parallel() + + domains, hostnames, err := config.ClassifyTargets([]string{ + "example.org", + "Example.org.", + "www.example.org", + "EXAMPLE.ORG", + "WWW.Example.org.", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !slices.Equal(domains, []string{"example.org"}) { + t.Errorf("domains = %v, want [example.org]", domains) + } + + if !slices.Equal(hostnames, []string{"www.example.org"}) { + t.Errorf("hostnames = %v, want [www.example.org]", hostnames) + } +} + func TestClassifyTargetsRejectsPublicSuffix(t *testing.T) { t.Parallel() -- 2.54.0 From af81f2ac7607c97228879dedc85b76c650313ce4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 03:14:26 +0200 Subject: [PATCH 46/92] README: correct claims the code does not bear out (closes #108) Checked every README claim against the code on next and fixed the ones that were wrong or missing: what /metrics serves and when, what DNSWATCHER_MAINTENANCE_MODE does, CORS on the public routes, notification retries and the in-memory alert history, the certificate error field and old port entries in the state file, and the Design tree's missing files. Also corrected: CNAMEs are not followed for watched names, the root server list is never refreshed, the NS set is the delegation from the domain's parent zone, notification contents, and the system resolver being used for webhooks. Code problems found are filed separately. Model: opus-5-5 --- README.md | 145 +++++++++++++++++++++++++++++++++++++----------------- TODO.md | 3 +- 2 files changed, 102 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index c195381..2c1c218 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ dnswatcher watches configured DNS domains and hostnames for changes, monitors TCP port availability, tracks TLS certificate expiry, and delivers real-time notifications via Slack, Mattermost, and/or ntfy webhooks. -It performs all DNS resolution itself via iterative (non-recursive) queries, +It resolves the names it watches itself via iterative (non-recursive) queries, tracing from root nameservers to authoritative servers directly—never relying on upstream recursive resolvers. @@ -69,14 +69,14 @@ notification endpoint set, changes show only on the dashboard; see - Accepts a list of DNS domain names (apex domains, identified via the [Public Suffix List](https://publicsuffix.org/)). -- Every **1 hour**, performs a full iterative trace from root servers to - discover all authoritative nameservers (NS records) for each domain. +- Every **1 hour** by default, performs a full iterative trace from root servers + to discover all authoritative nameservers (NS records) for each domain. - Queries **every** discovered authoritative nameserver independently. -- Stores the NS record set as observed by the delegation chain, and the IPv4 and - IPv6 addresses each nameserver's name resolves to. +- Stores the domain's NS record set, as its parent zone's servers delegate it, + and the IPv4 and IPv6 addresses each nameserver's name resolves to. - Any change triggers a notification: - - NS added to or removed from the delegation. - - NS address change: a nameserver that stays in the delegation resolves to + - NS added to or removed from that set. + - NS address change: a nameserver that stays in the set resolves to different addresses than on the previous check. A nameserver added or removed gets only the NS change notification. When the lookup of a nameserver's addresses fails or finds none, its previous addresses are @@ -86,7 +86,7 @@ notification endpoint set, changes show only on the dashboard; see - Accepts a list of DNS hostnames (subdomains, distinguished from apex domains via the Public Suffix List). -- Every **1 hour**, performs a full iterative trace to discover the +- Every **1 hour** by default, performs a full iterative trace to discover the authoritative nameservers of the zone the hostname is in, which is not always its last two labels (a name under `co.uk`, or in a delegated subdomain). - Queries **each** authoritative nameserver independently for **all** record @@ -125,13 +125,16 @@ notification endpoint set, changes show only on the dashboard; see ### TCP Port Monitoring - For every configured domain and hostname, constructs a deduplicated list of - all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain resolution - across all authoritative nameservers. + the IPv4 and IPv6 addresses in the A and AAAA records its authoritative + nameservers returned. A CNAME is not followed: a name whose CNAME points into + another zone usually has no addresses here, so its ports and certificate are + not checked. - Checks TCP connectivity on ports **80** and **443** for each IP address. -- Every **1 hour**, re-checks all ports. +- Every **1 hour** by default, re-checks all ports. - Any change in port availability triggers a notification: - Port transitioned from open to closed (or vice versa). - - New IP appeared (from DNS change) and its port state was recorded. + - New IP appeared (from DNS change): its port state is recorded without a + port notification; the DNS change notification shows the new address. - IP disappeared (from DNS change) — noted in the DNS change notification; port state for that IP is removed. When none of a name's nameservers answered, its addresses are not known, so the port state saved for them is @@ -139,14 +142,14 @@ notification endpoint set, changes show only on the dashboard; see ### TLS Certificate Monitoring -- Every **12 hours**, for each IP address listening on port 443, connects via - TLS using the correct SNI hostname. +- Every **12 hours** by default, for each IP address listening on port 443, + connects via TLS using the correct SNI hostname. - Records the certificate's Subject CN, SANs, issuer, and expiry date. - Any change triggers a notification: - - Certificate is expiring within **7 days** (warning, repeated each check - until renewed or expired). + - Certificate is expiring within **7 days** by default (warning, repeated + each check until renewed or expired). - Certificate CN, issuer, or SANs changed (replacement detected, reports old - and new values). + and new CN and issuer). - TLS connection failure to a previously-reachable IP:443 (handshake error, timeout, connection refused after previously succeeding). - TLS recovery: a previously-failing IP:443 now completes a handshake again. @@ -178,15 +181,25 @@ includes: - **NS recoveries**: Which nameserver recovered, which hostname/domain. - **NS inconsistencies**: Which nameservers disagree, what each one returned, which hostname affected. -- **Port changes**: Which IP:port, old state, new state, all associated - hostnames. -- **TLS expiry warnings**: Which certificate, days remaining, CN, issuer, - associated hostname and IP. -- **TLS certificate changes**: Old and new CN/issuer/SANs, associated hostname - and IP. +- **Port changes**: Which IP:port, its new state, all associated hostnames. +- **TLS expiry warnings**: Expiry date and days remaining, CN, associated + hostname and IP. +- **TLS certificate changes**: Old and new CN and issuer, associated hostname + and IP. A change to the SANs alone is notified, but the SANs are not listed. - **TLS connection failures/recoveries**: Which IP:port, error details, associated hostname. +Each endpoint is sent each notification on its own, in the background. A +delivery that fails (a network error, no reply within 10 seconds, or an HTTP +status of 400 or more) is retried up to 5 times: the first retry after about 1 +second, each wait after that twice as long up to 60 seconds, every wait varied +at random by up to 25%. A delivery still failing after that is logged and +dropped. + +The last 100 notifications, delivered or not, are kept in memory for the +dashboard's Recent alerts. They are not saved to the state file, so a restart +clears them. + ### State Management - All monitoring state is kept in memory and persisted to a JSON file on disk @@ -230,7 +243,16 @@ dnswatcher exposes a lightweight HTTP API for operational visibility: | `GET /.well-known/healthcheck` | Health check (JSON) | | `GET /health` | Health check (JSON, legacy) | | `GET /api/v1/status` | Current monitoring state | -| `GET /metrics` | Prometheus metrics (optional) | +| `GET /metrics` | Prometheus metrics, see below | + +`/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind +Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime, +process, and counts of `/metrics` requests); dnswatcher records no metrics of +its own. + +Every route but `/metrics` may be read from a page on any origin: a cross-origin +`GET` gets `Access-Control-Allow-Origin: *`. Only `GET` is allowed cross-origin, +and without credentials. `/metrics` sends no CORS headers. #### Server timeouts @@ -321,8 +343,8 @@ following precedence (highest to lowest): | `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` | | `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` | | `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | -| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` | -| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` | +| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` | +| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` | | `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | | `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | @@ -373,13 +395,15 @@ DNSWATCHER_SEND_TEST_NOTIFICATION=true ## DNS Resolution Strategy -dnswatcher never uses the system's configured recursive resolver. Instead, it -performs full iterative resolution: +dnswatcher never uses the system's configured recursive resolver for the names +it watches. Instead, it performs full iterative resolution: -1. **Root servers**: Starts from the IANA root nameserver list (hardcoded, with - periodic refresh). +1. **Root servers**: Starts from the IPv4 addresses of the 13 root servers, + built into the binary; the list is not refreshed. 2. **TLD delegation**: Queries root servers for the TLD NS records. 3. **Domain delegation**: Queries TLD nameservers for the domain's NS records. + The delegation they give, from the domain's parent zone, is the domain's NS + record set. 4. **Authoritative query**: Queries all discovered authoritative nameservers directly for the requested records. @@ -388,10 +412,13 @@ This approach ensures: - Independence from any upstream resolver's cache or filtering. - Ability to detect split-horizon or inconsistent responses across authoritative servers. -- Visibility into the full delegation chain. -For hostname monitoring, the resolver follows CNAME chains (with a depth limit -to prevent loops) before collecting terminal A/AAAA records. +CNAME chains are followed (with a depth limit to prevent loops) only to find the +addresses of nameservers. A watched name's records are stored as its nameservers +return them, CNAME included, without following it. + +Sending a notification or a Sentry report is the one use of the system's +resolver: the HTTP client looks up the webhook's or Sentry's host name with it. --- @@ -473,12 +500,17 @@ reachability: A nameserver that answers NXDOMAIN or with no records has status `ok` and empty `records`. A nameserver whose query failed, or that only referred it to other -nameservers, has status `error`, empty `records`, and the reason in `error`. +nameservers, has status `error`, empty `records`, and the reason in `error`. A +certificate entry whose TLS connection or handshake failed likewise has status +`error`, the reason in `error`, and the certificate fields left empty or zero. `nameserverAddresses` lists, by nameserver, the sorted addresses its name resolves to. A state file without it loads, and the next check fills it in without a notification. +A port entry in the older format, with one `hostname` instead of the `hostnames` +list, loads as a list of that one name. + --- ## Entrypoints @@ -616,9 +648,10 @@ docker run -d \ - Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes. - TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after DNS completes. - - Port and TLS checks always use freshly resolved IP addresses from the DNS - phase that immediately precedes them — never stale IPs from a previous - cycle. + - Port and TLS checks use the IP addresses found by the DNS phase that + immediately precedes them. When that phase cannot find a name's + nameservers at all, the addresses an earlier check saved for the name are + used. 4. **On change detection**: Send notifications to all configured endpoints, update in-memory state, persist to disk. 5. **Shutdown**: The watcher stops checking and saves the final state to disk, @@ -667,29 +700,51 @@ file, so it survives a restart without an external database. cmd/dnswatcher/main.go Entry point (uber/fx bootstrap) internal/ - config/config.go Viper-based configuration + config/ + config.go Viper-based configuration + classify.go Splits targets into domains and hostnames + (Public Suffix List) globals/globals.go Build-time variables (version) logger/logger.go slog structured logging (TTY detection) healthcheck/healthcheck.go Health check service - middleware/middleware.go HTTP middleware (logging, CORS, security + middleware/middleware.go HTTP middleware (logging, CORS, security headers, metrics auth and rate limit) - handlers/handlers.go HTTP request handlers + handlers/ + handlers.go Shared handler setup and JSON responses + dashboard.go Web dashboard + templates/dashboard.html Dashboard template (embedded) + status.go /api/v1/status + healthcheck.go Health check handler server/ server.go HTTP server lifecycle routes.go Route definitions state/state.go JSON file state persistence - resolver/resolver.go Iterative DNS resolution engine + resolver/ + resolver.go Resolver setup and query status values + iterative.go Iterative DNS resolution engine + dns_client.go UDP and TCP DNS clients + errors.go Resolver errors portcheck/portcheck.go TCP port connectivity checker - tlscheck/tlscheck.go TLS certificate inspector - notify/notify.go Notification service (Slack, Mattermost, ntfy) - watcher/watcher.go Main monitoring orchestrator and scheduler + tlscheck/tlscheck.go TLS certificate inspector + notify/ + notify.go Notification service (Slack, Mattermost, ntfy) + retry.go Delivery retries with backoff + history.go Last 100 notifications, for the dashboard + shutdown.go Waits for deliveries at shutdown + watcher/ + watcher.go Main monitoring orchestrator and scheduler + interfaces.go The resolver, checkers and notifier it uses livednstest/livednstest.go Retry and concurrency limit for tests against live DNS (imported only by tests) + +static/ + static.go Embeds the CSS served under /s/ + css/tailwind.min.css Dashboard stylesheet ``` ### Design Principles -- **No recursive resolvers**: All DNS resolution is performed iteratively, +- **No recursive resolvers**: The watched names are resolved iteratively, tracing from root nameservers through the delegation chain to authoritative servers. - **No external database**: State is persisted as a single JSON file. diff --git a/TODO.md b/TODO.md index f08c72d..dca30de 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 - 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any letter case or with a trailing dot, is watched once (closes #207). +- 2026-10-01: README checked against the code and corrected: metrics, CORS, + notification retries, CNAMEs, state file fields, Design tree (closes #108). - 2026-10-01: a certificate within the expiry warning period is warned about on every TLS check, where some checks used to skip it at random (closes #204). - 2026-10-01: a domain's NS set is its delegation from the parent zone's @@ -129,6 +131,5 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: https://git.eeqj.de/sneak/dnswatcher/issues/66 -- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 - fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138 - review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144 -- 2.54.0 From 82836b41fdcb698bdef36782c543e94bb318760e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 03:26:06 +0200 Subject: [PATCH 47/92] resolver: try servers in a random order on each resolution (closes #138) Every resolution walked the root servers in a fixed order, so a.root-servers.net got every first query and its timeouts were paid on every lookup. Each list of servers the resolver walks is now walked in a random order from rand.Shuffle, chosen anew each time; a server that does not reply, refuses, or gives an error reply or a referral that leads no closer is still passed over for the next. When a referral names a zone's nameservers without their addresses, all of them are now looked up, not only the first that resolves, so the zone is not given up because the first nameserver whose address was found gave no usable reply. No test fails if the walk stops shuffling: which server a live query reached is not observable. Model: opus-5-5 --- README.md | 7 ++++++ TODO.md | 3 ++- internal/livednstest/livednstest.go | 10 ++++----- internal/resolver/export_test.go | 21 ++++++++++++++++++ internal/resolver/iterative.go | 34 ++++++++++++++++++++++------- internal/resolver/iterative_test.go | 29 ++++++++++++++++++++++++ internal/resolver/livedns_test.go | 34 +++++++++++++++++++++++++++++ internal/resolver/resolver_test.go | 22 +++++++++++++++++++ 8 files changed, 146 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2c1c218..94bd31d 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,13 @@ it watches. Instead, it performs full iterative resolution: 4. **Authoritative query**: Queries all discovered authoritative nameservers directly for the requested records. +In steps 2 and 3 the servers are asked one at a time in a random order, chosen +anew each time, so no one root server gets every first query. A server that does +not reply, refuses the query, or gives an error reply such as SERVFAIL or a +referral that leads no closer to the name is passed over for the next one. When +a referral names a zone's nameservers without their addresses, the addresses of +all of them are looked up, so that each can be asked. + This approach ensures: - Independence from any upstream resolver's cache or filtering. diff --git a/TODO.md b/TODO.md index dca30de..49e335e 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: the resolver tries root servers, and every other server list it + walks, in a random order each time, not always from the top (closes #138). - 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any letter case or with a trailing dot, is watched once (closes #207). - 2026-10-01: README checked against the code and corrected: metrics, CORS, @@ -131,5 +133,4 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: https://git.eeqj.de/sneak/dnswatcher/issues/66 -- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138 - review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144 diff --git a/internal/livednstest/livednstest.go b/internal/livednstest/livednstest.go index 1554596..338f3aa 100644 --- a/internal/livednstest/livednstest.go +++ b/internal/livednstest/livednstest.go @@ -9,11 +9,11 @@ // // 1. Bounded concurrency. Tests run in parallel and the build hosts // have many cores, so without a limit every test starts its own -// iterative resolution at the same instant and they all hit the -// first root server within a few milliseconds of each other. Root -// servers rate-limit that, which shows up as a different arbitrary -// subset of tests failing on each run. Run caps how many live -// operations are in flight at once in one test binary. +// iterative resolution at the same instant and they all send their +// first queries to the root servers within a few milliseconds of +// each other. Root servers rate-limit that, which shows up as a +// different arbitrary subset of tests failing on each run. Run caps +// how many live operations are in flight at once in one test binary. // // 2. Retry with exponential backoff. Each live operation gets several // attempts with its own timeout. An attempt is retried when it diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 5c59228..868174c 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -36,3 +36,24 @@ func (r *Resolver) QueryEachNS( ) (map[string]*NameserverResponse, error) { return r.queryEachNS(ctx, nameservers, hostname) } + +// ResolveNSIPs exports resolveNSIPs for testing. +func (r *Resolver) ResolveNSIPs( + ctx context.Context, + nsNames []string, +) []string { + return r.resolveNSIPs(ctx, nsNames) +} + +// RootServerList exports rootServerList for testing. +func RootServerList() []string { + return rootServerList() +} + +// Shuffled exports shuffled for testing. +func Shuffled( + servers []string, + shuffle func(n int, swap func(i, j int)), +) []string { + return shuffled(servers, shuffle) +} diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index d495193..0c526e5 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "math/rand/v2" "net" + "slices" "sort" "strings" "time" @@ -259,9 +261,25 @@ func (r *Resolver) followDelegation( return nil, ErrNoNameservers } -// queryServers asks servers, the servers of zone, about name until one -// gives a usable reply. A server that times out, refuses or gives a -// reply that is not usable is passed over for the next. +// shuffled returns a copy of servers in the order shuffle puts them +// in. The resolver passes rand.Shuffle, so each time it walks a list of +// servers it starts at a random one, and no one server gets every +// first query. +func shuffled( + servers []string, + shuffle func(n int, swap func(i, j int)), +) []string { + order := slices.Clone(servers) + shuffle(len(order), func(i, j int) { + order[i], order[j] = order[j], order[i] + }) + + return order +} + +// queryServers asks servers, the servers of zone, about name in a random +// order until one gives a usable reply. A server that times out, refuses +// or gives a reply that is not usable is passed over for the next. func (r *Resolver) queryServers( ctx context.Context, servers []string, @@ -271,7 +289,7 @@ func (r *Resolver) queryServers( ) (*dns.Msg, error) { var lastErr error - for _, ip := range servers { + for _, ip := range shuffled(servers, rand.Shuffle) { if checkCtx(ctx) != nil { return nil, ErrContextCanceled } @@ -340,6 +358,10 @@ func nsSetFrom(resp *dns.Msg, domain string) []string { return extractNSSet(resp.Answer) } +// resolveNSIPs returns the addresses of every nameserver in nsNames +// whose name resolves, for a referral that carries none. The walk can +// then go on to the zone's other nameservers when one gives no usable +// reply. func (r *Resolver) resolveNSIPs( ctx context.Context, nsNames []string, @@ -351,10 +373,6 @@ func (r *Resolver) resolveNSIPs( if err == nil { ips = append(ips, resolved...) } - - if len(ips) > 0 { - break - } } return ips diff --git a/internal/resolver/iterative_test.go b/internal/resolver/iterative_test.go index 413cc71..4f539ae 100644 --- a/internal/resolver/iterative_test.go +++ b/internal/resolver/iterative_test.go @@ -1,6 +1,8 @@ package resolver_test import ( + "math/rand/v2" + "slices" "testing" "github.com/miekg/dns" @@ -235,3 +237,30 @@ func TestExtractRecordValue_LetterCase(t *testing.T) { }) } } + +// TestShuffled shuffles the root servers with many seeds. Every order +// must hold each root server once, so each is tried before a +// resolution fails; each root server must come first for some seed, so +// no one root server gets every first query; and the list passed in +// must be left as it was. +func TestShuffled(t *testing.T) { + t.Parallel() + + const seeds = 1000 + + roots := resolver.RootServerList() + before := slices.Clone(roots) + first := make(map[string]bool) + + for seed := range uint64(seeds) { + rng := rand.New(rand.NewPCG(seed, 0)) //nolint:gosec // seeded on purpose + order := resolver.Shuffled(roots, rng.Shuffle) + + assert.ElementsMatch(t, roots, order) + + first[order[0]] = true + } + + assert.Len(t, first, len(roots)) + assert.Equal(t, before, roots) +} diff --git a/internal/resolver/livedns_test.go b/internal/resolver/livedns_test.go index fb425a8..0bbccb4 100644 --- a/internal/resolver/livedns_test.go +++ b/internal/resolver/livedns_test.go @@ -383,3 +383,37 @@ func liveResolveIPsAllowingEmpty( return out } + +// liveResolveNSIPs looks up the addresses of the nameservers named +// names, retrying until there are at least atLeast of them: a name +// whose lookup got no reply is left out of the result, not an error. +func liveResolveNSIPs( + t *testing.T, + r *resolver.Resolver, + names []string, + atLeast int, +) []string { + t.Helper() + + var out []string + + livednstest.Retry( + t, + "ResolveNSIPs("+strings.Join(names, ", ")+")", + func(ctx context.Context) error { + ips := r.ResolveNSIPs(ctx, names) + if len(ips) < atLeast { + return fmt.Errorf( + "%w: %d addresses, expected at least %d", + livednstest.ErrNoAnswer, len(ips), atLeast, + ) + } + + out = ips + + return nil + }, + ) + + return out +} diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index dd585c8..abb6b92 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -139,6 +139,28 @@ func TestFindAuthoritativeNameservers_CloudflareDomain( } } +// TestResolveNSIPs_EveryNameserver looks up the addresses of two of +// google.com's nameservers together, as the walk does when a referral +// names a zone's nameservers without their addresses, and compares them +// with each looked up alone. Together they must give the addresses of +// both, not only of the first that resolves, so that when one gives no +// usable reply the walk goes on to the other. +func TestResolveNSIPs_EveryNameserver(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + names := []string{"ns3.google.com.", "ns4.google.com."} + want := make([]string, 0, len(names)) + + for _, name := range names { + want = append(want, liveResolveNSIPs(t, r, []string{name}, 1)...) + } + + got := liveResolveNSIPs(t, r, names, len(want)) + + assert.ElementsMatch(t, want, got) +} + // ---------------------------------------------------------------- // QueryNameserver tests // ---------------------------------------------------------------- -- 2.54.0 From dba932c9e393acba23d02f2be743f5644c7a79d2 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 06:04:31 +0200 Subject: [PATCH 48/92] watcher tests: far fewer live queries, longer live attempts (closes #214) A domain check looked up each nameserver's addresses by asking every nameserver of that name's zone for all eight record types; it now asks only for A, AAAA and CNAME, the ones it reads. The watcher tests now check example.org instead of cloudflare.com (two nameservers instead of five) and desec.io instead of example.com (its nameservers are in zones with two, not cloudflare.com's five). The record change and NS failure tests start from saved state built on one NS lookup instead of a first full check, and the port change test runs only the port checks again. A live test attempt may take 18 seconds, not 8. A new live test checks that a nameserver's addresses include IPv4 and IPv6. Model: opus-5-5 --- TODO.md | 2 + internal/livednstest/livednstest.go | 17 +++- internal/resolver/export_test.go | 2 +- internal/resolver/iterative.go | 59 +++++++++--- internal/resolver/resolver_test.go | 28 ++++++ internal/watcher/watcher_test.go | 139 ++++++++++++++-------------- 6 files changed, 157 insertions(+), 90 deletions(-) diff --git a/TODO.md b/TODO.md index 49e335e..27c8b3f 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: watcher tests send far fewer queries and a live attempt may take + 18s; nameserver addresses are asked only for A, AAAA, CNAME (closes #214). - 2026-10-02: the resolver tries root servers, and every other server list it walks, in a random order each time, not always from the top (closes #138). - 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any diff --git a/internal/livednstest/livednstest.go b/internal/livednstest/livednstest.go index 338f3aa..7ce20cc 100644 --- a/internal/livednstest/livednstest.go +++ b/internal/livednstest/livednstest.go @@ -36,11 +36,18 @@ const ( // before the test fails. attempts = 3 - // AttemptTimeout bounds one attempt. Worst case for an operation - // is attempts * AttemptTimeout plus the backoff — about 26 - // seconds, well inside the 90-second `go test -timeout` backstop - // even when several operations exhaust their attempts. - AttemptTimeout = 8 * time.Second + // AttemptTimeout bounds one attempt. It must fit the longest + // operation, a watcher check, which sends over a hundred queries one + // after another and on a slow build host takes several times as long + // as the few seconds it takes on a fast one. An operation whose + // every attempt fails takes attempts * AttemptTimeout plus the + // backoff, about 56 seconds, after it waits for one of the + // Concurrency slots that every live operation in the test binary + // shares. So when live DNS does not answer at all, a test binary + // with more live operations than slots runs into the 90-second + // `go test -timeout` backstop instead of each test failing on its + // own. + AttemptTimeout = 18 * time.Second // backoffBase is the delay after the first failed attempt; it is // multiplied by backoffFactor each time. diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 868174c..8840dd9 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -34,7 +34,7 @@ func (r *Resolver) QueryEachNS( nameservers []string, hostname string, ) (map[string]*NameserverResponse, error) { - return r.queryEachNS(ctx, nameservers, hostname) + return r.queryEachNS(ctx, nameservers, hostname, recordTypes()) } // ResolveNSIPs exports resolveNSIPs for testing. diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 0c526e5..629e043 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -533,12 +533,39 @@ func (r *Resolver) FindAuthoritativeNameservers( return nil, ErrNoNameservers } +// recordTypes returns the record types a nameserver is asked for when a +// name is checked. +func recordTypes() []uint16 { + return []uint16{ + dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, + dns.TypeMX, dns.TypeTXT, dns.TypeSRV, + dns.TypeCAA, dns.TypeNS, + } +} + +// addressTypes returns the record types ResolveIPAddresses asks for, +// the only ones it reads. +func addressTypes() []uint16 { + return []uint16{dns.TypeA, dns.TypeAAAA, dns.TypeCNAME} +} + // QueryNameserver queries a specific nameserver for all record // types and builds a NameserverResponse. func (r *Resolver) QueryNameserver( ctx context.Context, nsHostname string, hostname string, +) (*NameserverResponse, error) { + return r.queryNameserver(ctx, nsHostname, hostname, recordTypes()) +} + +// queryNameserver queries a specific nameserver for the record types +// in qtypes and builds a NameserverResponse. +func (r *Resolver) queryNameserver( + ctx context.Context, + nsHostname string, + hostname string, + qtypes []uint16, ) (*NameserverResponse, error) { if checkCtx(ctx) != nil { return nil, ErrContextCanceled @@ -551,7 +578,7 @@ func (r *Resolver) QueryNameserver( hostname = dns.Fqdn(hostname) - return r.queryAllTypes(ctx, nsHostname, nsIPs[0], hostname) + return r.queryTypes(ctx, nsHostname, nsIPs[0], hostname, qtypes) } // QueryNameserverIP queries a nameserver by its IP address directly, @@ -568,14 +595,15 @@ func (r *Resolver) QueryNameserverIP( hostname = dns.Fqdn(hostname) - return r.queryAllTypes(ctx, nsHostname, nsIP, hostname) + return r.queryTypes(ctx, nsHostname, nsIP, hostname, recordTypes()) } -func (r *Resolver) queryAllTypes( +func (r *Resolver) queryTypes( ctx context.Context, nsHostname string, nsIP string, hostname string, + qtypes []uint16, ) (*NameserverResponse, error) { resp := &NameserverResponse{ Nameserver: nsHostname, @@ -583,12 +611,6 @@ func (r *Resolver) queryAllTypes( Status: StatusOK, } - qtypes := []uint16{ - dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, - dns.TypeMX, dns.TypeTXT, dns.TypeSRV, - dns.TypeCAA, dns.TypeNS, - } - state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp) classifyResponse(resp, state) @@ -779,18 +801,19 @@ func (r *Resolver) QueryAllNameservers( return nil, err } - return r.queryEachNS(ctx, nameservers, hostname) + return r.queryEachNS(ctx, nameservers, hostname, recordTypes()) } func (r *Resolver) queryEachNS( ctx context.Context, nameservers []string, hostname string, + qtypes []uint16, ) (map[string]*NameserverResponse, error) { results := make(map[string]*NameserverResponse) for _, ns := range nameservers { - resp, err := r.QueryNameserver(ctx, ns, hostname) + resp, err := r.queryNameserver(ctx, ns, hostname, qtypes) // A query the context cut short says nothing about the // nameserver, so it must not be returned as its failure. @@ -835,9 +858,10 @@ func (r *Resolver) LookupAllRecords( } // ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 -// addresses, following CNAME chains up to MaxCNAMEDepth. When no -// nameserver of the name's zone answered, it returns an error rather -// than no addresses. +// addresses, following CNAME chains up to MaxCNAMEDepth. It asks each +// nameserver of the name's zone for its A, AAAA and CNAME records only. +// When no nameserver of the name's zone answered, it returns an error +// rather than no addresses. func (r *Resolver) ResolveIPAddresses( ctx context.Context, hostname string, @@ -858,7 +882,12 @@ func (r *Resolver) resolveIPWithCNAME( return nil, ErrCNAMEDepthExceeded } - results, err := r.QueryAllNameservers(ctx, hostname) + nameservers, err := r.FindAuthoritativeNameservers(ctx, hostname) + if err != nil { + return nil, err + } + + results, err := r.queryEachNS(ctx, nameservers, hostname, addressTypes()) if err != nil { return nil, err } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index abb6b92..30bdaab 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -562,6 +562,34 @@ func TestResolveIPAddresses_CloudflareDomain(t *testing.T) { assert.NotEmpty(t, ips) } +// TestResolveIPAddresses_NameserverIPv4AndIPv6 looks up the addresses of +// one of cloudflare.com's nameservers, as a domain check does for each +// nameserver. That name has A and AAAA records, so both kinds of address +// come back. +func TestResolveIPAddresses_NameserverIPv4AndIPv6(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ns := findOneNSForDomain(t, r, "cloudflare.com") + ips := liveResolveIPs(t, r, ns) + + var ipv4, ipv6 int + + for _, ip := range ips { + parsed := net.ParseIP(ip) + require.NotNil(t, parsed, "should be valid IP: %s", ip) + + if parsed.To4() != nil { + ipv4++ + } else { + ipv6++ + } + } + + assert.Positive(t, ipv4, "no IPv4 address for %s: %v", ns, ips) + assert.Positive(t, ipv6, "no IPv6 address for %s: %v", ns, ips) +} + // ---------------------------------------------------------------- // Context cancellation tests // ---------------------------------------------------------------- diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 9918fc8..b527cb1 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -26,18 +26,22 @@ import ( // The watcher looks these names up in live DNS with the real resolver, // so tests assert on what the watcher does with the answers, never on -// the records these zones publish. testHost's nameservers and addresses -// stay the same from one check to the next, which the tests that check -// it twice rely on, and testSmallDomain's nameservers stay the same -// between a test looking them up and its check. A domain check looks up -// each nameserver's addresses, about a second per nameserver, so the -// tests that check a domain use testSmallDomain, which has two -// nameservers, and check it once. The tests that query testDomain's -// nameservers directly do no domain check. +// the records these zones publish. The nameservers of testHost and +// testSmallDomain stay the same between a test looking them up and its +// check. Every query a check sends is one more that can be lost, so the +// tests keep them few. A check asks each of a name's nameservers about +// every record type, and both names have two. A domain check also looks +// up each nameserver's addresses at every nameserver of the zone that +// nameserver is in: testSmallDomain's nameservers are in zones with two +// nameservers, while a domain whose nameservers are in, say, +// cloudflare.com, which has five, makes each domain check much longer. +// A test checks a domain only when it is about domains, and checks once, +// from saved state it builds, rather than twice. The tests that query +// testDomain's nameservers directly do no domain check. const ( testDomain = "google.com" - testSmallDomain = "example.com" - testHost = "cloudflare.com" + testSmallDomain = "desec.io" + testHost = "example.org" testIssuer = "DigiCert" ) @@ -259,55 +263,48 @@ func checkOnce( // runChecks builds a watcher, lets prepare set up the saved state and // stand-ins it starts from, and runs its checks once against live DNS. -// If change is not nil, change then alters the saved state or stand-ins -// and the checks run a second time. When either check finds no fresh -// address for a name (see checkOnce), the watcher is thrown away and -// all of this runs again on a new one, so a failed attempt leaves -// nothing behind in the saved state, the stand-ins or the notifications. +// When the check finds no fresh address for a name (see checkOnce), the +// watcher is thrown away and all of this runs again on a new one, so a +// failed attempt leaves nothing behind in the saved state, the +// stand-ins or the notifications. func runChecks( t *testing.T, cfg *config.Config, - prepare, change func(deps *testDeps), -) *testDeps { + prepare func(deps *testDeps), +) (*watcher.Watcher, *testDeps) { t.Helper() - var deps *testDeps + var ( + w *watcher.Watcher + deps *testDeps + ) livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { - var w *watcher.Watcher - w, deps = newTestWatcher(t, cfg) if prepare != nil { prepare(deps) } - err := checkOnce(ctx, w, deps) - if err != nil || change == nil { - return err - } - - change(deps) - return checkOnce(ctx, w, deps) }) - return deps + return w, deps } -// lookupNameservers returns the nameservers live DNS lists for domain, +// lookupNameservers returns the nameservers live DNS lists for name, // for a test to save in the state its check starts from. -func lookupNameservers(t *testing.T, domain string) []string { +func lookupNameservers(t *testing.T, name string) []string { t.Helper() res := resolver.NewFromLogger(slog.Default()) var nameservers []string - livednstest.Retry(t, "LookupNS("+domain+")", func(ctx context.Context) error { + livednstest.Retry(t, "LookupNS("+name+")", func(ctx context.Context) error { var err error - nameservers, err = res.LookupNS(ctx, domain) + nameservers, err = res.LookupNS(ctx, name) return err }) @@ -370,7 +367,7 @@ func TestFirstRunBaseline(t *testing.T) { cfg.Domains = []string{testSmallDomain} cfg.Hostnames = []string{testHost} - deps := runChecks(t, cfg, nil, nil) + _, deps := runChecks(t, cfg, nil) assertNoNotifications(t, deps) assertStatePopulated(t, deps) @@ -422,7 +419,7 @@ func TestDomainPortAndTLSChecks(t *testing.T) { cfg := defaultTestConfig(t) cfg.Domains = []string{testSmallDomain} - deps := runChecks(t, cfg, nil, nil) + _, deps := runChecks(t, cfg, nil) snap := deps.state.GetSnapshot() @@ -462,11 +459,11 @@ func TestNSChangeDetection(t *testing.T) { cfg.Domains = []string{testSmallDomain} // The saved state lists nameservers that live DNS does not. - deps := runChecks(t, cfg, func(deps *testDeps) { + _, deps := runChecks(t, cfg, func(deps *testDeps) { deps.state.SetDomainState(testSmallDomain, &state.DomainState{ Nameservers: []string{oldNS1, oldNS2}, }) - }, nil) + }) assertNotified(t, deps, "NS Change: "+testSmallDomain, "warning") @@ -486,7 +483,7 @@ func TestNSAddressChangeDetection(t *testing.T) { // The saved state lists the nameservers live DNS lists, each at an // address live DNS never returns. - deps := runChecks(t, cfg, func(deps *testDeps) { + _, deps := runChecks(t, cfg, func(deps *testDeps) { nsAddresses := make(map[string][]string, len(nameservers)) for _, ns := range nameservers { nsAddresses[ns] = []string{oldIP} @@ -496,7 +493,7 @@ func TestNSAddressChangeDetection(t *testing.T) { Nameservers: nameservers, NameserverAddresses: nsAddresses, }) - }, nil) + }) title := "NS Address Change: " + testSmallDomain ds, _ := deps.state.GetDomainState(testSmallDomain) @@ -542,12 +539,12 @@ func TestNSAddedAndRemovedIsNoAddressChange(t *testing.T) { // The saved state lists oldNS1, which live DNS does not, in place of // the first nameserver live DNS lists, so that the check finds that // one added and oldNS1 removed. Only oldNS1 has addresses saved. - deps := runChecks(t, cfg, func(deps *testDeps) { + _, deps := runChecks(t, cfg, func(deps *testDeps) { deps.state.SetDomainState(testSmallDomain, &state.DomainState{ Nameservers: append([]string{oldNS1}, nameservers[1:]...), NameserverAddresses: map[string][]string{oldNS1: {oldIP}}, }) - }, nil) + }) if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 1 { t.Errorf("sent %d NS changes, want 1", n) @@ -565,15 +562,17 @@ func TestRecordChangeDetection(t *testing.T) { cfg := defaultTestConfig(t) cfg.Hostnames = []string{testHost} - // Between the checks, save for every nameserver an address live DNS - // never returns. - deps := runChecks(t, cfg, nil, func(deps *testDeps) { - hs, _ := deps.state.GetHostnameState(testHost) - for _, nsState := range hs.RecordsByNameserver { - nsState.Records = map[string][]string{"A": {oldIP}} + nameservers := lookupNameservers(t, testHost) + + // The saved state has every nameserver live DNS lists answering + // with an address live DNS never returns. + _, deps := runChecks(t, cfg, func(deps *testDeps) { + byNameserver := make(map[string]*state.NameserverRecordState) + for _, ns := range nameservers { + byNameserver[ns] = answered(map[string][]string{"A": {oldIP}}) } - deps.state.SetHostnameState(testHost, hs) + deps.state.SetHostnameState(testHost, saved(byNameserver)) }) assertNotified(t, deps, "Record Change: "+testHost, "warning") @@ -585,12 +584,15 @@ func TestPortStateChange(t *testing.T) { cfg := defaultTestConfig(t) cfg.Hostnames = []string{testHost} - // Between the checks, every port closes. - deps := runChecks(t, cfg, nil, func(deps *testDeps) { - deps.portChecker.mu.Lock() - deps.portChecker.closed = true - deps.portChecker.mu.Unlock() - }) + w, deps := runChecks(t, cfg, nil) + + // Every port closes, and the port checks run again. They look + // nothing up. + deps.portChecker.mu.Lock() + deps.portChecker.closed = true + deps.portChecker.mu.Unlock() + + w.CheckAllPorts(t.Context()) hs, _ := deps.state.GetHostnameState(testHost) assertNotified( @@ -610,7 +612,7 @@ func TestTLSExpiryWarning(t *testing.T) { cfg := defaultTestConfig(t) cfg.Hostnames = []string{testHost} - deps := runChecks(t, cfg, expiresInThreeDays, nil) + _, deps := runChecks(t, cfg, expiresInThreeDays) assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning") } @@ -782,7 +784,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { cfg.Hostnames = []string{testHost} // The saved state says the last check found testHost at oldIP. - deps := runChecks(t, cfg, func(deps *testDeps) { + _, deps := runChecks(t, cfg, func(deps *testDeps) { deps.state.SetHostnameState(testHost, &state.HostnameState{ RecordsByNameserver: map[string]*state.NameserverRecordState{ oldNS1: { @@ -791,7 +793,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { }, }, }) - }, nil) + }) snap := deps.state.GetSnapshot() @@ -922,21 +924,20 @@ func TestNSFailureAndRecovery(t *testing.T) { cfg := defaultTestConfig(t) cfg.Hostnames = []string{testHost} - // Between the checks, save every nameserver the first check found - // as one that did not answer, and add, as answering, one that live - // DNS does not list, which then disappears. - deps := runChecks(t, cfg, nil, func(deps *testDeps) { - hs, _ := deps.state.GetHostnameState(testHost) - for ns := range hs.RecordsByNameserver { - hs.RecordsByNameserver[ns] = failed() + nameservers := lookupNameservers(t, testHost) + + // The saved state has every nameserver live DNS lists as one that + // did not answer, and, as answering, one that live DNS does not + // list, which then disappears. + _, deps := runChecks(t, cfg, func(deps *testDeps) { + byNameserver := map[string]*state.NameserverRecordState{ + oldNS1: answered(map[string][]string{"A": {oldIP}}), + } + for _, ns := range nameservers { + byNameserver[ns] = failed() } - hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{ - Records: map[string][]string{"A": {oldIP}}, - Status: "ok", - } - - deps.state.SetHostnameState(testHost, hs) + deps.state.SetHostnameState(testHost, saved(byNameserver)) }) assertNotified(t, deps, "NS Failure: "+testHost, "error") -- 2.54.0 From a67fd20e4f3312252ed398b635f37fff53d9c24a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 06:07:53 +0200 Subject: [PATCH 49/92] ci: a push cancels its branch's older run, checkout keeps no token (closes #216) Every push queues a run on the shared runner, and a branch pushed again left its older run queued for a head nobody needed. The workflow now puts each branch's runs in one concurrency group with cancel-in-progress, so a new push cancels that branch's older run, queued or running. The group is keyed on the branch, so pushes to other branches never cancel runs on `next` or `main`; a push to `next` itself does cancel the older `next` run. The checkout step no longer writes the token into `.git/config`; `script/cibuild` does not need it. Model: opus-5-5 --- .gitea/workflows/check.yml | 8 ++++++++ TODO.md | 2 ++ 2 files changed, 10 insertions(+) diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index a55bc55..81b7e10 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -1,9 +1,17 @@ name: check on: [push] +# A new push to a branch cancels that branch's older run, queued or running; +# runs on other branches, `next` and `main` among them, are left alone. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: check: runs-on: ubuntu-latest steps: # actions/checkout v4.2.2, 2026-02-28 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + # script/cibuild needs no token, so none is left in .git/config. + with: + persist-credentials: false - run: script/cibuild diff --git a/TODO.md b/TODO.md index 27c8b3f..96b90c5 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a push to a branch cancels that branch's older CI run, and the + checkout leaves no token in `.git/config` (closes #216). - 2026-10-02: watcher tests send far fewer queries and a live attempt may take 18s; nameserver addresses are asked only for A, AAAA, CNAME (closes #214). - 2026-10-02: the resolver tries root servers, and every other server list it -- 2.54.0 From 889e17459bc8a806c8396380d57a480d29e41e80 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 06:10:54 +0200 Subject: [PATCH 50/92] resolver: never resend a refused query asking for recursion (closes #206) queryDNS resent a query that a server refused, this time asking for recursion, so on a network that intercepts DNS the answers could come from a recursive resolver without anyone knowing. A refusal is now only a refusal, and the server is passed over for the next. When every server of a zone refuses, the error says so. When every root server refuses, the error is ErrIntercepted: root servers refuse no query, so something on the network is answering in their place. FindAuthoritativeNameservers stops at that error instead of trying each parent name, so the watcher's log line says it. A live test asks Quad9, which refuses a query not asking for recursion, so that the resend cannot come back unnoticed. Model: opus-5-5 --- TODO.md | 2 + internal/resolver/errors.go | 5 + internal/resolver/export_test.go | 11 ++ internal/resolver/iterative.go | 55 +++++---- internal/resolver/resolver_test.go | 182 +++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 21 deletions(-) diff --git a/TODO.md b/TODO.md index 96b90c5..6ecc0d6 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a query a server refuses is not resent asking for recursion, and + every root server refusing is reported as DNS interception (closes #206). - 2026-10-02: a push to a branch cancels that branch's older CI run, and the checkout leaves no token in `.git/config` (closes #216). - 2026-10-02: watcher tests send far fewer queries and a live attempt may take diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index 91eea16..5e019c0 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -22,6 +22,11 @@ var ( "reply is an error or a referral that leads no closer", ) + // ErrIntercepted is returned when every root server refused a + // query. Root servers refuse no query, so the refusals came from + // something on the network answering in their place. + ErrIntercepted = errors.New("this network intercepts DNS queries") + // ErrCNAMEDepthExceeded is returned when a CNAME chain // exceeds MaxCNAMEDepth. ErrCNAMEDepthExceeded = errors.New( diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 8840dd9..17c57d7 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -28,6 +28,17 @@ func CollectIPs( return collectIPs(results) } +// QueryServers exports queryServers for testing. +func (r *Resolver) QueryServers( + ctx context.Context, + servers []string, + zone string, + name string, + qtype uint16, +) (*dns.Msg, error) { + return r.queryServers(ctx, servers, zone, name, qtype) +} + // QueryEachNS exports queryEachNS for testing. func (r *Resolver) QueryEachNS( ctx context.Context, diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 629e043..1f06a24 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -107,9 +107,8 @@ func (r *Resolver) retryTCP( return resp } -// queryDNS sends a DNS query to a specific server IP. -// Tries non-recursive first, falls back to recursive on -// REFUSED (handles DNS interception environments). +// queryDNS sends a DNS query to a specific server IP, never asking it +// for recursion. A reply of REFUSED is returned as ErrRefused. func (r *Resolver) queryDNS( ctx context.Context, serverIP string, @@ -133,25 +132,12 @@ func (r *Resolver) queryDNS( } if resp.Rcode == dns.RcodeRefused { - msg.RecursionDesired = true - - resp, err = r.tryExchange(ctx, msg, addr) - if err != nil { - return nil, fmt.Errorf( - "query %s @%s: %w", name, serverIP, err, - ) - } - - if resp.Rcode == dns.RcodeRefused { - return nil, fmt.Errorf( - "query %s @%s: %w", name, serverIP, ErrRefused, - ) - } + return nil, fmt.Errorf( + "query %s @%s: %w", name, serverIP, ErrRefused, + ) } - resp = r.retryTCP(ctx, msg, addr, resp) - - return resp, nil + return r.retryTCP(ctx, msg, addr, resp), nil } func extractNSSet(rrs []dns.RR) []string { @@ -279,7 +265,9 @@ func shuffled( // queryServers asks servers, the servers of zone, about name in a random // order until one gives a usable reply. A server that times out, refuses -// or gives a reply that is not usable is passed over for the next. +// or gives a reply that is not usable is passed over for the next. When +// every server refused, the error says so, and when they are the root +// servers it is ErrIntercepted. func (r *Resolver) queryServers( ctx context.Context, servers []string, @@ -289,6 +277,8 @@ func (r *Resolver) queryServers( ) (*dns.Msg, error) { var lastErr error + refused := 0 + for _, ip := range shuffled(servers, rand.Shuffle) { if checkCtx(ctx) != nil { return nil, ErrContextCanceled @@ -305,9 +295,27 @@ func (r *Resolver) queryServers( return resp, nil } + if errors.Is(err, ErrRefused) { + refused++ + } + lastErr = err } + if refused == len(servers) && zone == "." { + return nil, fmt.Errorf( + "every root server refused a query for %s: %w", + name, ErrIntercepted, + ) + } + + if refused == len(servers) { + return nil, fmt.Errorf( + "every server of %s refused a query for %s: %w", + zone, name, ErrRefused, + ) + } + return nil, fmt.Errorf("all servers failed: %w", lastErr) } @@ -528,6 +536,11 @@ func (r *Resolver) FindAuthoritativeNameservers( return nsNames, nil } + + // The root servers would refuse every parent name too. + if errors.Is(err, ErrIntercepted) { + return nil, err + } } return nil, ErrNoNameservers diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 30bdaab..e928a35 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -295,6 +296,187 @@ func TestQueryNameserver_Refused(t *testing.T) { assert.Equal(t, "server returned REFUSED", resp.Error) } +// TestQueryNameserverIP_RecursiveResolverRefused asks Quad9, a public +// recursive resolver, about google.com at both of its addresses. Quad9 +// refuses a query that does not ask for recursion and answers one that +// does. The resolver never asks for recursion, so it must be reported +// as refusing, never as answering. +func TestQueryNameserverIP_RecursiveResolverRefused(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + for _, ip := range []string{"9.9.9.9", "149.112.112.112"} { + var resp *resolver.NameserverResponse + + livednstest.Retry( + t, + "QueryNameserverIP("+ip+", google.com)", + func(ctx context.Context) error { + var err error + + resp, err = r.QueryNameserverIP( + ctx, ip, ip, "google.com", + ) + if err != nil { + return err + } + + // A timeout or a network error is no reply at all. + if resp.Status == resolver.StatusTimeout || + strings.HasPrefix(resp.Error, "network error") { + return fmt.Errorf( + "%w: %s: %s", + livednstest.ErrNoAnswer, ip, resp.Error, + ) + } + + return nil + }, + ) + + assert.Equal(t, resolver.StatusError, resp.Status, ip) + assert.Equal(t, "server returned REFUSED", resp.Error, ip) + } +} + +// googleNameserverIPv4s returns the IPv4 addresses of google.com's +// nameservers, the only addresses the resolver asks servers at. +func googleNameserverIPv4s(t *testing.T, r *resolver.Resolver) []string { + t.Helper() + + names := liveFindAuthoritative(t, r, "google.com") + + return liveResolveNSIPs(t, r, names, len(names)) +} + +// TestQueryServers_EveryServerRefused asks all of google.com's +// nameservers about cloudflare.com, a zone they do not serve, which +// they all refuse. The error says every server refused; it is not +// ErrIntercepted, which only the root servers refusing shows. +func TestQueryServers_EveryServerRefused(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + servers := googleNameserverIPv4s(t, r) + + var err error + + livednstest.Retry( + t, + "QueryServers(google.com servers, cloudflare.com)", + func(ctx context.Context) error { + _, err = r.QueryServers( + ctx, servers, "google.com.", "cloudflare.com.", + dns.TypeNS, + ) + + // When not every server refused, one may have given no + // reply at all, so the attempt is tried again. + if err != nil && + !strings.HasPrefix(err.Error(), "every server of") { + return fmt.Errorf( + "%w: %w", livednstest.ErrNoAnswer, err, + ) + } + + return nil + }, + ) + + require.ErrorIs(t, err, resolver.ErrRefused) + require.NotErrorIs(t, err, resolver.ErrIntercepted) + require.EqualError( + t, err, + "every server of google.com. refused a query for "+ + "cloudflare.com.: dns query refused", + ) +} + +// TestQueryServers_EveryRootServerRefused passes google.com's +// nameservers to QueryServers as the servers of the root zone. They +// refuse a query about cloudflare.com, as root servers would if +// something on the network answered in their place, so the error is +// ErrIntercepted. +func TestQueryServers_EveryRootServerRefused(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + servers := googleNameserverIPv4s(t, r) + + var err error + + livednstest.Retry( + t, + "QueryServers(google.com servers as root servers, cloudflare.com)", + func(ctx context.Context) error { + _, err = r.QueryServers( + ctx, servers, ".", "cloudflare.com.", dns.TypeNS, + ) + + // When not every server refused, one may have given no + // reply at all, so the attempt is tried again. Both errors + // for every server refusing say "refused a query for". + if err != nil && + !strings.Contains(err.Error(), "refused a query for") { + return fmt.Errorf( + "%w: %w", livednstest.ErrNoAnswer, err, + ) + } + + return nil + }, + ) + + require.ErrorIs(t, err, resolver.ErrIntercepted) + require.EqualError( + t, err, + "every root server refused a query for cloudflare.com.: "+ + "this network intercepts DNS queries", + ) +} + +// TestQueryServers_NotEveryRootServerRefused passes google.com's +// nameservers and 192.0.2.1 to QueryServers as the servers of the root +// zone. The google.com nameservers refuse a query about cloudflare.com, +// but nothing answers at 192.0.2.1, a documentation address, so not +// every server refused, wherever 192.0.2.1 falls in the random order: +// the error is not ErrIntercepted and does not say every server refused. +func TestQueryServers_NotEveryRootServerRefused(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + servers := googleNameserverIPv4s(t, r) + servers = append(servers, "192.0.2.1") + + var err error + + livednstest.Retry( + t, + "QueryServers(google.com servers and 192.0.2.1, cloudflare.com)", + func(ctx context.Context) error { + _, err = r.QueryServers( + ctx, servers, ".", "cloudflare.com.", dns.TypeNS, + ) + + // An attempt that ran out of time may not have asked every + // server, so it is tried again. + if ctx.Err() != nil { + return fmt.Errorf( + "%w: %w", livednstest.ErrNoAnswer, err, + ) + } + + return nil + }, + ) + + require.Error(t, err) + require.NotErrorIs(t, err, resolver.ErrIntercepted) + // Both errors for every server refusing say "refused a query for". + require.NotContains(t, err.Error(), "refused a query for") +} + func TestQueryNameserver_RecordsSorted(t *testing.T) { t.Parallel() -- 2.54.0 From 3182fc99a6b68d57378050807eaa5c5e9560736e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 06:27:47 +0200 Subject: [PATCH 51/92] docker: a plain docker build . stamps the git version (closes #210) A plain `docker build .`, which is how upaas builds, stamped `dev`: `.dockerignore` left out `.git` and the builder declared `ARG VERSION=dev`. `.dockerignore` now sends `.git` without `.git/config`, which can hold a credential, and lists no tracked file, which git would count as deleted. `ARG VERSION` has no default. The Makefile takes a non-empty `VERSION` from the command line or the environment, so a build arg still wins; otherwise `git describe` runs in the builder, which trusts the checkout whoever owns it, as a context sent as a tar archive keeps its owners. A new `make version` prints the version; the build fails when the context carries `.git` and it comes out empty, `dev` or `unknown`. Model: opus-5-5 --- .dockerignore | 14 +++++++------- Dockerfile | 30 +++++++++++++++++++++++++----- Dockerfile.fmt | 5 ++--- Makefile | 16 ++++++++++++---- README.md | 26 ++++++++++++++++++++------ TODO.md | 2 ++ script/docker | 4 ++-- 7 files changed, 70 insertions(+), 27 deletions(-) diff --git a/.dockerignore b/.dockerignore index 0e93e56..136b403 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,9 @@ -.git/ +# .git is sent, without its config: the builder stage derives the version it +# stamps into the binary from it, and `git describe` does not need the config, +# which can hold a credential (a password in the remote URL, a CI token). No +# tracked file may be listed here: git in the build would see it as deleted +# and mark the version -dirty, and an excluded .md would silently drop out of +# the prettier check in Dockerfile.fmt. +.git/config bin/ node_modules/ -# No .md may be excluded: Dockerfile.fmt checks every document with -# prettier, and an exclusion here would drop a file from that check while -# prettier still reports every file it was handed clean. -LICENSE -.editorconfig -.gitignore diff --git a/Dockerfile b/Dockerfile index c289c07..8f419c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,11 @@ FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4 RUN apk add --no-cache git make gcc musl-dev binutils-gold +# A build context sent as a tar archive keeps its files' owners, and git +# refuses to read a checkout owned by another user. Trust this one +# whoever owns it. +RUN git config --system --add safe.directory /src + # Force BuildKit to run the lint stage before proceeding COPY --from=lint /src/go.sum /dev/null @@ -36,11 +41,26 @@ COPY . . # Run the tests - build fails if any test fails RUN make test -# Build the binary. .dockerignore leaves out .git, so `git describe` in -# the Makefile cannot find the version here: script/docker passes it as -# --build-arg VERSION, and a build that passes none reports `dev`. -ARG VERSION=dev -RUN make build VERSION="${VERSION}" +# Version stamped into the binary: the VERSION build arg when one is +# given and not empty (script/docker passes one), otherwise what +# `git describe` says of the .git in the build context, so a plain +# `docker build .` of a clone stamps its tag or short commit. The build +# arg reaches make through the environment. +ARG VERSION + +# A context that carries .git, as a directory or as a file, must yield a +# real version: one that is empty, `dev` or `unknown` cannot be traced +# back to a commit. +RUN version="$(make version)"; \ + if [ -e .git ]; then \ + case "$version" in \ + "" | dev | unknown) \ + echo "version is \"$version\" although the build context carries .git" >&2; \ + exit 1 ;; \ + esac; \ + fi + +RUN make build # Runtime stage # alpine 3.21, 2026-02-28 diff --git a/Dockerfile.fmt b/Dockerfile.fmt index be7d0ba..9837231 100644 --- a/Dockerfile.fmt +++ b/Dockerfile.fmt @@ -30,9 +30,8 @@ COPY . . # --config, not discovery: a .prettierrc that failed to arrive would # otherwise leave prettier on its defaults, where proseWrap is "preserve" # and every wrap this check exists to enforce passes. Missing the file is -# a hard error instead. --no-editorconfig for the same reason in reverse: -# .editorconfig is not in the build context, so honouring it here and on -# a developer's machine would be two different answers. +# a hard error instead. --no-editorconfig so that .prettierrc alone sets +# the style. RUN prettier --config .prettierrc --no-editorconfig --check "**/*.md" # Write path. Not a check: script/fmt builds this and takes the files. diff --git a/Makefile b/Makefile index 5d36bc7..48a01cf 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,13 @@ -.PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker +.PHONY: all bootstrap setup build version lint fmt fmt-check test check clean hooks docker BINARY := dnswatcher -# `make build VERSION=...` overrides this; the Dockerfile does so, as the -# image has no .git to describe. -VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +# VERSION given on the command line (`make build VERSION=...`) or in the +# environment, which is how the Dockerfile's VERSION build arg arrives, +# wins over what `git describe` says of this checkout. An empty one counts +# as not given; `override` is what replaces an empty command-line value. +ifeq ($(VERSION),) +override VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +endif LDFLAGS := -X main.Version=$(VERSION) # Standard targets are thin shims; the implementations live in script/ @@ -21,6 +25,10 @@ setup: build: go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher +# Prints the version `make build` stamps; the Dockerfile checks it. +version: + @echo "$(VERSION)" + test: @script/test diff --git a/README.md b/README.md index 94bd31d..4f14c1d 100644 --- a/README.md +++ b/README.md @@ -574,6 +574,7 @@ provide: ```sh make build # Build binary to bin/dnswatcher +make version # Print the version make build stamps make test # Run tests with race detector make lint # Run golangci-lint in Docker (requires docker) make fmt # Format code and Markdown (requires docker) @@ -584,13 +585,26 @@ make clean # Remove build artifacts ### Build-Time Variables `make build` sets the version with `-ldflags "-X main.Version=..."`, taking it -from `git describe --tags --always --dirty`, or from `VERSION` when given on the -command line (`make build VERSION=1.2.3`). The version appears in the startup -log and in the health check response. +from `VERSION` when given on the command line (`make build VERSION=1.2.3`) or in +the environment, otherwise from `git describe --tags --always --dirty`, and +`dev` without git metadata. An empty `VERSION` counts as not given. The version +appears in the startup log and in the health check response. -The Docker image has no `.git`, so the `Dockerfile` takes the version as -`--build-arg VERSION`. `make docker` passes it; a plain `docker build` passes -none, and that image reports `dev`. +The image takes it the same way, from the `.git` the build context carries, so a +plain `docker build .` of a clone stamps the commit it was built from; a clone +without tags stamps the short commit. A clone made with `--depth 1` carries at +most a tag on its own commit, so such a clone of an untagged commit stamps the +short commit. In a build from a directory, `.dockerignore` keeps out +`.git/config`, which `git describe` does not need and which can hold a +credential. Docker does not apply `.dockerignore` to a context sent as a tar +archive, as upaas sends it, so that context carries `.git/config` into the +build. It also keeps its files' owners, so git in the build trusts the checkout +whoever owns it. A non-empty `--build-arg VERSION=...` takes precedence; +`make docker` passes the version `git describe` gives on the host. The build +fails when the context carries `.git`, as a directory or as a file, and the +version comes out empty, `dev` or `unknown`. `.dockerignore` must list no +tracked file: git in the build would see it as deleted and mark the version +`-dirty`. --- diff --git a/TODO.md b/TODO.md index 6ecc0d6..596ca42 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a plain `docker build .` of a clone stamps its tag or short + commit, not `dev`: the build context now carries `.git` (closes #210). - 2026-10-02: a query a server refuses is not resent asking for recursion, and every root server refusing is reported as DNS interception (closes #206). - 2026-10-02: a push to a branch cancels that branch's older CI run, and the diff --git a/script/docker b/script/docker index 93d2408..9ce387c 100755 --- a/script/docker +++ b/script/docker @@ -14,8 +14,8 @@ main() { cd "$ROOT" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. VERSION is computed here because .dockerignore - # excludes .git, so `git describe` in a build stage cannot find it. + # empty constant. The VERSION build arg takes precedence over what + # the build would derive from the .git in its context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" docker build --no-cache-filter=lint,builder \ -- 2.54.0 From a18803ff28e0f96fb2dddf5b7cddec16831efe81 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 07:52:44 +0200 Subject: [PATCH 52/92] resolver tests: retry an answer missing the record type read (closes #218) QueryNameserver sends one query per record type. When only the AAAA or MX query was lost, the answer still had status ok, liveQueryNameserver did not retry it, and the test found no AAAA or MX records. liveQueryNameserver now takes the record types a test reads and, through livednstest, retries an answer that holds records of none of them. The A, AAAA, MX and TXT tests name theirs. A resolver that loses a type for good still fails, after the last attempt instead of the first. Model: opus-5-5 --- TODO.md | 2 ++ internal/resolver/livedns_test.go | 18 ++++++++++++++++++ internal/resolver/resolver_test.go | 8 ++++---- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 596ca42..927a598 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a resolver test that reads one record type from a nameserver's + answer asks again when that type is missing from it (closes #218). - 2026-10-02: a plain `docker build .` of a clone stamps its tag or short commit, not `dev`: the build context now carries `.git` (closes #210). - 2026-10-02: a query a server refuses is not resent asking for recursion, and diff --git a/internal/resolver/livedns_test.go b/internal/resolver/livedns_test.go index 0bbccb4..59f95ca 100644 --- a/internal/resolver/livedns_test.go +++ b/internal/resolver/livedns_test.go @@ -226,11 +226,17 @@ func liveLookupNS( // liveQueryNameserver queries one nameserver, retrying while that // nameserver fails to answer. NXDOMAIN and NODATA are answers and // are returned to the caller to assert on. +// +// QueryNameserver sends one query per record type, so one lost query +// leaves its type out of an answer that is otherwise fine. A test names +// in types the record types it reads; an answer holding records of none +// of them is retried too. func liveQueryNameserver( t *testing.T, r *resolver.Resolver, nameserver string, hostname string, + types ...string, ) *resolver.NameserverResponse { t.Helper() @@ -260,6 +266,18 @@ func liveQueryNameserver( ) } + hasRecords := func(recordType string) bool { + return len(resp.Records[recordType]) > 0 + } + + if len(types) > 0 && !slices.ContainsFunc(types, hasRecords) { + return fmt.Errorf( + "%w: %s returned no %s records", + livednstest.ErrNoAnswer, nameserver, + strings.Join(types, " or "), + ) + } + out = resp return nil diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index e928a35..a07c718 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -171,7 +171,7 @@ func TestQueryNameserver_BasicA(t *testing.T) { r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") - resp := liveQueryNameserver(t, r, ns, "www.google.com") + resp := liveQueryNameserver(t, r, ns, "www.google.com", "A", "CNAME") require.NotNil(t, resp) @@ -190,7 +190,7 @@ func TestQueryNameserver_AAAA(t *testing.T) { r := newTestResolver(t) ns := findOneNSForDomain(t, r, "cloudflare.com") - resp := liveQueryNameserver(t, r, ns, "cloudflare.com") + resp := liveQueryNameserver(t, r, ns, "cloudflare.com", "AAAA") aaaaRecords := resp.Records["AAAA"] require.NotEmpty(t, aaaaRecords, @@ -210,7 +210,7 @@ func TestQueryNameserver_MX(t *testing.T) { r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") - resp := liveQueryNameserver(t, r, ns, "google.com") + resp := liveQueryNameserver(t, r, ns, "google.com", "MX") mxRecords := resp.Records["MX"] require.NotEmpty(t, mxRecords, @@ -223,7 +223,7 @@ func TestQueryNameserver_TXT(t *testing.T) { r := newTestResolver(t) ns := findOneNSForDomain(t, r, "google.com") - resp := liveQueryNameserver(t, r, ns, "google.com") + resp := liveQueryNameserver(t, r, ns, "google.com", "TXT") txtRecords := resp.Records["TXT"] require.NotEmpty(t, txtRecords, -- 2.54.0 From ee4cadbd05bba5db4edf91e26d1daa58a431de80 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 08:26:27 +0200 Subject: [PATCH 53/92] watcher: follow a watched name's CNAME for port and TLS checks (closes #203) When a watched name's nameservers answer with a CNAME and no address, the DNS check follows every target they gave with ResolveIPAddresses and saves all addresses found as cnameAddresses in the hostname state, so nameservers disagreeing on the target do not change them between checks. Port and TLS checks use them. A change, also from or to none, is notified as a CNAME address change; the first check from a state file without them sends none. When a target cannot be followed, or none of the name's nameservers answered, the last check's addresses are kept. The domain check now runs the hostname check for the apex instead of a copy of it. Model: opus-5-5 --- README.md | 41 +++- TODO.md | 2 + internal/state/state.go | 5 + internal/state/state_test.go | 89 ++++++++ internal/watcher/cname_test.go | 336 +++++++++++++++++++++++++++++++ internal/watcher/export_test.go | 24 +++ internal/watcher/watcher.go | 149 +++++++++++--- internal/watcher/watcher_test.go | 13 +- 8 files changed, 627 insertions(+), 32 deletions(-) create mode 100644 internal/watcher/cname_test.go diff --git a/README.md b/README.md index 4f14c1d..ffbb132 100644 --- a/README.md +++ b/README.md @@ -121,14 +121,26 @@ notification endpoint set, changes show only on the dashboard; see failed on it, and answers differently is reported on the check where it answers. If a pair agrees again and later disagrees, the alert is sent again. + - **CNAME address change**: The addresses at the end of a name's CNAME chain + differ from those of the previous check. They are found when its + nameservers answer with a CNAME and no address; a name that answers with + an address has none. A change from or to no addresses is sent too, as when + a name moves between A records and a CNAME. Nothing is sent when the + previous addresses were kept because a chain could not be followed or none + of the name's nameservers answered. The first check after loading a state + file without `cnameAddresses` sends nothing: it saves the addresses it + finds for the next check to compare. ### TCP Port Monitoring - For every configured domain and hostname, constructs a deduplicated list of the IPv4 and IPv6 addresses in the A and AAAA records its authoritative - nameservers returned. A CNAME is not followed: a name whose CNAME points into - another zone usually has no addresses here, so its ports and certificate are - not checked. + nameservers returned. When they returned a CNAME and no address, the CNAME + chain is followed and the addresses at its end are used, and a change in those + is notified as a CNAME address change. When the nameservers gave different + CNAME targets, each is followed and the addresses of all are used. When a + chain cannot be followed, or none of the name's nameservers answered, the + addresses the last check found at its end are used. - Checks TCP connectivity on ports **80** and **443** for each IP address. - Every **1 hour** by default, re-checks all ports. - Any change in port availability triggers a notification: @@ -176,6 +188,8 @@ includes: - **DNS NS changes**: Which domain, which nameservers were added/removed. - **NS address changes**: Which domain, which nameserver, its old and new addresses. +- **CNAME address changes**: Which hostname, the old and new addresses at the + end of its CNAME chain. - **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL, REFUSED, network error), which hostname/domain affected. - **NS recoveries**: Which nameserver recovered, which hostname/domain. @@ -420,9 +434,11 @@ This approach ensures: - Ability to detect split-horizon or inconsistent responses across authoritative servers. -CNAME chains are followed (with a depth limit to prevent loops) only to find the -addresses of nameservers. A watched name's records are stored as its nameservers -return them, CNAME included, without following it. +A watched name's records are stored as its nameservers return them, CNAME +included. When they return a CNAME and no address, the chain of every CNAME +target they gave is followed (with a depth limit to prevent loops) to the A and +AAAA records at its end, and the port and TLS checks use those addresses. +Nameservers' addresses are also found by following CNAME chains. Sending a notification or a Sentry report is the one use of the system's resolver: the HTTP client looks up the webhook's or Sentry's host name with it. @@ -469,6 +485,7 @@ merged view, to enable inconsistency detection. "lastChecked": "2026-02-19T12:00:00Z" } }, + "cnameAddresses": [], "lastChecked": "2026-02-19T12:00:00Z" } }, @@ -515,6 +532,14 @@ certificate entry whose TLS connection or handshake failed likewise has status resolves to. A state file without it loads, and the next check fills it in without a notification. +`cnameAddresses` lists the sorted addresses at the end of the chain of every +CNAME target a hostname's nameservers gave, found when they answered with a +CNAME and no address; it is empty when they answered with an address. When a +chain cannot be followed, or none of the name's nameservers answered, the +previous check's list is kept, or `null` when no earlier check saved one. A +state file without it loads, and the first check after that saves it without a +notification. + A port entry in the older format, with one `hostname` instead of the `hostnames` list, loads as a list of that one name. @@ -672,7 +697,9 @@ docker run -d \ - Port and TLS checks use the IP addresses found by the DNS phase that immediately precedes them. When that phase cannot find a name's nameservers at all, the addresses an earlier check saved for the name are - used. + used. When it cannot follow a name's CNAME chain, or none of the name's + nameservers answered, the addresses an earlier check found at the end of + the chain are used. 4. **On change detection**: Send notifications to all configured endpoints, update in-memory state, persist to disk. 5. **Shutdown**: The watcher stops checking and saves the final state to disk, diff --git a/TODO.md b/TODO.md index 927a598..e456bf2 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a watched name whose nameservers answer with a CNAME and no + address gets port and TLS checks at the end of its CNAME chain (closes #203). - 2026-10-02: a resolver test that reads one record type from a nameserver's answer asks again when that type is missing from it (closes #218). - 2026-10-02: a plain `docker build .` of a clone stamps its tag or short diff --git a/internal/state/state.go b/internal/state/state.go index a8af3f9..47ea4c6 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -53,8 +53,13 @@ type NameserverRecordState struct { } // HostnameState holds per-nameserver monitoring state for a hostname. +// CNAMEAddresses holds the sorted addresses at the end of the name's +// CNAME chain, found when its nameservers answered with a CNAME and no +// address; it is empty otherwise. It is nil when they are not known: a +// state file written before it existed loads with it nil. type HostnameState struct { RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"` + CNAMEAddresses []string `json:"cnameAddresses"` LastChecked time.Time `json:"lastChecked"` } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 16f709d..b0f0fd4 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -188,6 +188,95 @@ func TestLoadStateFromBeforeNameserverAddresses(t *testing.T) { } } +// TestSaveLoadRoundTrip_CNAMEAddresses checks that no addresses at the +// end of a hostname's CNAME chain load as an empty list, and addresses +// that are not known load as nil: the watcher tells the two apart. +func TestSaveLoadRoundTrip_CNAMEAddresses(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + want := map[string][]string{ + "cname.example.com": {testIP}, + "none.example.com": {}, + "not-known.example.com": nil, + } + + for name, addresses := range want { + s.SetHostnameState(name, &state.HostnameState{ + CNAMEAddresses: addresses, + }) + } + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + loaded := state.NewForTestWithDataDir(dir) + + err = loaded.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + for name, addresses := range want { + hs, ok := loaded.GetHostnameState(name) + if !ok { + t.Fatalf("missing hostname %s", name) + } + + if !reflect.DeepEqual(hs.CNAMEAddresses, addresses) { + t.Errorf( + "%s: loaded %#v, want %#v", + name, hs.CNAMEAddresses, addresses, + ) + } + } +} + +// TestLoadStateFromBeforeCNAMEAddresses loads a state file written +// before the addresses at the end of a hostname's CNAME chain were +// saved. They load as not known (nil), not as none. +func TestLoadStateFromBeforeCNAMEAddresses(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + data := []byte(`{ + "version": 1, + "lastUpdated": "2026-02-19T12:00:00Z", + "hostnames": { + "www.example.com": { + "recordsByNameserver": {}, + "lastChecked": "2026-02-19T12:00:00Z" + } + } + }`) + + err := os.WriteFile(filepath.Join(dir, "state.json"), data, 0o600) + if err != nil { + t.Fatalf("writing state file: %v", err) + } + + s := state.NewForTestWithDataDir(dir) + + err = s.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + hs, ok := s.GetHostnameState(testHostname) + if !ok { + t.Fatal("missing hostname " + testHostname) + } + + if hs.CNAMEAddresses != nil { + t.Errorf("CNAME addresses: got %#v, want nil", hs.CNAMEAddresses) + } +} + // TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle. func TestSaveLoadRoundTrip_Hostnames(t *testing.T) { t.Parallel() diff --git a/internal/watcher/cname_test.go b/internal/watcher/cname_test.go new file mode 100644 index 0000000..b3c549f --- /dev/null +++ b/internal/watcher/cname_test.go @@ -0,0 +1,336 @@ +package watcher_test + +import ( + "context" + "log/slog" + "slices" + "testing" + + "sneak.berlin/go/dnswatcher/internal/livednstest" + "sneak.berlin/go/dnswatcher/internal/resolver" + "sneak.berlin/go/dnswatcher/internal/state" + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +// TestCNAMEIntoAnotherZonePortAndTLSChecks runs the port and TLS +// checks on hostname state built here: the name's nameserver answered +// with a CNAME into another zone, and following it found ip1. Both +// checks must use ip1. They look nothing up, so the watcher has no +// resolver. +func TestCNAMEIntoAnotherZonePortAndTLSChecks(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{host} + + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + + deps.state.SetHostnameState(host, cnameState(ip1)) + + w.CheckAllPorts(t.Context()) + w.RunTLSChecks(t.Context()) + + snap := deps.state.GetSnapshot() + + ps, ok := snap.Ports[ip1+":443"] + if !ok || !slices.Contains(ps.Hostnames, host) { + t.Errorf("no port state for %s at %s:443", host, ip1) + } + + certKey := ip1 + ":443:" + host + if _, ok := snap.Certificates[certKey]; !ok { + t.Errorf("no certificate state %s", certKey) + } +} + +// TestCNAMEThatCannotBeFollowedKeepsPrevious runs a check of a name, not +// the watcher's first, from the point where its records have been looked +// up: they hold a CNAME to a target under .invalid, whose lookup fails. +// The previous check found the same records, and oldIP at the end of the +// CNAME. The check must keep oldIP and send nothing. +func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) { + t.Parallel() + + w, deps := newTestWatcher(t, defaultTestConfig(t)) + w.SetFirstRun(false) + + records := map[string]map[string][]string{ + nsA: cnameTo("target.example.invalid."), + } + + prev := hostnameState(records) + prev.CNAMEAddresses = []string{oldIP} + deps.state.SetHostnameState(host, prev) + + // The result is the same whether or not live DNS answers, so the + // lookup is not retried. + _ = livednstest.Run(func(ctx context.Context) error { + w.UpdateHostnameState(ctx, host, hostnameState(records)) + + return nil + }) + + hs, _ := deps.state.GetHostnameState(host) + if !slices.Equal(hs.CNAMEAddresses, prev.CNAMEAddresses) { + t.Errorf( + "saved %v, want %v", + hs.CNAMEAddresses, prev.CNAMEAddresses, + ) + } + + notifications := deps.notifier.getNotifications() + if len(notifications) != 0 { + t.Errorf("sent %v, want no notifications", notifications) + } +} + +// followLive follows in live DNS the CNAMEs in a name's records, built +// from records, and returns the addresses saved for the name. The +// previous check saved oldIP, which is kept when a target cannot be +// followed; that is retried. The tests point CNAMEs only at names in +// zones with two nameservers, to keep queries few (see the top of +// watcher_test.go). +func followLive( + t *testing.T, + records map[string]map[string][]string, +) []string { + t.Helper() + + w := watcher.NewForTest( + nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil, + ) + prev := cnameState(oldIP) + + var current *state.HostnameState + + livednstest.Retry(t, "following CNAMEs", func(ctx context.Context) error { + current = hostnameState(records) + + w.ResolveCNAMEAddresses(ctx, host, current, prev) + + if slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) { + return livednstest.ErrNoAnswer + } + + return nil + }) + + return current.CNAMEAddresses +} + +// TestCNAMEAddressesOfEveryTarget gives a name's two nameservers +// different CNAME targets, as when a secondary still serves an old one. +// The addresses at the end of both are saved, whichever answer is read +// first: one.one.one.one has 1.1.1.1, and dns.adguard-dns.com has +// 94.140.14.14. +func TestCNAMEAddressesOfEveryTarget(t *testing.T) { + t.Parallel() + + found := followLive(t, map[string]map[string][]string{ + nsA: cnameTo("one.one.one.one."), + nsB: cnameTo("dns.adguard-dns.com."), + }) + + for _, ip := range []string{"1.1.1.1", "94.140.14.14"} { + if !slices.Contains(found, ip) { + t.Errorf("saved %v, want %s among them", found, ip) + } + } +} + +// TestCNAMEChainEndingInNoAddressSavesEmptyList follows a CNAME to a +// name live DNS answers with NXDOMAIN. An empty list is saved, not nil, +// which would mean the addresses are not known. +func TestCNAMEChainEndingInNoAddressSavesEmptyList(t *testing.T) { + t.Parallel() + + found := followLive(t, map[string]map[string][]string{ + nsA: cnameTo("this-surely-does-not-exist-xyz.example.org."), + }) + + if found == nil || len(found) != 0 { + t.Errorf("saved %#v, want an empty list", found) + } +} + +// TestCNAMEBesideAnAddressNotFollowed gives one nameserver of a name an +// address and another a CNAME. The CNAME is not followed: an empty list +// is saved, not nil, and nothing is looked up, the watcher having no +// resolver. +func TestCNAMEBesideAnAddressNotFollowed(t *testing.T) { + t.Parallel() + + w := watcher.NewForTest(nil, nil, nil, nil, nil, nil) + + current := hostnameState(map[string]map[string][]string{ + nsA: {"A": {ip1}}, + nsB: cnameTo("target.example.org."), + }) + + w.ResolveCNAMEAddresses(t.Context(), host, current, nil) + + if current.CNAMEAddresses == nil || len(current.CNAMEAddresses) != 0 { + t.Errorf("saved %#v, want an empty list", current.CNAMEAddresses) + } +} + +// TestCNAMEWhoseNameserversAllFailedKeepsPrevious checks a name none of +// whose nameservers answered. The addresses the previous check saved +// from following its CNAME are kept, and nothing is looked up: the +// watcher has no resolver. +func TestCNAMEWhoseNameserversAllFailedKeepsPrevious(t *testing.T) { + t.Parallel() + + w := watcher.NewForTest(nil, nil, nil, nil, nil, nil) + + current := saved(map[string]*state.NameserverRecordState{ + nsA: failed(), nsB: failed(), + }) + prev := cnameState(oldIP) + + w.ResolveCNAMEAddresses(t.Context(), host, current, prev) + + if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) { + t.Errorf( + "saved %v, want %v", + current.CNAMEAddresses, prev.CNAMEAddresses, + ) + } +} + +// cnameTo builds the records of a nameserver that answered with a CNAME +// to target and no address. +func cnameTo(target string) map[string][]string { + return map[string][]string{"CNAME": {target}} +} + +// cnameState builds the state a check leaves behind for a name whose +// nameserver answered with a CNAME and no address, when following the +// CNAME found these addresses, which may be none. +func cnameState(addresses ...string) *state.HostnameState { + hs := hostnameState(map[string]map[string][]string{ + nsA: cnameTo("target.example.org."), + }) + + hs.CNAMEAddresses = append([]string{}, addresses...) + + return hs +} + +func TestCNAMEAddressChangeAlerts(t *testing.T) { + t.Parallel() + + // A state file written before the addresses were saved loads with + // them nil. + olderStateFile := cnameState() + olderStateFile.CNAMEAddresses = nil + + // Each case is the state saved by the previous check and by the + // current one. The name's records are the same in both. + tests := []struct { + name string + prev, current *state.HostnameState + want int + }{ + { + "same addresses", + cnameState(ip1, ip2), cnameState(ip1, ip2), 0, + }, + { + "same addresses in another order", + cnameState(ip2, ip1), cnameState(ip1, ip2), 0, + }, + { + "address replaced", + cnameState(ip1), cnameState(ip2), 1, + }, + { + "address added", + cnameState(ip1), cnameState(ip1, ip2), 1, + }, + { + "no address at the end of the chain now", + cnameState(ip1), cnameState(), 1, + }, + { + "addresses at the end of the chain again", + cnameState(), cnameState(ip1), 1, + }, + { + "state file from before addresses were saved", + olderStateFile, cnameState(ip1), 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + w.DetectHostnameChanges(t.Context(), host, tt.prev, tt.current) + + got := len(notifier.getNotifications()) + if got != tt.want { + t.Errorf("sent %d notifications, want %d", got, tt.want) + } + }) + } +} + +func TestCNAMEAddressChangeAlertNamesHostnameAndAddresses(t *testing.T) { + t.Parallel() + + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + w.DetectHostnameChanges( + t.Context(), host, cnameState(ip1), cnameState(ip2, ip3), + ) + + want := notification{ + Title: "CNAME Address Change: " + host, + Message: "Hostname: " + host + + "\nOld: " + ip1 + "\nNew: " + ip2 + ", " + ip3, + Priority: "warning", + } + + got := notifier.getNotifications() + if len(got) != 1 || got[0] != want { + t.Errorf("sent %v, want %v", got, want) + } +} + +// TestNameMovedFromARecordsToCNAMEAlerts checks a name that answers +// with an A record and then with a CNAME whose chain ends in ip2. The +// second check is notified as a CNAME address change from no addresses, +// beside the record change. Nothing is looked up: the watcher has no +// resolver. +func TestNameMovedFromARecordsToCNAMEAlerts(t *testing.T) { + t.Parallel() + + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + prev := hostnameState(map[string]map[string][]string{ + nsA: {"A": {ip1}}, + }) + w.ResolveCNAMEAddresses(t.Context(), host, prev, nil) + + w.DetectHostnameChanges(t.Context(), host, prev, cnameState(ip2)) + + title := "CNAME Address Change: " + host + message := "Hostname: " + host + "\nOld: \nNew: " + ip2 + + got := notifier.getNotifications() + if !slices.ContainsFunc(got, func(n notification) bool { + return n.Title == title && n.Message == message + }) { + t.Errorf("sent %v, want %q with %q among them", got, title, message) + } +} diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 59f0319..218a566 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -38,6 +38,21 @@ func NewlyDisagreeingPairs( return newlyDisagreeingPairs(prev, current) } +// SetFirstRun sets whether the watcher is on its first check, in which +// nothing is compared with the previous check. NewForTest's watcher is. +func (w *Watcher) SetFirstRun(firstRun bool) { + w.firstRun = firstRun +} + +// UpdateHostnameState exports updateHostnameState for testing. +func (w *Watcher) UpdateHostnameState( + ctx context.Context, + hostname string, + newState *state.HostnameState, +) { + w.updateHostnameState(ctx, hostname, newState) +} + // DetectHostnameChanges exports detectHostnameChanges for testing. func (w *Watcher) DetectHostnameChanges( ctx context.Context, @@ -57,6 +72,15 @@ func (w *Watcher) ResolveNameserverAddresses( return w.resolveNameserverAddresses(ctx, nameservers, prev) } +// ResolveCNAMEAddresses exports resolveCNAMEAddresses for testing. +func (w *Watcher) ResolveCNAMEAddresses( + ctx context.Context, + hostname string, + current, prev *state.HostnameState, +) { + w.resolveCNAMEAddresses(ctx, hostname, current, prev) +} + // DetectNSAddressChanges exports detectNSAddressChanges for testing. func (w *Watcher) DetectNSAddressChanges( ctx context.Context, diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index bcee203..ac0cd58 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -252,28 +252,9 @@ func (w *Watcher) checkDomain( LastChecked: now, }) - // Also look up A/AAAA records for the apex domain so that - // port and TLS checks (which read HostnameState) can find - // the domain's IP addresses. - results, err := w.resolver.LookupAllRecords(ctx, domain) - if err != nil { - w.log.Error( - "failed to lookup records for domain", - "domain", domain, - "error", err, - ) - - return - } - - newState := buildHostnameState(results, now) - - prevHS, hasPrevHS := w.state.GetHostnameState(domain) - if hasPrevHS && !w.firstRun { - w.detectHostnameChanges(ctx, domain, prevHS, newState) - } - - w.state.SetHostnameState(domain, newState) + // The apex domain's records are also checked as a hostname's, so + // that the port and TLS checks find its addresses. + w.checkHostname(ctx, domain) } func (w *Watcher) detectNSChanges( @@ -398,9 +379,23 @@ func (w *Watcher) checkHostname( return } - newState := buildHostnameState(results, time.Now().UTC()) + w.updateHostnameState( + ctx, hostname, buildHostnameState(results, time.Now().UTC()), + ) +} +// updateHostnameState finishes a check of hostname from newState, built +// from its nameservers' answers: it follows the CNAME in them, notifies +// what changed since the previous check, and saves newState. +func (w *Watcher) updateHostnameState( + ctx context.Context, + hostname string, + newState *state.HostnameState, +) { prev, hasPrev := w.state.GetHostnameState(hostname) + + w.resolveCNAMEAddresses(ctx, hostname, newState, prev) + if hasPrev && !w.firstRun { w.detectHostnameChanges(ctx, hostname, prev, newState) } @@ -408,6 +403,76 @@ func (w *Watcher) checkHostname( w.state.SetHostnameState(hostname, newState) } +// resolveCNAMEAddresses saves in current the addresses at the end of +// hostname's CNAME chain, when the nameservers' answers in current hold +// a CNAME and no address, and an empty list otherwise. Every CNAME +// target the nameservers gave is followed with ResolveIPAddresses and +// the addresses found for all of them are saved, so nameservers that +// disagree on the target do not change the result from check to check. +// The addresses saved in prev, which may be nil, are kept when none of +// the name's nameservers answered, and when a target cannot be +// followed, as when no nameserver of a zone in its chain answers. +func (w *Watcher) resolveCNAMEAddresses( + ctx context.Context, + hostname string, + current, prev *state.HostnameState, +) { + var prevAddresses []string + if prev != nil { + prevAddresses = prev.CNAMEAddresses + } + + // Empty, not nil: nil means the addresses are not known. + current.CNAMEAddresses = []string{} + + answered := false + targets := make(map[string]bool) + + for _, nsState := range current.RecordsByNameserver { + if nsState.Status != statusOK { + continue + } + + answered = true + + if len(nsState.Records["A"]) > 0 || len(nsState.Records["AAAA"]) > 0 { + return + } + + for _, target := range nsState.Records["CNAME"] { + targets[target] = true + } + } + + if !answered { + current.CNAMEAddresses = prevAddresses + + return + } + + for target := range targets { + ips, err := w.resolver.ResolveIPAddresses(ctx, target) + if err != nil { + w.log.Error( + "failed to follow CNAME", + "hostname", hostname, + "target", target, + "error", err, + ) + + current.CNAMEAddresses = prevAddresses + + return + } + + current.CNAMEAddresses = append(current.CNAMEAddresses, ips...) + } + + // Still the empty list when every chain ends in no address. + slices.Sort(current.CNAMEAddresses) + current.CNAMEAddresses = slices.Compact(current.CNAMEAddresses) +} + // buildHostnameState saves each nameserver's response. A nameserver // that answered, even with NXDOMAIN or no records, is saved as ok; one // that timed out or failed is saved as error with the reason, and its @@ -451,6 +516,37 @@ func (w *Watcher) detectHostnameChanges( w.detectNSDisappearances(ctx, hostname, prev, current) w.detectNSFailures(ctx, hostname, prev, current) w.detectInconsistencies(ctx, hostname, prev, current) + w.detectCNAMEAddressChanges(ctx, hostname, prev, current) +} + +// detectCNAMEAddressChanges notifies when the addresses at the end of +// hostname's CNAME chain differ from those the previous check saved, +// including a change from or to none. When the previous addresses are +// not known (nil), as on the first check after loading a state file +// written before they were saved, nothing is compared. +func (w *Watcher) detectCNAMEAddressChanges( + ctx context.Context, + hostname string, + prev, current *state.HostnameState, +) { + old, cur := prev.CNAMEAddresses, current.CNAMEAddresses + if old == nil || sliceEqual(old, cur) { + return + } + + msg := fmt.Sprintf( + "Hostname: %s\nOld: %s\nNew: %s", + hostname, + strings.Join(old, ", "), + strings.Join(cur, ", "), + ) + + w.notify.SendNotification( + ctx, + "CNAME Address Change: "+hostname, + msg, + "warning", + ) } // detectRecordChanges compares each nameserver's records with those of @@ -747,6 +843,9 @@ func (w *Watcher) noNameserverAnswered(name string) bool { return true } +// collectIPs returns the addresses saved for hostname: those in its +// nameservers' A and AAAA records, and those at the end of its CNAME +// chain. func (w *Watcher) collectIPs(hostname string) []string { hs, ok := w.state.GetHostnameState(hostname) if !ok { @@ -765,6 +864,10 @@ func (w *Watcher) collectIPs(hostname string) []string { } } + for _, ip := range hs.CNAMEAddresses { + ipSet[ip] = true + } + result := make([]string, 0, len(ipSet)) for ip := range ipSet { result = append(result, ip) diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index b527cb1..45bfd93 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -312,7 +312,8 @@ func lookupNameservers(t *testing.T, name string) []string { return nameservers } -// addresses returns the A and AAAA values saved for a hostname. +// addresses returns the A and AAAA values saved for a hostname, and the +// addresses saved at the end of its CNAME chain. func addresses(hs *state.HostnameState) []string { var ips []string @@ -321,7 +322,7 @@ func addresses(hs *state.HostnameState) []string { ips = append(ips, nsState.Records["AAAA"]...) } - return ips + return append(ips, hs.CNAMEAddresses...) } // assertNotified checks that a notification with this title and @@ -371,6 +372,14 @@ func TestFirstRunBaseline(t *testing.T) { assertNoNotifications(t, deps) assertStatePopulated(t, deps) + + // testHost answers with an address, so the check saves an empty list + // of CNAME addresses for it; nil would mean the check did not look + // at whether to follow a CNAME. + hs, _ := deps.state.GetHostnameState(testHost) + if hs.CNAMEAddresses == nil || len(hs.CNAMEAddresses) != 0 { + t.Errorf("saved CNAME addresses %#v, want []", hs.CNAMEAddresses) + } } func assertNoNotifications( -- 2.54.0 From ceb24c5004e81f1647888948d566723178aea3ca Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 08:37:47 +0200 Subject: [PATCH 54/92] log: write durations as text, not nanoseconds (closes #228) The JSON log wrote a Go duration as a bare count of nanoseconds, so the watcher starting line showed dnsInterval 120000000000 for 2m and a delivery retry showed retryIn 1015437050. Each duration logged is now passed through its String() form: dnsInterval and tlsInterval when the watcher starts, retryIn on a delivery retry, and latency on a succeeded port check. A test checks that retryIn is logged as the text of the wait the retry actually took. The request log's latency_ms is left as it is: its key names its unit. Model: opus-5-5 --- TODO.md | 2 ++ internal/notify/retry.go | 4 ++- internal/notify/retry_test.go | 45 +++++++++++++++++++++++++++++++++ internal/portcheck/portcheck.go | 4 ++- internal/watcher/watcher.go | 6 +++-- 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index e456bf2..6de64a5 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: durations in the log are written as text such as `2m0s`, not as a + bare count of nanoseconds (closes #228). - 2026-10-02: a watched name whose nameservers answer with a CNAME and no address gets port and TLS checks at the end of its CNAME chain (closes #203). - 2026-10-02: a resolver test that reads one record type from a nameserver's diff --git a/internal/notify/retry.go b/internal/notify/retry.go index 6745085..ccf6666 100644 --- a/internal/notify/retry.go +++ b/internal/notify/retry.go @@ -115,7 +115,9 @@ func (svc *Service) deliverWithRetry( "endpoint", endpoint, "attempt", attempt+1, "maxAttempts", cfg.MaxRetries+1, - "retryIn", delay, + // As text: the JSON log writes a time.Duration as + // bare nanoseconds. + "retryIn", delay.String(), "error", lastErr, ) diff --git a/internal/notify/retry_test.go b/internal/notify/retry_test.go index 878e1bf..4d47897 100644 --- a/internal/notify/retry_test.go +++ b/internal/notify/retry_test.go @@ -2,6 +2,7 @@ package notify_test import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -189,6 +190,50 @@ func TestDeliverWithRetryExhaustsAttempts(t *testing.T) { } } +// TestDeliverWithRetryLogsRetryInAsText checks that the wait +// before a retry is logged as text such as "1.02s", not as a +// count of nanoseconds. +func TestDeliverWithRetryLogsRetryInAsText(t *testing.T) { + t.Parallel() + + svc, logs := newLoggingService(http.DefaultTransport) + svc.SetRetryConfig(notify.RetryConfig{ + MaxRetries: 1, + BaseDelay: time.Second, + MaxDelay: time.Second, + }) + + var waited time.Duration + + svc.SetSleepFunc(func(d time.Duration) <-chan time.Time { + waited = d + + return instantSleep(d) + }) + + _ = svc.DeliverWithRetry( + context.Background(), "test", + func(_ context.Context) error { + return errFail + }, + ) + + // With one retry, only the first failure is logged. + var record map[string]any + + err := json.Unmarshal([]byte(logs.String()), &record) + if err != nil { + t.Fatalf("log is not one JSON record: %v\n%s", err, logs) + } + + if record["retryIn"] != waited.String() { + t.Errorf( + "retryIn logged as %v, want %q", + record["retryIn"], waited.String(), + ) + } +} + func TestDeliverWithRetryRespectsContextCancellation( t *testing.T, ) { diff --git a/internal/portcheck/portcheck.go b/internal/portcheck/portcheck.go index a57b230..3d795b2 100644 --- a/internal/portcheck/portcheck.go +++ b/internal/portcheck/portcheck.go @@ -193,7 +193,9 @@ func (c *Checker) checkConnection( c.log.Debug( "port check succeeded", "target", target, - "latency", latency, + // As text: the JSON log writes a time.Duration as bare + // nanoseconds. + "latency", latency.String(), ) return &PortResult{ diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index ac0cd58..4e006cc 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -123,8 +123,10 @@ func (w *Watcher) Run(ctx context.Context) { "watcher starting", "domains", len(w.config.Domains), "hostnames", len(w.config.Hostnames), - "dnsInterval", w.config.DNSInterval, - "tlsInterval", w.config.TLSInterval, + // As text: the JSON log writes a time.Duration as bare + // nanoseconds. + "dnsInterval", w.config.DNSInterval.String(), + "tlsInterval", w.config.TLSInterval.String(), ) w.RunOnce(ctx) -- 2.54.0 From 26c9c74d8e1213e452e56897243ba471e4639f4c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 08:40:19 +0200 Subject: [PATCH 55/92] notify: a failed Mattermost delivery's error names Mattermost (closes #227) Mattermost is sent by the Slack sender, which wrapped every HTTP error status in ErrSlackFailed, so a Mattermost endpoint answering 503 was logged as "slack notification failed". The sender now takes the error to wrap: the Slack endpoint passes ErrSlackFailed and the Mattermost endpoint passes ErrMattermostFailed, which was defined but unused. A new delivery test sets both endpoints to a stand-in server answering 503 and checks the error logged for each names its own endpoint. Model: opus-5-5 --- TODO.md | 2 + internal/notify/delivery_test.go | 72 ++++++++++++++++++++++++++++++-- internal/notify/export_test.go | 3 +- internal/notify/notify.go | 12 ++++-- 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index 6de64a5..baedf40 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a Mattermost webhook that answers an HTTP error is logged as + `mattermost notification failed`, not as a Slack failure (closes #227). - 2026-10-02: durations in the log are written as text such as `2m0s`, not as a bare count of nanoseconds (closes #228). - 2026-10-02: a watched name whose nameservers answer with a CNAME and no diff --git a/internal/notify/delivery_test.go b/internal/notify/delivery_test.go index fdccd73..064af7b 100644 --- a/internal/notify/delivery_test.go +++ b/internal/notify/delivery_test.go @@ -6,9 +6,11 @@ import ( "encoding/json" "errors" "io" + "maps" "net/http" "net/http/httptest" "net/url" + "strings" "sync" "testing" "time" @@ -413,7 +415,8 @@ func sendSlackInfo( svc *notify.Service, target *url.URL, ) error { return svc.SendSlack( - context.Background(), target, "t", "m", prioInfo, + context.Background(), target, notify.ErrSlackFailed, + "t", "m", prioInfo, ) } @@ -506,6 +509,7 @@ func TestSendSlackPayloadFields(t *testing.T) { err := svc.SendSlack( context.Background(), webhookURL, + notify.ErrSlackFailed, "Alert Title", "Alert body text", "warning", @@ -608,7 +612,8 @@ func TestSendSlackAllColors(t *testing.T) { err := svc.SendSlack( context.Background(), - webhookURL, "t", "m", tc.priority, + webhookURL, notify.ErrSlackFailed, + "t", "m", tc.priority, ) if err != nil { t.Fatalf("SendSlack error: %v", err) @@ -659,7 +664,8 @@ func TestSendSlackNetworkError(t *testing.T) { ) err := svc.SendSlack( - context.Background(), webhookURL, "t", "m", "info", + context.Background(), webhookURL, notify.ErrSlackFailed, + "t", "m", "info", ) if err == nil { t.Fatal("expected error for network failure") @@ -1028,6 +1034,66 @@ func TestSendNotificationMattermostError(t *testing.T) { ) } +// TestSendNotificationErrorNamesEndpoint verifies that, with both +// Slack and Mattermost set, a failed delivery's logged error names +// the endpoint that failed. Both are sent by the Slack sender. +func TestSendNotificationErrorNamesEndpoint(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }), + ) + defer srv.Close() + + target, _ := url.Parse(srv.URL) + + svc, logs := newLoggingService(http.DefaultTransport) + svc.SetSlackWebhookURL(target) + svc.SetMattermostWebhookURL(target) + svc.SetSleepFunc(instantSleep) + svc.SetRetryConfig(notify.RetryConfig{ + MaxRetries: 1, + BaseDelay: time.Millisecond, + MaxDelay: time.Millisecond, + }) + + svc.SendNotification( + context.Background(), "t", "m", prioError, + ) + + waitForCondition(t, func() bool { + return svc.OutstandingDeliveries() == 0 + }) + + got := map[string]string{} + + for line := range strings.Lines(logs.String()) { + var record struct { + Msg string `json:"msg"` + Endpoint string `json:"endpoint"` + Error string `json:"error"` + } + + _ = json.Unmarshal([]byte(line), &record) + + if record.Msg == "failed to send notification after retries" { + got[record.Endpoint] = record.Error + } + } + + want := map[string]string{ + "slack": "slack notification failed: status 503", + "mattermost": "mattermost notification failed: status 503", + } + + if !maps.Equal(got, want) { + t.Errorf("logged errors = %v, want %v", got, want) + } +} + // ── SlackPayload JSON marshaling ────────────────────────── func TestSlackPayloadJSON(t *testing.T) { diff --git a/internal/notify/export_test.go b/internal/notify/export_test.go index 723fee6..52c3b3d 100644 --- a/internal/notify/export_test.go +++ b/internal/notify/export_test.go @@ -85,10 +85,11 @@ func (svc *Service) SendNtfy( func (svc *Service) SendSlack( ctx context.Context, webhookURL *url.URL, + failed error, title, message, priority string, ) error { return svc.sendSlack( - ctx, webhookURL, title, message, priority, + ctx, webhookURL, failed, title, message, priority, ) } diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 1185ed6..ff9026a 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -277,7 +277,8 @@ func (svc *Service) dispatchSlack( svc.dispatch(ctx, "slack", func(c context.Context) error { return svc.sendSlack( - c, svc.slackWebhookURL, title, message, priority, + c, svc.slackWebhookURL, ErrSlackFailed, + title, message, priority, ) }) } @@ -294,7 +295,7 @@ func (svc *Service) dispatchMattermost( ctx, "mattermost", func(c context.Context) error { return svc.sendSlack( - c, svc.mattermostWebhookURL, + c, svc.mattermostWebhookURL, ErrMattermostFailed, title, message, priority, ) }, @@ -370,9 +371,14 @@ type SlackAttachment struct { Text string `json:"text"` } +// sendSlack posts to a Slack or Mattermost incoming webhook, which +// take the same payload. An HTTP error status is returned wrapped in +// failed, ErrSlackFailed or ErrMattermostFailed, so the error names +// the endpoint. func (svc *Service) sendSlack( ctx context.Context, webhookURL *url.URL, + failed error, title, message, priority string, ) error { ctx, cancel := context.WithTimeout( @@ -420,7 +426,7 @@ func (svc *Service) sendSlack( if resp.StatusCode >= httpStatusClientError { return fmt.Errorf( "%w: status %d", - ErrSlackFailed, resp.StatusCode, + failed, resp.StatusCode, ) } -- 2.54.0 From 5db504875436a14f8921151dbc059e9c23bce412 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 08:42:35 +0200 Subject: [PATCH 56/92] watcher: startup notification no longer says every endpoint works (closes #230) The startup notification ended "All notification channels are working.", but it is written once and handed to every notification endpoint before any delivery has succeeded or failed, so the claim was never checked and was false whenever one endpoint refused it. It now says only that it is a test sent to every configured notification endpoint. The startup notification test checks the whole message. Model: opus-5-5 --- TODO.md | 2 ++ internal/watcher/watcher.go | 8 +++++--- internal/watcher/watcher_test.go | 13 +++++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index baedf40..2910b41 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: the startup notification no longer says every notification + endpoint works; it says it is a test sent to each of them (closes #230). - 2026-10-02: a Mattermost webhook that answers an HTTP error is logged as `mattermost notification failed`, not as a Slack failure (closes #227). - 2026-10-02: durations in the log are written as text such as `2m0s`, not as a diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 4e006cc..50fe163 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -1154,8 +1154,9 @@ func (w *Watcher) saveState() { // maybeSendTestNotification sends a startup status notification // after the first full scan completes, if SEND_TEST_NOTIFICATION -// is enabled. The message is clearly informational ("all ok") -// and not an error or anomaly alert. +// is enabled. The message is informational, not an error or anomaly +// alert. It is written before it reaches any endpoint, so it claims +// nothing about whether the endpoints work. func (w *Watcher) maybeSendTestNotification(ctx context.Context) { if !w.config.SendTestNotification { return @@ -1167,7 +1168,8 @@ func (w *Watcher) maybeSendTestNotification(ctx context.Context) { "dnswatcher has started and completed its initial scan.\n"+ "Monitoring %d domain(s) and %d hostname(s).\n"+ "Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+ - "All notification channels are working.", + "This is a test notification, sent to every configured "+ + "notification endpoint.", len(snap.Domains), len(snap.Hostnames), len(snap.Ports), diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 45bfd93..46b40cc 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -875,18 +875,27 @@ func TestSendTestNotification_ViaRun(t *testing.T) { notifications := deps.notifier.getNotifications() + // No names are configured, so every count is 0. + wantMessage := "dnswatcher has started and completed its initial scan.\n" + + "Monitoring 0 domain(s) and 0 hostname(s).\n" + + "Tracking 0 port endpoint(s) and 0 TLS certificate(s).\n" + + "This is a test notification, sent to every configured " + + "notification endpoint." + found := false for _, n := range notifications { if n.Priority == "success" && - n.Title == "✅ dnswatcher startup complete" { + n.Title == "✅ dnswatcher startup complete" && + n.Message == wantMessage { found = true } } if !found { t.Errorf( - "expected startup test notification, got: %v", + "expected startup test notification with message %q, got: %v", + wantMessage, notifications, ) } -- 2.54.0 From f99de191c065823057afa7c8a3b2ce3eb277303e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 08:46:56 +0200 Subject: [PATCH 57/92] watcher: change messages name only the record types that differ (closes #219) A Record Change notification printed the nameserver's whole old and new record sets in Go map syntax, and an Inconsistency notification the two nameservers' whole sets, so a one-address change had to be found by eye among kilobytes of unchanged TXT, CAA, MX and NS values. Both now list, in sorted order of type, only the record types whose values differ: a line naming the type, then each side's values separated by commas, or none when that side has no records of that type. The dashboard's Recent alerts shows the same text. Model: opus-5-5 --- TODO.md | 2 + internal/watcher/message_test.go | 66 ++++++++++++++++++++++++++++++++ internal/watcher/watcher.go | 64 ++++++++++++++++++++++++++++--- 3 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 internal/watcher/message_test.go diff --git a/TODO.md b/TODO.md index 2910b41..41521ae 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: Record Change and Inconsistency notifications list only the record + types that differ, each with its values as plain text (closes #219). - 2026-10-02: the startup notification no longer says every notification endpoint works; it says it is a test sent to each of them (closes #230). - 2026-10-02: a Mattermost webhook that answers an HTTP error is logged as diff --git a/internal/watcher/message_test.go b/internal/watcher/message_test.go new file mode 100644 index 0000000..2716ba0 --- /dev/null +++ b/internal/watcher/message_test.go @@ -0,0 +1,66 @@ +package watcher_test + +import ( + "testing" + + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +// When one nameserver's A record changes and its TXT record does not, +// the record change and the inconsistency it starts name the A record +// alone, with its values written as plain text. +func TestChangeMessagesNameTheChangedType(t *testing.T) { + t.Parallel() + + // A nameserver's records: this A address and the same TXT record. + records := func(address string) map[string][]string { + return map[string][]string{ + "A": {address}, + "TXT": {"v=spf1 -all"}, + } + } + + before := hostnameState(map[string]map[string][]string{ + nsA: records(ip1), + nsB: records(ip1), + }) + after := hostnameState(map[string]map[string][]string{ + nsA: records(ip1), + nsB: records(ip2), + }) + + // The hostname change detection uses only the notifier. + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + w.DetectHostnameChanges(t.Context(), host, before, after) + + want := map[string]string{ + "Record Change: " + host: `Hostname: www.example.net +Nameserver: b.ns.example.net. +Type: A +Old: 192.0.2.1 +New: 192.0.2.2`, + "Inconsistency: " + host: `Hostname: www.example.net +Type: A +a.ns.example.net.: 192.0.2.1 +b.ns.example.net.: 192.0.2.2`, + } + + notifications := notifier.getNotifications() + if len(notifications) != len(want) { + t.Fatalf( + "sent %d notifications, want %d: %v", + len(notifications), len(want), notifications, + ) + } + + for _, n := range notifications { + if n.Message != want[n.Title] { + t.Errorf( + "%s message:\n%s\nwant:\n%s", + n.Title, n.Message, want[n.Title], + ) + } + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 50fe163..001ee95 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -570,10 +570,12 @@ func (w *Watcher) detectRecordChanges( } msg := fmt.Sprintf( - "Hostname: %s\nNameserver: %s\n"+ - "Old: %v\nNew: %v", + "Hostname: %s\nNameserver: %s\n%s", hostname, ns, - prevNS.Records, cur.Records, + recordDifferences( + "Old", prevNS.Records, + "New", cur.Records, + ), ) w.notify.SendNotification( @@ -661,10 +663,12 @@ func (w *Watcher) detectInconsistencies( ns1, ns2 := pair[0], pair[1] msg := fmt.Sprintf( - "Hostname: %s\n%s: %v\n%s: %v", + "Hostname: %s\n%s", hostname, - ns1, current.RecordsByNameserver[ns1].Records, - ns2, current.RecordsByNameserver[ns2].Records, + recordDifferences( + ns1, current.RecordsByNameserver[ns1].Records, + ns2, current.RecordsByNameserver[ns2].Records, + ), ) w.notify.SendNotification( @@ -1214,6 +1218,54 @@ func recordsEqual( return true } +// recordDifferences describes, in sorted order of type, each record +// type whose values differ between a and b: a line naming the type, +// then a line with a's values after labelA and one with b's after +// labelB. Types with the same values in both are left out. +func recordDifferences( + labelA string, a map[string][]string, + labelB string, b map[string][]string, +) string { + types := make([]string, 0, len(a)+len(b)) + + for recordType := range a { + types = append(types, recordType) + } + + for recordType := range b { + if _, ok := a[recordType]; !ok { + types = append(types, recordType) + } + } + + sort.Strings(types) + + var lines []string + + for _, recordType := range types { + if sliceEqual(a[recordType], b[recordType]) { + continue + } + + lines = append(lines, + "Type: "+recordType, + labelA+": "+joinValues(a[recordType]), + labelB+": "+joinValues(b[recordType]), + ) + } + + return strings.Join(lines, "\n") +} + +// joinValues lists record values separated by commas, or says none. +func joinValues(values []string) string { + if len(values) == 0 { + return "none" + } + + return strings.Join(values, ", ") +} + func sliceEqual(a, b []string) bool { if len(a) != len(b) { return false -- 2.54.0 From b047c3c64cd98a194803db4bac32396da5895275 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 08:56:52 +0200 Subject: [PATCH 58/92] watcher: a lookup cut short by shutdown is not logged as an error (closes #229) Stopping dnswatcher during a DNS check logged every lookup the stop cut short as an error, "context canceled". The four places in the watcher that log a failed lookup, checkDomain, checkHostname, resolveNameserverAddresses and resolveCNAMEAddresses, now do it through logFailedLookup, which logs nothing when the watcher's context was cancelled. It asks the context, not the lookup's error: the resolver reports a cancelled lookup with its own error, which does not wrap context.Canceled. A context whose deadline passed is not cancelled, so a lookup that ran out of time is still logged. The tests run each of these lookups on a cancelled context and on one whose deadline passed; neither sends a query. Model: opus-5-5 --- TODO.md | 2 + internal/watcher/cancelled_test.go | 72 ++++++++++++++++++++++++++++++ internal/watcher/export_test.go | 6 +++ internal/watcher/watcher.go | 28 ++++++++++-- 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 41521ae..0ed2e3a 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a DNS lookup that shutdown cuts short logs no error; one that + fails otherwise, or runs out of time, still does (closes #229). - 2026-10-02: Record Change and Inconsistency notifications list only the record types that differ, each with its values as plain text (closes #219). - 2026-10-02: the startup notification no longer says every notification diff --git a/internal/watcher/cancelled_test.go b/internal/watcher/cancelled_test.go index 7d600d2..5d83bc1 100644 --- a/internal/watcher/cancelled_test.go +++ b/internal/watcher/cancelled_test.go @@ -1,10 +1,13 @@ package watcher_test import ( + "bytes" "context" "log/slog" "reflect" + "strings" "testing" + "time" "sneak.berlin/go/dnswatcher/internal/portcheck" "sneak.berlin/go/dnswatcher/internal/resolver" @@ -79,3 +82,72 @@ func TestCancelledCheckSavesNothing(t *testing.T) { t.Errorf("sent %v, want no notifications", notifications) } } + +// newLoggingWatcher returns a watcher for a domain and a hostname, with +// the real resolver, that writes what it logs at warning level or above +// into the returned buffer. +func newLoggingWatcher(t *testing.T) (*watcher.Watcher, *bytes.Buffer) { + t.Helper() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{testSmallDomain} + cfg.Hostnames = []string{host} + + w, _ := newTestWatcher(t, cfg) + + logs := &bytes.Buffer{} + w.SetLogger(slog.New(slog.NewJSONHandler( + logs, &slog.HandlerOptions{Level: slog.LevelWarn}, + ))) + + return w, logs +} + +// TestLookupCutShortIsNotLogged checks a domain and a hostname, looks +// up a nameserver's addresses and follows a CNAME, with the context +// cancelled, as shutdown leaves it. The real resolver fails each lookup +// without sending a query. Shutdown cutting a lookup short is not a +// failure, so nothing may be logged at warning level or above. +func TestLookupCutShortIsNotLogged(t *testing.T) { + t.Parallel() + + w, logs := newLoggingWatcher(t) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + w.RunOnce(ctx) + w.ResolveNameserverAddresses(ctx, []string{nsA}, nil) + w.ResolveCNAMEAddresses(ctx, host, cnameState(), nil) + + if logs.Len() > 0 { + t.Errorf("logged at warning level or above:\n%s", logs) + } +} + +// TestLookupOutOfTimeIsLoggedAsError does what +// TestLookupCutShortIsNotLogged does, with the context's deadline passed +// instead. A lookup that ran out of time did fail, so the domain's NS +// lookup, the hostname's lookup, the nameserver's address lookup and the +// CNAME's are each logged as an error. +func TestLookupOutOfTimeIsLoggedAsError(t *testing.T) { + t.Parallel() + + w, logs := newLoggingWatcher(t) + + ctx, cancel := context.WithDeadline(t.Context(), time.Now()) + t.Cleanup(cancel) + + w.RunOnce(ctx) + w.ResolveNameserverAddresses(ctx, []string{nsA}, nil) + w.ResolveCNAMEAddresses(ctx, host, cnameState(), nil) + + const want = 4 + + lines := strings.Count(logs.String(), "\n") + errorLines := strings.Count(logs.String(), `"level":"ERROR"`) + + if lines != want || errorLines != want { + t.Errorf("logged:\n%s\nwant %d lines, each at error level", logs, want) + } +} diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 218a566..863bb2a 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -31,6 +31,12 @@ func NewForTest( } } +// SetLogger replaces the watcher's logger, so a test can read what it +// logs. +func (w *Watcher) SetLogger(log *slog.Logger) { + w.log = log +} + // NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing. func NewlyDisagreeingPairs( prev, current *state.HostnameState, diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 001ee95..6a76074 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -2,6 +2,7 @@ package watcher import ( "context" + "errors" "fmt" "log/slog" "slices" @@ -213,13 +214,29 @@ func (w *Watcher) runDNSChecks(ctx context.Context) { } } +// logFailedLookup logs a failed DNS lookup at error level, unless ctx +// was cancelled: shutdown cancels it, and a lookup it cut short did not +// fail. A lookup that ran out of time did fail, so it is logged. +func (w *Watcher) logFailedLookup( + ctx context.Context, + msg string, + args ...any, +) { + if errors.Is(ctx.Err(), context.Canceled) { + return + } + + w.log.Error(msg, args...) +} + func (w *Watcher) checkDomain( ctx context.Context, domain string, ) { nameservers, err := w.resolver.LookupNS(ctx, domain) if err != nil { - w.log.Error( + w.logFailedLookup( + ctx, "failed to lookup NS", "domain", domain, "error", err, @@ -320,7 +337,8 @@ func (w *Watcher) resolveNameserverAddresses( continue } - w.log.Error( + w.logFailedLookup( + ctx, "no addresses found for nameserver", "nameserver", ns, "error", err, @@ -372,7 +390,8 @@ func (w *Watcher) checkHostname( ) { results, err := w.resolver.LookupAllRecords(ctx, hostname) if err != nil { - w.log.Error( + w.logFailedLookup( + ctx, "failed to lookup records", "hostname", hostname, "error", err, @@ -455,7 +474,8 @@ func (w *Watcher) resolveCNAMEAddresses( for target := range targets { ips, err := w.resolver.ResolveIPAddresses(ctx, target) if err != nil { - w.log.Error( + w.logFailedLookup( + ctx, "failed to follow CNAME", "hostname", hostname, "target", target, -- 2.54.0 From c07976a73a8755ae5530a4b59744fa15c630f442 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 09:09:51 +0200 Subject: [PATCH 59/92] resolver: store a name's CNAME once per nameserver (closes #220) For a name with a CNAME, a nameserver answers a query of any type with that CNAME, and the records of every answer were added, so the CNAME was stored once for each of the eight record types asked for. collectAnswerRecords now adds each value once per record type. A state file saved before this holds the repeated values. Load keeps each record value once, so the first check after upgrading sees no record change and notifies nothing for them. Model: opus-5-5 --- TODO.md | 2 + internal/resolver/export_test.go | 7 ++++ internal/resolver/iterative.go | 13 ++++-- internal/resolver/iterative_test.go | 32 +++++++++++++++ internal/state/state.go | 14 +++++++ internal/state/state_test.go | 53 +++++++++++++++++++++++++ internal/watcher/inconsistency_test.go | 55 ++++++++++++++++++++++++++ 7 files changed, 173 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 0ed2e3a..4ce00d0 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a name's CNAME is stored once per nameserver, not once per record + type asked for; a state file with repeats loads each value once (closes #220). - 2026-10-02: a DNS lookup that shutdown cuts short logs no error; one that fails otherwise, or runs out of time, still does (closes #229). - 2026-10-02: Record Change and Inconsistency notifications list only the record diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 17c57d7..58b88b1 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -11,6 +11,13 @@ func ExtractRecordValue(rr dns.RR) string { return extractRecordValue(rr) } +// CollectAnswerRecords exports collectAnswerRecords for testing. +func CollectAnswerRecords(msg *dns.Msg, resp *NameserverResponse) { + var state queryState + + collectAnswerRecords(msg, resp, &state) +} + // UsableReply exports usableReply for testing. func UsableReply(resp *dns.Msg, zone string, name string) bool { return usableReply(resp, zone, name) diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 1f06a24..6ffedc5 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -714,6 +714,10 @@ func (r *Resolver) querySingleType( collectAnswerRecords(msg, resp, state) } +// collectAnswerRecords adds the records in msg's answer to resp, each +// value once per record type. For a name with a CNAME, a nameserver +// answers a query of any type with that CNAME, so the same value comes +// in the answer to every type asked for. func collectAnswerRecords( msg *dns.Msg, resp *NameserverResponse, @@ -726,9 +730,12 @@ func collectAnswerRecords( } typeName := dns.TypeToString[rr.Header().Rrtype] - resp.Records[typeName] = append( - resp.Records[typeName], val, - ) + if !slices.Contains(resp.Records[typeName], val) { + resp.Records[typeName] = append( + resp.Records[typeName], val, + ) + } + state.hasRecords = true } } diff --git a/internal/resolver/iterative_test.go b/internal/resolver/iterative_test.go index 4f539ae..ca422cd 100644 --- a/internal/resolver/iterative_test.go +++ b/internal/resolver/iterative_test.go @@ -238,6 +238,38 @@ func TestExtractRecordValue_LetterCase(t *testing.T) { } } +// TestCollectAnswerRecords_CNAMEOnce collects the answers a nameserver +// gives for a name with a CNAME, one for each record type a check asks +// for. Each answer holds the CNAME, which must be stored once. +func TestCollectAnswerRecords_CNAMEOnce(t *testing.T) { + t.Parallel() + + cname := &dns.CNAME{ + Hdr: dns.RR_Header{ + Name: "git.eeqj.de.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, + }, + Target: "fsn1app1.datavi.be.", + } + + resp := &resolver.NameserverResponse{Records: map[string][]string{}} + + for _, qtype := range []uint16{ + dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, dns.TypeMX, + dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeNS, + } { + msg := new(dns.Msg) + msg.SetQuestion("git.eeqj.de.", qtype) + msg.Answer = []dns.RR{cname} + + resolver.CollectAnswerRecords(msg, resp) + } + + assert.Equal(t, + map[string][]string{"CNAME": {"fsn1app1.datavi.be."}}, + resp.Records, + ) +} + // TestShuffled shuffles the root servers with many seeds. Every order // must hold each root server once, so each is tried before a // resolution fails; each root server must come first for some seed, so diff --git a/internal/state/state.go b/internal/state/state.go index 47ea4c6..ec1fff1 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "path/filepath" + "slices" "sync" "time" @@ -201,6 +202,19 @@ func (s *State) Load() error { return fmt.Errorf("parsing state file: %w", err) } + // A state file saved before each record value was stored once can + // hold a hostname's CNAME once for every record type asked for. + // Each value is kept once, so the first check does not see a + // record change. + for _, hs := range snapshot.Hostnames { + for _, ns := range hs.RecordsByNameserver { + for recordType, values := range ns.Records { + slices.Sort(values) + ns.Records[recordType] = slices.Compact(values) + } + } + } + s.snapshot = &snapshot s.log.Info("loaded state from disk", "path", path) diff --git a/internal/state/state_test.go b/internal/state/state_test.go index b0f0fd4..b2f7391 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -277,6 +277,59 @@ func TestLoadStateFromBeforeCNAMEAddresses(t *testing.T) { } } +// TestLoadStateWithRepeatedValues loads a state file saved when a +// hostname's CNAME was stored once for every record type asked for. +// Each value must load once, and every different value must load. +func TestLoadStateWithRepeatedValues(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + data := []byte(`{ + "version": 1, + "hostnames": { + "www.example.com": { + "recordsByNameserver": { + "ns1.example.com.": { + "records": { + "A": ["192.0.2.2", "192.0.2.1", "192.0.2.2", "192.0.2.1"], + "CNAME": ["a.example.net.", "a.example.net.", "a.example.net."] + }, + "status": "ok" + } + } + } + } + }`) + + err := os.WriteFile(filepath.Join(dir, "state.json"), data, 0o600) + if err != nil { + t.Fatalf("writing state file: %v", err) + } + + s := state.NewForTestWithDataDir(dir) + + err = s.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + hs, ok := s.GetHostnameState(testHostname) + if !ok { + t.Fatal("missing hostname " + testHostname) + } + + want := map[string][]string{ + "A": {"192.0.2.1", "192.0.2.2"}, + "CNAME": {"a.example.net."}, + } + + got := hs.RecordsByNameserver[testNS1].Records + if !reflect.DeepEqual(got, want) { + t.Errorf("records: got %v, want %v", got, want) + } +} + // TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle. func TestSaveLoadRoundTrip_Hostnames(t *testing.T) { t.Parallel() diff --git a/internal/watcher/inconsistency_test.go b/internal/watcher/inconsistency_test.go index 494ec2b..43c129e 100644 --- a/internal/watcher/inconsistency_test.go +++ b/internal/watcher/inconsistency_test.go @@ -183,3 +183,58 @@ func TestInconsistencyAlert(t *testing.T) { }) } } + +// TestFirstCheckAfterRepeatedValuesLoaded saves a state file holding a +// hostname's CNAME once for every record type asked for, as checks did +// before each value was stored once, and two addresses each repeated, +// and loads it. A check that then finds each value once at each +// nameserver must notify nothing. +func TestFirstCheckAfterRepeatedValuesLoaded(t *testing.T) { + t.Parallel() + + const ( + cnameType = "CNAME" + cname = "c.example.net." + ) + + cfg := defaultTestConfig(t) + repeated := map[string][]string{ + "A": {ip2, ip1, ip2, ip1}, + cnameType: {cname, cname, cname, cname, cname, cname, cname, cname}, + } + once := map[string][]string{"A": {ip1, ip2}, cnameType: {cname}} + + saved := newTestDeps(t, cfg).state + saved.SetHostnameState(host, hostnameState(map[string]map[string][]string{ + nsA: repeated, nsB: repeated, + })) + + err := saved.Save() + if err != nil { + t.Fatalf("saving the state file: %v", err) + } + + deps := newTestDeps(t, cfg) + + err = deps.state.Load() + if err != nil { + t.Fatalf("loading the state file: %v", err) + } + + prev, ok := deps.state.GetHostnameState(host) + if !ok { + t.Fatal("the state file has no state for " + host) + } + + current := hostnameState(map[string]map[string][]string{ + nsA: once, nsB: once, + }) + + // The hostname change detection uses only the notifier. + w := watcher.NewForTest(nil, nil, nil, nil, nil, deps.notifier) + w.DetectHostnameChanges(t.Context(), host, prev, current) + + if got := deps.notifier.getNotifications(); len(got) != 0 { + t.Errorf("sent %v, want no notification", got) + } +} -- 2.54.0 From 250f3dd687c3cf8bc82796c20cfcf1711f030d22 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 09:14:28 +0200 Subject: [PATCH 60/92] dashboard and status API show why a check failed (closes #225) /api/v1/status now gives `error` for each nameserver entry and certificate entry whose status is `error`, copied from the state, which already kept it. The dashboard shows that reason in place of the records for a failed nameserver, which used to show the same `-` as one that answered with no records, and across the CN, issuer and expiry cells for a failed certificate, wrapped at a width of 20rem so the long TLS error does not narrow the Endpoint column. The dashboard stylesheet is a trimmed build, so the new markup uses only classes the page already had. README Web Dashboard and HTTP API say so. Model: opus-5-5 --- README.md | 10 +- TODO.md | 2 + internal/handlers/dashboard_test.go | 37 +++++ internal/handlers/status.go | 4 + internal/handlers/status_test.go | 158 +++++++++++++++++++++ internal/handlers/templates/dashboard.html | 10 ++ 6 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 internal/handlers/status_test.go diff --git a/README.md b/README.md index ffbb132..daea081 100644 --- a/README.md +++ b/README.md @@ -230,9 +230,11 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL - **Summary counts** for monitored domains, hostnames, ports, and certificates. - **Domains** with their discovered nameservers. -- **Hostnames** with per-nameserver DNS records and status. +- **Hostnames** with per-nameserver DNS records and status. For a nameserver + whose query failed, the reason is shown in place of the records. - **Ports** with open/closed state and associated hostnames. -- **TLS certificates** with CN, issuer, expiry, and status. +- **TLS certificates** with CN, issuer, expiry, and status. For a failed check, + the reason is shown in place of CN, issuer and expiry. - **Recent alerts** (last 100 notifications sent since the process started), displayed in reverse chronological order. @@ -259,6 +261,10 @@ dnswatcher exposes a lightweight HTTP API for operational visibility: | `GET /api/v1/status` | Current monitoring state | | `GET /metrics` | Prometheus metrics, see below | +In `/api/v1/status`, each nameserver entry and certificate entry whose `status` +is `error` also has `error`, the reason, as in the state file (see State File +Format). + `/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime, process, and counts of `/metrics` requests); dnswatcher records no metrics of diff --git a/TODO.md b/TODO.md index 4ce00d0..f71bbcf 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: the dashboard and `/api/v1/status` show why a nameserver query or + a certificate check failed, which only the state file showed (closes #225). - 2026-10-02: a name's CNAME is stored once per nameserver, not once per record type asked for; a state file with repeats loads each value once (closes #220). - 2026-10-02: a DNS lookup that shutdown cuts short logs no error; one that diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go index 554cf9d..d60a36e 100644 --- a/internal/handlers/dashboard_test.go +++ b/internal/handlers/dashboard_test.go @@ -1,6 +1,7 @@ package handlers_test import ( + "strings" "testing" "time" @@ -78,3 +79,39 @@ func TestFormatRecords(t *testing.T) { t.Errorf("unexpected format: %q", got) } } + +// dashboardRow returns the table row of page that contains name. +func dashboardRow(t *testing.T, page string, name string) string { + t.Helper() + + for row := range strings.SplitSeq(page, " + {{ if $nsr.Error }} + {{ $nsr.Error }} + {{ else }} {{ formatRecords $nsr.Records }} + {{ end }} {{ relTime $nsr.LastChecked }} @@ -258,6 +262,11 @@ > {{ end }} + {{ if $cs.Error }} + +
{{ $cs.Error }}
+ + {{ else }} {{ $cs.CommonName }} @@ -285,6 +294,7 @@ {{ end }} {{ end }} + {{ end }} {{ relTime $cs.LastChecked }} -- 2.54.0 From 1218df9467d37dcd4a9dfba653e3bf6678feac3c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 09:55:07 +0200 Subject: [PATCH 61/92] dashboard: list record types in the README's order (closes #226) Each row of the Hostnames table listed a nameserver's record types in the order Go happens to walk the record map, which changed from row to row and on every page load, so two nameservers with the same records looked different. formatRecords now sorts the types by their place in the README's list (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS); any other type comes after them in alphabetical order. Values within a type were already sorted by the resolver. Model: opus-5-5 --- TODO.md | 2 ++ internal/handlers/dashboard.go | 30 +++++++++++++++++++--- internal/handlers/dashboard_test.go | 39 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index f71bbcf..2b8c2eb 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: the dashboard lists each nameserver's record types in one fixed + order, the README's, then any other type, not a random one (closes #226). - 2026-10-02: the dashboard and `/api/v1/status` show why a nameserver query or a certificate check failed, which only the state file showed (closes #225). - 2026-10-02: a name's CNAME is stored once per nameserver, not once per record diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 6d4ac57..6b65e51 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -1,11 +1,14 @@ package handlers import ( + "cmp" "embed" "fmt" "html/template" + "maps" "math" "net/http" + "slices" "strings" "time" @@ -122,16 +125,37 @@ func joinStrings(items []string, sep string) string { } // formatRecords formats a map of record type → values into a -// compact display string. +// compact display string. Record types are listed in the order the +// README lists them, any other type after them in alphabetical order, +// so rows of nameservers with the same records read the same. func formatRecords(records map[string][]string) string { if len(records) == 0 { return "-" } + order := []string{"A", "AAAA", "CNAME", "MX", "TXT", "SRV", "CAA", "NS"} + + position := func(rtype string) int { + i := slices.Index(order, rtype) + if i < 0 { + return len(order) + } + + return i + } + + rtypes := slices.Collect(maps.Keys(records)) + slices.SortFunc(rtypes, func(a, b string) int { + return cmp.Or( + cmp.Compare(position(a), position(b)), + strings.Compare(a, b), + ) + }) + var parts []string - for rtype, values := range records { - for _, v := range values { + for _, rtype := range rtypes { + for _, v := range records[rtype] { parts = append(parts, rtype+": "+v) } } diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go index d60a36e..01e8d3f 100644 --- a/internal/handlers/dashboard_test.go +++ b/internal/handlers/dashboard_test.go @@ -80,6 +80,45 @@ func TestFormatRecords(t *testing.T) { } } +// TestFormatRecordsTypeOrder checks that record types are listed in +// the README's order (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS), with +// any other type after them in alphabetical order. +func TestFormatRecordsTypeOrder(t *testing.T) { + t.Parallel() + + got := handlers.FormatRecords(map[string][]string{ + "SOA": {"ns1.example.com. hostmaster.example.com. 1 2 3 4 5"}, + "NS": {"ns1.example.com.", "ns2.example.com."}, + "CAA": {`0 issue "letsencrypt.org"`}, + "DNAME": {"example.net."}, + "TXT": {"v=spf1 -all"}, + "SRV": {"10 5 443 www.example.com."}, + "MX": {"10 mail.example.com."}, + "CNAME": {"www.example.com."}, + "AAAA": {"2001:db8::1"}, + "A": {"192.0.2.1"}, + }) + + want := strings.Join([]string{ + "A: 192.0.2.1", + "AAAA: 2001:db8::1", + "CNAME: www.example.com.", + "MX: 10 mail.example.com.", + "TXT: v=spf1 -all", + "SRV: 10 5 443 www.example.com.", + `CAA: 0 issue "letsencrypt.org"`, + "NS: ns1.example.com.", + "NS: ns2.example.com.", + "DNAME: example.net.", + "SOA: ns1.example.com. hostmaster.example.com. 1 2 3 4 5", + }, ", ") + + if got != want { + t.Errorf("FormatRecords lists types out of order:\n got %q\nwant %q", + got, want) + } +} + // dashboardRow returns the table row of page that contains name. func dashboardRow(t *testing.T, page string, name string) string { t.Helper() -- 2.54.0 From e46db71821ff79d7a24b5bbe58f50246c1846456 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 10:08:32 +0200 Subject: [PATCH 62/92] dashboard, status API and notifications tell apex domains from hostnames (closes #224) An apex domain's own records are still saved with the hostnames' records, under the domain's name, so the port and TLS checks find its addresses. Notifications about them now start `Domain:`, decided by the configured domains. The dashboard and /api/v1/status, which read only the saved state, take a hostname entry whose name also has a domain entry as that domain's own records: the dashboard shows them in a second table under Domains, the API in the domain's `recordsByNameserver`, and neither lists or counts them as hostnames. The startup notification counts domains and hostnames from the configuration. README says which of a domain's own records are watched and how their changes are notified. Model: opus-5-5 --- README.md | 26 ++++-- TODO.md | 2 + internal/handlers/dashboard.go | 25 +++-- internal/handlers/dashboard_test.go | 51 +++++++++++ internal/handlers/status.go | 88 +++++++++++++----- internal/handlers/status_test.go | 63 ++++++++++++- internal/handlers/templates/dashboard.html | 102 +++++++++++++-------- internal/state/state.go | 2 + internal/watcher/export_test.go | 13 ++- internal/watcher/message_test.go | 101 ++++++++++++++++++++ internal/watcher/watcher.go | 48 ++++++---- 11 files changed, 421 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index daea081..4461a44 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,12 @@ notification endpoint set, changes show only on the dashboard; see removed gets only the NS change notification. When the lookup of a nameserver's addresses fails or finds none, its previous addresses are kept and nothing is sent. +- Also watches the domain's own records as a hostname's are watched (see DNS + Hostname Monitoring below): its A, AAAA, CNAME, MX, TXT, SRV, CAA and NS + records, stored per nameserver. Their changes are notified as a hostname's + are, as a record change, NS query failure, NS recovery, inconsistency or CNAME + address change, in a message that starts `Domain:` where a hostname's starts + `Hostname:`. ### DNS Hostname Monitoring (Subdomains) @@ -183,18 +189,18 @@ Supported notification backends: All configured endpoints receive every notification. Notification content includes: -- **DNS record changes**: Which hostname, which nameserver, what record type, - old values, new values. +- **DNS record changes**: Which hostname or domain, which nameserver, what + record type, old values, new values. - **DNS NS changes**: Which domain, which nameservers were added/removed. - **NS address changes**: Which domain, which nameserver, its old and new addresses. -- **CNAME address changes**: Which hostname, the old and new addresses at the - end of its CNAME chain. +- **CNAME address changes**: Which hostname or domain, the old and new addresses + at the end of its CNAME chain. - **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL, REFUSED, network error), which hostname/domain affected. - **NS recoveries**: Which nameserver recovered, which hostname/domain. - **NS inconsistencies**: Which nameservers disagree, what each one returned, - which hostname affected. + which hostname or domain affected. - **Port changes**: Which IP:port, its new state, all associated hostnames. - **TLS expiry warnings**: Expiry date and days remaining, CN, associated hostname and IP. @@ -229,7 +235,8 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL (`/`). It displays: - **Summary counts** for monitored domains, hostnames, ports, and certificates. -- **Domains** with their discovered nameservers. +- **Domains** with their discovered nameservers, and each domain's own records + per nameserver and status, shown as a hostname's are. - **Hostnames** with per-nameserver DNS records and status. For a nameserver whose query failed, the reason is shown in place of the records. - **Ports** with open/closed state and associated hostnames. @@ -263,7 +270,9 @@ dnswatcher exposes a lightweight HTTP API for operational visibility: In `/api/v1/status`, each nameserver entry and certificate entry whose `status` is `error` also has `error`, the reason, as in the state file (see State File -Format). +Format). A domain's own records are in its entry in `domains`, under +`recordsByNameserver`, in the form a hostname's entry in `hostnames` has them +under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. `/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime, @@ -455,7 +464,8 @@ resolver: the HTTP client looks up the webhook's or Sentry's host name with it. The state file (`DATA_DIR/state.json`) contains the complete monitoring snapshot. Hostname records are stored **per authoritative nameserver**, not as a -merged view, to enable inconsistency detection. +merged view, to enable inconsistency detection. `hostnames` also holds each +domain's own records, under the domain's name. ```json { diff --git a/TODO.md b/TODO.md index 2b8c2eb..1ddb111 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: an apex domain is not counted or listed as a hostname; its records + show under Domains, and notifications about them say `Domain:` (closes #224). - 2026-10-02: the dashboard lists each nameserver's record types in one fixed order, the README's, then any other type, not a random one (closes #226). - 2026-10-02: the dashboard and `/api/v1/status` show why a nameserver query or diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 6b65e51..fa7e8d5 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -43,12 +43,16 @@ func newDashboardTemplate() *template.Template { ) } -// dashboardData is the data passed to the dashboard template. +// dashboardData is the data passed to the dashboard template. Hostnames +// and DomainRecords split the records in Snapshot.Hostnames, which also +// holds the apex domains' own (see splitHostnames). type dashboardData struct { - Snapshot state.Snapshot - Alerts []notify.AlertEntry - StateAge string - GeneratedAt string + Snapshot state.Snapshot + Hostnames map[string]*state.HostnameState + DomainRecords map[string]*state.HostnameState + Alerts []notify.AlertEntry + StateAge string + GeneratedAt string } // HandleDashboard returns the dashboard page handler. @@ -61,12 +65,15 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc { ) { snap := h.state.GetSnapshot() alerts := h.notifyHistory.Recent() + hostnames, domainRecords := splitHostnames(snap) data := dashboardData{ - Snapshot: snap, - Alerts: alerts, - StateAge: relTime(snap.LastUpdated), - GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"), + Snapshot: snap, + Hostnames: hostnames, + DomainRecords: domainRecords, + Alerts: alerts, + StateAge: relTime(snap.LastUpdated), + GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"), } writer.Header().Set( diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go index 01e8d3f..27149d8 100644 --- a/internal/handlers/dashboard_test.go +++ b/internal/handlers/dashboard_test.go @@ -1,6 +1,7 @@ package handlers_test import ( + "regexp" "strings" "testing" "time" @@ -154,3 +155,53 @@ func TestDashboardShowsFailureReasons(t *testing.T) { t.Errorf("row of %s does not show %q", certKey, certFailedReason) } } + +// dashboardSection returns the section of page under heading. +func dashboardSection(t *testing.T, page string, heading string) string { + t.Helper() + + for section := range strings.SplitSeq(page, " "+heading+" ") { + return section + } + } + + t.Fatalf("dashboard has no section headed %q", heading) + + return "" +} + +// TestDashboardShowsDomainRecordsUnderDomains checks that the dashboard +// shows an apex domain's own records in the Domains section, and +// neither lists nor counts the domain as a hostname. +func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) { + t.Parallel() + + page := get(t, newHandlersWithFailures(t).HandleDashboard()) + + domains := dashboardSection(t, page, "Domains") + if !strings.Contains(dashboardRow(t, domains, domainAddress), testDomain) { + t.Errorf("row of %s does not name %s", domainAddress, testDomain) + } + + if strings.Contains(dashboardSection(t, page, "Hostnames"), testDomain) { + t.Errorf("Hostnames section lists the domain %s", testDomain) + } + + words := strings.Join(strings.Fields(page), " ") + + footer := "monitoring 1 domains + 1 hostnames" + if !strings.Contains(words, footer) { + t.Errorf("dashboard does not say %q", footer) + } + + // With the tags taken out, the summary bar starts "Domains 1 + // Hostnames 1". + text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ") + summary := "Domains 1 Hostnames 1" + + if !strings.Contains(strings.Join(strings.Fields(text), " "), summary) { + t.Errorf("summary bar does not say %q", summary) + } +} diff --git a/internal/handlers/status.go b/internal/handlers/status.go index f1104ba..a76d98a 100644 --- a/internal/handlers/status.go +++ b/internal/handlers/status.go @@ -9,9 +9,12 @@ import ( ) // statusDomainInfo holds status information for a monitored domain. +// RecordsByNameserver holds the domain's own records, in the form a +// hostname's Nameservers holds the hostname's. type statusDomainInfo struct { - Nameservers []string `json:"nameservers"` - LastChecked time.Time `json:"lastChecked"` + Nameservers []string `json:"nameservers"` + RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"` + LastChecked time.Time `json:"lastChecked"` } // statusHostnameNSInfo holds per-nameserver status for a hostname. @@ -100,8 +103,10 @@ func buildStatusResponse( Certificates: make(map[string]*statusCertificateInfo), } - buildDomains(snap, resp) - buildHostnames(snap, resp) + hostnames, domainRecords := splitHostnames(snap) + + buildDomains(snap, domainRecords, resp) + buildHostnames(hostnames, resp) buildPorts(snap, resp) buildCertificates(snap, resp) buildCounts(resp) @@ -109,8 +114,30 @@ func buildStatusResponse( return resp } +// splitHostnames returns the records saved in snap.Hostnames in two +// maps: the hostnames' and the apex domains' own. The watcher saves a +// domain's own records there under the domain's name, which has an +// entry in snap.Domains too. +func splitHostnames( + snap state.Snapshot, +) (map[string]*state.HostnameState, map[string]*state.HostnameState) { + hostnames := make(map[string]*state.HostnameState) + domainRecords := make(map[string]*state.HostnameState) + + for name, hs := range snap.Hostnames { + if _, isDomain := snap.Domains[name]; isDomain { + domainRecords[name] = hs + } else { + hostnames[name] = hs + } + } + + return hostnames, domainRecords +} + func buildDomains( snap state.Snapshot, + domainRecords map[string]*state.HostnameState, resp *statusResponse, ) { for name, ds := range snap.Domains { @@ -118,41 +145,54 @@ func buildDomains( copy(ns, ds.Nameservers) sort.Strings(ns) + records := make(map[string]*statusHostnameNSInfo) + if hs, ok := domainRecords[name]; ok { + records = nameserverInfo(hs) + } + resp.Domains[name] = &statusDomainInfo{ - Nameservers: ns, - LastChecked: ds.LastChecked, + Nameservers: ns, + RecordsByNameserver: records, + LastChecked: ds.LastChecked, } } } func buildHostnames( - snap state.Snapshot, + hostnames map[string]*state.HostnameState, resp *statusResponse, ) { - for name, hs := range snap.Hostnames { - info := &statusHostnameInfo{ - Nameservers: make(map[string]*statusHostnameNSInfo), + for name, hs := range hostnames { + resp.Hostnames[name] = &statusHostnameInfo{ + Nameservers: nameserverInfo(hs), LastChecked: hs.LastChecked, } + } +} - for ns, nsState := range hs.RecordsByNameserver { - recs := make(map[string][]string, len(nsState.Records)) - for rtype, vals := range nsState.Records { - copied := make([]string, len(vals)) - copy(copied, vals) - recs[rtype] = copied - } +// nameserverInfo copies each nameserver's answer saved in hs. +func nameserverInfo( + hs *state.HostnameState, +) map[string]*statusHostnameNSInfo { + info := make(map[string]*statusHostnameNSInfo) - info.Nameservers[ns] = &statusHostnameNSInfo{ - Records: recs, - Status: nsState.Status, - Error: nsState.Error, - LastChecked: nsState.LastChecked, - } + for ns, nsState := range hs.RecordsByNameserver { + recs := make(map[string][]string, len(nsState.Records)) + for rtype, vals := range nsState.Records { + copied := make([]string, len(vals)) + copy(copied, vals) + recs[rtype] = copied } - resp.Hostnames[name] = info + info[ns] = &statusHostnameNSInfo{ + Records: recs, + Status: nsState.Status, + Error: nsState.Error, + LastChecked: nsState.LastChecked, + } } + + return info } func buildPorts( diff --git a/internal/handlers/status_test.go b/internal/handlers/status_test.go index 156e247..d93f503 100644 --- a/internal/handlers/status_test.go +++ b/internal/handlers/status_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "testing" "time" @@ -19,7 +20,8 @@ import ( // The state the handler tests serve: www.example.com has one nameserver // that answered and one whose query failed, and its certificate check -// failed. +// failed. example.net is an apex domain, whose own records are saved +// with the hostnames' records, as the watcher saves them. const ( testHostname = "www.example.com" answeringNS = "ns1.example.com." @@ -27,6 +29,9 @@ const ( nsFailureReason = "server returned a referral" certKey = "192.0.2.1:443:www.example.com" certFailedReason = "x509: certificate has expired or is not yet valid" + testDomain = "example.net" + domainNS = "a.iana-servers.net." + domainAddress = "192.0.2.2" ) // newHandlersWithFailures builds real Handlers whose state holds the @@ -85,6 +90,22 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers { LastChecked: now, }) + st.SetDomainState(testDomain, &state.DomainState{ + Nameservers: []string{domainNS}, + LastChecked: now, + }) + + st.SetHostnameState(testDomain, &state.HostnameState{ + RecordsByNameserver: map[string]*state.NameserverRecordState{ + domainNS: { + Records: map[string][]string{"A": {domainAddress}}, + Status: "ok", + LastChecked: now, + }, + }, + LastChecked: now, + }) + hnd, err := handlers.New(nil, handlers.Params{ Logger: log, Globals: glob, @@ -156,3 +177,43 @@ func TestStatusGivesFailureReasons(t *testing.T) { got, certFailedReason) } } + +// TestStatusGivesDomainRecordsUnderTheDomain checks that /api/v1/status +// gives an apex domain's own records in its domain entry, and neither +// lists nor counts the domain as a hostname. +func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) { + t.Parallel() + + body := get(t, newHandlersWithFailures(t).HandleStatus()) + + var resp struct { + Counts struct { + Hostnames int `json:"hostnames"` + } `json:"counts"` + Domains map[string]struct { + RecordsByNameserver map[string]struct { + Records map[string][]string `json:"records"` + } `json:"recordsByNameserver"` + } `json:"domains"` + Hostnames map[string]any `json:"hostnames"` + } + + err := json.Unmarshal([]byte(body), &resp) + if err != nil { + t.Fatalf("decoding response: %v", err) + } + + if resp.Counts.Hostnames != 1 { + t.Errorf("counts.hostnames = %d, want 1", resp.Counts.Hostnames) + } + + if _, listed := resp.Hostnames[testDomain]; listed { + t.Errorf("hostnames lists the domain %s", testDomain) + } + + records := resp.Domains[testDomain].RecordsByNameserver[domainNS].Records + if !slices.Equal(records["A"], []string{domainAddress}) { + t.Errorf("domain %s records at %s = %v, want A %s", + testDomain, domainNS, records, domainAddress) + } +} diff --git a/internal/handlers/templates/dashboard.html b/internal/handlers/templates/dashboard.html index 422e34f..6ab9d53 100644 --- a/internal/handlers/templates/dashboard.html +++ b/internal/handlers/templates/dashboard.html @@ -39,7 +39,7 @@ Hostnames
- {{ len .Snapshot.Hostnames }} + {{ len .Hostnames }}
@@ -94,6 +94,24 @@
+ {{ if .DomainRecords }} +
+ + + + + + + + + + + + {{ template "records" .DomainRecords }} + +
DomainNSStatusRecordsChecked
+
+ {{ end }} {{ else }}

No domains configured. @@ -108,7 +126,7 @@ > Hostnames - {{ if .Snapshot.Hostnames }} + {{ if .Hostnames }}

@@ -121,43 +139,7 @@ - {{ range $name, $hs := .Snapshot.Hostnames }} - {{ range $ns, $nsr := $hs.RecordsByNameserver }} - - - - - - - - {{ end }} - {{ end }} + {{ template "records" .Hostnames }}
- {{ $name }} - - {{ $ns }} - - {{ if eq $nsr.Status "ok" }} - ok - {{ else }} - {{ $nsr.Status }} - {{ end }} - - {{ if $nsr.Error }} - {{ $nsr.Error }} - {{ else }} - {{ formatRecords $nsr.Records }} - {{ end }} - - {{ relTime $nsr.LastChecked }} -
@@ -373,8 +355,48 @@ class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8" > dnswatcher · monitoring {{ len .Snapshot.Domains }} domains + - {{ len .Snapshot.Hostnames }} hostnames + {{ len .Hostnames }} hostnames +{{/* ---- One row per nameserver of each name in the map it is given ---- */}} +{{ define "records" }} +{{ range $name, $hs := . }} +{{ range $ns, $nsr := $hs.RecordsByNameserver }} + + + {{ $name }} + + + {{ $ns }} + + + {{ if eq $nsr.Status "ok" }} + ok + {{ else }} + {{ $nsr.Status }} + {{ end }} + + + {{ if $nsr.Error }} + {{ $nsr.Error }} + {{ else }} + {{ formatRecords $nsr.Records }} + {{ end }} + + + {{ relTime $nsr.LastChecked }} + + +{{ end }} +{{ end }} +{{ end }} diff --git a/internal/state/state.go b/internal/state/state.go index ec1fff1..4f5a325 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -122,6 +122,8 @@ type CertificateState struct { } // Snapshot is the complete monitoring state persisted to disk. +// Hostnames also holds each apex domain's own records, under the +// domain's name, which has an entry in Domains too. type Snapshot struct { Version int `json:"version"` LastUpdated time.Time `json:"lastUpdated"` diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 863bb2a..e932a2c 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -10,7 +10,8 @@ import ( "sneak.berlin/go/dnswatcher/internal/state" ) -// NewForTest creates a Watcher without fx for unit testing. +// NewForTest creates a Watcher without fx for unit testing. A nil cfg +// is an empty configuration. func NewForTest( cfg *config.Config, st *state.State, @@ -19,6 +20,10 @@ func NewForTest( tc TLSChecker, n Notifier, ) *Watcher { + if cfg == nil { + cfg = &config.Config{} + } + return &Watcher{ log: slog.Default(), config: cfg, @@ -96,6 +101,12 @@ func (w *Watcher) DetectNSAddressChanges( w.detectNSAddressChanges(ctx, domain, prev, current) } +// MaybeSendTestNotification exports maybeSendTestNotification for +// testing. +func (w *Watcher) MaybeSendTestNotification(ctx context.Context) { + w.maybeSendTestNotification(ctx) +} + // CheckAllPorts exports checkAllPorts for testing. func (w *Watcher) CheckAllPorts(ctx context.Context) { w.checkAllPorts(ctx) diff --git a/internal/watcher/message_test.go b/internal/watcher/message_test.go index 2716ba0..4659076 100644 --- a/internal/watcher/message_test.go +++ b/internal/watcher/message_test.go @@ -1,8 +1,12 @@ package watcher_test import ( + "maps" + "strings" "testing" + "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/watcher" ) @@ -64,3 +68,100 @@ b.ns.example.net.: 192.0.2.2`, } } } + +// Every kind of notification about a configured apex domain's own +// records names it as a domain. +func TestDomainRecordNotificationsNameTheDomain(t *testing.T) { + t.Parallel() + + notifier := &mockNotifier{} + w := watcher.NewForTest( + &config.Config{Domains: []string{domain}}, + nil, nil, nil, nil, notifier, + ) + + // nsA's address changes, which also makes it differ from nsC; nsB + // fails; nsC answers again; nsD is gone. + nsD := "d.ns.example.net." + w.DetectHostnameChanges(t.Context(), domain, + saved(map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip1}}), + nsB: answered(map[string][]string{"A": {ip1}}), + nsC: failed(), + nsD: answered(map[string][]string{"A": {ip1}}), + }), + saved(map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip2}}), + nsB: failed(), + nsC: answered(map[string][]string{"A": {ip1}}), + }), + ) + + // The address at the end of its CNAME chain changes. + w.DetectHostnameChanges( + t.Context(), domain, cnameState(ip1), cnameState(ip2), + ) + + // NS Failure is sent for nsB failing and for nsD being gone. + want := map[string]int{ + "Record Change": 1, + "Inconsistency": 1, + "NS Failure": 2, + "NS Recovery": 1, + "CNAME Address Change": 1, + } + + sent := make(map[string]int) + + for _, n := range notifier.getNotifications() { + kind, _, _ := strings.Cut(n.Title, ":") + sent[kind]++ + + if !strings.HasPrefix(n.Message, "Domain: "+domain+"\n") { + t.Errorf("%s message does not name the domain:\n%s", + n.Title, n.Message) + } + } + + if !maps.Equal(sent, want) { + t.Errorf("sent %v, want %v", sent, want) + } +} + +// The startup notification counts the configured domains and hostnames, +// although the state's hostnames also hold the apex domain's own +// records. Nothing is looked up: the watcher has no resolver. +func TestStartupNotificationCountsConfiguredNames(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.SendTestNotification = true + cfg.Domains = []string{domain} + cfg.Hostnames = []string{host} + + deps := newTestDeps(t, cfg) + w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier) + + // The state a check of both names saves. + deps.state.SetDomainState(domain, &state.DomainState{ + Nameservers: []string{nsA}, + }) + + for _, name := range []string{domain, host} { + deps.state.SetHostnameState(name, saved( + map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip1}}), + }, + )) + } + + w.MaybeSendTestNotification(t.Context()) + + notifications := deps.notifier.getNotifications() + + counts := "\nMonitoring 1 domain(s) and 1 hostname(s).\n" + if len(notifications) != 1 || + !strings.Contains(notifications[0].Message, counts) { + t.Errorf("sent %v, want one message with %q", notifications, counts) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 6a76074..87ab71a 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -271,8 +271,9 @@ func (w *Watcher) checkDomain( LastChecked: now, }) - // The apex domain's records are also checked as a hostname's, so - // that the port and TLS checks find its addresses. + // The apex domain's records are also checked and saved as a + // hostname's, so that the port and TLS checks find its addresses. + // Notifications about them name it as a domain (see nameLine). w.checkHostname(ctx, domain) } @@ -541,6 +542,17 @@ func (w *Watcher) detectHostnameChanges( w.detectCNAMEAddressChanges(ctx, hostname, prev, current) } +// nameLine is the line a notification about name's records starts with: +// "Domain: " and the name for a configured apex domain, whose own +// records are checked as a hostname's are, and "Hostname: " otherwise. +func (w *Watcher) nameLine(name string) string { + if slices.Contains(w.config.Domains, name) { + return "Domain: " + name + } + + return "Hostname: " + name +} + // detectCNAMEAddressChanges notifies when the addresses at the end of // hostname's CNAME chain differ from those the previous check saved, // including a change from or to none. When the previous addresses are @@ -557,8 +569,8 @@ func (w *Watcher) detectCNAMEAddressChanges( } msg := fmt.Sprintf( - "Hostname: %s\nOld: %s\nNew: %s", - hostname, + "%s\nOld: %s\nNew: %s", + w.nameLine(hostname), strings.Join(old, ", "), strings.Join(cur, ", "), ) @@ -590,8 +602,8 @@ func (w *Watcher) detectRecordChanges( } msg := fmt.Sprintf( - "Hostname: %s\nNameserver: %s\n%s", - hostname, ns, + "%s\nNameserver: %s\n%s", + w.nameLine(hostname), ns, recordDifferences( "Old", prevNS.Records, "New", cur.Records, @@ -618,8 +630,8 @@ func (w *Watcher) detectNSDisappearances( } msg := fmt.Sprintf( - "Hostname: %s\nNameserver: %s disappeared", - hostname, ns, + "%s\nNameserver: %s disappeared", + w.nameLine(hostname), ns, ) w.notify.SendNotification( @@ -648,8 +660,8 @@ func (w *Watcher) detectNSFailures( switch { case prevNS.Status == statusOK && cur.Status == statusError: msg := fmt.Sprintf( - "Hostname: %s\nNameserver: %s\nError: %s", - hostname, ns, cur.Error, + "%s\nNameserver: %s\nError: %s", + w.nameLine(hostname), ns, cur.Error, ) w.notify.SendNotification( @@ -660,8 +672,8 @@ func (w *Watcher) detectNSFailures( ) case prevNS.Status == statusError && cur.Status == statusOK: msg := fmt.Sprintf( - "Hostname: %s\nNameserver: %s recovered", - hostname, ns, + "%s\nNameserver: %s recovered", + w.nameLine(hostname), ns, ) w.notify.SendNotification( @@ -683,8 +695,8 @@ func (w *Watcher) detectInconsistencies( ns1, ns2 := pair[0], pair[1] msg := fmt.Sprintf( - "Hostname: %s\n%s", - hostname, + "%s\n%s", + w.nameLine(hostname), recordDifferences( ns1, current.RecordsByNameserver[ns1].Records, ns2, current.RecordsByNameserver[ns2].Records, @@ -1180,7 +1192,9 @@ func (w *Watcher) saveState() { // after the first full scan completes, if SEND_TEST_NOTIFICATION // is enabled. The message is informational, not an error or anomaly // alert. It is written before it reaches any endpoint, so it claims -// nothing about whether the endpoints work. +// nothing about whether the endpoints work. Domains and hostnames are +// counted from the configuration: the state's hostnames also hold each +// apex domain's own records. func (w *Watcher) maybeSendTestNotification(ctx context.Context) { if !w.config.SendTestNotification { return @@ -1194,8 +1208,8 @@ func (w *Watcher) maybeSendTestNotification(ctx context.Context) { "Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+ "This is a test notification, sent to every configured "+ "notification endpoint.", - len(snap.Domains), - len(snap.Hostnames), + len(w.config.Domains), + len(w.config.Hostnames), len(snap.Ports), len(snap.Certificates), ) -- 2.54.0 From 008ec5d13a3a18a30a2112d6b7963af52bae2b75 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 10:12:08 +0200 Subject: [PATCH 63/92] resolver: ask a referral's nameservers that come without addresses (closes #221) Looking up a nameserver's own address followed only the addresses a referral gave, so a nameserver whose zone is delegated without them, such as a.ntpns.org of pool.ntp.org, never resolved. The walk to a name's nameservers looked addresses up only when a referral gave none. Both now go through queryZone, which asks the nameservers whose addresses the referral gives first and, if none of them gives a usable reply, looks up and asks the others. maxLookupDepth stops lookups three deep, so delegations that point at each other still end; when the limit is why no address was found, the error is ErrLookupDepthExceeded, not "no address". Model: opus-5-5 --- README.md | 7 +- TODO.md | 2 + internal/resolver/errors.go | 7 ++ internal/resolver/export_test.go | 25 ++++- internal/resolver/iterative.go | 169 +++++++++++++++++++++-------- internal/resolver/resolver_test.go | 136 +++++++++++++++++++++++ 6 files changed, 300 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 4461a44..5939483 100644 --- a/README.md +++ b/README.md @@ -441,7 +441,12 @@ anew each time, so no one root server gets every first query. A server that does not reply, refuses the query, or gives an error reply such as SERVFAIL or a referral that leads no closer to the name is passed over for the next one. When a referral names a zone's nameservers without their addresses, the addresses of -all of them are looked up, so that each can be asked. +all of them are looked up, so that each can be asked. When it gives addresses +for only some of them, those are asked first, and the others are looked up and +asked only if none of those gives a usable reply. Both hold in the walk to a +name's nameservers and in the lookup of a nameserver's own address. Such a +lookup can need others in turn; lookups go at most three deep, one inside +another, so delegations that point at each other still end. This approach ensures: diff --git a/TODO.md b/TODO.md index 1ddb111..397b53c 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: nameservers a referral names without addresses are looked up, + three deep at most; `pool.ntp.org`'s nameservers resolve (closes #221). - 2026-10-02: an apex domain is not counted or listed as a hostname; its records show under Domains, and notifications about them say `Domain:` (closes #224). - 2026-10-02: the dashboard lists each nameserver's record types in one fixed diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index 5e019c0..f603217 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -33,6 +33,13 @@ var ( "CNAME chain depth exceeded", ) + // ErrLookupDepthExceeded is returned when nameserver addresses + // were not looked up because lookups were already maxLookupDepth + // deep, one inside another. + ErrLookupDepthExceeded = errors.New( + "lookups of nameserver addresses go too deep", + ) + // ErrContextCanceled wraps context cancellation for the // resolver's iterative queries. ErrContextCanceled = errors.New("context canceled") diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 58b88b1..07c293b 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -55,12 +55,33 @@ func (r *Resolver) QueryEachNS( return r.queryEachNS(ctx, nameservers, hostname, recordTypes()) } -// ResolveNSIPs exports resolveNSIPs for testing. +// ResolveNSIPs exports resolveNSIPs for testing, looking each name up +// as a lookup that no other lookup started. func (r *Resolver) ResolveNSIPs( ctx context.Context, nsNames []string, ) []string { - return r.resolveNSIPs(ctx, nsNames) + ips, _ := r.resolveNSIPs(ctx, nsNames, 1) + + return ips +} + +// MaxLookupDepth exports maxLookupDepth for testing. +const MaxLookupDepth = maxLookupDepth + +// QueryZone exports queryZone for testing. +func (r *Resolver) QueryZone( + ctx context.Context, + given []string, + withoutAddresses []string, + zone string, + name string, + qtype uint16, + depth int, +) (*dns.Msg, error) { + return r.queryZone( + ctx, given, withoutAddresses, zone, name, qtype, depth, + ) } // RootServerList exports rootServerList for testing. diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 6ffedc5..5686622 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -19,6 +19,16 @@ const ( maxRetries = 2 maxDelegation = 20 timeoutMultiplier = 2 + + // maxLookupDepth is how many lookups of nameserver addresses may be + // under way one inside another. Looking up a nameserver's address + // can meet a referral that names nameservers without their + // addresses, which are then looked up in turn; without a limit, + // delegations that point at each other would never end. Each level + // multiplies the queries sent. pool.ntp.org needs three: the + // address of its nameserver g.ntpns.org can need a.ntpns.org's, + // which needs a bitnames.com nameserver's. + maxLookupDepth = 3 ) // ErrRefused is returned when a DNS server refuses a query. @@ -198,13 +208,15 @@ func (r *Resolver) followDelegation( // servers are the root servers, the servers of zone ".". zone := "." + var withoutAddresses []string + for range maxDelegation { if checkCtx(ctx) != nil { return nil, ErrContextCanceled } - resp, err := r.queryServers( - ctx, servers, zone, domain, dns.TypeNS, + resp, err := r.queryZone( + ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0, ) if err != nil { return nil, err @@ -229,18 +241,7 @@ func (r *Resolver) followDelegation( return r.resolveNSIterative(ctx, domain) } - glue := extractGlue(resp.Extra) - nextServers := glueIPs(authNS, glue) - - if len(nextServers) == 0 { - nextServers = r.resolveNSIPs(ctx, authNS) - } - - if len(nextServers) == 0 { - return nil, ErrNoNameservers - } - - servers = nextServers + servers, withoutAddresses = referralNameservers(resp) zone = referralZone(resp) } @@ -366,24 +367,110 @@ func nsSetFrom(resp *dns.Msg, domain string) []string { return extractNSSet(resp.Answer) } -// resolveNSIPs returns the addresses of every nameserver in nsNames -// whose name resolves, for a referral that carries none. The walk can -// then go on to the zone's other nameservers when one gives no usable -// reply. -func (r *Resolver) resolveNSIPs( - ctx context.Context, - nsNames []string, -) []string { - var ips []string +// referralNameservers returns the IPv4 addresses that resp, a referral, +// gives for the nameservers it names, and the names of the nameservers +// it gives no address for. +func referralNameservers(resp *dns.Msg) ([]string, []string) { + glue := extractGlue(resp.Extra) - for _, ns := range nsNames { - resolved, err := r.resolveARecord(ctx, ns) + var given, withoutAddresses []string + + for _, ns := range extractNSSet(resp.Ns) { + ips := glueIPs([]string{ns}, glue) + if len(ips) == 0 { + withoutAddresses = append(withoutAddresses, ns) + } + + given = append(given, ips...) + } + + return given, withoutAddresses +} + +// queryZone asks the servers of zone about name as queryServers does: +// first those at given, the addresses a referral gave, and only when +// none of them gives a usable reply, the nameservers named +// withoutAddresses, once their addresses are looked up. depth is how +// many lookups of a nameserver's address are under way, 0 in the walk +// to a domain's nameservers; at maxLookupDepth, no address is looked +// up. When the limit is why none was found, here or in a lookup this +// one started, the error is ErrLookupDepthExceeded. +func (r *Resolver) queryZone( + ctx context.Context, + given []string, + withoutAddresses []string, + zone string, + name string, + qtype uint16, + depth int, +) (*dns.Msg, error) { + err := fmt.Errorf( + "no address for any nameserver of %s: %w", zone, ErrNoNameservers, + ) + + if len(given) > 0 { + var resp *dns.Msg + + resp, err = r.queryServers(ctx, given, zone, name, qtype) if err == nil { - ips = append(ips, resolved...) + return resp, nil } } - return ips + if len(withoutAddresses) == 0 { + return nil, err + } + + if depth >= maxLookupDepth { + return nil, fmt.Errorf( + "addresses of the nameservers of %s not looked up: %w", + zone, ErrLookupDepthExceeded, + ) + } + + lookedUp, limitErr := r.resolveNSIPs(ctx, withoutAddresses, depth+1) + if limitErr != nil { + return nil, limitErr + } + + if len(lookedUp) == 0 { + return nil, err + } + + return r.queryServers(ctx, lookedUp, zone, name, qtype) +} + +// resolveNSIPs returns the addresses of every nameserver in nsNames +// whose name resolves, each looked up at depth (see resolveARecord). +// The walk can then go on to the zone's other nameservers when one +// gives no usable reply. When none resolves and the depth limit +// stopped one of the lookups, it returns that lookup's error. +func (r *Resolver) resolveNSIPs( + ctx context.Context, + nsNames []string, + depth int, +) ([]string, error) { + var ( + ips []string + limitErr error + ) + + for _, ns := range nsNames { + resolved, err := r.resolveARecord(ctx, ns, depth) + + switch { + case err == nil: + ips = append(ips, resolved...) + case errors.Is(err, ErrLookupDepthExceeded): + limitErr = err + } + } + + if len(ips) > 0 { + return ips, nil + } + + return nil, limitErr } // resolveNSIterative queries for NS records using iterative @@ -438,11 +525,14 @@ func (r *Resolver) resolveNSIterative( return nil, ErrNoNameservers } -// resolveARecord resolves a hostname to IPv4 addresses using -// iterative resolution through the delegation chain. +// resolveARecord resolves a hostname, a nameserver's name, to IPv4 +// addresses using iterative resolution through the delegation chain. +// depth is how many lookups of a nameserver's address are under way, +// this one included: 1 for a lookup that no other lookup started. func (r *Resolver) resolveARecord( ctx context.Context, hostname string, + depth int, ) ([]string, error) { if checkCtx(ctx) != nil { return nil, ErrContextCanceled @@ -452,13 +542,16 @@ func (r *Resolver) resolveARecord( servers := rootServerList() zone := "." + var withoutAddresses []string + for range maxDelegation { if checkCtx(ctx) != nil { return nil, ErrContextCanceled } - resp, err := r.queryServers( - ctx, servers, zone, hostname, dns.TypeA, + resp, err := r.queryZone( + ctx, servers, withoutAddresses, zone, hostname, dns.TypeA, + depth, ) if err != nil { return nil, fmt.Errorf( @@ -485,17 +578,7 @@ func (r *Resolver) resolveARecord( break } - glue := extractGlue(resp.Extra) - nextServers := glueIPs(authNS, glue) - - if len(nextServers) == 0 { - // Resolve NS IPs iteratively — but guard - // against infinite recursion by using only - // already-resolved servers. - break - } - - servers = nextServers + servers, withoutAddresses = referralNameservers(resp) zone = referralZone(resp) } @@ -584,7 +667,7 @@ func (r *Resolver) queryNameserver( return nil, ErrContextCanceled } - nsIPs, err := r.resolveARecord(ctx, nsHostname) + nsIPs, err := r.resolveARecord(ctx, nsHostname, 1) if err != nil { return nil, fmt.Errorf("resolving NS %s: %w", nsHostname, err) } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index a07c718..ac45b89 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -2,6 +2,7 @@ package resolver_test import ( "context" + "errors" "fmt" "log/slog" "net" @@ -162,6 +163,112 @@ func TestResolveNSIPs_EveryNameserver(t *testing.T) { assert.ElementsMatch(t, want, got) } +// TestResolveNSIPs_ZoneDelegatedWithoutAddresses looks up the address +// of a.ntpns.org, a nameserver of pool.ntp.org. The org servers delegate +// ntpns.org to nameservers in other zones and give none of their +// addresses, so those are looked up on the way. +func TestResolveNSIPs_ZoneDelegatedWithoutAddresses(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ips := liveResolveNSIPs(t, r, []string{"a.ntpns.org."}, 1) + + for _, ip := range ips { + assert.NotNil(t, net.ParseIP(ip), "should be valid IP: %s", ip) + } +} + +// TestQueryZone_GivenAddressesFail asks the servers of ntp.org about +// pool.ntp.org, as the walk to a name under ntp.org does after the org +// servers' referral. That referral names four nameservers and gives an +// address for ns1.everett.org alone; here the given address is +// 192.0.2.1, where nothing answers, so the other three must be looked +// up and asked. +func TestQueryZone_GivenAddressesFail(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + var resp *dns.Msg + + livednstest.Retry( + t, + "QueryZone(192.0.2.1 and three ntp.org nameservers, pool.ntp.org)", + func(ctx context.Context) error { + var err error + + resp, err = r.QueryZone( + ctx, []string{"192.0.2.1"}, + []string{"anyns.pch.net.", "dns1.udel.edu.", "dns2.udel.edu."}, + "ntp.org.", "pool.ntp.org.", dns.TypeNS, 0, + ) + + return err + }, + ) + + assert.NotEmpty(t, resolver.NSSetFrom(resp, "pool.ntp.org.")) +} + +// TestQueryZone_LookupDepth asks the servers of g.ntpns.org, a +// nameserver of pool.ntp.org, for its address, as looking that address +// up does when anyns.pch.net, one of the servers of ntpns.org, gives the +// referral to g.ntpns.org without addresses. Their addresses are looked +// up (here only a.ntpns.org's), and that needs a bitnames.com +// nameserver's address, as the org servers delegate ntpns.org without +// addresses. From depth 1, where looking up g.ntpns.org's address +// starts, that makes three lookups and the address is found. From one +// below maxLookupDepth, the bitnames.com lookup would be past the limit, +// so nothing can be asked, and the error says the limit is why. +func TestQueryZone_LookupDepth(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + withoutAddresses := []string{"a.ntpns.org."} + + var resp *dns.Msg + + livednstest.Retry( + t, + "QueryZone(a.ntpns.org without its address, g.ntpns.org)", + func(ctx context.Context) error { + var err error + + resp, err = r.QueryZone( + ctx, nil, withoutAddresses, "g.ntpns.org.", "g.ntpns.org.", + dns.TypeA, 1, + ) + + return err + }, + ) + + assert.NotEmpty(t, resp.Answer) + + var limitErr error + + // Any other error is live DNS not answering, and is retried. + livednstest.Retry( + t, + "QueryZone(a.ntpns.org without its address, g.ntpns.org, "+ + "one below the limit)", + func(ctx context.Context) error { + _, limitErr = r.QueryZone( + ctx, nil, withoutAddresses, "g.ntpns.org.", "g.ntpns.org.", + dns.TypeA, resolver.MaxLookupDepth-1, + ) + if limitErr == nil || + errors.Is(limitErr, resolver.ErrLookupDepthExceeded) { + return nil + } + + return limitErr + }, + ) + + require.ErrorIs(t, limitErr, resolver.ErrLookupDepthExceeded) +} + // ---------------------------------------------------------------- // QueryNameserver tests // ---------------------------------------------------------------- @@ -185,6 +292,20 @@ func TestQueryNameserver_BasicA(t *testing.T) { ) } +// TestQueryNameserver_ZoneDelegatedWithoutAddresses asks a.ntpns.org, a +// nameserver of pool.ntp.org, about pool.ntp.org, as the watcher does. +// The org servers delegate ntpns.org without the addresses of its +// nameservers, so finding a.ntpns.org's address needs a lookup inside +// the one QueryNameserver starts. +func TestQueryNameserver_ZoneDelegatedWithoutAddresses(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + resp := liveQueryNameserver(t, r, "a.ntpns.org.", "pool.ntp.org", "A") + + assert.Equal(t, resolver.StatusOK, resp.Status) +} + func TestQueryNameserver_AAAA(t *testing.T) { t.Parallel() @@ -681,6 +802,21 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) { assert.Equal(t, fromFind, fromLookup) } +// TestLookupNS_ParentZoneDelegatedWithoutAddresses looks up the +// nameservers of g.ntpns.org. The org servers delegate its parent zone, +// ntpns.org, without the addresses of its nameservers, so the walk has +// to look them up to ask them. If it did not, the walk for g.ntpns.org +// would fail and LookupNS would return the nameservers of ntpns.org, +// which a.ntpns.org is not one of. +func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + nameservers := liveLookupNS(t, r, "g.ntpns.org") + + assert.Contains(t, nameservers, "a.ntpns.org.") +} + // ---------------------------------------------------------------- // ResolveIPAddresses tests // ---------------------------------------------------------------- -- 2.54.0 From b43402a6319b35a5f8e477c731372fe3327bd314 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 10:31:07 +0200 Subject: [PATCH 64/92] dashboard and status API list a port's domains apart from its hostnames (closes #245) A port entry in the state saves the apex domains that resolve to its address with its hostnames. The dashboard's Ports table now has a Domains column next to Hostnames, and a port entry in /api/v1/status has a `domains` list, with `hostnames` no longer holding a domain. A name is taken as a domain when it has a domain entry, as the dashboard and API already tell a domain's own records from a hostname's. Both read the split from one function, buildPorts. The state file is unchanged; README says its port `hostnames` include domains. Model: opus-5-5 --- README.md | 12 ++-- TODO.md | 2 + internal/handlers/dashboard.go | 6 +- internal/handlers/dashboard_test.go | 48 +++++++++++++++ internal/handlers/status.go | 35 ++++++++--- internal/handlers/status_test.go | 72 ++++++++++++++++++---- internal/handlers/templates/dashboard.html | 8 ++- 7 files changed, 155 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5939483..54db1ad 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,8 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL per nameserver and status, shown as a hostname's are. - **Hostnames** with per-nameserver DNS records and status. For a nameserver whose query failed, the reason is shown in place of the records. -- **Ports** with open/closed state and associated hostnames. +- **Ports** with open/closed state and the domains and hostnames that resolve to + each address, in separate columns. - **TLS certificates** with CN, issuer, expiry, and status. For a failed check, the reason is shown in place of CN, issuer and expiry. - **Recent alerts** (last 100 notifications sent since the process started), @@ -272,7 +273,9 @@ In `/api/v1/status`, each nameserver entry and certificate entry whose `status` is `error` also has `error`, the reason, as in the state file (see State File Format). A domain's own records are in its entry in `domains`, under `recordsByNameserver`, in the form a hostname's entry in `hostnames` has them -under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. +under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. A port +entry lists the domains that resolve to its address in `domains`, and the +hostnames in `hostnames`. `/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime, @@ -561,8 +564,9 @@ previous check's list is kept, or `null` when no earlier check saved one. A state file without it loads, and the first check after that saves it without a notification. -A port entry in the older format, with one `hostname` instead of the `hostnames` -list, loads as a list of that one name. +A port entry's `hostnames` lists every name that resolves to its address, +domains included. A port entry in the older format, with one `hostname` instead +of the `hostnames` list, loads as a list of that one name. --- diff --git a/TODO.md b/TODO.md index 397b53c..54a414c 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: the dashboard's Ports table and `/api/v1/status` port entries list + a port's domains apart from its hostnames (closes #245). - 2026-10-02: nameservers a referral names without addresses are looked up, three deep at most; `pool.ntp.org`'s nameservers resolve (closes #221). - 2026-10-02: an apex domain is not counted or listed as a hostname; its records diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index fa7e8d5..a5068c8 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -45,11 +45,14 @@ func newDashboardTemplate() *template.Template { // dashboardData is the data passed to the dashboard template. Hostnames // and DomainRecords split the records in Snapshot.Hostnames, which also -// holds the apex domains' own (see splitHostnames). +// holds the apex domains' own (see splitHostnames). Ports holds +// Snapshot.Ports with each port's names split into domains and +// hostnames, as /api/v1/status gives them (see buildPorts). type dashboardData struct { Snapshot state.Snapshot Hostnames map[string]*state.HostnameState DomainRecords map[string]*state.HostnameState + Ports map[string]*statusPortInfo Alerts []notify.AlertEntry StateAge string GeneratedAt string @@ -71,6 +74,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc { Snapshot: snap, Hostnames: hostnames, DomainRecords: domainRecords, + Ports: buildPorts(snap), Alerts: alerts, StateAge: relTime(snap.LastUpdated), GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"), diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go index 27149d8..d49278f 100644 --- a/internal/handlers/dashboard_test.go +++ b/internal/handlers/dashboard_test.go @@ -205,3 +205,51 @@ func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) { t.Errorf("summary bar does not say %q", summary) } } + +// rowCells returns the text of each cell of a dashboard table row +// whose cells start with tag, "]*>`) + parts := strings.Split(row, tag)[1:] + cells := make([]string, 0, len(parts)) + + for _, cell := range parts { + text := tags.ReplaceAllString(tag+cell, " ") + cells = append(cells, strings.Join(strings.Fields(text), " ")) + } + + return cells +} + +// TestDashboardPortsTellDomainsFromHostnames checks that the Ports +// table lists an apex domain under Domains and a hostname under +// Hostnames when both resolve to the port's address. +func TestDashboardPortsTellDomainsFromHostnames(t *testing.T) { + t.Parallel() + + page := get(t, newHandlersWithFailures(t).HandleDashboard()) + ports := dashboardSection(t, page, "Ports") + + headings := rowCells(dashboardRow(t, ports, "Address"), " Ports - {{ if .Snapshot.Ports }} + {{ if .Ports }}
+ - {{ range $key, $ps := .Snapshot.Ports }} + {{ range $key, $ps := .Ports }} + -- 2.54.0 From 9b524e9d63fb0a7f1359807c1afa1884da9d43f6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 10:42:07 +0200 Subject: [PATCH 65/92] watcher: Port Change notifications list domains apart from hostnames (closes #248) A Port Change notification's `Hosts:` line listed a port's apex domains and hostnames together. It now has a `Domains:` line and a `Hostnames:` line, and leaves out one that would name nothing. A name is a domain when it is a configured domain, the rule record notifications already use to start `Domain:` or `Hostname:`; that rule is now one method, isDomain, which both use. The port entries saved in the state are unchanged. README describes the new lines. Model: opus-5-5 --- README.md | 4 ++- TODO.md | 2 ++ internal/watcher/message_test.go | 48 ++++++++++++++++++++++++++++++++ internal/watcher/watcher.go | 43 ++++++++++++++++++++++++---- 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 54db1ad..6650221 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,9 @@ includes: - **NS recoveries**: Which nameserver recovered, which hostname/domain. - **NS inconsistencies**: Which nameservers disagree, what each one returned, which hostname or domain affected. -- **Port changes**: Which IP:port, its new state, all associated hostnames. +- **Port changes**: Which IP:port, its new state, and the domains and the + hostnames that resolve to it, on a `Domains:` line and a `Hostnames:` line. A + line that would name nothing is left out. - **TLS expiry warnings**: Expiry date and days remaining, CN, associated hostname and IP. - **TLS certificate changes**: Old and new CN and issuer, associated hostname diff --git a/TODO.md b/TODO.md index 54a414c..2d97fbb 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a Port Change notification lists the port's domains on a + `Domains:` line and its hostnames on a `Hostnames:` line (closes #248). - 2026-10-02: the dashboard's Ports table and `/api/v1/status` port entries list a port's domains apart from its hostnames (closes #245). - 2026-10-02: nameservers a referral names without addresses are looked up, diff --git a/internal/watcher/message_test.go b/internal/watcher/message_test.go index 4659076..e446bd2 100644 --- a/internal/watcher/message_test.go +++ b/internal/watcher/message_test.go @@ -165,3 +165,51 @@ func TestStartupNotificationCountsConfiguredNames(t *testing.T) { t.Errorf("sent %v, want one message with %q", notifications, counts) } } + +// A Port Change notification lists the configured apex domain and the +// hostname that resolve to the port's address on separate lines. The +// port checks read the saved hostname state and look nothing up, so the +// watcher has no resolver. +func TestPortChangeListsDomainsApartFromHostnames(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{domain} + cfg.Hostnames = []string{host} + + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + w.SetFirstRun(false) + + // Both names resolve to ip1, whose port 443 the previous check + // found open. It is closed now. + for _, name := range []string{domain, host} { + deps.state.SetHostnameState(name, saved( + map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip1}}), + }, + )) + } + + key := ip1 + ":443" + deps.state.SetPortState(key, &state.PortState{ + Open: true, Hostnames: []string{domain, host}, + }) + deps.portChecker.closed = true + + w.CheckAllPorts(t.Context()) + + title := "Port Change: " + key + want := `Domains: example.net +Hostnames: www.example.net +Address: 192.0.2.1:443 +Port now closed` + + got := deps.notifier.getNotifications() + if len(got) != 1 || got[0].Title != title || got[0].Message != want { + t.Errorf("sent %v, want one %q with message:\n%s", got, title, want) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 87ab71a..0a09014 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -542,17 +542,50 @@ func (w *Watcher) detectHostnameChanges( w.detectCNAMEAddressChanges(ctx, hostname, prev, current) } +// isDomain reports whether name is a configured apex domain, whose own +// records are checked and saved as a hostname's are. +func (w *Watcher) isDomain(name string) bool { + return slices.Contains(w.config.Domains, name) +} + // nameLine is the line a notification about name's records starts with: -// "Domain: " and the name for a configured apex domain, whose own -// records are checked as a hostname's are, and "Hostname: " otherwise. +// "Domain: " and the name for a configured apex domain, and +// "Hostname: " otherwise. func (w *Watcher) nameLine(name string) string { - if slices.Contains(w.config.Domains, name) { + if w.isDomain(name) { return "Domain: " + name } return "Hostname: " + name } +// portNameLines lists the names that resolve to a port's address, the +// configured apex domains on one line and the hostnames on the next, +// leaving out a line that would name nothing. +func (w *Watcher) portNameLines(names []string) string { + var domains, hostnames []string + + for _, name := range names { + if w.isDomain(name) { + domains = append(domains, name) + } else { + hostnames = append(hostnames, name) + } + } + + var lines []string + + if len(domains) > 0 { + lines = append(lines, "Domains: "+strings.Join(domains, ", ")) + } + + if len(hostnames) > 0 { + lines = append(lines, "Hostnames: "+strings.Join(hostnames, ", ")) + } + + return strings.Join(lines, "\n") +} + // detectCNAMEAddressChanges notifies when the addresses at the end of // hostname's CNAME chain differ from those the previous check saved, // including a change from or to none. When the previous addresses are @@ -952,8 +985,8 @@ func (w *Watcher) checkSinglePort( } msg := fmt.Sprintf( - "Hosts: %s\nAddress: %s\nPort now %s", - strings.Join(hostnames, ", "), key, stateStr, + "%s\nAddress: %s\nPort now %s", + w.portNameLines(hostnames), key, stateStr, ) w.notify.SendNotification( -- 2.54.0 From 9b9e26d6b2f9526bc1c089ebf18c22a9546d7fb9 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 10:51:23 +0200 Subject: [PATCH 66/92] resolver, watcher: a record type whose query fails keeps its previous records (closes #231) The resolver lists in FailedTypes each record type whose query to a nameserver got no usable reply (no reply, a code other than NOERROR or NXDOMAIN, a referral, or a truncated reply whose TCP retry failed) and logs it unless shutdown cut it short. A nameserver that answered no type has failed. The watcher saves such a type in failedTypes with the previous check's records, leaves it out of the comparison with other nameservers on that check, and compares it with the next answer. When the previous check did not know its records either, it is also in unknownTypes and not compared until it answers. A nameserver whose A, AAAA or CNAME query failed is no answer when following a CNAME or resolving addresses. Model: opus-5-5 --- README.md | 38 +- TODO.md | 2 + internal/resolver/errors.go | 6 + internal/resolver/export_test.go | 11 + internal/resolver/iterative.go | 136 +++++-- internal/resolver/iterative_internal_test.go | 131 +++++++ internal/resolver/iterative_test.go | 19 + internal/resolver/resolver.go | 12 +- internal/resolver/resolver_test.go | 48 +++ internal/state/state.go | 15 +- internal/state/state_test.go | 55 +++ internal/watcher/cname_test.go | 36 ++ internal/watcher/export_test.go | 3 +- internal/watcher/failedtype_test.go | 364 +++++++++++++++++++ internal/watcher/nsfailure_test.go | 6 +- internal/watcher/watcher.go | 119 +++++- 16 files changed, 936 insertions(+), 65 deletions(-) create mode 100644 internal/resolver/iterative_internal_test.go create mode 100644 internal/watcher/failedtype_test.go diff --git a/README.md b/README.md index 6650221..ec6a55d 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ notification endpoint set, changes show only on the dashboard; see different addresses than on the previous check. A nameserver added or removed gets only the NS change notification. When the lookup of a nameserver's addresses fails or finds none, its previous addresses are - kept and nothing is sent. + kept and nothing is sent. The lookup fails when no nameserver it asks + answers every one of its queries, for A, AAAA and CNAME. - Also watches the domain's own records as a hostname's are watched (see DNS Hostname Monitoring below): its A, AAAA, CNAME, MX, TXT, SRV, CAA and NS records, stored per nameserver. Their changes are notified as a hostname's @@ -97,6 +98,19 @@ notification endpoint set, changes show only on the dashboard; see its last two labels (a name under `co.uk`, or in a delegated subdomain). - Queries **each** authoritative nameserver independently for **all** record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. +- Each record type is a query of its own. When a nameserver answers some types + but the query for another gets no usable reply (no reply after two tries, an + error reply such as SERVFAIL, a referral, or a reply too large for UDP whose + retry over TCP fails), the failure is logged with the reason, and the type is + listed in the nameserver's `failedTypes` and keeps the records saved for the + nameserver by the previous check. On that check those records are not compared + with the other nameservers', so no record change or inconsistency is reported + for the type; on the next check they are compared with the nameserver's answer + as usual. When the previous check did not know the type's records either, + because the nameserver was new or failing then or the type was already listed + in `unknownTypes`, the type is also listed in `unknownTypes` and left out of + every comparison until it answers. A nameserver none of whose queries got a + usable reply has failed (see NS query failure below). - Stores results **per nameserver**. The state for a hostname is not a merged view — it is a map from nameserver to record set. - DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in @@ -125,8 +139,9 @@ notification endpoint set, changes show only on the dashboard; see they keep disagreeing, including after a restart. A nameserver that was not in the previous check (newly added, or back after dropping out), or failed on it, and answers differently is reported on the check where it - answers. If a pair agrees again and later disagrees, the alert is sent - again. + answers. So is a pair that differs in a record type whose query to either + nameserver failed on the previous check. If a pair agrees again and later + disagrees, the alert is sent again. - **CNAME address change**: The addresses at the end of a name's CNAME chain differ from those of the previous check. They are found when its nameservers answer with a CNAME and no address; a name that answers with @@ -545,7 +560,7 @@ reachability: | Status | Meaning | | ------- | -------------------------------------------------------- | -| `ok` | Query succeeded, records are current | +| `ok` | Query succeeded, records are current except as below | | `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) | A nameserver that answers NXDOMAIN or with no records has status `ok` and empty @@ -554,6 +569,13 @@ nameservers, has status `error`, empty `records`, and the reason in `error`. A certificate entry whose TLS connection or handshake failed likewise has status `error`, the reason in `error`, and the certificate fields left empty or zero. +A nameserver with status `ok` whose query for one record type failed lists that +type in `failedTypes` and holds its records from the previous check, which may +not be current. When the previous check did not know the type's records either, +because the nameserver was new or failing then or the type was already listed in +`unknownTypes`, the type is also listed in `unknownTypes`, and `records` holds +nothing for it. Both lists are left out when empty. + `nameserverAddresses` lists, by nameserver, the sorted addresses its name resolves to. A state file without it loads, and the next check fills it in without a notification. @@ -561,10 +583,10 @@ without a notification. `cnameAddresses` lists the sorted addresses at the end of the chain of every CNAME target a hostname's nameservers gave, found when they answered with a CNAME and no address; it is empty when they answered with an address. When a -chain cannot be followed, or none of the name's nameservers answered, the -previous check's list is kept, or `null` when no earlier check saved one. A -state file without it loads, and the first check after that saves it without a -notification. +chain cannot be followed, or none of the name's nameservers answered its queries +for A, AAAA and CNAME, the previous check's list is kept, or `null` when no +earlier check saved one. A state file without it loads, and the first check +after that saves it without a notification. A port entry's `hostnames` lists every name that resolves to its address, domains included. A port entry in the older format, with one `hostname` instead diff --git a/TODO.md b/TODO.md index 2d97fbb..2895ca3 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a record type whose query to a nameserver fails keeps its previous + records and alerts nothing; the other types are still saved (closes #231). - 2026-10-02: a Port Change notification lists the port's domains on a `Domains:` line and its hostnames on a `Hostnames:` line (closes #248). - 2026-10-02: the dashboard's Ports table and `/api/v1/status` port entries list diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index f603217..61ca007 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -22,6 +22,12 @@ var ( "reply is an error or a referral that leads no closer", ) + // ErrTruncated is the reason given for a reply too large for UDP + // whose retry over TCP failed. + ErrTruncated = errors.New( + "reply truncated and its retry over TCP failed", + ) + // ErrIntercepted is returned when every root server refused a // query. Root servers refuse no query, so the refusals came from // something on the network answering in their place. diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 07c293b..a0e4103 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -2,10 +2,21 @@ package resolver import ( "context" + "log/slog" + "time" "github.com/miekg/dns" ) +// NewWithFailingTCP returns a Resolver whose TCP client gives up before +// it can connect, so the retry over TCP of every truncated reply fails. +func NewWithFailingTCP(log *slog.Logger) *Resolver { + r := NewFromLogger(log) + r.tcp = &tcpClient{timeout: time.Nanosecond} + + return r +} + // ExtractRecordValue exports extractRecordValue for testing. func ExtractRecordValue(rr dns.RR) string { return extractRecordValue(rr) diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 5686622..4b15543 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -8,6 +8,7 @@ import ( "net" "slices" "sort" + "strconv" "strings" "time" @@ -99,6 +100,9 @@ func (r *Resolver) tryExchange( return resp, err } +// retryTCP returns the reply to msg over TCP when resp, its reply over +// UDP, is truncated. When that fails it returns resp, still truncated, +// which holds only the records that fit. func (r *Resolver) retryTCP( ctx context.Context, msg *dns.Msg, @@ -320,13 +324,20 @@ func (r *Resolver) queryServers( return nil, fmt.Errorf("all servers failed: %w", lastErr) } +// isErrorReply reports whether msg is an error reply: one with any code +// but NOERROR and NXDOMAIN, such as SERVFAIL, NOTIMP or FORMERR. An error +// reply says nothing about the name's records. +func isErrorReply(msg *dns.Msg) bool { + return msg.Rcode != dns.RcodeSuccess && msg.Rcode != dns.RcodeNameError +} + // usableReply reports whether resp, a reply from one of the servers of // zone to a query about name, is usable. An error reply such as SERVFAIL // is not. Nor is a referral, unless it refers the query to a zone below // zone that name is in: a server that refers it back to zone, up or // sideways does not serve zone as it should. func usableReply(resp *dns.Msg, zone string, name string) bool { - if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError { + if isErrorReply(resp) { return false } @@ -714,15 +725,21 @@ func (r *Resolver) queryTypes( } type queryState struct { - gotNXDomain bool - gotSERVFAIL bool - gotRefused bool - gotTimeout bool - gotReferral bool - netErr error - hasRecords bool + gotNXDomain bool + gotErrorReply bool + errorReply string // its code, such as SERVFAIL, or number if unnamed + gotRefused bool + gotTimeout bool + gotReferral bool + netErr error + hasRecords bool + answered bool } +// queryEachType asks the nameserver at nsIP about hostname once for each +// record type in qtypes, and lists in resp.FailedTypes the types whose +// query got no usable reply, logging each with the reason unless ctx was +// cancelled: shutdown cancels it, and a query it cut short did not fail. func (r *Resolver) queryEachType( ctx context.Context, nsIP string, @@ -737,7 +754,34 @@ func (r *Resolver) queryEachType( break } - r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state) + err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state) + if err == nil { + state.answered = true + + continue + } + + rtype := dns.TypeToString[qtype] + resp.FailedTypes = append(resp.FailedTypes, rtype) + + if errors.Is(ctx.Err(), context.Canceled) { + continue + } + + r.log.Warn( + "record type query failed", + "hostname", hostname, + "nameserver", resp.Nameserver, + "type", rtype, + "error", err, + ) + } + + // The reply about another type can carry the name's CNAME. When the + // query for CNAME itself failed, that is left out too, so Records + // holds nothing for a failed type. + for _, rtype := range resp.FailedTypes { + delete(resp.Records, rtype) } for k := range resp.Records { @@ -747,6 +791,9 @@ func (r *Resolver) queryEachType( return state } +// querySingleType asks the nameserver at nsIP about hostname's records +// of type qtype. It returns nil when the nameserver answered: with +// records, with none, or with NXDOMAIN; otherwise it returns why not. func (r *Resolver) querySingleType( ctx context.Context, nsIP string, @@ -754,7 +801,7 @@ func (r *Resolver) querySingleType( qtype uint16, resp *NameserverResponse, state *queryState, -) { +) error { msg, err := r.queryDNS(ctx, nsIP, hostname, qtype) if err != nil { switch { @@ -766,19 +813,40 @@ func (r *Resolver) querySingleType( state.netErr = err } - return + return err } + return readReply(msg, resp, state) +} + +// readReply adds to resp the records in msg, a nameserver's reply to a +// query about one record type. It returns nil when the nameserver +// answered: with records, with none, or with NXDOMAIN; otherwise it +// returns why not. +func readReply( + msg *dns.Msg, + resp *NameserverResponse, + state *queryState, +) error { if msg.Rcode == dns.RcodeNameError { state.gotNXDomain = true - return + return nil } - if msg.Rcode == dns.RcodeServerFailure { - state.gotSERVFAIL = true + if isErrorReply(msg) { + state.gotErrorReply = true - return + code, named := dns.RcodeToString[msg.Rcode] + if !named { + code = strconv.Itoa(msg.Rcode) + } + + state.errorReply = code + + return fmt.Errorf( + "server returned %s: %w", state.errorReply, ErrUnusableReply, + ) } // A reply with no answer that lists other nameservers, from a server @@ -791,10 +859,20 @@ func (r *Resolver) querySingleType( len(extractNSSet(msg.Ns)) > 0 { state.gotReferral = true - return + return fmt.Errorf("server returned a referral: %w", ErrUnusableReply) + } + + // A reply still truncated is one whose TCP retry failed, and holds + // only the records that fit. + if msg.Truncated { + state.netErr = ErrTruncated + + return ErrTruncated } collectAnswerRecords(msg, resp, state) + + return nil } // collectAnswerRecords adds the records in msg's answer to resp, each @@ -833,23 +911,26 @@ func isTimeout(err error) bool { return false } +// classifyResponse sets the nameserver's status. One that answered no +// record type has failed, and Error says why; one that answered some has +// the status of those answers. func classifyResponse(resp *NameserverResponse, state queryState) { switch { case state.gotNXDomain && !state.hasRecords: resp.Status = StatusNXDomain - case state.gotTimeout && !state.hasRecords: + case state.gotTimeout && !state.answered: resp.Status = StatusTimeout resp.Error = "all queries timed out" - case state.gotSERVFAIL && !state.hasRecords: + case state.gotErrorReply && !state.answered: resp.Status = StatusError - resp.Error = "server returned SERVFAIL" - case state.gotRefused && !state.hasRecords: + resp.Error = "server returned " + state.errorReply + case state.gotRefused && !state.answered: resp.Status = StatusError resp.Error = "server returned REFUSED" - case state.netErr != nil && !state.hasRecords: + case state.netErr != nil && !state.answered: resp.Status = StatusError resp.Error = "network error: " + state.netErr.Error() - case state.gotReferral && !state.hasRecords: + case state.gotReferral && !state.answered: resp.Status = StatusError resp.Error = "server returned a referral" case !state.hasRecords && !state.gotNXDomain: @@ -1010,9 +1091,11 @@ func (r *Resolver) resolveIPWithCNAME( } // collectIPs returns the addresses in the nameservers' answers and the -// first CNAME target among them. It returns ErrNoNameserverAnswered when -// every nameserver timed out, failed or returned a referral: that is not -// a name with no addresses. +// first CNAME target among them. A nameserver whose query for one of the +// types failed gave only part of the addresses, and is left out. It +// returns ErrNoNameserverAnswered when every nameserver timed out, +// failed, returned a referral or was left out: that is not a name with +// no addresses. func collectIPs( results map[string]*NameserverResponse, ) ([]string, string, error) { @@ -1025,7 +1108,8 @@ func collectIPs( answered := false for _, resp := range results { - if resp.Status == StatusTimeout || resp.Status == StatusError { + if resp.Status == StatusTimeout || resp.Status == StatusError || + len(resp.FailedTypes) > 0 { continue } diff --git a/internal/resolver/iterative_internal_test.go b/internal/resolver/iterative_internal_test.go new file mode 100644 index 0000000..f36097d --- /dev/null +++ b/internal/resolver/iterative_internal_test.go @@ -0,0 +1,131 @@ +package resolver + +import ( + "strconv" + "syscall" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestClassifyResponse sets a nameserver's status from the results of +// its queries, built here. One that answered some record types, even +// with no records, has not failed when its query for another type got +// no usable reply, whatever the reason; one whose every query got none +// has. +func TestClassifyResponse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + results queryState + wantStatus string + wantError string + }{ + { + "some types answered with no records, another timed out", + queryState{answered: true, gotTimeout: true}, + StatusNoData, "", + }, + { + "some types answered with no records, another got SERVFAIL", + queryState{ + answered: true, gotErrorReply: true, errorReply: "SERVFAIL", + }, + StatusNoData, "", + }, + { + "some types answered with no records, another was refused", + queryState{answered: true, gotRefused: true}, + StatusNoData, "", + }, + { + "some types answered with no records, another got a network error", + queryState{answered: true, netErr: syscall.ECONNREFUSED}, + StatusNoData, "", + }, + { + "some types answered with no records, another's reply was " + + "truncated and its retry over TCP failed", + queryState{answered: true, netErr: ErrTruncated}, + StatusNoData, "", + }, + { + "some types answered with no records, another got a referral", + queryState{answered: true, gotReferral: true}, + StatusNoData, "", + }, + { + "every query timed out", + queryState{gotTimeout: true}, + StatusTimeout, "all queries timed out", + }, + { + "every query got NOTIMP", + queryState{gotErrorReply: true, errorReply: "NOTIMP"}, + StatusError, "server returned NOTIMP", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp := &NameserverResponse{Status: StatusOK} + classifyResponse(resp, tt.results) + + assert.Equal(t, tt.wantStatus, resp.Status) + assert.Equal(t, tt.wantError, resp.Error) + }) + } +} + +// TestReadReply checks which replies to a query about one record type, +// built here, are an answer: one with the code NOERROR or NXDOMAIN. A +// reply with any other code is not, and the type's query has failed; a +// nameserver whose only reply it is has failed, and Error gives the +// code, or its number when the code has no name. +func TestReadReply(t *testing.T) { + t.Parallel() + + tests := []struct { + rcode int + wantStatus string + wantError string + }{ + {dns.RcodeSuccess, StatusNoData, ""}, + {dns.RcodeNameError, StatusNXDomain, ""}, + {dns.RcodeServerFailure, StatusError, "server returned SERVFAIL"}, + {dns.RcodeNotImplemented, StatusError, "server returned NOTIMP"}, + {dns.RcodeFormatError, StatusError, "server returned FORMERR"}, + {12, StatusError, "server returned 12"}, // unassigned, no name + } + + for _, tt := range tests { + t.Run(strconv.Itoa(tt.rcode), func(t *testing.T) { + t.Parallel() + + msg := new(dns.Msg) + msg.Authoritative = true + msg.Rcode = tt.rcode + + resp := &NameserverResponse{Records: map[string][]string{}} + + var state queryState + + err := readReply(msg, resp, &state) + classifyResponse(resp, state) + + if tt.wantStatus == StatusError { + require.ErrorIs(t, err, ErrUnusableReply) + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantStatus, resp.Status) + assert.Equal(t, tt.wantError, resp.Error) + }) + } +} diff --git a/internal/resolver/iterative_test.go b/internal/resolver/iterative_test.go index ca422cd..836b555 100644 --- a/internal/resolver/iterative_test.go +++ b/internal/resolver/iterative_test.go @@ -43,6 +43,25 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) { assert.Empty(t, ips) } +// TestCollectIPs_FailedTypeIsNoAnswer checks that a nameserver whose +// query for one of the types failed is no answer: its addresses are +// only part of them. +func TestCollectIPs_FailedTypeIsNoAnswer(t *testing.T) { + t.Parallel() + + ips, _, err := resolver.CollectIPs( + map[string]*resolver.NameserverResponse{ + nsExample1: { + Records: map[string][]string{"A": {"192.0.2.1"}}, + FailedTypes: []string{"AAAA"}, + Status: resolver.StatusOK, + }, + }, + ) + require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered) + assert.Empty(t, ips) +} + const ( // exampleCom is the zone most cases of TestUsableReply and // TestNSSetFrom are about, and wwwExampleCom a name in it. diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 83b3f47..1bdfdb9 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -31,11 +31,15 @@ type Params struct { } // NameserverResponse holds one nameserver's response for a query. +// FailedTypes lists the record types whose query got no usable reply, +// and Records holds nothing for them: their records are not known. When +// no record type got one, Status and Error say the nameserver failed. type NameserverResponse struct { - Nameserver string - Records map[string][]string - Status string - Error string + Nameserver string + Records map[string][]string + FailedTypes []string + Status string + Error string } // Resolver performs iterative DNS resolution from root servers. diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index ac45b89..94a2fa0 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -1,6 +1,7 @@ package resolver_test import ( + "bytes" "context" "errors" "fmt" @@ -366,6 +367,30 @@ func TestQueryNameserver_TXT(t *testing.T) { ) } +// TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails asks a google.com +// nameserver about google.com with a resolver whose retries over TCP +// fail. google.com's TXT records do not fit in a reply over UDP, so TXT +// is reported as failed, holding none of the records that fit, and +// logged with the reason, while the nameserver, which answered the other +// types, is ok. +func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) { + t.Parallel() + + ns := findOneNSForDomain(t, newTestResolver(t), "google.com") + + var logs bytes.Buffer + + r := resolver.NewWithFailingTCP(slog.New(slog.NewTextHandler(&logs, nil))) + resp := liveQueryNameserver(t, r, ns, "google.com") + + assert.Equal(t, resolver.StatusOK, resp.Status) + assert.Contains(t, resp.FailedTypes, "TXT") + assert.NotContains(t, resp.Records, "TXT") + assert.Contains(t, logs.String(), + "hostname=google.com. nameserver="+ns+" type=TXT error=", + ) +} + func TestQueryNameserver_NXDomain(t *testing.T) { t.Parallel() @@ -1001,6 +1026,29 @@ func TestQueryNameserverIP_Timeout(t *testing.T) { assert.NotEmpty(t, resp.Error) } +// TestQueryNameserverIP_CancelledLogsNothing cancels the context while +// a query to 192.0.2.1, where nothing answers, is waiting for a reply, +// as shutdown does. The query was cut short, not failed, so nothing is +// logged. +func TestQueryNameserverIP_CancelledLogsNothing(t *testing.T) { + t.Parallel() + + var logs bytes.Buffer + + r := resolver.NewFromLogger(slog.New(slog.NewTextHandler(&logs, nil))) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + time.AfterFunc(100*time.Millisecond, cancel) + + _, err := r.QueryNameserverIP( + ctx, "unreachable.test.", "192.0.2.1", "example.com", + ) + require.NoError(t, err) + + assert.Empty(t, logs.String()) +} + // TestCollectIPs_NoNameserverAnswered takes the response of a // nameserver at 192.0.2.1, where nothing answers, as // TestQueryNameserverIP_Timeout does. Addresses collected from diff --git a/internal/state/state.go b/internal/state/state.go index 4f5a325..eecca4e 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -46,11 +46,18 @@ type DomainState struct { } // NameserverRecordState holds one NS's response for a hostname. +// FailedTypes lists the record types whose query to the nameserver +// failed on this check: Records holds for them the records saved by the +// previous check, which are kept. UnknownTypes lists those of them whose +// records the previous check did not know either, as when the +// nameserver was new or failing then: Records holds nothing for them. type NameserverRecordState struct { - Records map[string][]string `json:"records"` - Status string `json:"status"` - Error string `json:"error,omitempty"` - LastChecked time.Time `json:"lastChecked"` + Records map[string][]string `json:"records"` + FailedTypes []string `json:"failedTypes,omitempty"` + UnknownTypes []string `json:"unknownTypes,omitempty"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + LastChecked time.Time `json:"lastChecked"` } // HostnameState holds per-nameserver monitoring state for a hostname. diff --git a/internal/state/state_test.go b/internal/state/state_test.go index b2f7391..e511014 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -236,6 +236,61 @@ func TestSaveLoadRoundTrip_CNAMEAddresses(t *testing.T) { } } +// TestSaveLoadRoundTrip_FailedTypes checks that a nameserver's +// failedTypes and unknownTypes survive a save and load. Without +// unknownTypes, a type whose records were not known would load as one +// with no records. +func TestSaveLoadRoundTrip_FailedTypes(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + s := state.NewForTestWithDataDir(dir) + + failed := []string{"TXT", "CAA"} + unknown := []string{"CAA"} + + s.SetHostnameState(testHostname, &state.HostnameState{ + RecordsByNameserver: map[string]*state.NameserverRecordState{ + testNS1: { + Records: map[string][]string{"TXT": {"v=spf1 -all"}}, + FailedTypes: failed, + UnknownTypes: unknown, + Status: "ok", + }, + }, + }) + + err := s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + loaded := state.NewForTestWithDataDir(dir) + + err = loaded.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + hs, ok := loaded.GetHostnameState(testHostname) + if !ok { + t.Fatal("missing hostname " + testHostname) + } + + ns1 := hs.RecordsByNameserver[testNS1] + if ns1 == nil { + t.Fatal("missing nameserver " + testNS1) + } + + if !reflect.DeepEqual(ns1.FailedTypes, failed) { + t.Errorf("failedTypes: got %#v", ns1.FailedTypes) + } + + if !reflect.DeepEqual(ns1.UnknownTypes, unknown) { + t.Errorf("unknownTypes: got %#v", ns1.UnknownTypes) + } +} + // TestLoadStateFromBeforeCNAMEAddresses loads a state file written // before the addresses at the end of a hostname's CNAME chain were // saved. They load as not known (nil), not as none. diff --git a/internal/watcher/cname_test.go b/internal/watcher/cname_test.go index b3c549f..9f492f1 100644 --- a/internal/watcher/cname_test.go +++ b/internal/watcher/cname_test.go @@ -202,6 +202,42 @@ func TestCNAMEWhoseNameserversAllFailedKeepsPrevious(t *testing.T) { } } +// TestCNAMEWhoseAddressQueryFailedKeepsPrevious checks a name whose +// nameserver answered, but whose query for A, AAAA or CNAME failed with +// nothing kept for it. That is not an answer with no address: the +// addresses the previous check saved from following its CNAME are kept, +// and nothing is looked up, the watcher having no resolver. +func TestCNAMEWhoseAddressQueryFailedKeepsPrevious(t *testing.T) { + t.Parallel() + + for _, rtype := range []string{"A", "AAAA", "CNAME"} { + t.Run(rtype, func(t *testing.T) { + t.Parallel() + + w := watcher.NewForTest(nil, nil, nil, nil, nil, nil) + + current := saved(map[string]*state.NameserverRecordState{ + nsA: { + Records: map[string][]string{}, + FailedTypes: []string{rtype}, + UnknownTypes: []string{rtype}, + Status: "ok", + }, + }) + prev := cnameState(oldIP) + + w.ResolveCNAMEAddresses(t.Context(), host, current, prev) + + if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) { + t.Errorf( + "saved %v, want %v", + current.CNAMEAddresses, prev.CNAMEAddresses, + ) + } + }) + } +} + // cnameTo builds the records of a nameserver that answered with a CNAME // to target and no address. func cnameTo(target string) map[string][]string { diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index e932a2c..e527a2d 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -120,7 +120,8 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) { // BuildHostnameState exports buildHostnameState for testing. func BuildHostnameState( results map[string]*resolver.NameserverResponse, + prev *state.HostnameState, now time.Time, ) *state.HostnameState { - return buildHostnameState(results, now) + return buildHostnameState(results, prev, now) } diff --git a/internal/watcher/failedtype_test.go b/internal/watcher/failedtype_test.go new file mode 100644 index 0000000..832ebf2 --- /dev/null +++ b/internal/watcher/failedtype_test.go @@ -0,0 +1,364 @@ +package watcher_test + +import ( + "maps" + "slices" + "testing" + "time" + + "sneak.berlin/go/dnswatcher/internal/resolver" + "sneak.berlin/go/dnswatcher/internal/state" + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +const ( + // txt is the record type whose query fails in these tests. + txt = "TXT" + spf1 = "v=spf1 -all" + spf2 = "v=spf1 include:example.net -all" +) + +// response is a nameserver's response with these records, whose queries +// for failedTypes failed. +func response( + records map[string][]string, + failedTypes ...string, +) *resolver.NameserverResponse { + return &resolver.NameserverResponse{ + Records: records, + FailedTypes: failedTypes, + Status: resolver.StatusOK, + } +} + +// savedChecks saves the state of each check in turn from the +// nameservers' responses, each from the state the check before saved. +func savedChecks( + checks ...map[string]*resolver.NameserverResponse, +) []*state.HostnameState { + states := make([]*state.HostnameState, 0, len(checks)) + + var prev *state.HostnameState + + for _, results := range checks { + prev = watcher.BuildHostnameState(results, prev, time.Now()) + states = append(states, prev) + } + + return states +} + +// TestFailedTypeKeepsPreviousRecords saves a check in which nsA's query +// for TXT failed, after previous checks of several kinds. TXT is always +// saved in FailedTypes, and in UnknownTypes when there was nothing to +// keep. +func TestFailedTypeKeepsPreviousRecords(t *testing.T) { + t.Parallel() + + aOnly := map[string][]string{"A": {ip1}} + withTXT := map[string][]string{"A": {ip1}, txt: {spf1}} + txtKept := &state.NameserverRecordState{ + Records: withTXT, FailedTypes: []string{txt}, Status: "ok", + } + txtNotKnown := &state.NameserverRecordState{ + Records: aOnly, + FailedTypes: []string{txt}, + UnknownTypes: []string{txt}, + Status: "ok", + } + + tests := []struct { + name string + prev *state.HostnameState + wantRecords map[string][]string + wantUnknown []string + }{ + { + "previous TXT records are kept", + saved(map[string]*state.NameserverRecordState{nsA: answered(withTXT)}), + withTXT, nil, + }, + { + "previous check had no TXT records", + saved(map[string]*state.NameserverRecordState{nsA: answered(aOnly)}), + aOnly, nil, + }, + { + "TXT failed on the previous check, which kept its records", + saved(map[string]*state.NameserverRecordState{nsA: txtKept}), + withTXT, nil, + }, + {"first check", nil, aOnly, []string{txt}}, + { + "nameserver new on this check", + saved(map[string]*state.NameserverRecordState{nsB: answered(withTXT)}), + aOnly, []string{txt}, + }, + { + "nameserver failed on the previous check", + saved(map[string]*state.NameserverRecordState{nsA: failed()}), + aOnly, []string{txt}, + }, + { + "TXT failed on the previous check with nothing to keep", + saved(map[string]*state.NameserverRecordState{nsA: txtNotKnown}), + aOnly, []string{txt}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + hs := watcher.BuildHostnameState( + map[string]*resolver.NameserverResponse{ + nsA: response(map[string][]string{"A": {ip1}}, txt), + }, + tt.prev, time.Now(), + ) + + got := hs.RecordsByNameserver[nsA] + if got.Status != "ok" || + !maps.EqualFunc(got.Records, tt.wantRecords, slices.Equal) || + !slices.Equal(got.FailedTypes, []string{txt}) || + !slices.Equal(got.UnknownTypes, tt.wantUnknown) { + t.Errorf( + "saved status %q, records %v, failed types %v, "+ + "unknown types %v; want ok, %v, [%s], %v", + got.Status, got.Records, got.FailedTypes, + got.UnknownTypes, tt.wantRecords, txt, tt.wantUnknown, + ) + } + }) + } +} + +// TestFailedTypeAlerts saves the checks of each case in turn from the +// nameservers' responses, the first being the state loaded at startup, +// and counts the alerts sent. nsB's TXT query fails on one check, and +// nothing changes. +func TestFailedTypeAlerts(t *testing.T) { + t.Parallel() + + records := map[string][]string{"A": {ip1}, txt: {spf1}} + aOnly := map[string][]string{"A": {ip1}} + + bothAnswer := map[string]*resolver.NameserverResponse{ + nsA: response(records), nsB: response(records), + } + bTXTFails := map[string]*resolver.NameserverResponse{ + nsA: response(records), nsB: response(aOnly, txt), + } + onlyA := map[string]*resolver.NameserverResponse{ + nsA: response(records), + } + bFails := map[string]*resolver.NameserverResponse{ + nsA: response(records), + nsB: { + Records: map[string][]string{}, + Status: resolver.StatusTimeout, + Error: "all queries timed out", + }, + } + + tests := []struct { + name string + checks []map[string]*resolver.NameserverResponse + want alertCounts + }{ + { + "type failing at one nameserver alerts nothing, nor its next answer", + []map[string]*resolver.NameserverResponse{ + bothAnswer, bTXTFails, bothAnswer, + }, + alertCounts{}, + }, + { + "type failing on the first check alerts nothing on the next", + []map[string]*resolver.NameserverResponse{bTXTFails, bothAnswer}, + alertCounts{}, + }, + { + "type failing at a nameserver new on that check alerts nothing", + []map[string]*resolver.NameserverResponse{ + onlyA, bTXTFails, bothAnswer, + }, + alertCounts{}, + }, + { + "type failing at a recovering nameserver alerts the recovery", + []map[string]*resolver.NameserverResponse{ + bFails, bTXTFails, bothAnswer, + }, + alertCounts{recoveries: 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + states := savedChecks(tt.checks...) + + got := countAlerts(t, states[0], states[1:]) + if got != tt.want { + t.Errorf("sent %+v, want %+v", got, tt.want) + } + }) + } +} + +// TestFailedTypeComparedOnceItAnswers saves the checks of each case in +// turn as TestFailedTypeAlerts does. nsB's TXT query fails on one check, +// and the TXT record changes: the change is sent as a Record Change for +// each nameserver on the check where it answers it, and an Inconsistency +// only when nsB still answers the old record. +func TestFailedTypeComparedOnceItAnswers(t *testing.T) { + t.Parallel() + + records := map[string][]string{"A": {ip1}, txt: {spf1}} + changed := map[string][]string{"A": {ip1}, txt: {spf2}} + aOnly := map[string][]string{"A": {ip1}} + + bothAnswer := map[string]*resolver.NameserverResponse{ + nsA: response(records), nsB: response(records), + } + bTXTFails := map[string]*resolver.NameserverResponse{ + nsA: response(records), nsB: response(aOnly, txt), + } + bothChange := map[string]*resolver.NameserverResponse{ + nsA: response(changed), nsB: response(changed), + } + aChangesBTXTFails := map[string]*resolver.NameserverResponse{ + nsA: response(changed), nsB: response(aOnly, txt), + } + bStillOld := map[string]*resolver.NameserverResponse{ + nsA: response(changed), nsB: response(records), + } + + tests := []struct { + name string + checks []map[string]*resolver.NameserverResponse + want alertCounts + }{ + { + "change made while the type failed", + []map[string]*resolver.NameserverResponse{ + bothAnswer, bTXTFails, bothChange, + }, + alertCounts{recordChanges: 2}, + }, + { + "change seen at one nameserver while the other's type failed", + []map[string]*resolver.NameserverResponse{ + bothAnswer, aChangesBTXTFails, bothChange, + }, + alertCounts{recordChanges: 2}, + }, + { + "old record answered after the type failed", + []map[string]*resolver.NameserverResponse{ + bothAnswer, aChangesBTXTFails, bStillOld, + }, + alertCounts{recordChanges: 1, inconsistencies: 1}, + }, + { + "change after the type failed on the first check and answered", + []map[string]*resolver.NameserverResponse{ + bTXTFails, bothAnswer, bothChange, + }, + alertCounts{recordChanges: 2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + states := savedChecks(tt.checks...) + + got := countAlerts(t, states[0], states[1:]) + if got != tt.want { + t.Errorf("sent %+v, want %+v", got, tt.want) + } + }) + } +} + +// TestFailedTypeLeftOutOfMessages checks that a Record Change and an +// Inconsistency name only the record types they compared. nsB's TXT +// records are not known on the first check, and on the second either +// answered or still not known; nsB's A record changes, so both alerts +// are sent and name the A record alone. +func TestFailedTypeLeftOutOfMessages(t *testing.T) { + t.Parallel() + + withTXT := map[string][]string{"A": {ip1}, txt: {spf1}} + txtNotKnown := func(address string) *state.NameserverRecordState { + return &state.NameserverRecordState{ + Records: map[string][]string{"A": {address}}, + FailedTypes: []string{txt}, + UnknownTypes: []string{txt}, + Status: "ok", + } + } + + before := saved(map[string]*state.NameserverRecordState{ + nsA: answered(withTXT), nsB: txtNotKnown(ip1), + }) + + tests := []struct { + name string + after *state.HostnameState + }{ + { + "TXT answers", + saved(map[string]*state.NameserverRecordState{ + nsA: answered(withTXT), + nsB: answered(map[string][]string{"A": {ip2}, txt: {spf1}}), + }), + }, + { + "TXT still not known", + saved(map[string]*state.NameserverRecordState{ + nsA: answered(withTXT), nsB: txtNotKnown(ip2), + }), + }, + } + + want := map[string]string{ + "Record Change: " + host: "Hostname: " + host + + "\nNameserver: " + nsB + "\nType: A\nOld: " + ip1 + "\nNew: " + ip2, + "Inconsistency: " + host: "Hostname: " + host + + "\nType: A\n" + nsA + ": " + ip1 + "\n" + nsB + ": " + ip2, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // The hostname change detection uses only the notifier. + notifier := &mockNotifier{} + w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) + + w.DetectHostnameChanges(t.Context(), host, before, tt.after) + + notifications := notifier.getNotifications() + if len(notifications) != len(want) { + t.Fatalf( + "sent %d notifications, want %d: %v", + len(notifications), len(want), notifications, + ) + } + + for _, n := range notifications { + if n.Message != want[n.Title] { + t.Errorf( + "%s message:\n%s\nwant:\n%s", + n.Title, n.Message, want[n.Title], + ) + } + } + }) + } +} diff --git a/internal/watcher/nsfailure_test.go b/internal/watcher/nsfailure_test.go index afae908..e9147c1 100644 --- a/internal/watcher/nsfailure_test.go +++ b/internal/watcher/nsfailure_test.go @@ -201,7 +201,7 @@ func TestNameserverThatNeverAnswers(t *testing.T) { } hs := watcher.BuildHostnameState( - map[string]*resolver.NameserverResponse{nsA: resp}, time.Now(), + map[string]*resolver.NameserverResponse{nsA: resp}, nil, time.Now(), ) got := hs.RecordsByNameserver[nsA] @@ -256,7 +256,7 @@ func TestNameserverThatAnswersNXDOMAIN(t *testing.T) { } hs := watcher.BuildHostnameState( - map[string]*resolver.NameserverResponse{ns: resp}, time.Now(), + map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(), ) got := hs.RecordsByNameserver[ns] @@ -320,7 +320,7 @@ func TestNameserverThatRefuses(t *testing.T) { } hs := watcher.BuildHostnameState( - map[string]*resolver.NameserverResponse{ns: resp}, time.Now(), + map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(), ) got := hs.RecordsByNameserver[ns] diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 0a09014..5649e92 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "maps" "slices" "sort" "strings" @@ -401,8 +402,10 @@ func (w *Watcher) checkHostname( return } + prev, _ := w.state.GetHostnameState(hostname) + w.updateHostnameState( - ctx, hostname, buildHostnameState(results, time.Now().UTC()), + ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()), ) } @@ -432,8 +435,9 @@ func (w *Watcher) updateHostnameState( // the addresses found for all of them are saved, so nameservers that // disagree on the target do not change the result from check to check. // The addresses saved in prev, which may be nil, are kept when none of -// the name's nameservers answered, and when a target cannot be -// followed, as when no nameserver of a zone in its chain answers. +// the name's nameservers answered its queries for A, AAAA and CNAME, +// and when a target cannot be followed, as when no nameserver of a zone +// in its chain answers. func (w *Watcher) resolveCNAMEAddresses( ctx context.Context, hostname string, @@ -451,7 +455,10 @@ func (w *Watcher) resolveCNAMEAddresses( targets := make(map[string]bool) for _, nsState := range current.RecordsByNameserver { - if nsState.Status != statusOK { + if nsState.Status != statusOK || + slices.Contains(nsState.FailedTypes, "A") || + slices.Contains(nsState.FailedTypes, "AAAA") || + slices.Contains(nsState.FailedTypes, "CNAME") { continue } @@ -497,11 +504,13 @@ func (w *Watcher) resolveCNAMEAddresses( } // buildHostnameState saves each nameserver's response. A nameserver -// that answered, even with NXDOMAIN or no records, is saved as ok; one -// that timed out or failed is saved as error with the reason, and its -// empty record set is not an answer. +// that answered, even with NXDOMAIN or no records, is saved as ok, with +// the record types whose query failed; one that timed out or failed is +// saved as error with the reason, and its empty record set is not an +// answer. prev is the hostname's state from the previous check, or nil. func buildHostnameState( results map[string]*resolver.NameserverResponse, + prev *state.HostnameState, now time.Time, ) *state.HostnameState { hs := &state.HostnameState{ @@ -513,7 +522,7 @@ func buildHostnameState( for ns, resp := range results { nsState := &state.NameserverRecordState{ - Records: resp.Records, + Records: maps.Clone(resp.Records), Status: statusOK, LastChecked: now, } @@ -522,6 +531,15 @@ func buildHostnameState( resp.Status == resolver.StatusError { nsState.Status = statusError nsState.Error = resp.Error + } else { + nsState.FailedTypes = resp.FailedTypes + + var prevNS *state.NameserverRecordState + if prev != nil { + prevNS = prev.RecordsByNameserver[ns] + } + + keepFailedTypes(nsState, prevNS) } hs.RecordsByNameserver[ns] = nsState @@ -530,6 +548,27 @@ func buildHostnameState( return hs } +// keepFailedTypes copies into nsState, for each record type in its +// FailedTypes, the records prevNS, the nameserver's state from the +// previous check, holds for that type, which may be none. When prevNS +// does not know them either, because the nameserver was new or failing +// then or the type was in its UnknownTypes, the type goes in +// nsState.UnknownTypes instead. +func keepFailedTypes(nsState, prevNS *state.NameserverRecordState) { + for _, rtype := range nsState.FailedTypes { + if prevNS == nil || prevNS.Status != statusOK || + slices.Contains(prevNS.UnknownTypes, rtype) { + nsState.UnknownTypes = append(nsState.UnknownTypes, rtype) + + continue + } + + if records, ok := prevNS.Records[rtype]; ok { + nsState.Records[rtype] = records + } + } +} + func (w *Watcher) detectHostnameChanges( ctx context.Context, hostname string, @@ -618,7 +657,10 @@ func (w *Watcher) detectCNAMEAddressChanges( // detectRecordChanges compares each nameserver's records with those of // the previous check. Only answers are compared: a nameserver that -// failed on either check has no records to compare. +// failed on either check has no records to compare. The records kept +// for a record type whose query failed are compared too, but not those +// of a type in UnknownTypes on either check, which the message leaves +// out as well. func (w *Watcher) detectRecordChanges( ctx context.Context, hostname string, @@ -630,7 +672,11 @@ func (w *Watcher) detectRecordChanges( continue } - if recordsEqual(prevNS.Records, cur.Records) { + unknown := slices.Concat(prevNS.UnknownTypes, cur.UnknownTypes) + oldRecords := withoutTypes(prevNS.Records, unknown) + newRecords := withoutTypes(cur.Records, unknown) + + if recordsEqual(oldRecords, newRecords) { continue } @@ -638,8 +684,8 @@ func (w *Watcher) detectRecordChanges( "%s\nNameserver: %s\n%s", w.nameLine(hostname), ns, recordDifferences( - "Old", prevNS.Records, - "New", cur.Records, + "Old", oldRecords, + "New", newRecords, ), ) @@ -726,13 +772,19 @@ func (w *Watcher) detectInconsistencies( ) { for _, pair := range newlyDisagreeingPairs(prev, current) { ns1, ns2 := pair[0], pair[1] + state1 := current.RecordsByNameserver[ns1] + state2 := current.RecordsByNameserver[ns2] + + // The record types left out of the comparison are left out of + // the message too. + failed := slices.Concat(state1.FailedTypes, state2.FailedTypes) msg := fmt.Sprintf( "%s\n%s", w.nameLine(hostname), recordDifferences( - ns1, current.RecordsByNameserver[ns1].Records, - ns2, current.RecordsByNameserver[ns2].Records, + ns1, withoutTypes(state1.Records, failed), + ns2, withoutTypes(state2.Records, failed), ), ) @@ -750,7 +802,9 @@ func (w *Watcher) detectInconsistencies( // except pairs where both nameservers answered in prev and already // differed there. A nameserver missing from prev, or that failed there, // is paired with every nameserver it differs from. A nameserver that -// failed in current has no records to compare and is in no pair. +// failed in current has no records to compare and is in no pair. In +// both checks, a record type whose query failed at either nameserver is +// not compared. func newlyDisagreeingPairs( prev, current *state.HostnameState, ) [][2]string { @@ -767,9 +821,9 @@ func newlyDisagreeingPairs( for i, ns1 := range nameservers { for _, ns2 := range nameservers[i+1:] { - if recordsEqual( - current.RecordsByNameserver[ns1].Records, - current.RecordsByNameserver[ns2].Records, + if nameserversAgree( + current.RecordsByNameserver[ns1], + current.RecordsByNameserver[ns2], ) { continue } @@ -779,7 +833,7 @@ func newlyDisagreeingPairs( if ok1 && ok2 && prev1.Status == statusOK && prev2.Status == statusOK && - !recordsEqual(prev1.Records, prev2.Records) { + !nameserversAgree(prev1, prev2) { continue } @@ -1268,6 +1322,33 @@ func toSet(items []string) map[string]bool { return set } +// nameserversAgree reports whether two nameservers' states from the same +// check hold the same records, leaving out the record types either lists +// in FailedTypes: the records held for those are kept from an earlier +// check, or not known. +func nameserversAgree(a, b *state.NameserverRecordState) bool { + failed := slices.Concat(a.FailedTypes, b.FailedTypes) + + return recordsEqual( + withoutTypes(a.Records, failed), withoutTypes(b.Records, failed), + ) +} + +// withoutTypes returns a copy of records without the record types in +// types. +func withoutTypes( + records map[string][]string, + types []string, +) map[string][]string { + records = maps.Clone(records) + + for _, rtype := range types { + delete(records, rtype) + } + + return records +} + func recordsEqual( a, b map[string][]string, ) bool { -- 2.54.0 From 6332b4837917d245bfb914bb0574ba0ed77256cd Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 11:17:59 +0200 Subject: [PATCH 67/92] watcher: a name removed from the targets leaves the state (closes #223) At startup, before the first check, Run removes from the loaded state the domain, hostname and certificate entries of names no longer in DNSWATCHER_TARGETS, takes those names off each port entry's list of names and removes a port entry left with none, so the dashboard, /api/v1/status and the startup notification count only configured names. A configured domain's own records, saved as a hostname entry under its name, are kept. Nothing is notified. Each port check, next to the removal of stale port entries, now also removes the certificate entries for an address a name no longer resolves to, except while none of its nameservers answered, as port entries already were. Model: opus-5-5 --- README.md | 11 +- TODO.md | 2 + internal/state/state.go | 64 +++++++++ internal/watcher/export_test.go | 5 + internal/watcher/removed_test.go | 224 +++++++++++++++++++++++++++++++ internal/watcher/watcher.go | 96 ++++++++++++- 6 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 internal/watcher/removed_test.go diff --git a/README.md b/README.md index ec6a55d..b943c4e 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,14 @@ clears them. false-positive change notifications. - State is written atomically (write to temp file, then rename) to prevent corruption. +- A name removed from `DNSWATCHER_TARGETS` is removed from the state at startup, + before the first check, without a notification: its domain, hostname and + certificate entries go, it is taken off each port entry's list of names, and a + port entry left with no name goes, so the dashboard and `/api/v1/status` no + longer list or count it. The first check's port checks remove the port entries + of addresses no configured name has. +- Each port check also removes the certificate entries for an address their name + no longer resolves to, except while none of the name's nameservers answer. ### Web Dashboard @@ -734,7 +742,8 @@ docker run -d \ 1. **Startup**: Check that the data directory can be written, and exit with an error naming it if not. Load state from disk. If no state file exists, start with empty state (first check will establish baseline without triggering - change notifications). + change notifications). Remove from the state the names no longer in + `DNSWATCHER_TARGETS` (see State Management). 2. **Initial check**: Immediately perform all DNS, port, and TLS checks on startup. 3. **Periodic checks** (DNS always runs first): diff --git a/TODO.md b/TODO.md index 2895ca3..dbc8924 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so + the dashboard and API, at startup, before the first check (closes #223). - 2026-10-02: a record type whose query to a nameserver fails keeps its previous records and alerts nothing; the other types are still saved (closes #231). - 2026-10-02: a Port Change notification lists the port's domains on a diff --git a/internal/state/state.go b/internal/state/state.go index eecca4e..5bd9aef 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -298,6 +298,27 @@ func (s *State) GetDomainState( return ds, ok } +// DeleteDomainState removes a domain state entry. +func (s *State) DeleteDomainState(domain string) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.snapshot.Domains, domain) +} + +// GetAllDomainNames returns the names of all domain state entries. +func (s *State) GetAllDomainNames() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + names := make([]string, 0, len(s.snapshot.Domains)) + for name := range s.snapshot.Domains { + names = append(names, name) + } + + return names +} + // SetHostnameState updates the state for a hostname. func (s *State) SetHostnameState( hostname string, @@ -321,6 +342,28 @@ func (s *State) GetHostnameState( return hs, ok } +// DeleteHostnameState removes a hostname state entry. +func (s *State) DeleteHostnameState(hostname string) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.snapshot.Hostnames, hostname) +} + +// GetAllHostnames returns the names of all hostname state entries, +// which include each apex domain's own records. +func (s *State) GetAllHostnames() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + names := make([]string, 0, len(s.snapshot.Hostnames)) + for name := range s.snapshot.Hostnames { + names = append(names, name) + } + + return names +} + // SetPortState updates the state for a port. func (s *State) SetPortState(key string, ps *PortState) { s.mu.Lock() @@ -383,6 +426,27 @@ func (s *State) GetCertificateState( return cs, ok } +// DeleteCertificateState removes a certificate state entry. +func (s *State) DeleteCertificateState(key string) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.snapshot.Certificates, key) +} + +// GetAllCertificateKeys returns all certificate state keys. +func (s *State) GetAllCertificateKeys() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + keys := make([]string, 0, len(s.snapshot.Certificates)) + for k := range s.snapshot.Certificates { + keys = append(keys, k) + } + + return keys +} + // checkDataDirWritable creates the data directory if needed, then writes // and removes the temp file that Save uses. It runs at startup so that an // unwritable directory stops the process, instead of the process running diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index e527a2d..a3448e9 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -107,6 +107,11 @@ func (w *Watcher) MaybeSendTestNotification(ctx context.Context) { w.maybeSendTestNotification(ctx) } +// CleanupRemovedTargets exports cleanupRemovedTargets for testing. +func (w *Watcher) CleanupRemovedTargets() { + w.cleanupRemovedTargets() +} + // CheckAllPorts exports checkAllPorts for testing. func (w *Watcher) CheckAllPorts(ctx context.Context) { w.checkAllPorts(ctx) diff --git a/internal/watcher/removed_test.go b/internal/watcher/removed_test.go new file mode 100644 index 0000000..a7c51e2 --- /dev/null +++ b/internal/watcher/removed_test.go @@ -0,0 +1,224 @@ +package watcher_test + +import ( + "maps" + "slices" + "testing" + + "sneak.berlin/go/dnswatcher/internal/state" + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +// TestRemovedTargetsLeaveTheState loads a state saved while a domain +// and a hostname now removed from the configuration were still in it, +// and runs the removal that Run does before the first check. The +// removed names' domain, hostname and certificate entries are gone, +// the configured names' are kept, and nothing is notified. Nothing is +// looked up: the watcher has no resolver. +func TestRemovedTargetsLeaveTheState(t *testing.T) { + t.Parallel() + + const ( + removedDomain = "example.com" + removedHost = "www.example.com" + ) + + cfg := defaultTestConfig(t) + cfg.Domains = []string{domain} + cfg.Hostnames = []string{host} + + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + + // The state a check of all four names saves, each name at ip1. + for _, name := range []string{domain, removedDomain} { + deps.state.SetDomainState(name, &state.DomainState{ + Nameservers: []string{nsA}, + }) + } + + for _, name := range []string{domain, host, removedDomain, removedHost} { + deps.state.SetHostnameState(name, saved( + map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip1}}), + }, + )) + deps.state.SetCertificateState( + ip1+":443:"+name, &state.CertificateState{Status: "ok"}, + ) + } + + err := deps.state.Save() + if err != nil { + t.Fatalf("saving the state: %v", err) + } + + err = deps.state.Load() + if err != nil { + t.Fatalf("loading the state: %v", err) + } + + w.CleanupRemovedTargets() + + snap := deps.state.GetSnapshot() + + got := slices.Sorted(maps.Keys(snap.Domains)) + if want := []string{domain}; !slices.Equal(got, want) { + t.Errorf("domain entries %v, want %v", got, want) + } + + got = slices.Sorted(maps.Keys(snap.Hostnames)) + if want := []string{domain, host}; !slices.Equal(got, want) { + t.Errorf("hostname entries %v, want %v", got, want) + } + + got = slices.Sorted(maps.Keys(snap.Certificates)) + if want := []string{ + ip1 + ":443:" + domain, ip1 + ":443:" + host, + }; !slices.Equal(got, want) { + t.Errorf("certificate entries %v, want %v", got, want) + } + + if sent := deps.notifier.getNotifications(); len(sent) != 0 { + t.Errorf("sent %v, want nothing", sent) + } +} + +// TestRemovedTargetsLeaveThePortEntries loads a state whose port +// entries name a domain and a hostname now removed from the +// configuration, and runs the removal that Run does before the first +// check. The removed names are off each port entry's list of names, the +// entry only they had is gone, the entry that also names configured +// names is kept for the port checks, and nothing is notified. +func TestRemovedTargetsLeaveThePortEntries(t *testing.T) { + t.Parallel() + + const ( + removedDomain = "example.com" + removedHost = "www.example.com" + ) + + cfg := defaultTestConfig(t) + cfg.Domains = []string{domain} + cfg.Hostnames = []string{host} + + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + + // The port 443 entries a check of all four names saves: each name + // at ip1, except the removed hostname, at ip2. + deps.state.SetPortState(ip1+":443", &state.PortState{ + Open: true, Hostnames: []string{removedDomain, domain, host}, + }) + deps.state.SetPortState(ip2+":443", &state.PortState{ + Open: true, Hostnames: []string{removedHost}, + }) + + err := deps.state.Save() + if err != nil { + t.Fatalf("saving the state: %v", err) + } + + err = deps.state.Load() + if err != nil { + t.Fatalf("loading the state: %v", err) + } + + w.CleanupRemovedTargets() + + if sent := deps.notifier.getNotifications(); len(sent) != 0 { + t.Errorf("sent %v, want nothing", sent) + } + + snap := deps.state.GetSnapshot() + + got := slices.Sorted(maps.Keys(snap.Ports)) + if want := []string{ip1 + ":443"}; !slices.Equal(got, want) { + t.Fatalf("port entries %v, want %v", got, want) + } + + got = snap.Ports[ip1+":443"].Hostnames + if want := []string{domain, host}; !slices.Equal(got, want) { + t.Errorf("names of port entry %s:443 %v, want %v", ip1, got, want) + } +} + +// TestCertificateStateForAnAddressGone runs the port checks on hostname +// state built here for a configured hostname, with certificate entries +// saved for it at ip1, ip2 and an IPv6 address. When its nameservers +// answered with ip1 and the IPv6 address, the entry for ip2 is removed. +// When none of them answered, its addresses are not known, and every +// entry is kept. Nothing is notified, and nothing is looked up. +func TestCertificateStateForAnAddressGone(t *testing.T) { + t.Parallel() + + const ip6 = "2001:db8::1" + + tests := []struct { + name string + hostname *state.HostnameState + want []string + }{ + { + "answered without ip2", + saved(map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{ + "A": {ip1}, "AAAA": {ip6}, + }), + }), + []string{ip1 + ":443:" + host, ip6 + ":443:" + host}, + }, + { + "no nameserver answered", + saved(map[string]*state.NameserverRecordState{ + nsA: failed(), nsB: failed(), + }), + []string{ + ip1 + ":443:" + host, + ip2 + ":443:" + host, + ip6 + ":443:" + host, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{host} + + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + w.SetFirstRun(false) + + deps.state.SetHostnameState(host, tt.hostname) + + for _, ip := range []string{ip1, ip2, ip6} { + deps.state.SetCertificateState( + ip+":443:"+host, &state.CertificateState{Status: "ok"}, + ) + } + + w.CheckAllPorts(t.Context()) + + got := slices.Sorted(maps.Keys(deps.state.GetSnapshot().Certificates)) + if !slices.Equal(got, tt.want) { + t.Errorf("certificate entries %v, want %v", got, tt.want) + } + + if sent := deps.notifier.getNotifications(); len(sent) != 0 { + t.Errorf("sent %v, want nothing", sent) + } + }) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 5649e92..2825055 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -131,6 +131,7 @@ func (w *Watcher) Run(ctx context.Context) { "tlsInterval", w.config.TLSInterval.String(), ) + w.cleanupRemovedTargets() w.RunOnce(ctx) w.maybeSendTestNotification(ctx) @@ -201,6 +202,59 @@ func (w *Watcher) detectFirstRun() { } } +// cleanupRemovedTargets removes from the loaded state the domain, +// hostname and certificate entries of names no longer in the +// configuration, which changes only at a restart, and takes those names +// off each port entry's list of names, removing a port entry left with +// none. Nothing is notified. A configured domain's own records are +// saved as a hostname entry under its name, which is kept. +func (w *Watcher) cleanupRemovedTargets() { + for _, name := range w.state.GetAllDomainNames() { + if !w.isDomain(name) { + w.state.DeleteDomainState(name) + } + } + + for _, name := range w.state.GetAllHostnames() { + if !w.isConfigured(name) { + w.state.DeleteHostnameState(name) + } + } + + for _, key := range w.state.GetAllCertificateKeys() { + if _, hostname := parseCertKey(key); !w.isConfigured(hostname) { + w.state.DeleteCertificateState(key) + } + } + + for _, key := range w.state.GetAllPortKeys() { + ps, ok := w.state.GetPortState(key) + if !ok { + continue + } + + var names []string + + for _, name := range ps.Hostnames { + if w.isConfigured(name) { + names = append(names, name) + } + } + + if len(names) == 0 { + w.state.DeletePortState(key) + + continue + } + + w.state.SetPortState(key, &state.PortState{ + Open: ps.Open, + Hostnames: names, + LastChecked: ps.LastChecked, + }) + } +} + // runDNSChecks performs DNS resolution for all configured domains // and hostnames, updating state with freshly resolved records. // This must complete before port or TLS checks run so those @@ -861,8 +915,10 @@ func (w *Watcher) checkAllPorts(ctx context.Context) { } // Phase 3: Remove port state entries that no longer have - // any hostname referencing them. + // any hostname referencing them, and certificate entries for + // an address their name no longer has. w.cleanupStalePorts(associations) + w.cleanupStaleCertificates() } // buildPortAssociations constructs a map from IP:port keys to @@ -946,11 +1002,45 @@ func (w *Watcher) cleanupStalePorts( } } +// cleanupStaleCertificates removes the certificate entries for an +// address their name no longer resolves to. An entry saved for a name +// none of whose nameservers answered is kept: that name's addresses are +// not known, not gone. +func (w *Watcher) cleanupStaleCertificates() { + for _, key := range w.state.GetAllCertificateKeys() { + ip, hostname := parseCertKey(key) + + if slices.Contains(w.collectIPs(hostname), ip) || + w.noNameserverAnswered(hostname) { + continue + } + + w.state.DeleteCertificateState(key) + } +} + +// parseCertKey splits an "ip:port:hostname" certificate key into its +// address and hostname. +func parseCertKey(key string) (string, string) { + lastColon := strings.LastIndex(key, ":") + if lastColon < 0 { + return "", key + } + + ip, _ := parsePortKey(key[:lastColon]) + + return ip, key[lastColon+1:] +} + +// isConfigured reports whether name is a configured domain or hostname. +func (w *Watcher) isConfigured(name string) bool { + return w.isDomain(name) || slices.Contains(w.config.Hostnames, name) +} + // noNameserverAnswered reports whether name is a configured domain or // hostname and none of its nameservers answered on its last check. func (w *Watcher) noNameserverAnswered(name string) bool { - if !slices.Contains(w.config.Hostnames, name) && - !slices.Contains(w.config.Domains, name) { + if !w.isConfigured(name) { return false } -- 2.54.0 From 9bd1a71d8f3114ed2810510f644391e793bc85ff Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 12:00:48 +0200 Subject: [PATCH 68/92] resolver: the refused-query test asks several operators, one query each (closes #251) The test asked one operator's recursive resolver for eight record types at each of its two addresses, and passed only when all eight were refused within one attempt; when that operator stopped answering, next went red. It is now TestQueryServers_RecursiveResolverRefused: through the existing QueryServers test export it sends one A query to public resolvers of four operators in turn, inside livednstest.Retry, moving on when one gives no reply. Each refuses a query not asking for recursion and answers one that does, so putting the resend asking for recursion back still fails the test at once. Model: opus-5-5 --- TODO.md | 2 + internal/resolver/resolver_test.go | 73 ++++++++++++++---------------- 2 files changed, 37 insertions(+), 38 deletions(-) diff --git a/TODO.md b/TODO.md index dbc8924..85263d0 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: the refused-query test sends one query to four operators' public + resolvers in turn until one replies, not eight to one operator (closes #251). - 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so the dashboard and API, at startup, before the first check (closes #223). - 2026-10-02: a record type whose query to a nameserver fails keeps its previous diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 94a2fa0..9399a1e 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -442,48 +442,45 @@ func TestQueryNameserver_Refused(t *testing.T) { assert.Equal(t, "server returned REFUSED", resp.Error) } -// TestQueryNameserverIP_RecursiveResolverRefused asks Quad9, a public -// recursive resolver, about google.com at both of its addresses. Quad9 -// refuses a query that does not ask for recursion and answers one that -// does. The resolver never asks for recursion, so it must be reported -// as refusing, never as answering. -func TestQueryNameserverIP_RecursiveResolverRefused(t *testing.T) { +// TestQueryServers_RecursiveResolverRefused passes a public recursive +// resolver to QueryServers as the server of google.com. These resolvers +// refuse a query that does not ask for recursion and answer one that +// does. The resolver never asks for recursion, so the query must be +// reported as refused, never answered. Each resolver is run by a +// different operator, and they are asked in turn until one replies, so +// one operator not answering does not fail the test. +func TestQueryServers_RecursiveResolverRefused(t *testing.T) { t.Parallel() r := newTestResolver(t) - - for _, ip := range []string{"9.9.9.9", "149.112.112.112"} { - var resp *resolver.NameserverResponse - - livednstest.Retry( - t, - "QueryNameserverIP("+ip+", google.com)", - func(ctx context.Context) error { - var err error - - resp, err = r.QueryNameserverIP( - ctx, ip, ip, "google.com", - ) - if err != nil { - return err - } - - // A timeout or a network error is no reply at all. - if resp.Status == resolver.StatusTimeout || - strings.HasPrefix(resp.Error, "network error") { - return fmt.Errorf( - "%w: %s: %s", - livednstest.ErrNoAnswer, ip, resp.Error, - ) - } - - return nil - }, - ) - - assert.Equal(t, resolver.StatusError, resp.Status, ip) - assert.Equal(t, "server returned REFUSED", resp.Error, ip) + resolvers := []string{ + "64.6.64.6", "185.222.222.222", "4.2.2.1", "9.9.9.9", } + + var err error + + livednstest.Retry( + t, + "QueryServers(public recursive resolvers, google.com)", + func(ctx context.Context) error { + for _, ip := range resolvers { + _, err = r.QueryServers( + ctx, []string{ip}, "google.com.", "google.com.", + dns.TypeA, + ) + + // A refusal or an answer is a reply; anything else may + // be no reply at all, so the next resolver is asked. + if err == nil || errors.Is(err, resolver.ErrRefused) { + return nil + } + } + + return fmt.Errorf("%w: %w", livednstest.ErrNoAnswer, err) + }, + ) + + require.ErrorIs(t, err, resolver.ErrRefused) } // googleNameserverIPv4s returns the IPv4 addresses of google.com's -- 2.54.0 From 67b67b8475b1912fadfc55115bea99f96eec6cdf Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 12:38:45 +0200 Subject: [PATCH 69/92] resolver, watcher: a domain's nameservers are only its own delegation (closes #222) When a domain's parent zone's servers answer NXDOMAIN, LookupNS returns ErrNXDomain. The watcher then saves the domain with no nameservers and nxdomain set, shown on the dashboard and in /api/v1/status, asks for none of its records and removes those saved, so its old nameservers go in one NS Change. A domain with no delegation of its own gets an empty set and its records are still asked at the zone it is in. FindAuthoritativeNameservers moves to a parent name only on one of those two answers; when the servers do not answer, it returns the error. After an upgrade, a domain without its own delegation that was saved with its parent zone's nameservers gets one NS Change; the README says so. Model: opus-5-5 --- README.md | 45 ++++-- TODO.md | 2 + internal/handlers/dashboard_test.go | 24 ++- internal/handlers/status.go | 5 +- internal/handlers/status_test.go | 42 +++++ internal/handlers/templates/dashboard.html | 4 + internal/resolver/errors.go | 4 + internal/resolver/export_test.go | 37 +++++ internal/resolver/iterative.go | 80 +++++++--- internal/resolver/livedns_test.go | 4 +- internal/resolver/resolver_test.go | 176 ++++++++++++++++++++- internal/state/state.go | 5 +- internal/watcher/interfaces.go | 4 +- internal/watcher/watcher.go | 16 ++ internal/watcher/watcher_test.go | 113 +++++++++++++ 15 files changed, 521 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index b943c4e..c1988ed 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,21 @@ notification endpoint set, changes show only on the dashboard; see to discover all authoritative nameservers (NS records) for each domain. - Queries **every** discovered authoritative nameserver independently. - Stores the domain's NS record set, as its parent zone's servers delegate it, - and the IPv4 and IPv6 addresses each nameserver's name resolves to. + and the IPv4 and IPv6 addresses each nameserver's name resolves to. The set is + only ever the domain's own delegation. A domain whose parent zone's servers + answer NXDOMAIN, that it does not exist, has no nameservers and is shown as + not existing (see Web Dashboard and HTTP API). A domain that exists but has no + delegation of its own, such as `octocat.github.io`, has no nameservers either. + When the parent zone's servers do not answer, the check fails and the set from + the previous check is kept. - Any change triggers a notification: - - NS added to or removed from that set. + - NS added to or removed from that set. A domain that had nameservers on the + previous check and no longer exists gets one with all of them removed. + After an upgrade, a domain with no delegation of its own, for which an + earlier version saved its parent zone's nameservers, also gets one with + all of them removed, on its first check. That one does not mean the domain + stopped existing: it is not shown as not existing, and its records are + still watched. - NS address change: a nameserver that stays in the set resolves to different addresses than on the previous check. A nameserver added or removed gets only the NS change notification. When the lookup of a @@ -87,7 +99,10 @@ notification endpoint set, changes show only on the dashboard; see records, stored per nameserver. Their changes are notified as a hostname's are, as a record change, NS query failure, NS recovery, inconsistency or CNAME address change, in a message that starts `Domain:` where a hostname's starts - `Hostname:`. + `Hostname:`. A domain with no delegation of its own has these records asked at + the servers of the zone it is in, as a hostname has. A domain that does not + exist has none: they are not asked for, and those saved by an earlier check + are removed without a notification. ### DNS Hostname Monitoring (Subdomains) @@ -95,7 +110,11 @@ notification endpoint set, changes show only on the dashboard; see via the Public Suffix List). - Every **1 hour** by default, performs a full iterative trace to discover the authoritative nameservers of the zone the hostname is in, which is not always - its last two labels (a name under `co.uk`, or in a delegated subdomain). + its last two labels (a name under `co.uk`, or in a delegated subdomain). The + trace moves from a name to its parent only when the servers asked answer that + the name has no delegation of its own, or does not exist. When they do not + answer, the check fails and the hostname's records from the previous check are + kept. - Queries **each** authoritative nameserver independently for **all** record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. - Each record type is a query of its own. When a nameserver answers some types @@ -260,8 +279,9 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL (`/`). It displays: - **Summary counts** for monitored domains, hostnames, ports, and certificates. -- **Domains** with their discovered nameservers, and each domain's own records - per nameserver and status, shown as a hostname's are. +- **Domains** with their discovered nameservers, or "does not exist" for a + domain whose parent zone's servers answered NXDOMAIN, and each domain's own + records per nameserver and status, shown as a hostname's are. - **Hostnames** with per-nameserver DNS records and status. For a nameserver whose query failed, the reason is shown in place of the records. - **Ports** with open/closed state and the domains and hostnames that resolve to @@ -298,9 +318,11 @@ In `/api/v1/status`, each nameserver entry and certificate entry whose `status` is `error` also has `error`, the reason, as in the state file (see State File Format). A domain's own records are in its entry in `domains`, under `recordsByNameserver`, in the form a hostname's entry in `hostnames` has them -under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. A port -entry lists the domains that resolve to its address in `domains`, and the -hostnames in `hostnames`. +under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. A domain +entry's `nxdomain` is `true` when the domain's parent zone's servers answered +NXDOMAIN, that it does not exist; its `nameservers` and `recordsByNameserver` +are then empty. A port entry lists the domains that resolve to its address in +`domains`, and the hostnames in `hostnames`. `/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime, @@ -588,6 +610,11 @@ nothing for it. Both lists are left out when empty. resolves to. A state file without it loads, and the next check fills it in without a notification. +A domain entry has `"nxdomain": true` when the domain's parent zone's servers +answered NXDOMAIN, that it does not exist. Its `nameservers` and +`nameserverAddresses` are then empty, and `hostnames` holds no entry for it. +`nxdomain` is left out when false. + `cnameAddresses` lists the sorted addresses at the end of the chain of every CNAME target a hostname's nameservers gave, found when they answered with a CNAME and no address; it is empty when they answered with an address. When a diff --git a/TODO.md b/TODO.md index 85263d0..c0c6c2a 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a domain that does not exist is shown so, with no nameservers; no + name gets a parent's nameservers when its own did not answer (closes #222). - 2026-10-02: the refused-query test sends one query to four operators' public resolvers in turn until one replies, not eight to one operator (closes #251). - 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go index d49278f..57d4f9c 100644 --- a/internal/handlers/dashboard_test.go +++ b/internal/handlers/dashboard_test.go @@ -191,21 +191,39 @@ func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) { words := strings.Join(strings.Fields(page), " ") - footer := "monitoring 1 domains + 1 hostnames" + footer := "monitoring 2 domains + 1 hostnames" if !strings.Contains(words, footer) { t.Errorf("dashboard does not say %q", footer) } - // With the tags taken out, the summary bar starts "Domains 1 + // With the tags taken out, the summary bar starts "Domains 2 // Hostnames 1". text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ") - summary := "Domains 1 Hostnames 1" + summary := "Domains 2 Hostnames 1" if !strings.Contains(strings.Join(strings.Fields(text), " "), summary) { t.Errorf("summary bar does not say %q", summary) } } +// TestDashboardMarksDomainThatDoesNotExist checks that the Domains +// section says a domain that does not exist does not exist, and does +// not say so of a domain that exists. +func TestDashboardMarksDomainThatDoesNotExist(t *testing.T) { + t.Parallel() + + page := get(t, newHandlersWithFailures(t).HandleDashboard()) + domains := dashboardSection(t, page, "Domains") + + if !strings.Contains(dashboardRow(t, domains, missingDomain), "does not exist") { + t.Errorf("row of %s does not say it does not exist", missingDomain) + } + + if strings.Contains(dashboardRow(t, domains, testDomain), "does not exist") { + t.Errorf("row of %s says it does not exist", testDomain) + } +} + // rowCells returns the text of each cell of a dashboard table row // whose cells start with tag, "
Address StateDomains Hostnames Checked
{{ $key }} @@ -187,6 +188,9 @@ > {{ end }} + {{ joinStrings $ps.Domains ", " }} + {{ joinStrings $ps.Hostnames ", " }} + {{ if $ds.NXDomain }} + does not exist + {{ else }} {{ joinStrings $ds.Nameservers ", " }} + {{ end }} {{ relTime $ds.LastChecked }} diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index 61ca007..b628dd9 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -10,6 +10,10 @@ var ( "no authoritative nameservers found", ) + // ErrNXDomain is returned when the servers of the zone a domain + // is in answer NXDOMAIN: the domain does not exist. + ErrNXDomain = errors.New("domain does not exist") + // ErrNoNameserverAnswered is returned when every nameserver // asked about a name timed out, failed or returned a referral, // so whether the name has addresses is unknown. diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index a0e4103..0ea1a1f 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -17,6 +17,43 @@ func NewWithFailingTCP(log *slog.Logger) *Resolver { return r } +// NewWithQueryTimeout returns a Resolver whose queries over UDP give up +// after timeout, so a test that asks an address where nothing answers +// does not wait out the usual timeout. +func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver { + r := NewFromLogger(log) + r.client = &udpClient{timeout: timeout} + + return r +} + +// FollowDelegation exports followDelegation for testing. +func (r *Resolver) FollowDelegation( + ctx context.Context, + domain string, + servers []string, +) ([]string, error) { + return r.followDelegation(ctx, domain, servers) +} + +// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers +// for testing. +func (r *Resolver) FindAuthoritativeNameserversFrom( + ctx context.Context, + domain string, + servers []string, +) ([]string, error) { + return r.findAuthoritativeNameservers(ctx, domain, servers) +} + +// ResolveNSIterative exports resolveNSIterative for testing. +func (r *Resolver) ResolveNSIterative( + ctx context.Context, + domain string, +) ([]string, error) { + return r.resolveNSIterative(ctx, domain) +} + // ExtractRecordValue exports extractRecordValue for testing. func ExtractRecordValue(rr dns.RR) string { return extractRecordValue(rr) diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 4b15543..b275a2f 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -204,6 +204,12 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string { return ips } +// followDelegation follows referrals from servers, the root servers, to +// domain and returns the NS set of domain's delegation. When the servers +// of the zone domain is in answer that domain does not exist, the error +// is ErrNXDomain. When they answer that it has no delegation of its own, +// because it is not the zone's apex, the set is empty and there is no +// error. Any other error means that no such answer came. func (r *Resolver) followDelegation( ctx context.Context, domain string, @@ -234,10 +240,15 @@ func (r *Resolver) followDelegation( // An authoritative reply comes from the servers of the zone // domain is in; it is not a referral, even when its authority // section lists that zone's NS records. Without NS records in - // the answer, domain is not the zone's apex and has no - // nameservers of its own. + // the answer, domain has no nameservers of its own: it does + // not exist, when the reply is NXDOMAIN, or else it is not the + // zone's apex. + if resp.Authoritative && resp.Rcode == dns.RcodeNameError { + return nil, ErrNXDomain + } + if resp.Authoritative { - return nil, ErrNoNameservers + return []string{}, nil } authNS := extractNSSet(resp.Ns) @@ -486,7 +497,8 @@ func (r *Resolver) resolveNSIPs( // resolveNSIterative queries for NS records using iterative // resolution as a fallback when followDelegation finds no -// authoritative answer in the delegation chain. +// authoritative answer in the delegation chain. Its result means what +// followDelegation's does. func (r *Resolver) resolveNSIterative( ctx context.Context, domain string, @@ -516,6 +528,16 @@ func (r *Resolver) resolveNSIterative( return nsNames, nil } + // As in followDelegation: domain has no nameservers of its + // own. + if resp.Authoritative && resp.Rcode == dns.RcodeNameError { + return nil, ErrNXDomain + } + + if resp.Authoritative { + return []string{}, nil + } + // Follow delegation. authNS := extractNSSet(resp.Ns) if len(authNS) == 0 { @@ -601,12 +623,23 @@ func (r *Resolver) resolveARecord( // FindAuthoritativeNameservers traces the delegation chain from // root servers to discover all authoritative nameservers for the // given domain, as the delegation from its parent zone's servers lists -// them. For a name that is not a zone apex it tries each -// parent name in turn, so it returns the nameservers of the zone the -// name is in. +// them. When the servers asked answer that the name has no delegation +// of its own, or does not exist, it tries each parent name in turn, so +// it returns the nameservers of the zone the name is in. When they do +// not answer, it returns the error and tries no parent name. func (r *Resolver) FindAuthoritativeNameservers( ctx context.Context, domain string, +) ([]string, error) { + return r.findAuthoritativeNameservers(ctx, domain, rootServerList()) +} + +// findAuthoritativeNameservers is FindAuthoritativeNameservers with each +// walk starting at servers, the root servers. +func (r *Resolver) findAuthoritativeNameservers( + ctx context.Context, + domain string, + servers []string, ) ([]string, error) { if checkCtx(ctx) != nil { return nil, ErrContextCanceled @@ -622,19 +655,16 @@ func (r *Resolver) FindAuthoritativeNameservers( candidate := strings.Join(labels[i:], ".") + "." - nsNames, err := r.followDelegation( - ctx, candidate, rootServerList(), - ) - if err == nil && len(nsNames) > 0 { + nsNames, err := r.followDelegation(ctx, candidate, servers) + if err != nil && !errors.Is(err, ErrNXDomain) { + return nil, err + } + + if len(nsNames) > 0 { sort.Strings(nsNames) return nsNames, nil } - - // The root servers would refuse every parent name too. - if errors.Is(err, ErrIntercepted) { - return nil, err - } } return nil, ErrNoNameservers @@ -852,9 +882,7 @@ func readReply( // A reply with no answer that lists other nameservers, from a server // that does not hold the name's zone, is a referral and says nothing // about the name's records. A server named in the delegation that - // does not hold the zone may send one, as do a parent zone's servers - // when FindAuthoritativeNameservers found no delegation for the - // name's zone and moved on to a parent name. + // does not hold the zone may send one. if !msg.Authoritative && len(msg.Answer) == 0 && len(extractNSSet(msg.Ns)) > 0 { state.gotReferral = true @@ -1022,12 +1050,22 @@ func (r *Resolver) queryEachNS( return results, nil } -// LookupNS returns the NS record set for a domain. +// LookupNS returns the NS record set of a domain, as the delegation from +// its parent zone's servers lists it, and never a parent name's. When +// they answer that the domain does not exist, the error is ErrNXDomain. +// When they answer that it has no delegation of its own, the set is +// empty and there is no error. func (r *Resolver) LookupNS( ctx context.Context, domain string, ) ([]string, error) { - return r.FindAuthoritativeNameservers(ctx, domain) + if checkCtx(ctx) != nil { + return nil, ErrContextCanceled + } + + return r.followDelegation( + ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(), + ) } // LookupAllRecords performs iterative resolution to find all DNS diff --git a/internal/resolver/livedns_test.go b/internal/resolver/livedns_test.go index 59f95ca..53946d6 100644 --- a/internal/resolver/livedns_test.go +++ b/internal/resolver/livedns_test.go @@ -187,8 +187,8 @@ func liveFindAuthoritative( return out } -// liveLookupNS is liveFindAuthoritative through the LookupNS entry -// point, so that both entry points stay independently exercised. +// liveLookupNS looks up the NS record set of domain, a domain that has +// one, retrying until the delegation chain can be walked. func liveLookupNS( t *testing.T, r *resolver.Resolver, diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 9399a1e..58033b7 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -25,6 +25,13 @@ import ( // Test helpers // ---------------------------------------------------------------- +// nonexistentDomain is a .com domain that does not exist. +const nonexistentDomain = "dnswatcher-test-does-not-exist.com" + +// noAnswerAddress is 192.0.2.1, a documentation address: nothing +// answers there. +const noAnswerAddress = "192.0.2.1" + func newTestResolver(t *testing.T) *resolver.Resolver { t.Helper() @@ -88,6 +95,47 @@ func TestFindAuthoritativeNameservers_Subdomain( assert.Equal(t, fromZone, fromHost) } +// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the +// nameservers of www.cs.cmu.edu, a name in cs.cmu.edu, a zone that +// cmu.edu delegates to other servers. The servers of cs.cmu.edu answer +// that the name has no delegation of its own, so it gets their names, +// not those of the cmu.edu servers. Every referral on the way gives the +// nameservers' addresses, so the walk sends few queries. +func TestFindAuthoritativeNameservers_DelegatedSubdomain( + t *testing.T, +) { + t.Parallel() + + r := newTestResolver(t) + fromHost := liveFindAuthoritative(t, r, "www.cs.cmu.edu") + fromZone := liveLookupNS(t, r, "cs.cmu.edu") + fromParent := liveLookupNS(t, r, "cmu.edu") + + assert.Equal(t, fromZone, fromHost) + assert.NotEqual(t, fromParent, fromHost) +} + +// TestFindAuthoritativeNameservers_NoAnswer starts each walk for +// www.google.com at 192.0.2.1, a documentation address where nothing +// answers. A walk that got no answer does not say that the name has no +// delegation of its own, so the lookup returns that walk's error, about +// www.google.com, and tries no parent name: trying google.com and com +// would end in ErrNoNameservers, or in the error of a walk for one of +// them. +func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) { + t.Parallel() + + r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond) + + nameservers, err := r.FindAuthoritativeNameserversFrom( + t.Context(), "www.google.com", []string{noAnswerAddress}, + ) + require.Error(t, err) + require.NotErrorIs(t, err, resolver.ErrNoNameservers) + assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress) + assert.Empty(t, nameservers) +} + func TestFindAuthoritativeNameservers_ReturnsSorted( t *testing.T, ) { @@ -828,8 +876,7 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) { // nameservers of g.ntpns.org. The org servers delegate its parent zone, // ntpns.org, without the addresses of its nameservers, so the walk has // to look them up to ask them. If it did not, the walk for g.ntpns.org -// would fail and LookupNS would return the nameservers of ntpns.org, -// which a.ntpns.org is not one of. +// would fail. func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) { t.Parallel() @@ -839,6 +886,131 @@ func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) { assert.Contains(t, nameservers, "a.ntpns.org.") } +// TestLookupNS_DomainThatDoesNotExist looks up the nameservers of a .com +// domain that does not exist. The .com servers answer NXDOMAIN, so the +// error is ErrNXDomain, and the domain does not get their names. +func TestLookupNS_DomainThatDoesNotExist(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + var ( + nameservers []string + err error + ) + + livednstest.Retry( + t, + "LookupNS("+nonexistentDomain+")", + func(ctx context.Context) error { + nameservers, err = r.LookupNS(ctx, nonexistentDomain) + if errors.Is(err, resolver.ErrNXDomain) { + return nil + } + + return err + }, + ) + + require.ErrorIs(t, err, resolver.ErrNXDomain) + assert.Empty(t, nameservers) +} + +// TestLookupNS_NoDelegationOfItsOwn looks up the nameservers of +// www.google.com, a name in the google.com zone with no delegation of +// its own, as a domain such as octocat.github.io is. The google.com +// servers answer with no NS records for it: the set is empty, and it is +// not ErrNXDomain. +func TestLookupNS_NoDelegationOfItsOwn(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + var nameservers []string + + livednstest.Retry( + t, + "LookupNS(www.google.com)", + func(ctx context.Context) error { + var err error + + nameservers, err = r.LookupNS(ctx, "www.google.com") + + return err + }, + ) + + assert.Empty(t, nameservers) +} + +// TestFollowDelegation_NoAnswer starts the walk LookupNS uses, for +// google.com, at 192.0.2.1, a documentation address where nothing +// answers. A walk that got no answer is an error, not an empty set, +// which the watcher would report as an NS Change with every nameserver +// removed. +func TestFollowDelegation_NoAnswer(t *testing.T) { + t.Parallel() + + r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond) + + nameservers, err := r.FollowDelegation( + t.Context(), "google.com.", []string{noAnswerAddress}, + ) + require.Error(t, err) + assert.Empty(t, nameservers) +} + +// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers +// of www.google.com as the fallback walk does. As in +// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error. +func TestResolveNSIterative_NoDelegationOfItsOwn(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + var nameservers []string + + livednstest.Retry( + t, + "ResolveNSIterative(www.google.com)", + func(ctx context.Context) error { + var err error + + nameservers, err = r.ResolveNSIterative(ctx, "www.google.com") + + return err + }, + ) + + assert.Empty(t, nameservers) +} + +// TestResolveNSIterative_DomainThatDoesNotExist walks to the nameservers +// of a .com domain that does not exist as the fallback walk does. As in +// TestLookupNS_DomainThatDoesNotExist, the error is ErrNXDomain. +func TestResolveNSIterative_DomainThatDoesNotExist(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + var err error + + livednstest.Retry( + t, + "ResolveNSIterative("+nonexistentDomain+")", + func(ctx context.Context) error { + _, err = r.ResolveNSIterative(ctx, nonexistentDomain) + if errors.Is(err, resolver.ErrNXDomain) { + return nil + } + + return err + }, + ) + + require.ErrorIs(t, err, resolver.ErrNXDomain) +} + // ---------------------------------------------------------------- // ResolveIPAddresses tests // ---------------------------------------------------------------- diff --git a/internal/state/state.go b/internal/state/state.go index 5bd9aef..39da3cb 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -38,10 +38,13 @@ type Params struct { // DomainState holds the monitoring state for an apex domain. // NameserverAddresses holds the sorted addresses each nameserver's name // resolves to, by nameserver name. A state file written before it -// existed loads with it nil. +// existed loads with it nil. NXDomain is true when the domain's parent +// zone's servers answered that it does not exist; it then has no +// nameservers. type DomainState struct { Nameservers []string `json:"nameservers"` NameserverAddresses map[string][]string `json:"nameserverAddresses"` + NXDomain bool `json:"nxdomain,omitempty"` LastChecked time.Time `json:"lastChecked"` } diff --git a/internal/watcher/interfaces.go b/internal/watcher/interfaces.go index 41f53bc..166ada4 100644 --- a/internal/watcher/interfaces.go +++ b/internal/watcher/interfaces.go @@ -11,7 +11,9 @@ import ( // DNSResolver performs iterative DNS resolution. type DNSResolver interface { - // LookupNS discovers authoritative nameservers for a domain. + // LookupNS returns a domain's NS record set, as its parent zone's + // servers delegate it: empty when they answer that it has none, and + // resolver.ErrNXDomain when they answer that it does not exist. LookupNS( ctx context.Context, domain string, diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2825055..7368dab 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -289,6 +289,13 @@ func (w *Watcher) checkDomain( domain string, ) { nameservers, err := w.resolver.LookupNS(ctx, domain) + + // A domain that does not exist has no nameservers. + nxdomain := errors.Is(err, resolver.ErrNXDomain) + if nxdomain { + nameservers, err = []string{}, nil + } + if err != nil { w.logFailedLookup( ctx, @@ -323,9 +330,18 @@ func (w *Watcher) checkDomain( w.state.SetDomainState(domain, &state.DomainState{ Nameservers: nameservers, NameserverAddresses: addresses, + NXDomain: nxdomain, LastChecked: now, }) + // A domain that does not exist has no records of its own: none are + // asked for, and those saved by an earlier check are removed. + if nxdomain { + w.state.DeleteHostnameState(domain) + + return + } + // The apex domain's records are also checked and saved as a // hostname's, so that the port and TLS checks find its addresses. // Notifications about them name it as a domain (see nameLine). diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 46b40cc..32517d7 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -482,6 +482,119 @@ func TestNSChangeDetection(t *testing.T) { } } +// TestDomainThatDoesNotExist checks a .com domain that does not exist, +// with nameservers and records saved by an earlier check. The .com +// servers answer that it does not exist, so it is saved with nxdomain +// set and no nameservers, an NS Change removes them all, and its saved +// records are removed rather than asked for at the .com servers. +func TestDomainThatDoesNotExist(t *testing.T) { + t.Parallel() + + const domain = "dnswatcher-test-does-not-exist.com" + + cfg := defaultTestConfig(t) + cfg.Domains = []string{domain} + + var deps *testDeps + + livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { + var w *watcher.Watcher + + w, deps = newTestWatcher(t, cfg) + + deps.state.SetDomainState(domain, &state.DomainState{ + Nameservers: []string{oldNS1, oldNS2}, + }) + deps.state.SetHostnameState(domain, &state.HostnameState{ + RecordsByNameserver: map[string]*state.NameserverRecordState{ + oldNS1: { + Records: map[string][]string{"A": {oldIP}}, + Status: "ok", + }, + }, + }) + + started := time.Now() + + w.RunOnce(ctx) + + // When no server answered, the domain's state is not saved. + ds, _ := deps.state.GetDomainState(domain) + if ds.LastChecked.Before(started) { + return fmt.Errorf("%s: %w", domain, livednstest.ErrNoAnswer) + } + + return nil + }) + + ds, _ := deps.state.GetDomainState(domain) + if !ds.NXDomain || len(ds.Nameservers) != 0 { + t.Errorf("saved nxdomain %v and nameservers %v, want true and none", + ds.NXDomain, ds.Nameservers) + } + + if hs, ok := deps.state.GetHostnameState(domain); ok { + t.Errorf("records saved for %s: %v", domain, hs.RecordsByNameserver) + } + + assertNotified(t, deps, "NS Change: "+domain, "warning") + + // That is the only notification, and it removes both nameservers, + // in either order. + for _, n := range deps.notifier.getNotifications() { + removed := strings.TrimPrefix( + n.Message, "Domain: "+domain+"\nAdded: \nRemoved: ", + ) + if removed != oldNS1+", "+oldNS2 && removed != oldNS2+", "+oldNS1 { + t.Errorf("unexpected notification: %v", n) + } + } +} + +// TestDomainWithNoDelegationOfItsOwn checks a domain with no delegation +// of its own: codeberg.page is on the public suffix list, so +// docs.codeberg.page is a domain, but the .page servers delegate only +// codeberg.page, whose servers answer for it. It is saved with no +// nameservers and without nxdomain, and its records, asked at the +// codeberg.page servers, are saved. Those are testSmallDomain's two +// nameservers; github.io, the zone of the README's example, has eight. +func TestDomainWithNoDelegationOfItsOwn(t *testing.T) { + t.Parallel() + + const domain = "docs.codeberg.page" + + cfg := defaultTestConfig(t) + cfg.Domains = []string{domain} + + var deps *testDeps + + livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { + var w *watcher.Watcher + + w, deps = newTestWatcher(t, cfg) + + err := checkOnce(ctx, w, deps) + + // A domain saved as not existing has no records to wait for; + // the checks below fail on it. + if ds, ok := deps.state.GetDomainState(domain); ok && ds.NXDomain { + return nil + } + + return err + }) + + ds, _ := deps.state.GetDomainState(domain) + if ds.NXDomain || len(ds.Nameservers) != 0 { + t.Errorf("saved nxdomain %v and nameservers %v, want false and none", + ds.NXDomain, ds.Nameservers) + } + + if _, ok := deps.state.GetHostnameState(domain); !ok { + t.Errorf("no records saved for %s", domain) + } +} + func TestNSAddressChangeDetection(t *testing.T) { t.Parallel() -- 2.54.0 From 682299613408df1fe91e4411b58e157e5b0b514d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 13:16:42 +0200 Subject: [PATCH 70/92] resolver: a nameserver with a failed record type is not nodata (closes #253) classifyResponse set nodata when every record type that answered had no records, even when another type's query got no usable reply. That type is listed in FailedTypes and its records are unknown, so the nameserver has not said it has none. It now stays ok, the status README describes for a nameserver with a failed type, and nodata is set only when no type failed. The watcher saved nodata as ok already, so saved state is unchanged; the live test that rejects nodata no longer fails when one of a nameserver's queries is lost. Model: opus-5-5 --- TODO.md | 2 + internal/resolver/iterative.go | 7 ++- internal/resolver/iterative_internal_test.go | 48 ++++++++++++++------ 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/TODO.md b/TODO.md index c0c6c2a..0a5c99f 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: a nameserver whose query for one record type failed while the + others answered with no records is `ok`, not `nodata` (closes #253). - 2026-10-02: a domain that does not exist is shown so, with no nameservers; no name gets a parent's nameservers when its own did not answer (closes #222). - 2026-10-02: the refused-query test sends one query to four operators' public diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index b275a2f..35df296 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -941,7 +941,9 @@ func isTimeout(err error) bool { // classifyResponse sets the nameserver's status. One that answered no // record type has failed, and Error says why; one that answered some has -// the status of those answers. +// the status of those answers. It has no data only when every type +// answered with no records: a type in FailedTypes may have records, so a +// nameserver with one stays ok. func classifyResponse(resp *NameserverResponse, state queryState) { switch { case state.gotNXDomain && !state.hasRecords: @@ -961,7 +963,8 @@ func classifyResponse(resp *NameserverResponse, state queryState) { case state.gotReferral && !state.answered: resp.Status = StatusError resp.Error = "server returned a referral" - case !state.hasRecords && !state.gotNXDomain: + // An NXDOMAIN reply with no records was taken by the first case. + case !state.hasRecords && len(resp.FailedTypes) == 0: resp.Status = StatusNoData } } diff --git a/internal/resolver/iterative_internal_test.go b/internal/resolver/iterative_internal_test.go index f36097d..c756bb0 100644 --- a/internal/resolver/iterative_internal_test.go +++ b/internal/resolver/iterative_internal_test.go @@ -11,60 +11,77 @@ import ( ) // TestClassifyResponse sets a nameserver's status from the results of -// its queries, built here. One that answered some record types, even -// with no records, has not failed when its query for another type got -// no usable reply, whatever the reason; one whose every query got none -// has. +// its queries and the record types whose query failed, built here. One +// that answered some record types, even with no records, has not failed +// when its query for another type got no usable reply, whatever the +// reason, and is ok, not nodata: that type may have records. One whose +// every query got none has failed. Only one whose every type answered +// with no records is nodata. func TestClassifyResponse(t *testing.T) { t.Parallel() tests := []struct { - name string - results queryState - wantStatus string - wantError string + name string + results queryState + failedTypes []string + wantStatus string + wantError string }{ + { + "every type answered with no records", + queryState{answered: true}, + nil, + StatusNoData, "", + }, { "some types answered with no records, another timed out", queryState{answered: true, gotTimeout: true}, - StatusNoData, "", + []string{"A"}, + StatusOK, "", }, { "some types answered with no records, another got SERVFAIL", queryState{ answered: true, gotErrorReply: true, errorReply: "SERVFAIL", }, - StatusNoData, "", + []string{"A"}, + StatusOK, "", }, { "some types answered with no records, another was refused", queryState{answered: true, gotRefused: true}, - StatusNoData, "", + []string{"A"}, + StatusOK, "", }, { "some types answered with no records, another got a network error", queryState{answered: true, netErr: syscall.ECONNREFUSED}, - StatusNoData, "", + []string{"A"}, + StatusOK, "", }, { "some types answered with no records, another's reply was " + "truncated and its retry over TCP failed", queryState{answered: true, netErr: ErrTruncated}, - StatusNoData, "", + []string{"TXT"}, + StatusOK, "", }, { "some types answered with no records, another got a referral", queryState{answered: true, gotReferral: true}, - StatusNoData, "", + []string{"A"}, + StatusOK, "", }, { "every query timed out", queryState{gotTimeout: true}, + []string{"A", "AAAA", "CNAME"}, StatusTimeout, "all queries timed out", }, { "every query got NOTIMP", queryState{gotErrorReply: true, errorReply: "NOTIMP"}, + []string{"A", "AAAA", "CNAME"}, StatusError, "server returned NOTIMP", }, } @@ -73,11 +90,12 @@ func TestClassifyResponse(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - resp := &NameserverResponse{Status: StatusOK} + resp := &NameserverResponse{Status: StatusOK, FailedTypes: tt.failedTypes} classifyResponse(resp, tt.results) assert.Equal(t, tt.wantStatus, resp.Status) assert.Equal(t, tt.wantError, resp.Error) + assert.Equal(t, tt.failedTypes, resp.FailedTypes) }) } } -- 2.54.0 From d1060315b733880d0c57634a144ea721c1273e06 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 03:01:46 +0200 Subject: [PATCH 71/92] watcher, config: no Record Change or Inconsistency for listed names (closes #255) DNSWATCHER_SKIP_RECORD_NOTIFICATIONS takes a comma-separated list of names from DNSWATCHER_TARGETS, read as the targets are (letter case, trailing dot, repeats). For a listed name no Record Change and no Inconsistency notification is sent; its records are still checked and saved, and its other notifications are sent. A listed name that is not a target stops startup with an error naming it. The two detections return early for a listed name. Apex domains are covered too: their own records go through the same detection. Live DNS cannot be made to disagree on purpose, so the Inconsistency test feeds records to the change detection directly, as the existing inconsistency tests do; the live test covers Record Change. Model: opus-5-5 --- README.md | 73 ++++++----- TODO.md | 3 + internal/config/config.go | 117 ++++++++++++------ internal/config/config_test.go | 28 +++++ .../watcher/skiprecordnotifications_test.go | 107 ++++++++++++++++ internal/watcher/watcher.go | 13 +- 6 files changed, 275 insertions(+), 66 deletions(-) create mode 100644 internal/watcher/skiprecordnotifications_test.go diff --git a/README.md b/README.md index c1988ed..3e3c0fa 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,11 @@ notification endpoint set, changes show only on the dashboard; see records, stored per nameserver. Their changes are notified as a hostname's are, as a record change, NS query failure, NS recovery, inconsistency or CNAME address change, in a message that starts `Domain:` where a hostname's starts - `Hostname:`. A domain with no delegation of its own has these records asked at - the servers of the zone it is in, as a hostname has. A domain that does not - exist has none: they are not asked for, and those saved by an earlier check - are removed without a notification. + `Hostname:`. A domain listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no + record change or inconsistency notification. A domain with no delegation of + its own has these records asked at the servers of the zone it is in, as a + hostname has. A domain that does not exist has none: they are not asked for, + and those saved by an earlier check are removed without a notification. ### DNS Hostname Monitoring (Subdomains) @@ -136,10 +137,14 @@ notification endpoint set, changes show only on the dashboard; see lower case, because names are case-insensitive and nameservers may answer in any letter case. TXT and CAA values keep their letter case; they are not lower-cased. -- Any observable change in any nameserver's response triggers a notification. - This includes: +- Any observable change in any nameserver's response triggers a notification, + except a record change or an inconsistency for a domain or hostname listed in + `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS`. This includes: - **Record change**: A nameserver returns different records than it did on - the previous check (additions, removals, value changes). + the previous check (additions, removals, value changes). For a domain or + hostname listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS`, neither a + record change nor an inconsistency is notified; its records are still + checked and saved, and its other notifications are sent. - **NS query failure**: A nameserver that previously responded becomes unreachable (timeout, SERVFAIL, REFUSED, network error). This is distinct from "responded with no records": a nameserver that answers NXDOMAIN or @@ -160,7 +165,8 @@ notification endpoint set, changes show only on the dashboard; see failed on it, and answers differently is reported on the check where it answers. So is a pair that differs in a record type whose query to either nameserver failed on the previous check. If a pair agrees again and later - disagrees, the alert is sent again. + disagrees, the alert is sent again. For a domain or hostname listed in + `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS`, no inconsistency is notified. - **CNAME address change**: The addresses at the end of a name's CNAME chain differ from those of the previous check. They are found when its nameservers answer with a CNAME and no address; a name that answers with @@ -186,9 +192,13 @@ notification endpoint set, changes show only on the dashboard; see - Any change in port availability triggers a notification: - Port transitioned from open to closed (or vice versa). - New IP appeared (from DNS change): its port state is recorded without a - port notification; the DNS change notification shows the new address. + port notification; the DNS change notification shows the new address. A + domain or hostname listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets + no notification for an address added to its own A or AAAA records. - IP disappeared (from DNS change) — noted in the DNS change notification; - port state for that IP is removed. When none of a name's nameservers + port state for that IP is removed. A domain or hostname listed in + `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no notification for an address + removed from its own A or AAAA records. When none of a name's nameservers answered, its addresses are not known, so the port state saved for them is kept. @@ -210,7 +220,9 @@ notification endpoint set, changes show only on the dashboard; see **Every observable state change produces a notification.** dnswatcher is designed as a real-time change feed — degradations, failures, recoveries, and -routine changes are all reported equally. +routine changes are all reported equally. A domain or hostname listed in +`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency +notification. Supported notification backends: @@ -409,23 +421,24 @@ following precedence (highest to lowest): ### Environment Variables -| Variable | Description | Default | -| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------- | -| `PORT` | HTTP listen port | `8080` | -| `DNSWATCHER_DEBUG` | Enable debug logging | `false` | -| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` | -| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` | -| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` | -| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` | -| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` | -| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` | -| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` | -| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` | -| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | -| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` | -| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` | -| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | -| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | +| Variable | Description | Default | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| `PORT` | HTTP listen port | `8080` | +| `DNSWATCHER_DEBUG` | Enable debug logging | `false` | +| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` | +| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` | +| `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` | Comma-separated names from `DNSWATCHER_TARGETS` for which no record change or inconsistency is notified; any other name stops startup | `""` | +| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` | +| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` | +| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` | +| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` | +| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` | +| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` | +| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | +| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` | +| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` | +| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | +| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | **`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no monitoring targets are configured. A monitoring daemon with nothing to monitor @@ -786,7 +799,9 @@ docker run -d \ nameservers answered, the addresses an earlier check found at the end of the chain are used. 4. **On change detection**: Send notifications to all configured endpoints, - update in-memory state, persist to disk. + update in-memory state, persist to disk. A record change or inconsistency for + a domain or hostname listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` sends + no notification, but the state is still updated and saved. 5. **Shutdown**: The watcher stops checking and saves the final state to disk, and shutdown waits for that save before it goes on. Then it waits for in-flight notification deliveries to complete. Both waits share the fx diff --git a/TODO.md b/TODO.md index 0a5c99f..878dd2e 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,9 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-05: a name listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no + Record Change or Inconsistency notification; a name listed there that is not + in `DNSWATCHER_TARGETS` stops startup (closes #255). - 2026-10-02: a nameserver whose query for one record type failed while the others answered with no records is `ok`, not `nodata` (closes #253). - 2026-10-02: a domain that does not exist is shown so, with no nameservers; no diff --git a/internal/config/config.go b/internal/config/config.go index 8d58433..2f82f40 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" "time" @@ -35,6 +36,10 @@ var ErrInvalidInterval = errors.New( "interval must be a positive duration such as 30m or 1h", ) +// ErrNotInTargets is returned when DNSWATCHER_SKIP_RECORD_NOTIFICATIONS +// lists a name that is not in DNSWATCHER_TARGETS. +var ErrNotInTargets = errors.New("name is not in DNSWATCHER_TARGETS") + // Params contains dependencies for Config. type Params struct { fx.In @@ -45,24 +50,25 @@ type Params struct { // Config holds application configuration. type Config struct { - Port int - Debug bool - DataDir string - Domains []string - Hostnames []string - SlackWebhook string - MattermostWebhook string - NtfyTopic string - DNSInterval time.Duration - TLSInterval time.Duration - TLSExpiryWarning int - SentryDSN string - MaintenanceMode bool - MetricsUsername string - MetricsPassword string - SendTestNotification bool - params *Params - log *slog.Logger + Port int + Debug bool + DataDir string + Domains []string + Hostnames []string + SkipRecordNotifications []string + SlackWebhook string + MattermostWebhook string + NtfyTopic string + DNSInterval time.Duration + TLSInterval time.Duration + TLSExpiryWarning int + SentryDSN string + MaintenanceMode bool + MetricsUsername string + MetricsPassword string + SendTestNotification bool + params *Params + log *slog.Logger } // New creates a new Config instance from environment and config files. @@ -103,6 +109,7 @@ func setupViper(name string) { viper.SetDefault("DEBUG", false) viper.SetDefault("DATA_DIR", "/var/lib/"+name) viper.SetDefault("TARGETS", "") + viper.SetDefault("SKIP_RECORD_NOTIFICATIONS", "") viper.SetDefault("SLACK_WEBHOOK", "") viper.SetDefault("MATTERMOST_WEBHOOK", "") viper.SetDefault("NTFY_TOPIC", "") @@ -147,25 +154,33 @@ func buildConfig( return nil, err } + skipRecordNotifications, err := parseSkipRecordNotifications( + domains, hostnames, + ) + if err != nil { + return nil, err + } + cfg := &Config{ - Port: viper.GetInt("PORT"), - Debug: viper.GetBool("DEBUG"), - DataDir: viper.GetString("DATA_DIR"), - Domains: domains, - Hostnames: hostnames, - SlackWebhook: viper.GetString("SLACK_WEBHOOK"), - MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"), - NtfyTopic: viper.GetString("NTFY_TOPIC"), - DNSInterval: dnsInterval, - TLSInterval: tlsInterval, - TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"), - SentryDSN: viper.GetString("SENTRY_DSN"), - MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), - MetricsUsername: viper.GetString("METRICS_USERNAME"), - MetricsPassword: viper.GetString("METRICS_PASSWORD"), - SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"), - params: params, - log: log, + Port: viper.GetInt("PORT"), + Debug: viper.GetBool("DEBUG"), + DataDir: viper.GetString("DATA_DIR"), + Domains: domains, + Hostnames: hostnames, + SkipRecordNotifications: skipRecordNotifications, + SlackWebhook: viper.GetString("SLACK_WEBHOOK"), + MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"), + NtfyTopic: viper.GetString("NTFY_TOPIC"), + DNSInterval: dnsInterval, + TLSInterval: tlsInterval, + TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"), + SentryDSN: viper.GetString("SENTRY_DSN"), + MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), + MetricsUsername: viper.GetString("METRICS_USERNAME"), + MetricsPassword: viper.GetString("METRICS_PASSWORD"), + SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"), + params: params, + log: log, } return cfg, nil @@ -204,6 +219,36 @@ func parseAndValidateTargets() ([]string, []string, error) { return domains, hostnames, nil } +// parseSkipRecordNotifications reads DNSWATCHER_SKIP_RECORD_NOTIFICATIONS, +// a comma-separated list of names from the targets. Each name is written +// as ClassifyTargets writes a target, in lower case without a trailing +// dot, and a name listed more than once is kept once. A name that is +// not one of domains or hostnames is an error naming it. +func parseSkipRecordNotifications( + domains, hostnames []string, +) ([]string, error) { + value := viper.GetString("SKIP_RECORD_NOTIFICATIONS") + + var names []string + + for _, listed := range parseCSV(value) { + name := strings.ToLower(strings.TrimSuffix(listed, ".")) + + if !slices.Contains(domains, name) && !slices.Contains(hostnames, name) { + return nil, fmt.Errorf( + "invalid DNSWATCHER_SKIP_RECORD_NOTIFICATIONS %q: %w", + listed, ErrNotInTargets, + ) + } + + if !slices.Contains(names, name) { + names = append(names, name) + } + } + + return names, nil +} + func parseCSV(input string) []string { if input == "" { return nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c4471ff..ffb93ab 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -57,6 +57,7 @@ func TestNew_DefaultValues(t *testing.T) { assert.Empty(t, cfg.MetricsUsername) assert.Empty(t, cfg.MetricsPassword) assert.False(t, cfg.SendTestNotification) + assert.Empty(t, cfg.SkipRecordNotifications) } func TestNew_EnvironmentOverrides(t *testing.T) { @@ -235,6 +236,33 @@ func TestNew_TargetsWithTrailingComma(t *testing.T) { "trailing comma should be ignored") } +func TestNew_SkipRecordNotifications(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.net,www.example.net,example.org") + t.Setenv("DNSWATCHER_SKIP_RECORD_NOTIFICATIONS", + " WWW.Example.net. , example.net,www.example.net") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.Equal(t, + []string{"www.example.net", "example.net"}, + cfg.SkipRecordNotifications, + "names are written as targets are, each once", + ) +} + +func TestNew_SkipRecordNotificationsNotInTargetsStopsStartup(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.net") + t.Setenv("DNSWATCHER_SKIP_RECORD_NOTIFICATIONS", + "example.net,www.example.net") + + _, err := config.New(nil, newTestParams(t)) + require.ErrorIs(t, err, config.ErrNotInTargets) + require.ErrorContains(t, err, "DNSWATCHER_SKIP_RECORD_NOTIFICATIONS") + require.ErrorContains(t, err, `"www.example.net"`) +} + func TestNew_CustomDNSIntervalDuration(t *testing.T) { viper.Reset() t.Setenv("DNSWATCHER_TARGETS", "example.com") diff --git a/internal/watcher/skiprecordnotifications_test.go b/internal/watcher/skiprecordnotifications_test.go new file mode 100644 index 0000000..b28afb5 --- /dev/null +++ b/internal/watcher/skiprecordnotifications_test.go @@ -0,0 +1,107 @@ +package watcher_test + +import ( + "slices" + "testing" + + "sneak.berlin/go/dnswatcher/internal/config" + "sneak.berlin/go/dnswatcher/internal/state" + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +// TestSkipRecordNotificationsAlerts runs the hostname change detection +// for two names on the same check: nsA's address changes, so that nsA +// now differs from nsB, and nsC stops answering. The name in +// SkipRecordNotifications gets only the NS Failure; the other name also +// gets the Record Change and the Inconsistency. +func TestSkipRecordNotificationsAlerts(t *testing.T) { + t.Parallel() + + const skipped = "skipped.example.net" + + prev := saved(map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip1}}), + nsB: answered(map[string][]string{"A": {ip1}}), + nsC: answered(map[string][]string{"A": {ip1}}), + }) + current := saved(map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip2}}), + nsB: answered(map[string][]string{"A": {ip1}}), + nsC: failed(), + }) + + cfg := &config.Config{ + Hostnames: []string{host, skipped}, + SkipRecordNotifications: []string{skipped}, + } + + // The hostname change detection uses only the configuration and the + // notifier. + notifier := &mockNotifier{} + w := watcher.NewForTest(cfg, nil, nil, nil, nil, notifier) + + w.DetectHostnameChanges(t.Context(), host, prev, current) + w.DetectHostnameChanges(t.Context(), skipped, prev, current) + + sent := notifier.getNotifications() + titles := make([]string, 0, len(sent)) + + for _, n := range sent { + titles = append(titles, n.Title) + } + + slices.Sort(titles) + + want := []string{ + "Inconsistency: " + host, + "NS Failure: " + skipped, + "NS Failure: " + host, + "Record Change: " + host, + } + + if !slices.Equal(titles, want) { + t.Errorf("sent %v, want %v", titles, want) + } +} + +// TestSkipRecordNotificationsCheck checks testHost, which is in +// SkipRecordNotifications, from a saved state in which every nameserver +// live DNS lists answered with an address live DNS never returns. No +// Record Change and no Inconsistency is sent, and the check still saves +// what the nameservers answer. +func TestSkipRecordNotificationsCheck(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{testHost} + cfg.SkipRecordNotifications = []string{testHost} + + nameservers := lookupNameservers(t, testHost) + + _, deps := runChecks(t, cfg, func(deps *testDeps) { + byNameserver := make(map[string]*state.NameserverRecordState) + for _, ns := range nameservers { + byNameserver[ns] = answered(map[string][]string{"A": {oldIP}}) + } + + deps.state.SetHostnameState(testHost, saved(byNameserver)) + }) + + for _, title := range []string{ + "Record Change: " + testHost, + "Inconsistency: " + testHost, + } { + if n := countNotifications(deps, title); n != 0 { + t.Errorf("sent %d %q, want 0", n, title) + } + } + + // A nameserver whose query for A failed keeps oldIP, so the check + // is that some address live DNS gave was saved. + hs, _ := deps.state.GetHostnameState(testHost) + fromLiveDNS := func(ip string) bool { return ip != oldIP } + + if !slices.ContainsFunc(addresses(hs), fromLiveDNS) { + t.Errorf("saved addresses %v, want those live DNS gave", addresses(hs)) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 7368dab..8c5b17b 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -730,12 +730,16 @@ func (w *Watcher) detectCNAMEAddressChanges( // failed on either check has no records to compare. The records kept // for a record type whose query failed are compared too, but not those // of a type in UnknownTypes on either check, which the message leaves -// out as well. +// out as well. Nothing is sent for a name in SkipRecordNotifications. func (w *Watcher) detectRecordChanges( ctx context.Context, hostname string, prev, current *state.HostnameState, ) { + if slices.Contains(w.config.SkipRecordNotifications, hostname) { + return + } + for ns, cur := range current.RecordsByNameserver { prevNS, ok := prev.RecordsByNameserver[ns] if !ok || prevNS.Status != statusOK || cur.Status != statusOK { @@ -835,11 +839,18 @@ func (w *Watcher) detectNSFailures( } } +// detectInconsistencies notifies each pair of nameservers that newly +// disagree (see newlyDisagreeingPairs). Nothing is sent for a name in +// SkipRecordNotifications. func (w *Watcher) detectInconsistencies( ctx context.Context, hostname string, prev, current *state.HostnameState, ) { + if slices.Contains(w.config.SkipRecordNotifications, hostname) { + return + } + for _, pair := range newlyDisagreeingPairs(prev, current) { ns1, ns2 := pair[0], pair[1] state1 := current.RecordsByNameserver[ns1] -- 2.54.0 From e8b40062dabe6c4a2933cb2907d91cb203868802 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 04:29:48 +0200 Subject: [PATCH 72/92] build: re-vendor canonical files from prompts dd4027b (closes #257) The canonical files are fetched whole from sneak/prompts at dd4027b. Kept after the canonical content: the livednstest deny entry in .golangci.yml, /bin in .dockerignore, this repo's own .gitignore lines and a [*.go] tab section in .editorconfig. The workflow keeps its concurrency block and persist-credentials: false. Lint and test are phases of the Dockerfile (golangci-lint v2.14.0; tests on the Debian Go 1.25.7 image as an ordinary user, with the same flags); the build stage depends on both and stamps the version the canonical way. Dockerfile.lint is gone; the prettier stages of Dockerfile.fmt moved into the Dockerfile. Every scripted docker build passes --no-cache; script/cibuild bootstraps, runs script/check, then builds the image. script/fmt-check absorbs fmt-check-go and fmt-check-markdown. Model: opus-5-5 --- .dockerignore | 84 ++++++- .editorconfig | 3 + .gitea/workflows/check.yml | 2 +- .gitignore | 54 ++++- .golangci.yml | 1 + .prettierignore | 5 +- Dockerfile | 132 +++++++---- Dockerfile.fmt | 54 ----- Dockerfile.lint | 29 --- Makefile | 8 +- README.md | 73 +++--- REPO_POLICIES.md | 443 +++++++++++++++++++++++++++++-------- TESTING.md | 6 +- TODO.md | 3 + script/bootstrap | 16 +- script/cibuild | 29 ++- script/docker | 14 +- script/fmt | 21 +- script/fmt-check | 31 ++- script/fmt-check-go | 31 --- script/fmt-check-markdown | 29 --- script/lint | 29 +-- script/test | 36 +-- 23 files changed, 699 insertions(+), 434 deletions(-) delete mode 100644 Dockerfile.fmt delete mode 100644 Dockerfile.lint delete mode 100755 script/fmt-check-go delete mode 100755 script/fmt-check-markdown diff --git a/.dockerignore b/.dockerignore index 136b403..322166a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,75 @@ -# .git is sent, without its config: the builder stage derives the version it -# stamps into the binary from it, and `git describe` does not need the config, -# which can hold a credential (a password in the remote URL, a CI token). No -# tracked file may be listed here: git in the build would see it as deleted -# and mark the version -dirty, and an excluded .md would silently drop out of -# the prettier check in Dockerfile.fmt. -.git/config -bin/ -node_modules/ +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. + +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +# Each submodule keeps a config with the same exposure in its git directory +# under .git/modules/, nested again for a submodule's own submodules, or in +# its own .git directory when it keeps one. +# KNOWN GAP: a submodule whose name has a `config` segment (`config`, +# `deploy/config`, `config/lib`) loses its whole git directory, because +# `**/.git/modules/**/config` also matches that segment's directory +# under .git/modules/. Go's version stamping then fails the build; +# nothing leaks. Name such a submodule without that segment: +# `git submodule add --name`. +**/.git/config +**/.git/modules/**/config + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. +.claude + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +**/[iI][dD]_[eE][dD]25519 +**/[iI][dD]_[eE][dD]25519_[sS][kK] + +# Dependencies: restored inside the image, never copied in. +**/node_modules + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# This repository's host-built artifacts: `make build` writes bin/. +/bin diff --git a/.editorconfig b/.editorconfig index 2fe0ce0..92ec261 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,6 @@ insert_final_newline = true [Makefile] indent_style = tab + +[*.go] +indent_style = tab diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 81b7e10..78de800 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -9,7 +9,7 @@ jobs: check: runs-on: ubuntu-latest steps: - # actions/checkout v4.2.2, 2026-02-28 + # actions/checkout v4.2.2, 2026-02-22 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # script/cibuild needs no token, so none is left in .git/config. with: diff --git a/.gitignore b/.gitignore index d0077e3..79453f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,57 @@ -bin/ +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +*.bak +.idea/ +.vscode/ +*.sublime-* + +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + +# Node node_modules/ + +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# ranges rather than a lowercase form that misses `Server.Key`. + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Only the templates `example.env` and `sample.env` are +# re-included below. A repository that commits any other template adds +# its own negation after these lines, for example `!.env.example`. +*.[eE][nN][vV] +.[eE][nN][vV].* +.[eE][nN][vV][rR][cC] +!example.env +!sample.env + +# Private keys and the bundles carrying them. +*.[pP][eE][mM] +*.[kK][eE][yY] +*.[pP]12 +*.[pP][fF][xX] +[iI][dD]_[rR][sS][aA] +[iI][dD]_[dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +[iI][dD]_[eE][dD]25519 +[iI][dD]_[eE][dD]25519_[sS][kK] + +# This repository's own entries, kept after the canonical content above. +*.log +*.out +*.test +bin/ vendor/ data/ -.env *.exe /dnswatcher diff --git a/.golangci.yml b/.golangci.yml index 35b5a7d..786cedd 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -17,6 +17,7 @@ linters: disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - godot # Requires comments to end with periods - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go diff --git a/.prettierignore b/.prettierignore index c8d365b..23d67fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,2 @@ -bin/ -data/ node_modules/ -.claude/ -static/css/tailwind.min.css +yarn.lock diff --git a/Dockerfile b/Dockerfile index 8f419c2..79729c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,68 +1,102 @@ -# Lint stage - fast feedback on lint issues, before the build starts. -# The linter is invoked directly rather than through `make lint`: that -# target shells out to `docker build -f Dockerfile.lint`, and there is -# no docker daemon inside a docker build. For the same reason this stage -# runs only the Go half of `make fmt-check`; script/cibuild runs the -# markdown half after this build. -# script/cibuild and script/docker name this stage in --no-cache-filter. -# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10 -FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint - +# Lint phase, built alone by script/lint. The linter is invoked directly +# rather than through `make lint`, which is itself a docker build and +# would recurse into a daemon that does not exist in a build step. +# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-10-06 +FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download - COPY . . - -RUN script/fmt-check-go RUN golangci-lint run --config .golangci.yml ./... -# Build stage -# script/cibuild and script/docker name this stage in --no-cache-filter. -# golang 1.25-alpine, 2026-02-28 -FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder - -RUN apk add --no-cache git make gcc musl-dev binutils-gold - -# A build context sent as a tar archive keeps its files' owners, and git -# refuses to read a checkout owned by another user. Trust this one -# whoever owns it. -RUN git config --system --add safe.directory /src - -# Force BuildKit to run the lint stage before proceeding -COPY --from=lint /src/go.sum /dev/null - +# Test phase, built alone by script/test. -race needs cgo and so a C +# compiler, which the Debian Go image ships and the alpine one does not. +# The tests query live DNS (TESTING.md), so this step needs the network. +# -count=1 keeps Go's test result cache out of both runs, as TESTING.md +# requires. -timeout 90s is a backstop above the 60-second cap on the +# suite. The rerun with -v only shows details: the build fails however +# it ends, because the first run already failed. +# golang 1.25.7-trixie, 2026-10-06 +FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test +# The file permission tests skip themselves as root, so the tests run as +# an ordinary user, whose home directory holds Go's build cache. +RUN useradd --create-home tester +USER tester WORKDIR /src COPY go.mod go.sum ./ RUN go mod download +COPY . . +RUN go test -count=1 -race -timeout 90s -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -count=1 -race -timeout 90s -v ./...; exit 1; } +# Markdown formatting with prettier, at the version package.json and +# yarn.lock pin, so it is never installed on the host. script/fmt-check +# builds the fmt-check stage and script/fmt the fmt-out stage; the image +# does not depend on any of these stages, so a plain `docker build .` +# skips them. +# node:22-bookworm-slim, 2026-09-05 +FROM node@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 AS nodedeps +# prettier lives outside /src, so the COPY of the repo cannot overwrite +# it and node_modules is not in the tree prettier walks. +WORKDIR /tools +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --non-interactive --no-progress +ENV PATH="/tools/node_modules/.bin:${PATH}" +WORKDIR /src + +# --config rather than discovery: a missing .prettierrc is then an error +# instead of prettier's defaults, under which every wrap passes. +# --no-editorconfig so .prettierrc alone sets the style. +FROM nodedeps AS fmt-check +COPY . . +RUN prettier --config .prettierrc --no-editorconfig --check "**/*.md" + +# Only the markdown is copied out, with its paths, so the export cannot +# put anything else back over the working tree. +FROM nodedeps AS fmt +COPY . . +RUN prettier --config .prettierrc --no-editorconfig --write "**/*.md" && \ + mkdir -p /out && \ + find . -name '*.md' -type f -exec cp --parents '{}' /out/ ';' + +FROM scratch AS fmt-out +COPY --from=fmt /out/ / + +# Build stage. Nothing is wanted from the lint and test phases; the +# copies are what make BuildKit build them first, so this stage cannot +# run unless lint and test passed. +# golang 1.25-alpine, 2026-02-28 +FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder +COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null +RUN apk add --no-cache git +# A tar-stream context keeps the sender's file owners, which git refuses. +RUN git config --system --add safe.directory /src +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download COPY . . -# Run the tests - build fails if any test fails -RUN make test - -# Version stamped into the binary: the VERSION build arg when one is -# given and not empty (script/docker passes one), otherwise what -# `git describe` says of the .git in the build context, so a plain -# `docker build .` of a clone stamps its tag or short commit. The build -# arg reaches make through the environment. +# The VERSION build arg when one is given (script/docker and +# script/cibuild pass one), otherwise `git describe --tags --always` on +# the .git in the build context. With .git present, a version that is +# still empty, dev or unknown fails the build: git is missing or could +# not read the checkout. ARG VERSION - -# A context that carries .git, as a directory or as a file, must yield a -# real version: one that is empty, `dev` or `unknown` cannot be traced -# back to a commit. -RUN version="$(make version)"; \ +RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ if [ -e .git ]; then \ - case "$version" in \ - "" | dev | unknown) \ - echo "version is \"$version\" although the build context carries .git" >&2; \ - exit 1 ;; \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ esac; \ - fi + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /src/bin/dnswatcher ./cmd/dnswatcher/ -RUN make build - -# Runtime stage +# Runtime stage, and the last one: a plain `docker build .` builds this +# stage's chain and nothing else. # alpine 3.21, 2026-02-28 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 diff --git a/Dockerfile.fmt b/Dockerfile.fmt deleted file mode 100644 index 9837231..0000000 --- a/Dockerfile.fmt +++ /dev/null @@ -1,54 +0,0 @@ -# prettier over the markdown, in a container, so it is never installed -# on the host. script/fmt-check-markdown builds the fmt-check stage; -# script/fmt builds fmt-out and takes the formatted files back. - -# node:22-bookworm-slim, 2026-09-05 -FROM node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 AS nodedeps - -# prettier lives outside /src so that a `COPY . .` of the repo cannot -# overwrite it, and so that node_modules never appears in the tree -# prettier is about to walk. -WORKDIR /tools - -# package.json pins the version and yarn.lock pins the bytes: -# --frozen-lockfile installs exactly the lockfile's resolution and fails -# if package.json disagrees with it, so the tool cannot float between -# runs. yarn is the one in the image above. -COPY package.json yarn.lock ./ -RUN yarn install --frozen-lockfile --non-interactive --no-progress - -ENV PATH="/tools/node_modules/.bin:${PATH}" - -WORKDIR /src - -# Read-only markdown check. Must match $stage in -# script/fmt-check-markdown. -FROM nodedeps AS fmt-check - -COPY . . - -# --config, not discovery: a .prettierrc that failed to arrive would -# otherwise leave prettier on its defaults, where proseWrap is "preserve" -# and every wrap this check exists to enforce passes. Missing the file is -# a hard error instead. --no-editorconfig so that .prettierrc alone sets -# the style. -RUN prettier --config .prettierrc --no-editorconfig --check "**/*.md" - -# Write path. Not a check: script/fmt builds this and takes the files. -FROM nodedeps AS fmt - -COPY . . - -RUN prettier --config .prettierrc --no-editorconfig --write "**/*.md" - -# Only the markdown leaves, with its paths intact, so that the export -# below cannot put anything else back over the caller's working tree. -RUN mkdir -p /out && cd /src && \ - find . -name '*.md' -type f -exec cp --parents '{}' /out/ ';' - -# Export target: `docker build --target fmt-out --output type=local` -# writes /out's tree into a directory on the client, which is how -# script/fmt gets formatted markdown back without a bind mount. -# Must match $stage in script/fmt. -FROM scratch AS fmt-out -COPY --from=fmt /out/ / diff --git a/Dockerfile.lint b/Dockerfile.lint deleted file mode 100644 index 4dcb14e..0000000 --- a/Dockerfile.lint +++ /dev/null @@ -1,29 +0,0 @@ -# Lint-only image: used by script/lint. golangci-lint is never run on -# the host — the repo is COPYed into the build context and the linter -# runs as a build step, so a successful build IS a clean lint. This -# also works where the docker daemon is remote and bind mounts are -# impossible. -# -# `golangci-lint config verify` is deliberately NOT run here: it -# fetches its JSON schema over a live, unpinned HTTPS call, which would -# make linting network-dependent and defeat hash-pinning. The cost of -# that: unknown top-level keys in .golangci.yml are silently ignored, -# so a mistyped or wrong-schema key lints clean while applying nothing. -# -# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10 -FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps - -WORKDIR /src - -# Dependencies first, so this stage stays cached across lint runs. -COPY go.mod go.sum ./ -RUN go mod download - -# Everything below is invalidated on every run by the -# --no-cache-filter=lint that script/lint passes: caching is explicitly -# waived for linting, and a cached build lints nothing. -FROM deps AS lint - -COPY . . - -RUN golangci-lint run --config .golangci.yml ./... diff --git a/Makefile b/Makefile index 48a01cf..251a12c 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,9 @@ BINARY := dnswatcher # VERSION given on the command line (`make build VERSION=...`) or in the -# environment, which is how the Dockerfile's VERSION build arg arrives, -# wins over what `git describe` says of this checkout. An empty one counts -# as not given; `override` is what replaces an empty command-line value. +# environment wins over what `git describe` says of this checkout. An +# empty one counts as not given; `override` is what replaces an empty +# command-line value. ifeq ($(VERSION),) override VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") endif @@ -25,7 +25,7 @@ setup: build: go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher -# Prints the version `make build` stamps; the Dockerfile checks it. +# Prints the version `make build` stamps. version: @echo "$(VERSION)" diff --git a/README.md b/README.md index 3e3c0fa..089ef95 100644 --- a/README.md +++ b/README.md @@ -656,48 +656,42 @@ provide: - `script/setup` — make a fresh clone ready for development: bootstrap plus the git pre-commit hook - `script/projectname` — print the project name (used for the Docker image tag) -- `script/test` — run the test suite (race detector, coverage). Caching is - waived for testing, exactly as it is for linting: `-count=1` forces every - invocation to execute, because the suite queries live DNS and a cached pass - queries nothing. Failures are rerun with `-v` automatically, and the build +- `script/test` — run the test suite (race detector, coverage) by building the + `Dockerfile`'s test phase, which queries live DNS. `-count=1` keeps Go's test + result cache out. Failures are rerun with `-v` automatically, and the build fails even if that rerun passes. -- `script/lint` — run golangci-lint, always inside Docker: it builds - `Dockerfile.lint`, which COPYs the repo into the digest-pinned `golangci-lint` - image and lints as a build step, so a successful build is a clean lint. The - linter is never installed or run on the host, and Docker is the only - prerequisite. Caching is waived for linting: the lint stage is forced to - execute on every run with `--no-cache-filter`, because a cached build lints - nothing. +- `script/lint` — run golangci-lint by building the `Dockerfile`'s lint phase, + on the digest-pinned `golangci-lint` image, so a successful build is a clean + lint. The linter is never installed or run on the host, and Docker is the only + prerequisite. - `script/fmt` — format all code (gofmt -s, goimports) and all Markdown (prettier). goimports runs with `go run` at a pinned commit, never from your - `PATH`. prettier runs inside Docker, built from `Dockerfile.fmt` on a + `PATH`. prettier runs inside Docker, in a stage of the `Dockerfile` on a digest-pinned node image, at the version pinned by `package.json` and `yarn.lock`; it is never installed on the host. - `script/fmt-check` — check formatting (read-only) with the same tools, failing - on any file `script/fmt` would change. It runs the two scripts below. -- `script/fmt-check-go` — the gofmt and goimports half, on the host. The - `Dockerfile` lint stage runs it. -- `script/fmt-check-markdown` — the prettier half, inside Docker, forced to - execute on every run with `--no-cache-filter` + on any file `script/fmt` would change: gofmt and goimports on the host, + prettier inside Docker - `script/check` — run test, lint, and fmt-check - `script/docker` — build the Docker image tagged via `script/projectname`, with - `--no-cache-filter=lint,builder` so the lint stage and the builder stage, - which runs the tests, run on every invocation, and with the version from - `git describe` passed as `--build-arg VERSION` -- `script/cibuild` — CI entrypoint: `docker build` with - `--no-cache-filter=lint,builder`, so the lint stage and the builder stage, - which runs the tests, run on every invocation, because a cached build lints - nothing and queries no DNS; then `script/fmt-check-markdown` + the version from `git describe` passed as `--build-arg VERSION` +- `script/cibuild` — CI entrypoint: `script/bootstrap`, then `script/check`, + then the image build `script/docker` does, which runs the lint and test phases + again - `script/precommit` — run by the git pre-commit hook; `go mod tidy` guard, then `script/check` - `script/install-precommit` — install the git pre-commit hook +Linting and testing are phases of the `Dockerfile`, and the image is built only +when both pass. Every `docker build` in `script/` passes `--no-cache`, because a +cached build lints nothing and queries no DNS. + ## Building ```sh make build # Build binary to bin/dnswatcher make version # Print the version make build stamps -make test # Run tests with race detector +make test # Run tests with race detector in Docker (requires docker) make lint # Run golangci-lint in Docker (requires docker) make fmt # Format code and Markdown (requires docker) make check # Run all checks (test, lint, fmt-check) @@ -712,21 +706,20 @@ the environment, otherwise from `git describe --tags --always --dirty`, and `dev` without git metadata. An empty `VERSION` counts as not given. The version appears in the startup log and in the health check response. -The image takes it the same way, from the `.git` the build context carries, so a -plain `docker build .` of a clone stamps the commit it was built from; a clone -without tags stamps the short commit. A clone made with `--depth 1` carries at -most a tag on its own commit, so such a clone of an untagged commit stamps the -short commit. In a build from a directory, `.dockerignore` keeps out -`.git/config`, which `git describe` does not need and which can hold a -credential. Docker does not apply `.dockerignore` to a context sent as a tar -archive, as upaas sends it, so that context carries `.git/config` into the -build. It also keeps its files' owners, so git in the build trusts the checkout -whoever owns it. A non-empty `--build-arg VERSION=...` takes precedence; -`make docker` passes the version `git describe` gives on the host. The build -fails when the context carries `.git`, as a directory or as a file, and the -version comes out empty, `dev` or `unknown`. `.dockerignore` must list no -tracked file: git in the build would see it as deleted and mark the version -`-dirty`. +The image takes it from `git describe --tags --always` on the `.git` the build +context carries, so a plain `docker build .` of a clone stamps the commit it was +built from; a clone without tags stamps the short commit. A clone made with +`--depth 1` carries at most a tag on its own commit, so such a clone of an +untagged commit stamps the short commit. In a build from a directory, +`.dockerignore` keeps out `.git/config` and every submodule's git config, which +`git describe` does not need and which can hold a credential. Docker does not +apply `.dockerignore` to a context sent as a tar archive, as upaas sends it, so +that context carries `.git/config` into the build. It also keeps its files' +owners, so git in the build trusts the checkout whoever owns it. A non-empty +`--build-arg VERSION=...` takes precedence; `make docker` passes the version +`git describe` gives on the host. The build fails when the context carries +`.git`, as a directory or as a file, and the version comes out empty, `dev` or +`unknown`. --- diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index 9aba6b0..20382d1 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-08-07 +last_modified: 2026-10-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -60,17 +60,28 @@ style conventions are in separate documents: prerequisite since nvm requires bash. yarn is then pinned via `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; always exact versions. `script/cibuild` runs the CI build: it changes to the - repo root and runs `docker build .`; the Gitea workflow calls it. Four further - scripts are our own extensions to the standard: `script/check` runs - `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is - what the git pre-commit hook runs, and it calls `script/check`; - `script/install-precommit` installs the git pre-commit hook (the `make hooks` - target shims to it); and `script/projectname` (literally that filename) simply - outputs the project's name. Scripts that need the name call - `script/projectname` — e.g. `script/docker` assembles its image tag from it — - so those scripts stay byte-identical across all repos. Repo-type-specific - pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in - `script/precommit`, not in the hook itself. Model scripts are at + repo root, runs `script/bootstrap`, runs `script/check`, and builds the image + with the version; the Gitea workflow calls it. **`script/cibuild` runs + `script/bootstrap` first**, because the workflow checks out the repo and runs + nothing else, while `script/fmt-check` runs the formatter on the host: on a + pristine checkout with nothing installed the run dies there, after the + containerised gates have passed. **The bootstrap alone is not enough**: + `script/bootstrap` installs node and yarn under nvm and leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host + entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore + source nvm for the pinned node version before invoking it, exactly as + `script/bootstrap`'s own install step does. A runner carrying nothing but + docker and git then gets through `script/check`. Four further scripts are our + own extensions to the standard: `script/check` runs `script/test`, + `script/lint` and `script/fmt-check`; `script/precommit` is what the git + pre-commit hook runs, and it calls `script/check`; `script/install-precommit` + installs the git pre-commit hook (the `make hooks` target shims to it); and + `script/projectname` (literally that filename) simply outputs the project's + name. Scripts that need the name call `script/projectname` — e.g. + `script/docker` assembles its image tag from it — so those scripts stay + byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. + `go mod tidy` verification in Go repos) belong in `script/precommit`, not in + the hook itself. Model scripts are at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README must document the provided scripts in an **Entrypoints** section (see the README requirements below). @@ -89,87 +100,198 @@ style conventions are in separate documents: contributor should be able to understand the entire development workflow by reading the Makefile. -- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - as a build step so the build fails if the branch is not green. For non-server - repos, the Dockerfile should bring up a development environment and run - `make check`. For server repos, `make check` should run as an early build - stage before the final image is assembled. Dockerfiles install development - prerequisites by running `script/bootstrap` rather than duplicating installs - inline; COPY `script/` and the dependency manifests (`package.json` + - `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap - layer stays cached until dependencies change. +- Every repo should have a `Dockerfile`, and it carries the repo's gates: a + `lint` phase and a `test` phase, with the final stage depending on both so the + image cannot be built unless they pass. For non-server repos the final stage + brings up a development environment; for server repos it is the runtime image. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + environment stage installs development prerequisites by running + `script/bootstrap` rather than duplicating its installs inline. A stage that + runs `script/bootstrap` COPYs `script/` and the dependency manifests + (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it. -- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - repos use a multistage build where linting runs in an independent stage based - on the `golangci/golangci-lint` image (pinned by hash). This stage runs - `make fmt-check` and `make lint` before the full build begins. The build stage - then declares an explicit dependency on the lint stage via - `COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete - linting before proceeding to compilation and tests. This ensures lint failures - surface in seconds rather than minutes, without blocking on dependency - download or compilation in the build stage. +- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is + no separate lint file. `script/lint` and `script/test` each build one phase + and nothing else: - The standard pattern for a Go repo Dockerfile is: + ```sh + docker build --no-cache --target lint -t "$(script/projectname)-lint" . + docker build --no-cache --target test -t "$(script/projectname)-test" . + ``` + + **A stage that is not the last one in the file is built only when the final + stage's chain depends on it, or when `--target` names it.** That is why the + two gates are always invoked by name here, and why the final stage carries a + `COPY --from=` of a harmless file from each of them: without that edge a + plain `docker build .` builds the last stage alone and exits 0 having linted + and tested nothing. + + **Every `docker build` in `script/` is tagged**, here and in + `script/cibuild` and `script/docker`. An untagged build leaves a dangling + image behind on every invocation, on every developer host and every CI + runner; a tagged one replaces the previous image. + + Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, + `eslint`, `prettier` — never through `make lint` or `script/test`, which are + themselves a `docker build` and would recurse into a daemon that does not + exist in a build step. Formatting is the exception and stays on the host: + `script/fmt` writes the working tree, and `script/fmt-check` is its + read-only twin. + + **No lint verdict may come from a host invocation of the linter.** On a + shared host golangci-lint reads a result cache keyed on file content rather + than location, so a second checkout of the same content is served the first + one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs + exit non-zero with `parallel golangci-lint is running` — a status a caller + cannot tell from real findings. Both have produced wrong verdicts in this + org, in both directions. A container has its own cache, its own `TMPDIR` and + a digest-pinned binary, so neither is reachable. + +- **Any build that runs checks is built with `--no-cache`.** Docker invalidates + a `COPY` layer only when the copied content changes, so on an unchanged tree + the check `RUN` is served from cache, nothing executes, and the build still + exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: + `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the + four, and there is no fifth — `script/check` runs the two gate phases and + `script/fmt-check`, and builds no image of its own. A bare `docker build .` is + not evidence that anything ran: a sub-second build reporting success is a + cache hit, not a result. Never invalidate by pruning — `docker builder prune` + and friends destroy a build cache shared with every other build on the host. + When a check is added or changed, prove it works by planting a defect it must + catch and watching the run fail on it, then revert the defect. A green run + alone shows neither that the check ran nor that it covers what it should. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Debian Go image. The canonical Go repo + `Dockerfile`: ```dockerfile - # Lint stage — fast feedback on formatting and lint issues + # Lint phase # golangci/golangci-lint:v2.x.x, YYYY-MM-DD FROM golangci/golangci-lint@sha256:... AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make fmt-check - RUN make lint + RUN golangci-lint run --config .golangci.yml ./... - # Build stage - # golang:1.x-alpine, YYYY-MM-DD - FROM golang@sha256:... AS builder + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. + # golang:1.x, YYYY-MM-DD + FROM golang@sha256:... AS test WORKDIR /src - - # Force BuildKit to run the lint stage before proceeding - COPY --from=lint /src/go.sum /dev/null - COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make test + RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } - ARG VERSION=dev - RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /app ./cmd/app/ + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so this stage cannot run + # unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache git + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . - # Runtime stage + # The VERSION build arg when one is given, otherwise + # `git describe --tags --always` on the .git in the build context. With + # .git present, a version that is still empty, dev or unknown fails the + # build: git is missing or could not read the checkout. + ARG VERSION + RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage, and the last one FROM alpine@sha256:... COPY --from=builder /app /usr/local/bin/app ENTRYPOINT ["app"] ``` Key points: - - The lint stage uses the `golangci/golangci-lint` image directly (it - includes both Go and the linter), so there is no need to install the - linter separately. - - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates - a stage dependency. BuildKit runs stages in parallel by default; without - this line, the build stage would not wait for lint to finish and a lint - failure might not fail the overall build. + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only + purpose is the ordering edge. BuildKit runs stages in parallel by default, + and a stage nothing depends on is not built at all, so without these two + lines a red gate would not fail the build. + - Keep the runtime stage last, and if you add a stage after it, give it the + same two copies. A plain `docker build .` builds the last stage's chain + and nothing else. - If the project uses `//go:embed` directives that reference build artifacts - (e.g. a web frontend compiled in a separate stage), the lint stage must + (e.g. a web frontend compiled in a separate stage), the lint phase must create placeholder files so the embed directives resolve. Example: `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. - The lint stage should not depend on the actual build output — it exists to - fail fast. - - If the project requires CGO or system libraries for linting (e.g. - `vips-dev`), install them in the lint stage with `apk add`. - - The build stage runs `make test` after compilation setup. Tests run in the - build stage, not the lint stage, because they may require compiled - artifacts or heavier dependencies. + - If the project requires CGO or system libraries for linting, install them + in the lint phase. The `golangci/golangci-lint` image is Debian-based and + has no `apk`, so install with `apt-get` under the Debian package name + (`libvips-dev`, where alpine says `vips-dev`), and delete the package + lists in the same `RUN`, so the layer does not keep them: + + ```dockerfile + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the + repository's own, each submodule's under `.git/modules/`, and that of a + submodule keeping its own `.git` directory. `git describe` does not need + them, and each can hold a credential: a password in a remote URL, or the + token the CI checkout step stores there. A submodule whose name has a + `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole + git directory to `**/.git/modules/**/config`, and Go's version stamping + then fails the build: give it a name without that segment + (`git submodule add --name`). The stage that compiles has `git` (the + Debian Go image has it; an alpine one needs `apk add --no-cache git`) and + takes the version from the `VERSION` build argument when one is given, + otherwise from `git describe --tags --always`. That gives the tag on a + tagged commit; on a later commit, the tag, the number of commits since it + and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no + tag is reachable. The stage that compiles also marks its working directory + safe for git (`git config --system --add safe.directory /src`): a context + sent as a tar stream keeps the sender's file owners, and git refuses a + checkout owned by another user, so the version would come out empty. + `ARG VERSION` has no default, and the build fails if the context carries + `.git` and the version still comes out empty, `dev` or `unknown`. A plain + `docker build .` with no build arguments must succeed; a Dockerfile that + refuses an empty build argument drops that refusal and keeps the argument. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - runs `script/cibuild` (which runs `docker build .`) on push. Since the - Dockerfile already runs `make check`, a successful build implies all checks - pass. + runs `script/cibuild` on push, and checks out the repo as its only other step. + That script bootstraps, runs the gate phases, and then builds the image, so a + successful run means every check passed; a bare `docker build .` does not + carry the same guarantee, because its gate phases may come from the cache. The + image build is uncached and so runs the gate phases a second time. That is the + price of the rule above, and it is worth paying: the image that ships is built + from a run of its own gates rather than from a cache entry. A separate + workflow limited to `main` by a `branches` list under `on: push` cannot be + checked by review: to try a change to it, add the feature branch to that list + and push, then remove the branch from the list again before merging. Keep any + job in it that publishes behind `if: github.ref_name == 'main'`, so the run + from the feature branch publishes nothing. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -193,15 +315,17 @@ style conventions are in separate documents: suite that exceeds it fails. Under 20 seconds is the target. A suite between 20 and 60 seconds is still green, but the overage must be filed as an improvement bug against that repo. Add a 90-second timeout to the test - invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately - sits above the hard cap so that it catches a genuinely hung test rather than a - merely slow one. + invocation (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely slow + one. -- **`make test` should use the conditional verbose rerun pattern.** Run tests - without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to - show full output. This keeps CI logs and `docker build` output clean on - success (just package/suite summaries) while providing full diagnostic detail - on failure (every test case, every assertion). The general shell pattern: +- **The test command should use the conditional verbose rerun pattern.** Run + tests without `-v` (verbose) first. If tests fail, automatically rerun with + `-v` to show full output. This keeps CI logs and `docker build` output clean + on success (just package/suite summaries) while providing full diagnostic + detail on failure (every test case, every assertion). The command lives in the + `test` phase of the `Dockerfile`, since `script/test` builds that phase; the + Makefile form below is the same pattern for any repo-local invocation: ```makefile test: @@ -214,11 +338,26 @@ style conventions are in separate documents: ```makefile test: - @go test -timeout 90s -race -cover ./... || \ + @go test -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 90s -race -v ./...; exit 1; } + go test -count=1 -timeout 90s -race -v ./...; exit 1; } ``` + `-count=1` is required on both invocations: it defeats Go's test _result_ + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. + + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. That matters on a developer's machine, where this + target runs and the directory lasts from one run to the next. The `test` + phase of the `Dockerfile` needs no `-count=1`: its base image holds no + result for this repo's tests and nothing before its `go test` step runs a + test, so there is nothing to replay. `--no-cache` (above) is what makes that + step run on an unchanged tree. + Python example: ```makefile @@ -244,10 +383,84 @@ style conventions are in separate documents: must be in `.gitignore`. No exceptions. - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. - Fetch the standard `.gitignore` from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up - a new repo. + editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), + language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` + from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when + setting up a new repo. These patterns are written to `.gitignore`'s own + semantics, in which an unanchored pattern already matches at every depth; they + are not a `.dockerignore` and must not be transplanted into one unmodified. + +- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns + across unmodified leaves secrets in the build context.** Docker matches with + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so + `*` does not cross `/` and a pattern without a leading `**/` is anchored at + the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` + therefore excludes only the copies at the repository root, while `config/.env` + and `certs/server.key` still reach the context and can land in an image layer + — which is more dangerous than a short file with no secret patterns at all, + because it reads as solved and stops anyone looking. Give every + depth-independent pattern the `**/` prefix and leave only genuinely + root-anchored entries unprefixed: `.claude`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, + and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern + also catches something the build needs, re-include it with a negation + (`!docs/example.env`); deleting the pattern reopens the exposure for every + other file it covers. Fetch the standard `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend + it with the repo's own artifacts. + +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so under `COPY . .` the build context + inflates by a multiple of the repo and another session's unreviewed work can + be copied into an image layer. In `.gitignore` the entry is `.claude/`, + unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` + prefix, because the prefixed form would also delete any nested directory of + that name from the build. Anchoring carries a known gap that the canonical + `.dockerignore` states in its own comment, since consuming repos receive the + file and not the tracker: the directory is created in the agent's working + directory, so a repo running agents in subdirectories still ships + `services/api/.claude/` and must add its own anchored entry there. + +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: + + ```sh + # Own line: a failing command substitution inside an argument does not + # trip `set -e`, so the inline form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$(script/projectname)" . + ``` + + `--always` makes an untagged repo yield an abbreviated commit hash rather + than failing, and the `[ -n "$version" ]` line is the single place the + fallback is applied — a live check that fires on a build from an export with + no `.git` and on a repository with no commits yet. Do not fold it into the + substitution as `|| echo unknown`, which makes the guard unreachable. The + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared + there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + Dockerfile declares no such `ARG` is ignored and costs nothing, which is why + the scripts stay byte-identical. One consequence for CI: the standard + checkout action clones shallow and fetches no tags, so a repo that embeds a + tag-derived version must set `fetch-depth: 0` on its checkout step. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + patterns.** Plant files at the root _and_ at least two directories deep, build + a probe image that does `COPY . .`, and list what actually landed + (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` + size is not a substitute: a nested secret is a few bytes, and BuildKit + transfers only the delta from the previous build. - **No build artifacts in version control.** Code-derived data (compiled bundles, minified output, generated assets) must never be committed to the @@ -263,12 +476,56 @@ style conventions are in separate documents: - Make all changes on a feature branch. You can do whatever you want on a feature branch. -- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - manually by the user. Fetch from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The - canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed - commit-pinned via - `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. +- `.golangci.yml` is standardized. The vendored copy in a consuming repo must + _NEVER_ be modified by an agent: fetch it from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it + byte-identical, so that no repo can quietly loosen its own linting. Linter + configuration changes are made to the canonical copy in the `prompts` repo and + reach consuming repos by re-vendoring; an agent may open a PR against + canonical, which only the user merges. One list is exempt from byte-identity, + because it cannot be written once for every repo: the `deny` list of the + `test-support` depguard rule, where a repo names its own test-support packages + by full import path. A repo adds entries there and changes nothing else, and a + re-vendor carries its entries forward. The canonical golangci-lint version is + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `PATH` only, so on an already-provisioned machine the pin is inert and a + version bump is a silent no-op — while the Dockerfile, installing into a clean + image, gets the pinned version, so a local `make check` and `make docker` can + disagree about what the tool even is. The canonical form: + - compares the installed version against the pin over the **whole** version + token; a parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install; + - treats absent, non-zero, empty or unrecognised `--version` output as a + mismatch, so the failure direction is a redundant install and never a + skipped one; + - after installing, re-resolves the binary the way callers do — `hash -r`, + then through `PATH`, not through the directory the installer wrote to — + and fails naming the resolved path, since an install that a shadowing + binary hides succeeds while changing nothing any caller sees; + - is actually called, and prints the version on both success paths: a + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + + A Go tool a repo needs on the host is installed with `go install` pinned to + a commit hash (`go install @`). It is never tracked as + a `go.mod` tool dependency or through a `tools.go` file, either of which + pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`. - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). @@ -382,12 +639,14 @@ style conventions are in separate documents: settings. - Avoid putting files in the repo root unless necessary. Root should contain - only project-level config files (`README.md`, `Makefile`, `Dockerfile`, - `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and - language-specific config). Everything else goes in a subdirectory. Canonical - subdirectory names: + only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, + `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, + and language-specific config). Everything else goes in a subdirectory. + Canonical subdirectory names: - `bin/` — executable scripts and tools - - `cmd/` — Go command entrypoints + - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose + body is a single call into `internal/` or `pkg/`, no project logic in + `cmd/` - `configs/` — configuration templates and examples - `deploy/` — deployment manifests (k8s, compose, terraform) - `docs/` — documentation and markdown (README.md stays in root) @@ -414,3 +673,7 @@ style conventions are in separate documents: - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml` + +- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It + is never committed under a file or directory named after one agent tool, such + as `CLAUDE.md` or `.claude/`, and never split into separate memory files. diff --git a/TESTING.md b/TESTING.md index 3971309..d216a73 100644 --- a/TESTING.md +++ b/TESTING.md @@ -38,7 +38,7 @@ works correctly in production. responses - **Do not add `-short` flags** to skip slow tests - **Do not increase `-timeout`** to hide hanging queries -- **Do not remove `-count=1` from `script/test`** — Go's test cache replays a - previous run's output without querying anything, so a cached pass is not - evidence that live resolution works +- **Do not remove `-count=1` from the test phase of the `Dockerfile`** — Go's + test cache replays a previous run's output without querying anything, so a + cached pass is not evidence that live resolution works - **Do not modify linter configuration** to suppress findings diff --git a/TODO.md b/TODO.md index 878dd2e..2f43241 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,9 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint + and test are phases of the `Dockerfile`, every scripted build uncached (closes + #257). - 2026-10-05: a name listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no Record Change or Inconsistency notification; a name listed there that is not in `DNSWATCHER_TARGETS` stops startup (closes #255). diff --git a/script/bootstrap b/script/bootstrap index dd76db4..b40a411 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,12 +3,12 @@ # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes nothing is present. -# goimports is not installed here: script/fmt and script/fmt-check-go -# run it with `go run` at a pinned commit. +# goimports is not installed here: script/fmt and script/fmt-check run +# it with `go run` at a pinned commit. # The linter is NOT installed here: golangci-lint runs via docker only # (script/lint), pinned by image digest, so its only prerequisite is a -# working docker. Nor is prettier: script/fmt and -# script/fmt-check-markdown run it in a container from Dockerfile.fmt. +# working docker. Nor is prettier: script/fmt and script/fmt-check run +# it in a stage of the Dockerfile. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -67,12 +67,12 @@ main() { if missing make; then pkg_install gnumake make make make; fi if missing go; then pkg_install go golang go go; fi - # Linting and the markdown formatter run via docker only. Warn, - # don't fail: building and testing work without it. + # Testing, linting and the markdown formatter run via docker only. + # Warn, don't fail: make build works without it. if missing docker; then echo "bootstrap: WARNING: docker not found; install it to" \ - "run make lint, make fmt, make fmt-check, make check" \ - "and make docker." >&2 + "run make test, make lint, make fmt, make fmt-check," \ + "make check and make docker." >&2 fi go mod download diff --git a/script/cibuild b/script/cibuild index ad4ed76..d8d3200 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,14 +1,10 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile's lint stage runs -# the Go half of make fmt-check and golangci-lint; its builder stage -# runs make test and make build. The markdown half of make fmt-check -# runs after that build, as its own build of Dockerfile.fmt, because -# there is no docker inside a docker build. -# -# --no-cache-filter=lint,builder runs both stages on every invocation; -# otherwise an unchanged tree is served from the layer cache and passes -# without linting or querying live DNS. script/fmt-check-markdown busts -# its own cache the same way. +# script/cibuild: run the CI build. It bootstraps first: a CI runner +# checks out and runs this and nothing else, and script/fmt-check runs +# the formatter on the host, which a pristine checkout cannot do. +# --no-cache for the same reason as script/docker: the gate phases the +# final stage depends on are RUN steps, and a cached one is a check that +# did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -16,8 +12,17 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build --no-cache-filter=lint,builder . - "$SCRIPT_DIR/fmt-check-markdown" + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/docker b/script/docker index 9ce387c..07b626c 100755 --- a/script/docker +++ b/script/docker @@ -1,10 +1,8 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. -# The tag comes from script/projectname. -# -# --no-cache-filter=lint,builder runs the lint stage and the builder -# stage (make test) on every invocation; otherwise an unchanged tree is -# served from the layer cache without linting or querying live DNS. +# Identical in all repos; the tag comes from script/projectname. +# --no-cache because the gate phases the final stage depends on are RUN +# steps, and a cached one is a check that did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -14,11 +12,11 @@ main() { cd "$ROOT" # Own line: a failing command substitution inside an argument does # not trip `set -e`, so the inline form degrades silently to an - # empty constant. The VERSION build arg takes precedence over what - # the build would derive from the .git in its context. + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. version="$(git describe --tags --always --dirty 2>/dev/null || true)" [ -n "$version" ] || version="unknown" - docker build --no-cache-filter=lint,builder \ + docker build --no-cache \ --build-arg VERSION="$version" \ -t "$("$SCRIPT_DIR/projectname")" . } diff --git a/script/fmt b/script/fmt index d64453a..9470b03 100755 --- a/script/fmt +++ b/script/fmt @@ -1,6 +1,6 @@ #!/bin/sh # script/fmt: format all files (writes). Go with gofmt and goimports on -# the host, markdown with the prettier pinned by Dockerfile.fmt. +# the host, markdown with prettier in the Dockerfile's fmt stage. # # goimports runs with `go run` at a pinned commit, never from PATH, so # every machine formats with the same version and nothing installs it. @@ -8,21 +8,15 @@ # The markdown pass is a `docker build --output type=local` rather than a # `docker run -v`, so it needs no bind mount and behaves the same against # a remote daemon; the formatted documents come back out of the build and -# are copied over the tree here. -# -# Unlike script/fmt-check-markdown this does not bust the cache: it is -# not a gate, and any edit to a document changes the COPY layer above the -# prettier step, so a cached result is a result over this exact tree. +# are copied over the tree here. --output writes files and makes no +# image, so there is nothing to tag. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# goimports v0.42.0, 2026-08-07. Must match script/fmt-check-go. +# goimports v0.42.0, 2026-08-07. Must match script/fmt-check. GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" -# Must match the export stage name in Dockerfile.fmt. -stage=fmt-out - die() { echo "script/fmt: $*" >&2 exit 1 @@ -36,10 +30,9 @@ main() { tmp="$(mktemp -d "${TMPDIR:-/tmp}/dnswatcher-fmt.XXXXXX")" trap 'rm -rf "$tmp"' EXIT INT TERM - docker build \ - --target "$stage" \ - --output "type=local,dest=$tmp/out" \ - -f Dockerfile.fmt . + docker build --no-cache \ + --target fmt-out \ + --output "type=local,dest=$tmp/out" . # An empty export means prettier was handed nothing, which must not # read as "already formatted". diff --git a/script/fmt-check b/script/fmt-check index c5fb94f..b9b9ca9 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -1,14 +1,37 @@ #!/bin/sh # script/fmt-check: check formatting (read-only). Same tools and scope -# as script/fmt, but fails instead of writing: the Go on the host, the -# markdown with prettier in a container. +# as script/fmt, but fails instead of writing and names what it would +# change: the Go on the host, the markdown with prettier in the +# Dockerfile's fmt-check stage. +# +# --no-cache because a cached check layer is a check that did not run. +# The tag makes each build replace the previous image instead of leaving +# a dangling one behind. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +# goimports v0.42.0, 2026-08-07. Must match script/fmt. +GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" main() { - "$SCRIPT_DIR/fmt-check-go" - "$SCRIPT_DIR/fmt-check-markdown" + cd "$ROOT" + files="$(gofmt -s -l .)" + if [ -n "$files" ]; then + echo "gofmt: files not formatted:" >&2 + echo "$files" >&2 + exit 1 + fi + files="$(go run "$GOIMPORTS_REF" -l .)" + if [ -n "$files" ]; then + echo "goimports: files not formatted:" >&2 + echo "$files" >&2 + exit 1 + fi + docker build --no-cache \ + --target fmt-check \ + -t "$("$SCRIPT_DIR/projectname")-fmt-check" . } main "$@" diff --git a/script/fmt-check-go b/script/fmt-check-go deleted file mode 100755 index fa21fc5..0000000 --- a/script/fmt-check-go +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# script/fmt-check-go: fail unless every Go source is formatted the way -# script/fmt would leave it, and name the files that are not. Read-only. -# -# Its own script because the Dockerfile's lint stage runs this half -# alone: there is no docker inside a docker build to run the markdown -# half in. -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -# goimports v0.42.0, 2026-08-07. Must match script/fmt. -GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" - -main() { - cd "$ROOT" - files="$(gofmt -s -l .)" - if [ -n "$files" ]; then - echo "gofmt: files not formatted:" >&2 - echo "$files" >&2 - exit 1 - fi - files="$(go run "$GOIMPORTS_REF" -l .)" - if [ -n "$files" ]; then - echo "goimports: files not formatted:" >&2 - echo "$files" >&2 - exit 1 - fi -} - -main "$@" diff --git a/script/fmt-check-markdown b/script/fmt-check-markdown deleted file mode 100755 index e849b59..0000000 --- a/script/fmt-check-markdown +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/sh -# script/fmt-check-markdown: fail unless every .md is formatted the way -# script/fmt would leave it. Read-only. -# -# prettier is never installed on the host: it runs in a container built -# from Dockerfile.fmt, pinned by package.json and yarn.lock. -# --no-cache-filter is here for the reason script/lint gives: a cached -# build checks nothing. -# -# Its own script because script/cibuild runs this half alone, after the -# Dockerfile's lint stage has checked the Go. -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -# Must match the markdown check stage name in Dockerfile.fmt. -stage=fmt-check - -main() { - cd "$ROOT" - docker build \ - --progress=plain \ - --no-cache-filter="$stage" \ - --target "$stage" \ - -f Dockerfile.fmt \ - . -} - -main "$@" diff --git a/script/lint b/script/lint index 84a1972..2d8b075 100755 --- a/script/lint +++ b/script/lint @@ -1,28 +1,23 @@ #!/bin/sh -# script/lint: run the linter. golangci-lint is never installed or run -# on the host: it runs via docker only, one way, everywhere. This -# builds Dockerfile.lint, which COPYs the repo into the digest-pinned -# golangci-lint image and lints as a build step, so a successful build -# means a clean lint. +# script/lint: run the linter. Linting is a phase of the Dockerfile and +# this builds that phase alone; the linter is never installed or run on +# a developer host, where a shared result cache and a host-global lock +# make its answer untrustworthy. # -# --no-cache-filter=lint forces the lint stage (source copy + linter -# run) to execute on every invocation. Without it an unchanged tree -# returns success in well under a second having linted nothing. The -# deps stage (base image + go mod download) stays cached, and no global -# cache invalidation is performed. --progress=plain keeps the linter's -# own output visible. +# The phase is not the last stage in the file, so it is built only when +# --target names it. --no-cache because a cached lint layer is a lint +# that did not run. The tag makes each build replace the previous image +# instead of leaving a dangling one behind. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build \ - --progress=plain \ - --no-cache-filter=lint \ + docker build --no-cache \ --target lint \ - -f Dockerfile.lint \ - . + -t "$("$SCRIPT_DIR/projectname")-lint" . } main "$@" diff --git a/script/test b/script/test index b4ef492..cd239f2 100755 --- a/script/test +++ b/script/test @@ -1,35 +1,19 @@ #!/bin/sh -# script/test: run the test suite. -# -# -count=1 disables Go's test cache, and is load-bearing here. This -# suite queries live DNS on every run by policy (TESTING.md); a cached -# result is a replay of an earlier run's output with no query made at -# all. On an unchanged tree the whole suite would return success in -# under a second having resolved nothing, which makes the repeated-run -# green that is used as evidence for flakiness fixes worthless. Do not -# remove it. -# -# Conditional verbose rerun per REPO_POLICIES.md: run quiet first so -# CI and docker build logs stay readable, and rerun with -v only on -# failure. The rerun also carries -count=1 (a cached replay of the -# failure would show nothing new), and the exit status is forced to 1 -# no matter how the rerun ends: the first failure already proved the -# suite broken, so a flaky test that passes the second time must not -# turn the build green. -# -# -timeout 90s is a deliberate backstop above the 60s hard cap on -# suite duration. Do not lower it. +# script/test: run the test suite. Testing is a phase of the Dockerfile +# and this builds that phase alone, on the same terms as script/lint: +# --target because a phase that is not the last stage is built only when +# named, --no-cache because a cached test layer is a test that did not +# run, and a tag so each build replaces the previous image. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - go test -count=1 -race -timeout 90s -cover ./... || { - echo "--- Rerunning with -v for details ---" >&2 - go test -count=1 -race -timeout 90s -v ./... || true - exit 1 - } + docker build --no-cache \ + --target test \ + -t "$("$SCRIPT_DIR/projectname")-test" . } main "$@" -- 2.54.0 From 02cb1ec5806951da5655233b4b14fcd45edf5327 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 13:44:31 +0200 Subject: [PATCH 73/92] state: GetSnapshot copies the state's maps (closes #261) The dashboard and /api/v1/status range over the maps in the snapshot GetSnapshot returns after the read lock is released, while the watcher writes the same maps under the lock. Go aborts the process when a write lands during such a range. GetSnapshot now returns copies of the four maps. The entries in the maps are still shared with the state. That is safe because the watcher replaces an entry with a Set call and never changes one in place; the comment on GetSnapshot says so. TestGetSnapshot_WhileWritten ranges over snapshots while another goroutine writes the state and fails under -race without the copies. TestGetSnapshot_ReturnsCopy now checks that each map is not the state's own. Model: opus-5-5 --- TODO.md | 2 ++ internal/state/state.go | 14 ++++++-- internal/state/state_test.go | 69 +++++++++++++++++++++++++++++++----- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 2f43241..5c3c05e 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: a check saving the state while the dashboard or `/api/v1/status` + is read cannot crash the process (closes #261). - 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint and test are phases of the `Dockerfile`, every scripted build uncached (closes #257). diff --git a/internal/state/state.go b/internal/state/state.go index 39da3cb..d8348bb 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "log/slog" + "maps" "os" "path/filepath" "slices" @@ -270,12 +271,21 @@ func (s *State) Save() error { return nil } -// GetSnapshot returns a copy of the current snapshot. +// GetSnapshot returns a copy of the current snapshot, with copies of +// its maps, so that a caller can range over them while the state is +// written. The entries in the maps are not copied: an entry saved in the +// state must be replaced, never changed in place. func (s *State) GetSnapshot() Snapshot { s.mu.RLock() defer s.mu.RUnlock() - return *s.snapshot + snap := *s.snapshot + snap.Domains = maps.Clone(snap.Domains) + snap.Hostnames = maps.Clone(snap.Hostnames) + snap.Ports = maps.Clone(snap.Ports) + snap.Certificates = maps.Clone(snap.Certificates) + + return snap } // SetDomainState updates the state for a domain. diff --git a/internal/state/state_test.go b/internal/state/state_test.go index e511014..ce65140 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -995,7 +995,8 @@ func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) { } } -// TestGetSnapshot_ReturnsCopy verifies that GetSnapshot returns a value copy. +// TestGetSnapshot_ReturnsCopy verifies that the maps in the snapshot +// GetSnapshot returns are not the state's own. func TestGetSnapshot_ReturnsCopy(t *testing.T) { t.Parallel() @@ -1005,15 +1006,67 @@ func TestGetSnapshot_ReturnsCopy(t *testing.T) { snap := s.GetSnapshot() - // Mutate the returned snapshot's map. - snap.Domains["mutated.example.com"] = &state.DomainState{ - Nameservers: []string{"ns1.mutated.com."}, + const added = "added.example.com" + + snap.Domains[added] = &state.DomainState{} + snap.Hostnames[added] = &state.HostnameState{} + snap.Ports[added] = &state.PortState{} + snap.Certificates[added] = &state.CertificateState{} + + if _, ok := s.GetDomainState(added); ok { + t.Error("the snapshot's Domains map is the state's own") } - // The original domain data should still be accessible. - _, ok := s.GetDomainState("example.com") - if !ok { - t.Error("original domain should still exist") + if _, ok := s.GetHostnameState(added); ok { + t.Error("the snapshot's Hostnames map is the state's own") + } + + if _, ok := s.GetPortState(added); ok { + t.Error("the snapshot's Ports map is the state's own") + } + + if _, ok := s.GetCertificateState(added); ok { + t.Error("the snapshot's Certificates map is the state's own") + } +} + +// TestGetSnapshot_WhileWritten ranges over snapshots while another +// goroutine writes the state, as the dashboard does while a check runs. +// Under -race it fails when a snapshot's maps are the state's own. +func TestGetSnapshot_WhileWritten(t *testing.T) { + t.Parallel() + + s := state.NewForTestWithDataDir(t.TempDir()) + + const writes = 1000 + + written := make(chan struct{}) + + go func() { + defer close(written) + + for range writes { + s.SetDomainState("example.com", &state.DomainState{}) + s.SetHostnameState(testHostname, &state.HostnameState{}) + s.SetPortState(testIPv4+":443", &state.PortState{}) + s.SetCertificateState( + testIPv4+":443:"+testHostname, &state.CertificateState{}, + ) + } + }() + + for { + select { + case <-written: + return + default: + } + + // json.Marshal ranges over each of the snapshot's maps. + _, err := json.Marshal(s.GetSnapshot()) + if err != nil { + t.Fatal(err) + } } } -- 2.54.0 From 39e7bc90673ec7b4fbd20068ade933d6054dd849 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 15:27:32 +0200 Subject: [PATCH 74/92] config: a bad number or true/false setting stops startup (closes #269) PORT and DNSWATCHER_TLS_EXPIRY_WARNING were read with viper.GetInt, and DNSWATCHER_DEBUG, DNSWATCHER_MAINTENANCE_MODE and DNSWATCHER_SEND_TEST_NOTIFICATION with viper.GetBool, which turn a value that does not parse into 0 or false with no error. Each is now parsed the way parseInterval parses an interval: PORT must be 1 to 65535, the expiry warning 1 or more days, the true/false settings a value strconv.ParseBool accepts. Anything else stops startup with an error naming the variable and the value; empty still means the default. Reading the config file moved, unchanged, from buildConfig into readConfigFile, so buildConfig stays within the linter's length limit. Model: opus-5-5 --- README.md | 10 +++ TODO.md | 2 + internal/config/config.go | 129 +++++++++++++++++++++++++++++---- internal/config/config_test.go | 95 +++++++++++++++++++++++- 4 files changed, 220 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 089ef95..7386c61 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,16 @@ or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If either is set to anything else, including a bare number or a zero or negative duration, dnswatcher refuses to start with an error naming the variable and the value. +**`PORT` and `DNSWATCHER_TLS_EXPIRY_WARNING`** take a whole number: 1 to 65535 +for `PORT`, and a number of days, 1 or more, for +`DNSWATCHER_TLS_EXPIRY_WARNING`. `DNSWATCHER_DEBUG`, +`DNSWATCHER_MAINTENANCE_MODE` and `DNSWATCHER_SEND_TEST_NOTIFICATION` take +`true` or `false`; `1`, `t`, `T`, `TRUE` and `True` also mean true, and `0`, +`f`, `F`, `FALSE` and `False` also mean false. An unset or empty variable means +the default. Any other value, such as `PORT=http`, +`DNSWATCHER_TLS_EXPIRY_WARNING=7d` or `DNSWATCHER_DEBUG=yes`, makes dnswatcher +refuse to start with an error naming the variable and the value. + **`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it is set, a panic in an HTTP request handler is sent to Sentry, and the request still gets a `500 Internal Server Error` answer. Nothing else is sent to Sentry: diff --git a/TODO.md b/TODO.md index 5c3c05e..368b81f 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: a bad `PORT`, `DNSWATCHER_TLS_EXPIRY_WARNING` or true/false + setting stops startup instead of being read as 0 or false (closes #269). - 2026-10-13: a check saving the state while the dashboard or `/api/v1/status` is read cannot crash the process (closes #261). - 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint diff --git a/internal/config/config.go b/internal/config/config.go index 2f82f40..5e7b30b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "slices" + "strconv" "strings" "time" @@ -24,6 +25,9 @@ const ( defaultTLSExpiryWarning = 7 ) +// maxPort is the highest TCP port number. +const maxPort = 65535 + // ErrNoTargets is returned when no monitoring targets are configured. var ErrNoTargets = errors.New( "no monitoring targets configured: set DNSWATCHER_TARGETS environment variable", @@ -40,6 +44,23 @@ var ErrInvalidInterval = errors.New( // lists a name that is not in DNSWATCHER_TARGETS. var ErrNotInTargets = errors.New("name is not in DNSWATCHER_TARGETS") +// ErrInvalidPort is returned when PORT is set but is not a whole number +// from 1 to 65535. An empty value counts as unset and means the default. +var ErrInvalidPort = errors.New("port must be a whole number from 1 to 65535") + +// ErrInvalidTLSExpiryWarning is returned when DNSWATCHER_TLS_EXPIRY_WARNING +// is set but is not a whole number, 1 or more. An empty value counts as unset +// and means the default. +var ErrInvalidTLSExpiryWarning = errors.New( + "TLS expiry warning must be a whole number of days, 1 or more", +) + +// ErrInvalidBool is returned when DNSWATCHER_DEBUG, +// DNSWATCHER_MAINTENANCE_MODE or DNSWATCHER_SEND_TEST_NOTIFICATION is set +// but is not a value strconv.ParseBool accepts. An empty value counts as +// unset and means the default. +var ErrInvalidBool = errors.New("value must be true or false") + // Params contains dependencies for Config. type Params struct { fx.In @@ -127,16 +148,9 @@ func buildConfig( log *slog.Logger, params *Params, ) (*Config, error) { - err := viper.ReadInConfig() + err := readConfigFile(log) if err != nil { - var notFound viper.ConfigFileNotFoundError - if !errors.As(err, ¬Found) { - log.Error("config file malformed", "error", err) - - return nil, fmt.Errorf( - "config file malformed: %w", err, - ) - } + return nil, err } dnsInterval, err := parseInterval("DNS_INTERVAL") @@ -161,9 +175,34 @@ func buildConfig( return nil, err } + port, err := parsePort() + if err != nil { + return nil, err + } + + tlsExpiryWarning, err := parseTLSExpiryWarning() + if err != nil { + return nil, err + } + + debug, err := parseBool("DEBUG") + if err != nil { + return nil, err + } + + maintenanceMode, err := parseBool("MAINTENANCE_MODE") + if err != nil { + return nil, err + } + + sendTestNotification, err := parseBool("SEND_TEST_NOTIFICATION") + if err != nil { + return nil, err + } + cfg := &Config{ - Port: viper.GetInt("PORT"), - Debug: viper.GetBool("DEBUG"), + Port: port, + Debug: debug, DataDir: viper.GetString("DATA_DIR"), Domains: domains, Hostnames: hostnames, @@ -173,12 +212,12 @@ func buildConfig( NtfyTopic: viper.GetString("NTFY_TOPIC"), DNSInterval: dnsInterval, TLSInterval: tlsInterval, - TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"), + TLSExpiryWarning: tlsExpiryWarning, SentryDSN: viper.GetString("SENTRY_DSN"), - MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), + MaintenanceMode: maintenanceMode, MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), - SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"), + SendTestNotification: sendTestNotification, params: params, log: log, } @@ -186,6 +225,21 @@ func buildConfig( return cfg, nil } +// readConfigFile reads the config file, if there is one. +func readConfigFile(log *slog.Logger) error { + err := viper.ReadInConfig() + if err != nil { + var notFound viper.ConfigFileNotFoundError + if !errors.As(err, ¬Found) { + log.Error("config file malformed", "error", err) + + return fmt.Errorf("config file malformed: %w", err) + } + } + + return nil +} + // parseInterval reads the DNSWATCHER_-prefixed setting key as a duration. A // value that does not parse, or is zero or negative, is an error naming the // variable and the value; an unset variable has its default from setupViper. @@ -202,6 +256,53 @@ func parseInterval(key string) (time.Duration, error) { return interval, nil } +// parsePort reads PORT, which has no DNSWATCHER_ prefix, as a port number. A +// value that is not a whole number from 1 to 65535 is an error naming the +// variable and the value; an unset variable has its default from setupViper. +func parsePort() (int, error) { + value := viper.GetString("PORT") + + port, err := strconv.Atoi(value) + if err != nil || port < 1 || port > maxPort { + return 0, fmt.Errorf("invalid PORT %q: %w", value, ErrInvalidPort) + } + + return port, nil +} + +// parseTLSExpiryWarning reads DNSWATCHER_TLS_EXPIRY_WARNING as a number of +// days. A value that is not a whole number, 1 or more, is an error naming the +// variable and the value; an unset variable has its default from setupViper. +func parseTLSExpiryWarning() (int, error) { + value := viper.GetString("TLS_EXPIRY_WARNING") + + days, err := strconv.Atoi(value) + if err != nil || days < 1 { + return 0, fmt.Errorf( + "invalid DNSWATCHER_TLS_EXPIRY_WARNING %q: %w", + value, ErrInvalidTLSExpiryWarning, + ) + } + + return days, nil +} + +// parseBool reads the DNSWATCHER_-prefixed setting key as true or false. A +// value strconv.ParseBool does not accept is an error naming the variable and +// the value; an unset variable has its default from setupViper. +func parseBool(key string) (bool, error) { + value := viper.GetString(key) + + result, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf( + "invalid DNSWATCHER_%s %q: %w", key, value, ErrInvalidBool, + ) + } + + return result, nil +} + func parseAndValidateTargets() ([]string, []string, error) { domains, hostnames, err := ClassifyTargets( parseCSV(viper.GetString("TARGETS")), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ffb93ab..0c44726 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -116,7 +116,7 @@ func TestNew_OnlyEmptyCSVSegments(t *testing.T) { } // TestNew_InvalidIntervalStopsStartup checks values that must stop startup; -// TestNew_DefaultValues and TestNew_EmptyIntervalMeansDefault check that an +// TestNew_DefaultValues and TestNew_EmptyValueMeansDefault check that an // unset or empty interval means the default. func TestNew_InvalidIntervalStopsStartup(t *testing.T) { variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"} @@ -144,16 +144,107 @@ func TestNew_InvalidIntervalStopsStartup(t *testing.T) { } } -func TestNew_EmptyIntervalMeansDefault(t *testing.T) { +func TestNew_EmptyValueMeansDefault(t *testing.T) { viper.Reset() t.Setenv("DNSWATCHER_TARGETS", "example.com") t.Setenv("DNSWATCHER_DNS_INTERVAL", "") t.Setenv("DNSWATCHER_TLS_INTERVAL", "") + t.Setenv("PORT", "") + t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "") + t.Setenv("DNSWATCHER_DEBUG", "") + t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "") + t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "") cfg, err := config.New(nil, newTestParams(t)) require.NoError(t, err) assert.Equal(t, time.Hour, cfg.DNSInterval) assert.Equal(t, 12*time.Hour, cfg.TLSInterval) + assert.Equal(t, 8080, cfg.Port) + assert.Equal(t, 7, cfg.TLSExpiryWarning) + assert.False(t, cfg.Debug) + assert.False(t, cfg.MaintenanceMode) + assert.False(t, cfg.SendTestNotification) +} + +// TestNew_InvalidNumberOrBoolStopsStartup checks values that must stop +// startup; TestNew_EnvironmentOverrides, the two tests after this one and +// TestNew_EmptyValueMeansDefault check values that must not. +func TestNew_InvalidNumberOrBoolStopsStartup(t *testing.T) { + notBools := []string{"yes", "on", "2", "tRUE"} + tests := []struct { + variable string + values []string + want error + }{ + { + variable: "PORT", + values: []string{"notaport", "0", "-1", "65536", "8080.5"}, + want: config.ErrInvalidPort, + }, + { + variable: "DNSWATCHER_TLS_EXPIRY_WARNING", + values: []string{"abc", "7d", "0", "-5"}, + want: config.ErrInvalidTLSExpiryWarning, + }, + { + variable: "DNSWATCHER_DEBUG", + values: notBools, + want: config.ErrInvalidBool, + }, + { + variable: "DNSWATCHER_MAINTENANCE_MODE", + values: notBools, + want: config.ErrInvalidBool, + }, + { + variable: "DNSWATCHER_SEND_TEST_NOTIFICATION", + values: notBools, + want: config.ErrInvalidBool, + }, + } + + for _, tt := range tests { + for _, value := range tt.values { + t.Run(tt.variable+"="+value, func(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv(tt.variable, value) + + _, err := config.New(nil, newTestParams(t)) + require.ErrorIs(t, err, tt.want) + require.ErrorContains(t, err, tt.variable) + require.ErrorContains(t, err, strconv.Quote(value)) + }) + } + } +} + +func TestNew_LowestNumbersAndOtherSpellingsOfTrue(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("PORT", "1") + t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "1") + t.Setenv("DNSWATCHER_DEBUG", "1") + t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "T") + t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "True") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.Equal(t, 1, cfg.Port) + assert.Equal(t, 1, cfg.TLSExpiryWarning) + assert.True(t, cfg.Debug) + assert.True(t, cfg.MaintenanceMode) + assert.True(t, cfg.SendTestNotification) +} + +func TestNew_HighestPort(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("PORT", "65535") + + cfg, err := config.New(nil, newTestParams(t)) + require.NoError(t, err) + assert.Equal(t, 65535, cfg.Port) } func TestNew_DebugEnablesDebugLogging(t *testing.T) { -- 2.54.0 From 2ae61104141cd02249e4bd5aa4cb1a29af8a29b1 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 18:00:00 +0200 Subject: [PATCH 75/92] server: a port that cannot be bound stops startup (closes #266) The start hook now binds the listen port itself and serves on it in a goroutine. A bind failure, such as a port already in use or a privileged port without root, is returned to fx, which stops startup and exits 1 with an error like "starting http server: listen tcp :8080: bind: address already in use". Before, the bind happened inside the goroutine and its error was only logged while the watcher kept running without the dashboard, API or health check. The http.Server is now built before that goroutine starts, so Shutdown no longer reads a field another goroutine may still be writing. TestPortInUseStopsStartup holds a port and checks that starting the server on it fails with an error naming it. Model: opus-5-5 --- README.md | 2 +- TODO.md | 2 ++ internal/server/export_test.go | 4 +-- internal/server/server.go | 30 +++++++++++++------ internal/server/server_test.go | 54 +++++++++++++++++++++++++++++----- 5 files changed, 72 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7386c61..8a7e98e 100644 --- a/README.md +++ b/README.md @@ -423,7 +423,7 @@ following precedence (highest to lowest): | Variable | Description | Default | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| `PORT` | HTTP listen port | `8080` | +| `PORT` | HTTP listen port; a port that cannot be bound, such as one already in use, stops startup | `8080` | | `DNSWATCHER_DEBUG` | Enable debug logging | `false` | | `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` | | `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` | diff --git a/TODO.md b/TODO.md index 368b81f..775c8fb 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: a `PORT` that cannot be bound stops startup with an error naming + the address, instead of running without the HTTP server (closes #266). - 2026-10-13: a bad `PORT`, `DNSWATCHER_TLS_EXPIRY_WARNING` or true/false setting stops startup instead of being read as 0 or false (closes #269). - 2026-10-13: a check saving the state while the dashboard or `/api/v1/status` diff --git a/internal/server/export_test.go b/internal/server/export_test.go index acd4d8c..0fecf2f 100644 --- a/internal/server/export_test.go +++ b/internal/server/export_test.go @@ -13,8 +13,8 @@ import ( const RequestTimeout time.Duration = requestTimeout // SetListenPort overrides the port Run binds. A test uses it to hand -// Run an unbindable port so ListenAndServe fails immediately and Run -// returns after storing its http.Server. +// Run an unbindable port, so that Run stores its http.Server and then +// returns an error without listening. func SetListenPort(s *Server, port int) { s.port = port } diff --git a/internal/server/server.go b/internal/server/server.go index e18a4d3..7f333bf 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "net" "net/http" "time" @@ -112,7 +113,7 @@ func New( } lifecycle.Append(fx.Hook{ - OnStart: func(_ context.Context) error { + OnStart: func(ctx context.Context) error { srv.startupTime = time.Now() err := srv.enableSentry() @@ -120,9 +121,7 @@ func New( return err } - go srv.Run() - - return nil + return srv.Run(ctx) }, OnStop: func(ctx context.Context) error { return srv.Shutdown(ctx) @@ -149,8 +148,10 @@ func newHTTPServer( } } -// Run starts the HTTP server. -func (s *Server) Run() { +// Run binds the listen port, then serves HTTP on it in a new goroutine +// and returns. A port that cannot be bound is returned as an error, so +// that startup stops instead of running without the HTTP server. +func (s *Server) Run(ctx context.Context) error { s.SetupRoutes() listenAddr := fmt.Sprintf(":%d", s.port) @@ -158,10 +159,21 @@ func (s *Server) Run() { s.log.Info("http server starting", "addr", listenAddr) - err := s.httpServer.ListenAndServe() - if err != nil && !errors.Is(err, http.ErrServerClosed) { - s.log.Error("http server error", "error", err) + lc := &net.ListenConfig{} + + listener, err := lc.Listen(ctx, "tcp", listenAddr) + if err != nil { + return fmt.Errorf("starting http server: %w", err) } + + go func() { + err := s.httpServer.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.log.Error("http server error", "error", err) + } + }() + + return nil } // Shutdown gracefully shuts down the server, then sends the error diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8aa5f2d..297d505 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,6 +1,8 @@ package server_test import ( + "net" + "strings" "testing" "github.com/spf13/viper" @@ -58,15 +60,13 @@ func buildServer(t *testing.T) *server.Server { } // TestRunWiresSocketTimeouts pins that the http.Server the running -// server actually serves — the one Run builds and hands to -// ListenAndServe — carries every socket-level timeout, plus the two +// server actually serves — the one Run builds and serves on the port +// it binds — carries every socket-level timeout, plus the two // relationships the values must satisfy. // -// Run is driven to completion with an unbindable port: it builds and -// stores s.httpServer, then ListenAndServe fails at once and Run -// returns without ever listening. The assertions run in the same -// goroutine after Run returns, so reading s.httpServer is free of any -// data race. Nothing here measures elapsed time. +// Run is given an unbindable port: it builds and stores s.httpServer, +// then the bind fails and Run returns the error without ever +// listening. Nothing here measures elapsed time. // // ReadTimeout must be at least ReadHeaderTimeout. net/http reads the // headers under ReadHeaderTimeout, then sets the read deadline for the @@ -84,7 +84,10 @@ func TestRunWiresSocketTimeouts(t *testing.T) { srv := buildServer(t) server.SetListenPort(srv, -1) - srv.Run() + err := srv.Run(t.Context()) + if err == nil { + t.Fatal("Run bound port -1") + } hs := server.HTTPServerOf(srv) if hs == nil { @@ -133,3 +136,38 @@ func TestRunWiresSocketTimeouts(t *testing.T) { ) } } + +func TestPortInUseStopsStartup(t *testing.T) { + // Bound on every address, as Run binds, so that Run's bind fails. + lc := &net.ListenConfig{} + + taken, err := lc.Listen(t.Context(), "tcp", ":0") + if err != nil { + t.Fatalf("binding a port for the test: %v", err) + } + + defer func() { _ = taken.Close() }() + + _, port, err := net.SplitHostPort(taken.Addr().String()) + if err != nil { + t.Fatalf("reading the bound port: %v", err) + } + + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir()) + t.Setenv("PORT", port) + + app := newServerApp(fx.Invoke(func(*server.Server) {})) + + err = app.Start(t.Context()) + if err == nil { + _ = app.Stop(t.Context()) + + t.Fatal("startup succeeded on a port that is already bound") + } + + if !strings.Contains(err.Error(), ":"+port) { + t.Errorf("startup error does not name port %s: %v", port, err) + } +} -- 2.54.0 From 5617c92fb36653f1a0673dae76baefc9467d2fdc Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 18:27:31 +0200 Subject: [PATCH 76/92] state: Save syncs the state file before renaming it into place (closes #264) Save wrote state.json.tmp with os.WriteFile and renamed it over state.json without syncing. After a power loss the rename could reach the disk before the data, leaving an empty state.json that Load cannot parse, so every start failed. Save now writes the temp file through its own handle, syncs and closes it, renames it, then syncs the data directory so the rename is on disk too. The new test starts from a leftover temp file longer than the state; a test cannot observe the syncs, which are confirmed by reading. Rule suppressed: gosec's file-path warning on the temp file's open, as Load already does. Model: opus-5-5 --- TODO.md | 2 ++ internal/state/state.go | 24 ++++++++++++++++-- internal/state/state_test.go | 47 ++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 775c8fb..c212724 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: a save syncs the state file to disk before renaming it into place, + so a crash cannot leave it empty and stop the next start (closes #264). - 2026-10-13: a `PORT` that cannot be bound stops startup with an error naming the address, instead of running without the HTTP server (closes #266). - 2026-10-13: a bad `PORT`, `DNSWATCHER_TLS_EXPIRY_WARNING` or true/false diff --git a/internal/state/state.go b/internal/state/state.go index d8348bb..f183764 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -4,6 +4,7 @@ package state import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "maps" @@ -253,10 +254,20 @@ func (s *State) Save() error { return fmt.Errorf("creating data directory: %w", err) } - // Atomic write: write to temp file, then rename + // Atomic write: write to temp file, then rename. The temp file is + // synced before the rename and the directory after it, or a crash + // can leave the renamed state file empty. tmpPath := path + ".tmp" - err = os.WriteFile(tmpPath, data, filePermissions) + //nolint:gosec // path is from trusted config + tmpFile, err := os.OpenFile( + tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePermissions, + ) + if err == nil { + _, err = tmpFile.Write(data) + err = errors.Join(err, tmpFile.Sync(), tmpFile.Close()) + } + if err != nil { return fmt.Errorf("writing temp state file: %w", err) } @@ -266,6 +277,15 @@ func (s *State) Save() error { return fmt.Errorf("renaming state file: %w", err) } + dir, err := os.Open(filepath.Dir(path)) + if err == nil { + err = errors.Join(dir.Sync(), dir.Close()) + } + + if err != nil { + return fmt.Errorf("syncing data directory: %w", err) + } + s.log.Debug("state saved to disk", "path", path) return nil diff --git a/internal/state/state_test.go b/internal/state/state_test.go index ce65140..d2a660f 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -618,6 +618,53 @@ func TestSaveAtomicWrite(t *testing.T) { } } +// TestSaveOverLeftoverTempFile verifies that a temp file left by a +// crash, longer than the state Save writes, leaves nothing of itself in +// the state file, and that no temp file remains after Save. +func TestSaveOverLeftoverTempFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + statePath := filepath.Join(dir, "state.json") + tmpPath := statePath + ".tmp" + + err := os.WriteFile(tmpPath, []byte(strings.Repeat("x", 64*1024)), 0o600) + if err != nil { + t.Fatalf("writing leftover temp file: %v", err) + } + + s := state.NewForTestWithDataDir(dir) + + populateState(t, s) + + err = s.Save() + if err != nil { + t.Fatalf("Save() error: %v", err) + } + + //nolint:gosec // test file with known path + data, err := os.ReadFile(statePath) + if err != nil { + t.Fatalf("reading state file: %v", err) + } + + var snap state.Snapshot + + err = json.Unmarshal(data, &snap) + if err != nil { + t.Fatalf("state file is not complete JSON: %v", err) + } + + if len(snap.Domains) != 2 { + t.Errorf("state file has %d domains, want 2", len(snap.Domains)) + } + + _, err = os.Stat(tmpPath) + if !os.IsNotExist(err) { + t.Error("temp file should not exist after save") + } +} + // TestLoadMissingFile verifies that loading a nonexistent file is not an error. func TestLoadMissingFile(t *testing.T) { t.Parallel() -- 2.54.0 From b9686c6d7bd46cdf845a33a589a9e6e215e64122 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 19:10:58 +0200 Subject: [PATCH 77/92] config: a metrics username without a password stops startup (closes #268) DNSWATCHER_METRICS_USERNAME turns on /metrics, and its Basic Auth used DNSWATCHER_METRICS_PASSWORD as it was, empty included, so the username with an empty password got in. config.New now refuses a username without a password with ErrNoMetricsPassword, whose message names both variables. The README's variable table says the password is required with the username. Judgement call: the check is in New, after buildConfig, since it compares two settings buildConfig has already read; buildConfig is also at the linter's length limit. Model: opus-5-5 --- README.md | 2 +- TODO.md | 2 ++ internal/config/config.go | 11 +++++++++++ internal/config/config_test.go | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a7e98e..f1a4108 100644 --- a/README.md +++ b/README.md @@ -437,7 +437,7 @@ following precedence (highest to lowest): | `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | | `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` | | `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` | -| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | +| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics; required when `DNSWATCHER_METRICS_USERNAME` is set, and startup stops without it | `""` | | `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | **`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no diff --git a/TODO.md b/TODO.md index c212724..96cbcee 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: `DNSWATCHER_METRICS_USERNAME` set without + `DNSWATCHER_METRICS_PASSWORD` stops startup (closes #268). - 2026-10-13: a save syncs the state file to disk before renaming it into place, so a crash cannot leave it empty and stop the next start (closes #264). - 2026-10-13: a `PORT` that cannot be bound stops startup with an error naming diff --git a/internal/config/config.go b/internal/config/config.go index 5e7b30b..4f1d6b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,6 +61,13 @@ var ErrInvalidTLSExpiryWarning = errors.New( // unset and means the default. var ErrInvalidBool = errors.New("value must be true or false") +// ErrNoMetricsPassword is returned when DNSWATCHER_METRICS_USERNAME is set and +// DNSWATCHER_METRICS_PASSWORD is empty. Basic Auth on /metrics would otherwise +// let in anyone who sends the username with an empty password. +var ErrNoMetricsPassword = errors.New( + "DNSWATCHER_METRICS_PASSWORD must be set when DNSWATCHER_METRICS_USERNAME is set", +) + // Params contains dependencies for Config. type Params struct { fx.In @@ -108,6 +115,10 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) { return nil, err } + if cfg.MetricsUsername != "" && cfg.MetricsPassword == "" { + return nil, ErrNoMetricsPassword + } + configureDebugLogging(cfg, params) return cfg, nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0c44726..35b4307 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -354,6 +354,24 @@ func TestNew_SkipRecordNotificationsNotInTargetsStopsStartup(t *testing.T) { require.ErrorContains(t, err, `"www.example.net"`) } +// TestNew_MetricsUsernameWithoutPasswordStopsStartup checks an unset and an +// empty password; TestNew_EnvironmentOverrides checks a username with one. +func TestNew_MetricsUsernameWithoutPasswordStopsStartup(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_METRICS_USERNAME", "prom") + + _, err := config.New(nil, newTestParams(t)) + require.ErrorIs(t, err, config.ErrNoMetricsPassword, "password unset") + require.ErrorContains(t, err, "DNSWATCHER_METRICS_PASSWORD") + + viper.Reset() + t.Setenv("DNSWATCHER_METRICS_PASSWORD", "") + + _, err = config.New(nil, newTestParams(t)) + require.ErrorIs(t, err, config.ErrNoMetricsPassword, "password empty") +} + func TestNew_CustomDNSIntervalDuration(t *testing.T) { viper.Reset() t.Setenv("DNSWATCHER_TARGETS", "example.com") -- 2.54.0 From 79761c29e146072ac5597764c5a8e5a669ae1e9e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 19:27:36 +0200 Subject: [PATCH 78/92] watcher: a removed nameserver is an NS Change, not an NS Failure (closes #262) A nameserver that left a domain's delegation was reported twice: as the domain's NS Change, and by the check of the domain's own records as an error-level NS Failure saying it disappeared. The check of a name's records now sends one NS Change at warning level, starting Hostname: or Domain:, listing the nameservers it was asked at before and not now. For a domain it leaves out those of its saved delegation, which the domain's own NS Change lists, so a domain's new delegation is now notified and saved only after, and only together with, its records. Nothing is sent for a nameserver that joins. A nameserver that was failing before it left is now listed too; the NS Failure skipped it. Model: opus-5-5 --- README.md | 28 ++++++--- TODO.md | 2 + internal/watcher/message_test.go | 103 +++++++++++++++++++++++++++---- internal/watcher/watcher.go | 90 ++++++++++++++++++--------- internal/watcher/watcher_test.go | 68 ++++++++++++++++++-- 5 files changed, 236 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index f1a4108..2eb5697 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,9 @@ notification endpoint set, changes show only on the dashboard; see answer NXDOMAIN, that it does not exist, has no nameservers and is shown as not existing (see Web Dashboard and HTTP API). A domain that exists but has no delegation of its own, such as `octocat.github.io`, has no nameservers either. - When the parent zone's servers do not answer, the check fails and the set from - the previous check is kept. + When the parent zone's servers do not answer, or the lookup of the domain's + own records (below) fails, the check fails and the set from the previous check + is kept. - Any change triggers a notification: - NS added to or removed from that set. A domain that had nameservers on the previous check and no longer exists gets one with all of them removed. @@ -99,11 +100,16 @@ notification endpoint set, changes show only on the dashboard; see records, stored per nameserver. Their changes are notified as a hostname's are, as a record change, NS query failure, NS recovery, inconsistency or CNAME address change, in a message that starts `Domain:` where a hostname's starts - `Hostname:`. A domain listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no - record change or inconsistency notification. A domain with no delegation of - its own has these records asked at the servers of the zone it is in, as a - hostname has. A domain that does not exist has none: they are not asked for, - and those saved by an earlier check are removed without a notification. + `Hostname:`. A nameserver removed from the domain's delegation gets only the + NS change notification above. A domain listed in + `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency + notification. A domain with no delegation of its own has these records asked + at the servers of the zone it is in, as a hostname has. A nameserver the + domain's records were asked at on the previous check but not on this one gets + a hostname's NS change notification (see below), unless it was in the domain's + delegation on the previous check, as when that delegation is withdrawn. A + domain that does not exist has none: they are not asked for, and those saved + by an earlier check are removed without a notification. ### DNS Hostname Monitoring (Subdomains) @@ -156,6 +162,10 @@ notification endpoint set, changes show only on the dashboard; see - **NS recovery**: A previously-unreachable nameserver starts responding again. Its records are not compared with those from before it failed, so a change made while it was failing is not reported as a record change. + - **NS change**: A nameserver of the zone the hostname is in on the previous + check is not one now, whether or not it answered. One notification lists + every such nameserver on a `Removed:` line. Nothing is sent for a + nameserver that joins the zone. - **Inconsistency detected**: Two nameservers return different record sets for the same hostname and did not already differ on the previous check. Every pair of nameservers is compared. The alert is sent once for each @@ -237,7 +247,9 @@ includes: - **DNS record changes**: Which hostname or domain, which nameserver, what record type, old values, new values. -- **DNS NS changes**: Which domain, which nameservers were added/removed. +- **DNS NS changes**: Which domain, which nameservers were added to or removed + from its delegation. For a hostname, or a domain with no delegation of its + own, which nameservers left the zone it is in. - **NS address changes**: Which domain, which nameserver, its old and new addresses. - **CNAME address changes**: Which hostname or domain, the old and new addresses diff --git a/TODO.md b/TODO.md index 96cbcee..0de395d 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change; + one leaving a hostname's zone sends NS Change, not NS Failure (closes #262). - 2026-10-13: `DNSWATCHER_METRICS_USERNAME` set without `DNSWATCHER_METRICS_PASSWORD` stops startup (closes #268). - 2026-10-13: a save syncs the state file to disk before renaming it into place, diff --git a/internal/watcher/message_test.go b/internal/watcher/message_test.go index e446bd2..c9857f1 100644 --- a/internal/watcher/message_test.go +++ b/internal/watcher/message_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - "sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/watcher" ) @@ -74,21 +73,19 @@ b.ns.example.net.: 192.0.2.2`, func TestDomainRecordNotificationsNameTheDomain(t *testing.T) { t.Parallel() - notifier := &mockNotifier{} - w := watcher.NewForTest( - &config.Config{Domains: []string{domain}}, - nil, nil, nil, nil, notifier, - ) + cfg := defaultTestConfig(t) + cfg.Domains = []string{domain} + + deps := newTestDeps(t, cfg) + w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier) // nsA's address changes, which also makes it differ from nsC; nsB - // fails; nsC answers again; nsD is gone. - nsD := "d.ns.example.net." + // fails; nsC answers again. w.DetectHostnameChanges(t.Context(), domain, saved(map[string]*state.NameserverRecordState{ nsA: answered(map[string][]string{"A": {ip1}}), nsB: answered(map[string][]string{"A": {ip1}}), nsC: failed(), - nsD: answered(map[string][]string{"A": {ip1}}), }), saved(map[string]*state.NameserverRecordState{ nsA: answered(map[string][]string{"A": {ip2}}), @@ -102,18 +99,17 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) { t.Context(), domain, cnameState(ip1), cnameState(ip2), ) - // NS Failure is sent for nsB failing and for nsD being gone. want := map[string]int{ "Record Change": 1, "Inconsistency": 1, - "NS Failure": 2, + "NS Failure": 1, "NS Recovery": 1, "CNAME Address Change": 1, } sent := make(map[string]int) - for _, n := range notifier.getNotifications() { + for _, n := range deps.notifier.getNotifications() { kind, _, _ := strings.Cut(n.Title, ":") sent[kind]++ @@ -128,6 +124,89 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) { } } +// nsB, which answered, and nsC, which did not, are no longer among the +// nameservers. One NS Change lists both, for the hostname and for the +// configured apex domain with no delegation of its own. Nothing is sent +// for the domain whose delegation on the previous check listed them: +// the check of its delegation sends its NS Change. +func TestRemovedNameserversAreAnNSChange(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target string + // The domain's nameservers saved by the previous check. + delegation []string + // The NS Change sent, or "" for none. + want string + }{ + { + name: "hostname", + target: host, + want: `Hostname: www.example.net +Removed: b.ns.example.net., c.ns.example.net.`, + }, + { + name: "domain with no delegation of its own", + target: domain, + delegation: []string{}, + want: `Domain: example.net +Removed: b.ns.example.net., c.ns.example.net.`, + }, + { + name: "domain whose delegation listed them", + target: domain, + delegation: []string{nsA, nsB, nsC}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{domain} + + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, nil, nil, deps.notifier, + ) + + deps.state.SetDomainState(domain, &state.DomainState{ + Nameservers: tt.delegation, + }) + + prev := saved(map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip1}}), + nsB: answered(map[string][]string{"A": {ip1}}), + nsC: failed(), + }) + current := saved(map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip1}}), + }) + + w.DetectHostnameChanges(t.Context(), tt.target, prev, current) + + got := deps.notifier.getNotifications() + + if tt.want == "" { + if len(got) != 0 { + t.Errorf("sent %v, want nothing", got) + } + + return + } + + title := "NS Change: " + tt.target + if len(got) != 1 || got[0].Title != title || + got[0].Message != tt.want { + t.Errorf("sent %v, want one %q with message:\n%s", + got, title, tt.want) + } + }) + } +} + // The startup notification counts the configured domains and hostnames, // although the state's hostnames also hold the apex domain's own // records. Nothing is looked up: the watcher has no resolver. diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 8c5b17b..0748ee1 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -322,6 +322,20 @@ func (w *Watcher) checkDomain( ctx, nameservers, prevAddresses, ) + // A domain that does not exist has no records of its own: none are + // asked for, and those saved by an earlier check are removed. + // Otherwise the apex domain's records are also checked and saved as + // a hostname's, so that the port and TLS checks find its addresses. + // Notifications about them name it as a domain (see nameLine). When + // their lookup fails, the check fails as when the NS lookup does, so + // the saved delegation and the saved records come from the same + // check, as detectNSDisappearances needs. + if nxdomain { + w.state.DeleteHostnameState(domain) + } else if !w.checkHostname(ctx, domain) { + return + } + if hasPrev && !w.firstRun { w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers) w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses) @@ -333,19 +347,6 @@ func (w *Watcher) checkDomain( NXDomain: nxdomain, LastChecked: now, }) - - // A domain that does not exist has no records of its own: none are - // asked for, and those saved by an earlier check are removed. - if nxdomain { - w.state.DeleteHostnameState(domain) - - return - } - - // The apex domain's records are also checked and saved as a - // hostname's, so that the port and TLS checks find its addresses. - // Notifications about them name it as a domain (see nameLine). - w.checkHostname(ctx, domain) } func (w *Watcher) detectNSChanges( @@ -456,10 +457,12 @@ func (w *Watcher) detectNSAddressChanges( } } +// checkHostname checks hostname's records and reports whether it saved +// them; it does not when their lookup fails. func (w *Watcher) checkHostname( ctx context.Context, hostname string, -) { +) bool { results, err := w.resolver.LookupAllRecords(ctx, hostname) if err != nil { w.logFailedLookup( @@ -469,7 +472,7 @@ func (w *Watcher) checkHostname( "error", err, ) - return + return false } prev, _ := w.state.GetHostnameState(hostname) @@ -477,6 +480,8 @@ func (w *Watcher) checkHostname( w.updateHostnameState( ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()), ) + + return true } // updateHostnameState finishes a check of hostname from newState, built @@ -772,28 +777,53 @@ func (w *Watcher) detectRecordChanges( } } +// detectNSDisappearances sends one NS Change listing the nameservers of +// the previous check that are no longer those of the zone hostname is +// in. For a configured apex domain it leaves out those of the domain's +// saved delegation, which detectNSChanges notifies when they are removed +// from it. checkDomain saves a new delegation only after this runs, and +// only together with the records, so the saved delegation and the +// records in prev come from the same check. func (w *Watcher) detectNSDisappearances( ctx context.Context, hostname string, prev, current *state.HostnameState, ) { - for ns, prevNS := range prev.RecordsByNameserver { - if _, ok := current.RecordsByNameserver[ns]; ok || prevNS.Status != statusOK { - continue + var delegation []string + + if w.isDomain(hostname) { + if domainState, ok := w.state.GetDomainState(hostname); ok { + delegation = domainState.Nameservers } - - msg := fmt.Sprintf( - "%s\nNameserver: %s disappeared", - w.nameLine(hostname), ns, - ) - - w.notify.SendNotification( - ctx, - "NS Failure: "+hostname, - msg, - "error", - ) } + + var removed []string + + for ns := range prev.RecordsByNameserver { + _, ok := current.RecordsByNameserver[ns] + if !ok && !slices.Contains(delegation, ns) { + removed = append(removed, ns) + } + } + + if len(removed) == 0 { + return + } + + slices.Sort(removed) + + msg := fmt.Sprintf( + "%s\nRemoved: %s", + w.nameLine(hostname), + strings.Join(removed, ", "), + ) + + w.notify.SendNotification( + ctx, + "NS Change: "+hostname, + msg, + "warning", + ) } // detectNSFailures notifies when a nameserver that answered on the diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 32517d7..3c3df91 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -139,6 +139,22 @@ func (m *mockNotifier) getNotifications() []notification { return result } +// cancelOnNotify is a mockNotifier that calls cancel at each +// notification, as if shutdown cut the check short there. +type cancelOnNotify struct { + *mockNotifier + + cancel context.CancelFunc +} + +func (n cancelOnNotify) SendNotification( + ctx context.Context, + title, message, priority string, +) { + n.mockNotifier.SendNotification(ctx, title, message, priority) + n.cancel() +} + // --- Helpers to build a Watcher and run its checks against live DNS --- type testDeps struct { @@ -558,6 +574,11 @@ func TestDomainThatDoesNotExist(t *testing.T) { // nameservers and without nxdomain, and its records, asked at the // codeberg.page servers, are saved. Those are testSmallDomain's two // nameservers; github.io, the zone of the README's example, has eight. +// The previous check saved a delegation to oldNS1, now withdrawn, and +// records asked at oldNS1. A first check is cut short at its first +// notification, as shutdown would, and a second runs in full: across +// them the NS Change of its delegation removes oldNS1, and no other NS +// Change lists it again. func TestDomainWithNoDelegationOfItsOwn(t *testing.T) { t.Parallel() @@ -569,9 +590,30 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) { var deps *testDeps livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { - var w *watcher.Watcher + deps = newTestDeps(t, cfg) - w, deps = newTestWatcher(t, cfg) + firstCtx, cancel := context.WithCancel(ctx) + defer cancel() + + w := watcher.NewForTest( + cfg, + deps.state, + resolver.NewFromLogger(slog.Default()), + deps.portChecker, + deps.tlsChecker, + cancelOnNotify{mockNotifier: deps.notifier, cancel: cancel}, + ) + + deps.state.SetDomainState(domain, &state.DomainState{ + Nameservers: []string{oldNS1}, + }) + deps.state.SetHostnameState(domain, saved( + map[string]*state.NameserverRecordState{ + oldNS1: answered(map[string][]string{"A": {oldIP}}), + }, + )) + + w.RunOnce(firstCtx) err := checkOnce(ctx, w, deps) @@ -593,6 +635,22 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) { if _, ok := deps.state.GetHostnameState(domain); !ok { t.Errorf("no records saved for %s", domain) } + + title := "NS Change: " + domain + want := "Domain: " + domain + "\nAdded: \nRemoved: " + oldNS1 + + var changes []notification + + for _, n := range deps.notifier.getNotifications() { + if n.Title == title { + changes = append(changes, n) + } + } + + if len(changes) != 1 || changes[0].Message != want { + t.Errorf("sent %v, want one %q with message:\n%s", + changes, title, want) + } } func TestNSAddressChangeDetection(t *testing.T) { @@ -1049,7 +1107,7 @@ func TestSendTestNotification_Disabled(t *testing.T) { } } -func TestNSFailureAndRecovery(t *testing.T) { +func TestNSChangeAndRecovery(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) @@ -1059,7 +1117,7 @@ func TestNSFailureAndRecovery(t *testing.T) { // The saved state has every nameserver live DNS lists as one that // did not answer, and, as answering, one that live DNS does not - // list, which then disappears. + // list, which the check then finds removed. _, deps := runChecks(t, cfg, func(deps *testDeps) { byNameserver := map[string]*state.NameserverRecordState{ oldNS1: answered(map[string][]string{"A": {oldIP}}), @@ -1071,7 +1129,7 @@ func TestNSFailureAndRecovery(t *testing.T) { deps.state.SetHostnameState(testHost, saved(byNameserver)) }) - assertNotified(t, deps, "NS Failure: "+testHost, "error") + assertNotified(t, deps, "NS Change: "+testHost, "warning") assertNotified(t, deps, "NS Recovery: "+testHost, "success") // A nameserver that did not answer has no records to compare, so -- 2.54.0 From 359230261551b0303e326813faa3b890f899ca58 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 20:00:00 +0200 Subject: [PATCH 79/92] watcher: a stop during the first scan sends no startup notification (closes #271) Run sent the startup notification right after the first scan without checking the context. RunOnce returns as usual when shutdown cuts the scan short, so a stop during the first scan sent "has started and completed its initial scan" to every endpoint as the process shut down. Run now sends it only when the context was not cancelled. The new test cancels the context before Run starts, so the scan never sends its lookup; a stop part way through a scan follows the same path. Model: opus-5-5 --- TODO.md | 2 ++ internal/watcher/cancelled_test.go | 24 ++++++++++++++++++++++++ internal/watcher/watcher.go | 7 ++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 0de395d..855cb8f 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: a stop before the first scan completes sends no startup + notification; a scan that ran to the end still sends it (closes #271). - 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change; one leaving a hostname's zone sends NS Change, not NS Failure (closes #262). - 2026-10-13: `DNSWATCHER_METRICS_USERNAME` set without diff --git a/internal/watcher/cancelled_test.go b/internal/watcher/cancelled_test.go index 5d83bc1..247ab7c 100644 --- a/internal/watcher/cancelled_test.go +++ b/internal/watcher/cancelled_test.go @@ -83,6 +83,30 @@ func TestCancelledCheckSavesNothing(t *testing.T) { } } +// TestStopBeforeFirstScanSendsNoStartupNotification runs a watcher with +// the startup notification on and its context already cancelled, so the +// first scan is cut short before its lookup is sent. The scan did not +// complete, so no startup notification may be sent. +func TestStopBeforeFirstScanSendsNoStartupNotification(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{testHost} + cfg.SendTestNotification = true + + w, deps := newTestWatcher(t, cfg) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + w.Run(ctx) + + notifications := deps.notifier.getNotifications() + if len(notifications) != 0 { + t.Errorf("sent %v, want no notifications", notifications) + } +} + // newLoggingWatcher returns a watcher for a domain and a hostname, with // the real resolver, that writes what it logs at warning level or above // into the returned buffer. diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 0748ee1..f7346f9 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -133,7 +133,12 @@ func (w *Watcher) Run(ctx context.Context) { w.cleanupRemovedTargets() w.RunOnce(ctx) - w.maybeSendTestNotification(ctx) + + // RunOnce returns as usual when shutdown cuts the scan short, and + // the startup notification says the scan completed. + if ctx.Err() == nil { + w.maybeSendTestNotification(ctx) + } dnsTicker := time.NewTicker(w.config.DNSInterval) tlsTicker := time.NewTicker(w.config.TLSInterval) -- 2.54.0 From 9e6871817a95e20f93c2b940abe0dceb0f742810 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 20:27:27 +0200 Subject: [PATCH 80/92] state: a state file without a section loads it empty (closes #267) Load fills in each of domains, hostnames, ports and certificates that the file leaves out or holds as null with an empty map. Before, such a section loaded as a nil map, and the first check's write into it panicked, so a state file holding {} stopped the watcher at every start. A state test loads {} and a file missing each section in turn, and checks that every section is an empty map. A watcher test loads {}, and a file without the certificates section, runs the first check against live DNS, and checks that it saved domain, hostname, port and certificate entries. Model: opus-5-5 --- README.md | 4 +++ TODO.md | 2 ++ internal/state/state.go | 18 +++++++++++ internal/state/state_test.go | 52 ++++++++++++++++++++++++++++++++ internal/watcher/watcher_test.go | 48 +++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+) diff --git a/README.md b/README.md index 2eb5697..f073613 100644 --- a/README.md +++ b/README.md @@ -662,6 +662,10 @@ A port entry's `hostnames` lists every name that resolves to its address, domains included. A port entry in the older format, with one `hostname` instead of the `hostnames` list, loads as a list of that one name. +A state file that leaves out any of `domains`, `hostnames`, `ports` and +`certificates`, or holds `null` for one, loads with that section empty. A file +holding `{}` loads as empty state. + --- ## Entrypoints diff --git a/TODO.md b/TODO.md index 855cb8f..d856eae 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: a state file holding `{}`, or leaving out a section, loads with + that section empty; the first check no longer crashes on it (closes #267). - 2026-10-13: a stop before the first scan completes sends no startup notification; a scan that ran to the end still sends it (closes #271). - 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change; diff --git a/internal/state/state.go b/internal/state/state.go index f183764..744f4e8 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -216,6 +216,24 @@ func (s *State) Load() error { return fmt.Errorf("parsing state file: %w", err) } + // A section the file leaves out, or holds as null, loads empty: the + // setters write into these maps, and writing into a nil map panics. + if snapshot.Domains == nil { + snapshot.Domains = make(map[string]*DomainState) + } + + if snapshot.Hostnames == nil { + snapshot.Hostnames = make(map[string]*HostnameState) + } + + if snapshot.Ports == nil { + snapshot.Ports = make(map[string]*PortState) + } + + if snapshot.Certificates == nil { + snapshot.Certificates = make(map[string]*CertificateState) + } + // A state file saved before each record value was stored once can // hold a hostname's CNAME once for every record type asked for. // Each value is kept once, so the first check does not see a diff --git a/internal/state/state_test.go b/internal/state/state_test.go index d2a660f..9b3d998 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -695,6 +695,58 @@ func TestLoadMissingFile(t *testing.T) { } } +// TestLoadFileWithoutSections loads a state file holding {}, and files +// that each leave out one section. Every section loads as an empty map. +// TestFirstCheckAfterLoadingFileWithoutSections in the watcher tests +// runs the first check after such a load. +func TestLoadFileWithoutSections(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + }{ + {"no sections", `{}`}, + {"no domains", `{"hostnames": {}, "ports": {}, "certificates": {}}`}, + {"no hostnames", `{"domains": {}, "ports": {}, "certificates": {}}`}, + {"no ports", `{"domains": {}, "hostnames": {}, "certificates": {}}`}, + {"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + err := os.WriteFile( + filepath.Join(dir, "state.json"), []byte(tt.file), 0o600, + ) + if err != nil { + t.Fatalf("writing state file: %v", err) + } + + s := state.NewForTestWithDataDir(dir) + + err = s.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + want := state.Snapshot{ + Domains: map[string]*state.DomainState{}, + Hostnames: map[string]*state.HostnameState{}, + Ports: map[string]*state.PortState{}, + Certificates: map[string]*state.CertificateState{}, + } + + if got := s.GetSnapshot(); !reflect.DeepEqual(got, want) { + t.Errorf("loaded %#v, want %#v", got, want) + } + }) + } +} + // TestLoadCorruptFile verifies that a corrupt state file returns an error. func TestLoadCorruptFile(t *testing.T) { t.Parallel() diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 3c3df91..869df60 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -477,6 +477,54 @@ func TestDomainPortAndTLSChecks(t *testing.T) { } } +// TestFirstCheckAfterLoadingFileWithoutSections loads a state file +// holding {}, and one that leaves out the certificates section, and runs +// the first check. It saves domain, hostname, port and certificate +// entries; a section loaded as nil made it panic. +func TestFirstCheckAfterLoadingFileWithoutSections(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + }{ + {"no sections", `{}`}, + {"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{testSmallDomain} + + _, deps := runChecks(t, cfg, func(deps *testDeps) { + err := os.WriteFile(cfg.StatePath(), []byte(tt.file), 0o600) + if err != nil { + t.Fatalf("writing the state file: %v", err) + } + + err = deps.state.Load() + if err != nil { + t.Fatalf("loading the state file: %v", err) + } + }) + + snap := deps.state.GetSnapshot() + if len(snap.Domains) == 0 || len(snap.Hostnames) == 0 || + len(snap.Ports) == 0 || len(snap.Certificates) == 0 { + t.Errorf( + "saved %d domain, %d hostname, %d port and %d "+ + "certificate entries, want at least one of each", + len(snap.Domains), len(snap.Hostnames), + len(snap.Ports), len(snap.Certificates), + ) + } + }) + } +} + func TestNSChangeDetection(t *testing.T) { t.Parallel() -- 2.54.0 From cb89e4f8cfce3a6885c810105fc77fc1b93cb29f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 21:27:34 +0200 Subject: [PATCH 81/92] watcher: a port check that finds 443 closed removes its certificate entries (closes #265) The TLS check runs only on an open port 443, and the certificate cleanup removed an entry only when its name stopped resolving to the address, so an entry stayed ok for as long as its port was closed. The cleanup after the port checks now also removes the entries of an address whose port 443 was found closed. Nothing is notified for the removal; the Port Change already says the port closed. When the port opens again, the next TLS check saves the certificate as new, with no change or recovery notification. The README says both. Judgement call: the entry goes even while none of the name's nameservers answer, since the port check reached the address itself. Model: opus-5-5 --- README.md | 14 +++++-- TODO.md | 2 + internal/watcher/removed_test.go | 65 ++++++++++++++++++++++++++++++++ internal/watcher/watcher.go | 17 +++++++-- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f073613..6ea6eab 100644 --- a/README.md +++ b/README.md @@ -223,8 +223,14 @@ notification endpoint set, changes show only on the dashboard; see - Certificate CN, issuer, or SANs changed (replacement detected, reports old and new CN and issuer). - TLS connection failure to a previously-reachable IP:443 (handshake error, - timeout, connection refused after previously succeeding). + timeout, or connection refused when port 443 closed after the port check + that found it open). - TLS recovery: a previously-failing IP:443 now completes a handshake again. +- A port check that finds port 443 closed removes the certificate entries for + that IP address, without a notification of its own: the port change + notification reports the closed port. When the port opens again, the next TLS + check saves the certificate as new, without a certificate change or recovery + notification. ### Notifications @@ -294,8 +300,10 @@ clears them. port entry left with no name goes, so the dashboard and `/api/v1/status` no longer list or count it. The first check's port checks remove the port entries of addresses no configured name has. -- Each port check also removes the certificate entries for an address their name - no longer resolves to, except while none of the name's nameservers answer. +- Each port check also removes, without a notification, the certificate entries + for an address whose port 443 it found closed, and those for an address their + name no longer resolves to, except while none of the name's nameservers + answer. ### Web Dashboard diff --git a/TODO.md b/TODO.md index d856eae..03e9ee3 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: a port check that finds port 443 closed removes that address's + certificate entries, so none is left shown as `ok` (closes #265). - 2026-10-13: a state file holding `{}`, or leaving out a section, loads with that section empty; the first check no longer crashes on it (closes #267). - 2026-10-13: a stop before the first scan completes sends no startup diff --git a/internal/watcher/removed_test.go b/internal/watcher/removed_test.go index a7c51e2..fe5e22c 100644 --- a/internal/watcher/removed_test.go +++ b/internal/watcher/removed_test.go @@ -3,6 +3,7 @@ package watcher_test import ( "maps" "slices" + "strings" "testing" "sneak.berlin/go/dnswatcher/internal/state" @@ -222,3 +223,67 @@ func TestCertificateStateForAnAddressGone(t *testing.T) { }) } } + +// TestCertificateStateForAPortClosed runs the port checks on hostname +// and port state built here for a configured hostname at ip1, whose +// port 443 the previous check found open and whose certificate entry +// is ok. The port is closed now: the certificate entry is removed, and +// only the Port Change is notified. When the port opens again, the next +// TLS check saves the certificate as new and notifies nothing about it. +// Nothing is looked up. +func TestCertificateStateForAPortClosed(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{host} + + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + w.SetFirstRun(false) + + certKey := ip1 + ":443:" + host + + deps.state.SetHostnameState(host, saved( + map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{"A": {ip1}}), + }, + )) + deps.state.SetPortState(ip1+":443", &state.PortState{ + Open: true, Hostnames: []string{host}, + }) + deps.state.SetCertificateState(certKey, &state.CertificateState{ + CommonName: host, Status: "ok", + }) + deps.portChecker.closed = true + + w.CheckAllPorts(t.Context()) + + if _, ok := deps.state.GetCertificateState(certKey); ok { + t.Errorf("certificate entry %s kept with port 443 closed", certKey) + } + + sent := deps.notifier.getNotifications() + if title := "Port Change: " + ip1 + ":443"; len(sent) != 1 || + sent[0].Title != title { + t.Errorf("sent %v, want one %q", sent, title) + } + + deps.portChecker.closed = false + + w.CheckAllPorts(t.Context()) + w.RunTLSChecks(t.Context()) + + cert, ok := deps.state.GetCertificateState(certKey) + if !ok || cert.Status != "ok" { + t.Errorf("certificate entry %s after port 443 opened: %+v", certKey, cert) + } + + for _, n := range deps.notifier.getNotifications() { + if strings.HasPrefix(n.Title, "TLS") { + t.Errorf("sent %v, want no TLS notification", n) + } + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index f7346f9..3def3b0 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -978,7 +978,8 @@ func (w *Watcher) checkAllPorts(ctx context.Context) { // Phase 3: Remove port state entries that no longer have // any hostname referencing them, and certificate entries for - // an address their name no longer has. + // an address whose port 443 is closed or that their name no + // longer has. w.cleanupStalePorts(associations) w.cleanupStaleCertificates() } @@ -1065,13 +1066,21 @@ func (w *Watcher) cleanupStalePorts( } // cleanupStaleCertificates removes the certificate entries for an -// address their name no longer resolves to. An entry saved for a name -// none of whose nameservers answered is kept: that name's addresses are -// not known, not gone. +// address whose port 443 was found closed, and for an address their +// name no longer resolves to. An entry saved for a name none of whose +// nameservers answered is kept unless its port is closed: that name's +// addresses are not known, not gone. func (w *Watcher) cleanupStaleCertificates() { for _, key := range w.state.GetAllCertificateKeys() { ip, hostname := parseCertKey(key) + ps, ok := w.state.GetPortState(fmt.Sprintf("%s:%d", ip, tlsPort)) + if ok && !ps.Open { + w.state.DeleteCertificateState(key) + + continue + } + if slices.Contains(w.collectIPs(hostname), ip) || w.noNameserverAnswered(hostname) { continue -- 2.54.0 From 6b580a5a0f2d7f2aad368bb425269f0ac7501065 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 22:00:04 +0200 Subject: [PATCH 82/92] docs: TODO.md names only open issues as next steps (closes #281) Next Step was the trial run in issue 149 and Future Steps listed issue 144, both closed. Next Step is now the 1.0 tracking issue, 66, and Future Steps holds only DNSSEC validation, 59, which comes after 1.0. Status drops "No git tags", which stops being true once a tag is made. Issue 149 is not added to Completed Steps: the issue asked for no change there beyond this entry. Model: opus-5-5 --- TODO.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 03e9ee3..83d0bf8 100644 --- a/TODO.md +++ b/TODO.md @@ -10,15 +10,17 @@ # Status -pre-1.0. No git tags. Work lands on `next` by PR. Open work for 1.0 is tracked -on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 +pre-1.0. Work lands on `next` by PR. Open work for 1.0 is tracked on the 1.0 +milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Next Step -trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 +1.0 tracking issue: https://git.eeqj.de/sneak/dnswatcher/issues/66 # Completed Steps +- 2026-10-13: `TODO.md` names only open issues as next and future steps, and its + Status says nothing about git tags (closes #281). - 2026-10-13: a port check that finds port 443 closed removes that address's certificate entries, so none is left shown as `ok` (closes #265). - 2026-10-13: a state file holding `{}`, or leaving out a section, loads with @@ -201,6 +203,4 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Future Steps -- 1.0 readiness: run it with a real config and read the logs: - https://git.eeqj.de/sneak/dnswatcher/issues/66 -- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144 +- DNSSEC validation, after 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/59 -- 2.54.0 From aaccabad502e4b867b3544fbd2580eb0b04bb31b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 22:27:34 +0200 Subject: [PATCH 83/92] watcher: an IPv6 address is written in brackets before its port (closes #270) Port and certificate keys were joined as address:port, so an IPv6 address ran into its port: 2a01:4f8:2200:fd11::1:80 is also an address on its own. Keys are now joined with net.JoinHostPort and split with net.SplitHostPort, giving [2a01:4f8:2200:fd11::1]:80 in the state file, /api/v1/status, the dashboard and Port Change notifications; an IPv4 address is unchanged. A certificate key still splits at its last colon, since a hostname has none. Tests that built IPv6 keys by hand now use the bracketed form, and a test checks that an IPv6 address's certificate entry is removed when its port 443 closes. Model: opus-5-5 --- README.md | 6 +++ TODO.md | 2 + internal/watcher/ipv6_test.go | 83 ++++++++++++++++++++++++++++++++ internal/watcher/removed_test.go | 8 +-- internal/watcher/watcher.go | 25 +++++----- internal/watcher/watcher_test.go | 11 +++-- 6 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 internal/watcher/ipv6_test.go diff --git a/README.md b/README.md index 6ea6eab..1bcbf1c 100644 --- a/README.md +++ b/README.md @@ -666,6 +666,12 @@ for A, AAAA and CNAME, the previous check's list is kept, or `null` when no earlier check saved one. A state file without it loads, and the first check after that saves it without a notification. +A port entry's key is its address and port, and a certificate entry's key its +address, port 443 and hostname, joined by colons. An IPv6 address is written in +brackets in these keys, as in `[2001:db8::1]:443` and +`[2001:db8::1]:443:www.example.com`. `/api/v1/status` and the dashboard show the +same keys. + A port entry's `hostnames` lists every name that resolves to its address, domains included. A port entry in the older format, with one `hostname` instead of the `hostnames` list, loads as a list of that one name. diff --git a/TODO.md b/TODO.md index 83d0bf8..0ebda04 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-13: an IPv6 address is written in brackets before its port, as in + `[2001:db8::1]:443`, in keys and Port Change notifications (closes #270). - 2026-10-13: `TODO.md` names only open issues as next and future steps, and its Status says nothing about git tags (closes #281). - 2026-10-13: a port check that finds port 443 closed removes that address's diff --git a/internal/watcher/ipv6_test.go b/internal/watcher/ipv6_test.go new file mode 100644 index 0000000..7b1608d --- /dev/null +++ b/internal/watcher/ipv6_test.go @@ -0,0 +1,83 @@ +package watcher_test + +import ( + "maps" + "slices" + "testing" + + "sneak.berlin/go/dnswatcher/internal/state" + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +// TestIPv6AddressInBrackets runs the port checks, not the watcher's +// first, on hostname state built here for a hostname at an IPv6 address +// whose port 443 the previous check found closed. It is open now. Then +// it runs the TLS checks and the port checks again. The port and +// certificate keys and the Port Change notification write the address +// in brackets, so its last group cannot be read as the port, and the +// second port checks keep the certificate entry, whose address they read +// back from its key. When a last port check finds port 443 closed, the +// certificate entry is removed. Nothing is looked up. +func TestIPv6AddressInBrackets(t *testing.T) { + t.Parallel() + + const aaaaType = "AAAA" + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{host} + + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + w.SetFirstRun(false) + + deps.state.SetHostnameState(host, saved( + map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{aaaaType: {"2001:db8::1"}}), + }, + )) + deps.state.SetPortState("[2001:db8::1]:443", &state.PortState{ + Hostnames: []string{host}, + }) + + w.CheckAllPorts(t.Context()) + w.RunTLSChecks(t.Context()) + w.CheckAllPorts(t.Context()) + + snap := deps.state.GetSnapshot() + + gotPorts := slices.Sorted(maps.Keys(snap.Ports)) + wantPorts := []string{"[2001:db8::1]:443", "[2001:db8::1]:80"} + + if !slices.Equal(gotPorts, wantPorts) { + t.Errorf("port entries %v, want %v", gotPorts, wantPorts) + } + + gotCerts := slices.Sorted(maps.Keys(snap.Certificates)) + wantCerts := []string{"[2001:db8::1]:443:www.example.net"} + + if !slices.Equal(gotCerts, wantCerts) { + t.Errorf("certificate entries %v, want %v", gotCerts, wantCerts) + } + + title := "Port Change: [2001:db8::1]:443" + want := `Hostnames: www.example.net +Address: [2001:db8::1]:443 +Port now open` + + got := deps.notifier.getNotifications() + if len(got) != 1 || got[0].Title != title || got[0].Message != want { + t.Errorf("sent %v, want one %q with message:\n%s", got, title, want) + } + + deps.portChecker.closed = true + + w.CheckAllPorts(t.Context()) + + gotCerts = slices.Sorted(maps.Keys(deps.state.GetSnapshot().Certificates)) + if len(gotCerts) != 0 { + t.Errorf("certificate entries %v with port 443 closed, want none", gotCerts) + } +} diff --git a/internal/watcher/removed_test.go b/internal/watcher/removed_test.go index fe5e22c..2549b18 100644 --- a/internal/watcher/removed_test.go +++ b/internal/watcher/removed_test.go @@ -2,6 +2,7 @@ package watcher_test import ( "maps" + "net" "slices" "strings" "testing" @@ -173,7 +174,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) { "A": {ip1}, "AAAA": {ip6}, }), }), - []string{ip1 + ":443:" + host, ip6 + ":443:" + host}, + []string{ip1 + ":443:" + host, "[" + ip6 + "]:443:" + host}, }, { "no nameserver answered", @@ -183,7 +184,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) { []string{ ip1 + ":443:" + host, ip2 + ":443:" + host, - ip6 + ":443:" + host, + "[" + ip6 + "]:443:" + host, }, }, } @@ -206,7 +207,8 @@ func TestCertificateStateForAnAddressGone(t *testing.T) { for _, ip := range []string{ip1, ip2, ip6} { deps.state.SetCertificateState( - ip+":443:"+host, &state.CertificateState{Status: "ok"}, + net.JoinHostPort(ip, "443")+":"+host, + &state.CertificateState{Status: "ok"}, ) } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 3def3b0..45962b9 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -6,8 +6,10 @@ import ( "fmt" "log/slog" "maps" + "net" "slices" "sort" + "strconv" "strings" "time" @@ -1000,7 +1002,7 @@ func (w *Watcher) buildPortAssociations() map[string][]string { ips := w.collectIPs(name) for _, ip := range ips { for _, port := range monitoredPorts { - key := fmt.Sprintf("%s:%d", ip, port) + key := net.JoinHostPort(ip, strconv.Itoa(port)) if assoc[key] == nil { assoc[key] = make(map[string]bool) } @@ -1025,18 +1027,15 @@ func (w *Watcher) buildPortAssociations() map[string][]string { return result } -// parsePortKey splits an "ip:port" key into its components. +// parsePortKey splits an "ip:port" key, written by net.JoinHostPort, +// into its components. func parsePortKey(key string) (string, int) { - lastColon := strings.LastIndex(key, ":") - if lastColon < 0 { + ip, port, err := net.SplitHostPort(key) + if err != nil { return key, 0 } - ip := key[:lastColon] - - var p int - - _, err := fmt.Sscanf(key[lastColon+1:], "%d", &p) + p, err := strconv.Atoi(port) if err != nil { return ip, 0 } @@ -1074,7 +1073,7 @@ func (w *Watcher) cleanupStaleCertificates() { for _, key := range w.state.GetAllCertificateKeys() { ip, hostname := parseCertKey(key) - ps, ok := w.state.GetPortState(fmt.Sprintf("%s:%d", ip, tlsPort)) + ps, ok := w.state.GetPortState(net.JoinHostPort(ip, strconv.Itoa(tlsPort))) if ok && !ps.Open { w.state.DeleteCertificateState(key) @@ -1189,7 +1188,7 @@ func (w *Watcher) checkSinglePort( return } - key := fmt.Sprintf("%s:%d", ip, port) + key := net.JoinHostPort(ip, strconv.Itoa(port)) now := time.Now().UTC() prev, hasPrev := w.state.GetPortState(key) @@ -1251,7 +1250,7 @@ func (w *Watcher) checkTLSForHostname( ips := w.collectIPs(hostname) for _, ip := range ips { - portKey := fmt.Sprintf("%s:%d", ip, tlsPort) + portKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) ps, ok := w.state.GetPortState(portKey) if !ok || !ps.Open { @@ -1275,7 +1274,7 @@ func (w *Watcher) checkTLSCert( return } - certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname) + certKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) + ":" + hostname now := time.Now().UTC() prev, hasPrev := w.state.GetCertificateState(certKey) diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 869df60..79feb23 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "net" "os" "slices" "strings" @@ -824,7 +825,8 @@ func TestPortStateChange(t *testing.T) { hs, _ := deps.state.GetHostnameState(testHost) assertNotified( - t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning", + t, deps, "Port Change: "+net.JoinHostPort(addresses(hs)[0], "443"), + "warning", ) } @@ -1031,11 +1033,12 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { // Port and TLS checks must use the addresses this check found. for _, ip := range addresses(snap.Hostnames[testHost]) { - if _, ok := snap.Ports[ip+":80"]; !ok { - t.Error("port check used stale DNS: missing " + ip + ":80") + portKey := net.JoinHostPort(ip, "80") + if _, ok := snap.Ports[portKey]; !ok { + t.Error("port check used stale DNS: missing " + portKey) } - certKey := ip + ":443:" + testHost + certKey := net.JoinHostPort(ip, "443") + ":" + testHost if _, ok := snap.Certificates[certKey]; !ok { t.Error("TLS check used stale DNS: missing " + certKey) } -- 2.54.0 From b79726acc6effd279c5f01cff95433b9c79097c2 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 00:27:29 +0200 Subject: [PATCH 84/92] watcher: a hostname whose zone's nameservers all stop answering gets NS Failure (closes #284) When none of the nameservers of a hostname's zone answered, the walk to them failed and the check was only logged. The walk now returns a NoAnswerError naming the nameservers of the zone it reached through a referral when none of them answers. When those are the nameservers the previous check saved for the name, each is saved as failed with the reason, so it gets an NS Failure and later an NS Recovery. A domain with no delegation of its own, whose NS lookup is that same walk, gets the same. A walk that fails before that zone, as when the host loses its network, is still only logged. Model: opus-5-5 --- README.md | 16 +++- TODO.md | 2 + internal/resolver/errors.go | 17 ++++ internal/resolver/export_test.go | 4 +- internal/resolver/iterative.go | 34 +++++--- internal/resolver/resolver_test.go | 31 ++++++- internal/watcher/export_test.go | 28 ++++++ internal/watcher/interfaces.go | 5 +- internal/watcher/nsfailure_test.go | 133 +++++++++++++++++++++++++++++ internal/watcher/watcher.go | 82 ++++++++++++++++-- 10 files changed, 328 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 1bcbf1c..dd94301 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,11 @@ notification endpoint set, changes show only on the dashboard; see NS change notification above. A domain listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency notification. A domain with no delegation of its own has these records asked - at the servers of the zone it is in, as a hostname has. A nameserver the - domain's records were asked at on the previous check but not on this one gets - a hostname's NS change notification (see below), unless it was in the domain's + at the servers of the zone it is in, as a hostname has. When none of those + servers answers, the lookup of its NS record set fails, and they are saved as + failed and notified as a hostname's are (see below). A nameserver the domain's + records were asked at on the previous check but not on this one gets a + hostname's NS change notification (see below), unless it was in the domain's delegation on the previous check, as when that delegation is withdrawn. A domain that does not exist has none: they are not asked for, and those saved by an earlier check are removed without a notification. @@ -121,7 +123,13 @@ notification endpoint set, changes show only on the dashboard; see trace moves from a name to its parent only when the servers asked answer that the name has no delegation of its own, or does not exist. When they do not answer, the check fails and the hostname's records from the previous check are - kept. + kept, unless they are the nameservers the previous check saved for the + hostname. Each of those is then saved as failed, with the reason, as a + domain's nameservers are when none of them answers, so it gets an NS query + failure notification, and an NS recovery notification when it answers again + (see below). A trace that gets no answer before it reaches them, as when the + host running dnswatcher loses its network, fails the check without a + notification. - Queries **each** authoritative nameserver independently for **all** record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. - Each record type is a query of its own. When a nameserver answers some types diff --git a/TODO.md b/TODO.md index 0ebda04..4fb2e39 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering + gets an NS Failure for each, then an NS Recovery (closes #284). - 2026-10-13: an IPv6 address is written in brackets before its port, as in `[2001:db8::1]:443`, in keys and Port Change notifications (closes #270). - 2026-10-13: `TODO.md` names only open issues as next and future steps, and its diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index b628dd9..8c53adb 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -2,6 +2,23 @@ package resolver import "errors" +// NoAnswerError is returned when the walk to a name's nameservers +// reached a zone through a referral and none of that zone's +// nameservers, Nameservers as the referral names them, answered. Err +// says why they did not. +type NoAnswerError struct { + Nameservers []string + Err error +} + +func (e *NoAnswerError) Error() string { + return e.Err.Error() +} + +func (e *NoAnswerError) Unwrap() error { + return e.Err +} + // Sentinel errors returned by the resolver. var ( // ErrNoNameservers is returned when no authoritative NS diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 0ea1a1f..23471b8 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -31,9 +31,11 @@ func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver { func (r *Resolver) FollowDelegation( ctx context.Context, domain string, + zone string, + nameservers []string, servers []string, ) ([]string, error) { - return r.followDelegation(ctx, domain, servers) + return r.followDelegation(ctx, domain, zone, nameservers, servers) } // FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 35df296..e5689f7 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -204,20 +204,24 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string { return ips } -// followDelegation follows referrals from servers, the root servers, to -// domain and returns the NS set of domain's delegation. When the servers -// of the zone domain is in answer that domain does not exist, the error -// is ErrNXDomain. When they answer that it has no delegation of its own, +// followDelegation follows referrals to domain from servers, the +// addresses of the servers of zone, and returns the NS set of domain's +// delegation. nameservers are the names a referral gave the servers of +// zone; the walk to a name's nameservers starts at the root servers, the +// servers of zone ".", which no referral names. When the servers of the +// zone domain is in answer that domain does not exist, the error is +// ErrNXDomain. When they answer that it has no delegation of its own, // because it is not the zone's apex, the set is empty and there is no -// error. Any other error means that no such answer came. +// error. Any other error means that no such answer came; it is a +// *NoAnswerError when the servers that did not answer are those of a +// zone a referral named. func (r *Resolver) followDelegation( ctx context.Context, domain string, + zone string, + nameservers []string, servers []string, ) ([]string, error) { - // servers are the root servers, the servers of zone ".". - zone := "." - var withoutAddresses []string for range maxDelegation { @@ -228,6 +232,12 @@ func (r *Resolver) followDelegation( resp, err := r.queryZone( ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0, ) + + // A query that ctx cut short says nothing about the servers. + if err != nil && len(nameservers) > 0 && checkCtx(ctx) == nil { + return nil, &NoAnswerError{Nameservers: nameservers, Err: err} + } + if err != nil { return nil, err } @@ -256,6 +266,7 @@ func (r *Resolver) followDelegation( return r.resolveNSIterative(ctx, domain) } + nameservers = authNS servers, withoutAddresses = referralNameservers(resp) zone = referralZone(resp) } @@ -626,7 +637,8 @@ func (r *Resolver) resolveARecord( // them. When the servers asked answer that the name has no delegation // of its own, or does not exist, it tries each parent name in turn, so // it returns the nameservers of the zone the name is in. When they do -// not answer, it returns the error and tries no parent name. +// not answer, it returns the error and tries no parent name; when a +// referral named them, the error is a *NoAnswerError naming them. func (r *Resolver) FindAuthoritativeNameservers( ctx context.Context, domain string, @@ -655,7 +667,7 @@ func (r *Resolver) findAuthoritativeNameservers( candidate := strings.Join(labels[i:], ".") + "." - nsNames, err := r.followDelegation(ctx, candidate, servers) + nsNames, err := r.followDelegation(ctx, candidate, ".", nil, servers) if err != nil && !errors.Is(err, ErrNXDomain) { return nil, err } @@ -1067,7 +1079,7 @@ func (r *Resolver) LookupNS( } return r.followDelegation( - ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(), + ctx, dns.Fqdn(strings.ToLower(domain)), ".", nil, rootServerList(), ) } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 58033b7..1c535e7 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -121,7 +121,9 @@ func TestFindAuthoritativeNameservers_DelegatedSubdomain( // delegation of its own, so the lookup returns that walk's error, about // www.google.com, and tries no parent name: trying google.com and com // would end in ErrNoNameservers, or in the error of a walk for one of -// them. +// them. The servers that did not answer are those the walk started at, +// which no referral named, so the error is no NoAnswerError, as when +// the host running dnswatcher has lost its network. func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) { t.Parallel() @@ -134,6 +136,9 @@ func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) { require.NotErrorIs(t, err, resolver.ErrNoNameservers) assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress) assert.Empty(t, nameservers) + + var noAnswer *resolver.NoAnswerError + assert.NotErrorAs(t, err, &noAnswer) } func TestFindAuthoritativeNameservers_ReturnsSorted( @@ -954,12 +959,34 @@ func TestFollowDelegation_NoAnswer(t *testing.T) { r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond) nameservers, err := r.FollowDelegation( - t.Context(), "google.com.", []string{noAnswerAddress}, + t.Context(), "google.com.", ".", nil, []string{noAnswerAddress}, ) require.Error(t, err) assert.Empty(t, nameservers) } +// TestFollowDelegation_NoAnswerAfterReferral starts the walk for +// www.example.net at the servers of example.net as a referral names +// them, ns1.example.net and ns2.example.net, at 192.0.2.1, where nothing +// answers. The error is a NoAnswerError naming them, which the watcher +// compares with the nameservers it saved for the name. +func TestFollowDelegation_NoAnswerAfterReferral(t *testing.T) { + t.Parallel() + + r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond) + nameservers := []string{"ns1.example.net.", "ns2.example.net."} + + _, err := r.FollowDelegation( + t.Context(), "www.example.net.", "example.net.", nameservers, + []string{noAnswerAddress}, + ) + + var noAnswer *resolver.NoAnswerError + require.ErrorAs(t, err, &noAnswer) + assert.Equal(t, nameservers, noAnswer.Nameservers) + assert.Contains(t, err.Error(), "query www.example.net. @"+noAnswerAddress) +} + // TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers // of www.google.com as the fallback walk does. As in // TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error. diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index a3448e9..f13a0b0 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -122,6 +122,34 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) { w.runTLSChecks(ctx) } +// CheckHostnameResult exports checkHostnameResult for testing. +func (w *Watcher) CheckHostnameResult( + ctx context.Context, + hostname string, + results map[string]*resolver.NameserverResponse, + err error, +) bool { + return w.checkHostnameResult(ctx, hostname, results, err) +} + +// CheckDomainResult exports checkDomainResult for testing. +func (w *Watcher) CheckDomainResult( + ctx context.Context, + domain string, + nameservers []string, + err error, +) { + w.checkDomainResult(ctx, domain, nameservers, err) +} + +// FailedNameservers exports failedNameservers for testing. +func FailedNameservers( + err error, + prev *state.HostnameState, +) map[string]*resolver.NameserverResponse { + return failedNameservers(err, prev) +} + // BuildHostnameState exports buildHostnameState for testing. func BuildHostnameState( results map[string]*resolver.NameserverResponse, diff --git a/internal/watcher/interfaces.go b/internal/watcher/interfaces.go index 166ada4..c92d8d6 100644 --- a/internal/watcher/interfaces.go +++ b/internal/watcher/interfaces.go @@ -20,7 +20,10 @@ type DNSResolver interface { ) ([]string, error) // LookupAllRecords queries all record types for a hostname, - // returning each nameserver's response keyed by nameserver. + // returning each nameserver's response keyed by nameserver. When + // the walk to the nameservers reached a zone through a referral and + // none of that zone's nameservers answered, the error is a + // *resolver.NoAnswerError naming them. LookupAllRecords( ctx context.Context, hostname string, diff --git a/internal/watcher/nsfailure_test.go b/internal/watcher/nsfailure_test.go index e9147c1..745a185 100644 --- a/internal/watcher/nsfailure_test.go +++ b/internal/watcher/nsfailure_test.go @@ -2,6 +2,7 @@ package watcher_test import ( "context" + "errors" "fmt" "log/slog" "strings" @@ -14,6 +15,10 @@ import ( "sneak.berlin/go/dnswatcher/internal/watcher" ) +// errNoReply is why a walk that got no reply from a zone's servers +// failed. +var errNoReply = errors.New("all servers failed: i/o timeout") + // answered is what a check saves for a nameserver that answered with // these records. func answered(records map[string][]string) *state.NameserverRecordState { @@ -183,6 +188,134 @@ func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) { } } +// TestZoneNameserversAllStopAnswering runs three checks of host from +// the point where its lookup has returned. On the first, nsA and nsB, +// the nameservers of its zone, answer. On the second, the lookup fails +// with the error the walk to them returns when neither answers (see +// the resolver's TestFollowDelegation_NoAnswerAfterReferral), given +// here because no live zone has nameservers that never answer. For a +// hostname it is the lookup of its records that fails, and for a domain +// with no delegation of its own the lookup of its delegation. Both are +// saved as failed, with the reason, and each gets an NS Failure. On the +// third, both answer, and each gets an NS Recovery. A domain's records +// are checked as a hostname's, so its first and third checks are too. +func TestZoneNameserversAllStopAnswering(t *testing.T) { + t.Parallel() + + records := map[string][]string{"A": {ip1}} + bothAnswer := map[string]*resolver.NameserverResponse{ + nsA: response(records), nsB: response(records), + } + noAnswer := &resolver.NoAnswerError{ + Nameservers: []string{nsA, nsB}, Err: errNoReply, + } + + tests := []struct { + name string + isDomain bool + }{ + {"hostname", false}, + {"domain with no delegation of its own", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + if tt.isDomain { + cfg.Domains = []string{host} + } else { + cfg.Hostnames = []string{host} + } + + // The watcher is given each lookup's result, so it has no + // resolver. + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + w.SetFirstRun(false) + + w.CheckHostnameResult(t.Context(), host, bothAnswer, nil) + + if tt.isDomain { + w.CheckDomainResult(t.Context(), host, nil, noAnswer) + } else { + w.CheckHostnameResult(t.Context(), host, nil, noAnswer) + } + + current, _ := deps.state.GetHostnameState(host) + + for _, ns := range []string{nsA, nsB} { + got := current.RecordsByNameserver[ns] + if got.Status != failed().Status || got.Error != errNoReply.Error() { + t.Errorf( + "%s saved with status %q, error %q; want %q, %q", + ns, got.Status, got.Error, failed().Status, errNoReply, + ) + } + } + + if n := countNotifications(deps, "NS Failure: "+host); n != 2 { + t.Errorf("sent %d NS Failure notifications, want 2", n) + } + + w.CheckHostnameResult(t.Context(), host, bothAnswer, nil) + + if n := countNotifications(deps, "NS Recovery: "+host); n != 2 { + t.Errorf("sent %d NS Recovery notifications, want 2", n) + } + }) + } +} + +// TestFailedNameserversOnlyForThoseOfThePreviousCheck gives +// FailedNameservers lookup errors after a check in which nsA and nsB +// answered. Only an error that names both makes them failed. +func TestFailedNameserversOnlyForThoseOfThePreviousCheck(t *testing.T) { + t.Parallel() + + records := map[string][]string{"A": {ip1}} + bothAnswered := saved(map[string]*state.NameserverRecordState{ + nsA: answered(records), nsB: answered(records), + }) + noAnswer := func(nameservers ...string) error { + return &resolver.NoAnswerError{Nameservers: nameservers, Err: errNoReply} + } + + tests := []struct { + name string + err error + prev *state.HostnameState + wantFailed bool + }{ + {"both did not answer", noAnswer(nsA, nsB), bothAnswered, true}, + { + "the servers of a parent zone did not answer", + noAnswer(nsC), bothAnswered, false, + }, + { + "the zone's delegation lists only nsA now", + noAnswer(nsA), bothAnswered, false, + }, + {"no root server answered", errNoReply, bothAnswered, false}, + {"no previous check", noAnswer(nsA, nsB), nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := watcher.FailedNameservers(tt.err, tt.prev) + if (got != nil) != tt.wantFailed { + t.Errorf("returned %v, want failed: %v", got, tt.wantFailed) + } + }) + } +} + // TestNameserverThatNeverAnswers asks a nameserver address where // nothing answers, 192.0.2.1, and checks what the watcher saves for it. // The deadline outlasts the resolver's first two-second try, as in the diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 45962b9..f0f26ff 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -291,12 +291,25 @@ func (w *Watcher) logFailedLookup( w.log.Error(msg, args...) } +// checkDomain looks up domain's delegation and checks domain with it +// (see checkDomainResult). func (w *Watcher) checkDomain( ctx context.Context, domain string, ) { nameservers, err := w.resolver.LookupNS(ctx, domain) + w.checkDomainResult(ctx, domain, nameservers, err) +} + +// checkDomainResult finishes a check of domain from nameservers and err, +// what the lookup of its delegation returned. +func (w *Watcher) checkDomainResult( + ctx context.Context, + domain string, + nameservers []string, + err error, +) { // A domain that does not exist has no nameservers. nxdomain := errors.Is(err, resolver.ErrNXDomain) if nxdomain { @@ -311,6 +324,12 @@ func (w *Watcher) checkDomain( "error", err, ) + // For a domain with no delegation of its own, the servers that + // did not answer can be those of the zone it is in, at which + // its records are asked. They are then saved as failed, as a + // hostname's are. + w.checkHostnameResult(ctx, domain, nil, err) + return } @@ -464,8 +483,8 @@ func (w *Watcher) detectNSAddressChanges( } } -// checkHostname checks hostname's records and reports whether it saved -// them; it does not when their lookup fails. +// checkHostname looks up hostname's records and checks them (see +// checkHostnameResult). It reports whether it saved them. func (w *Watcher) checkHostname( ctx context.Context, hostname string, @@ -478,12 +497,31 @@ func (w *Watcher) checkHostname( "hostname", hostname, "error", err, ) - - return false } + return w.checkHostnameResult(ctx, hostname, results, err) +} + +// checkHostnameResult finishes a check of hostname from results and err, +// what the lookup of its records returned, and reports whether it saved +// them. When the lookup failed, it saves nothing, unless the nameservers +// that did not answer are those of the previous check (see +// failedNameservers). +func (w *Watcher) checkHostnameResult( + ctx context.Context, + hostname string, + results map[string]*resolver.NameserverResponse, + err error, +) bool { prev, _ := w.state.GetHostnameState(hostname) + if err != nil { + results = failedNameservers(err, prev) + if results == nil { + return false + } + } + w.updateHostnameState( ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()), ) @@ -491,6 +529,40 @@ func (w *Watcher) checkHostname( return true } +// failedNameservers returns a failed response for each nameserver in +// prev, a name's state from the previous check, with err as the reason, +// when err says that the walk to the name's nameservers reached their +// zone and none of them answered. For any other error, as when the walk +// got no answer before it reached their zone, or when prev is nil, it +// returns nil. +func failedNameservers( + err error, + prev *state.HostnameState, +) map[string]*resolver.NameserverResponse { + var noAnswer *resolver.NoAnswerError + if !errors.As(err, &noAnswer) || prev == nil { + return nil + } + + saved := slices.Sorted(maps.Keys(prev.RecordsByNameserver)) + if !slices.Equal(noAnswer.Nameservers, saved) { + return nil + } + + results := make(map[string]*resolver.NameserverResponse) + + for _, ns := range saved { + results[ns] = &resolver.NameserverResponse{ + Nameserver: ns, + Records: make(map[string][]string), + Status: resolver.StatusError, + Error: err.Error(), + } + } + + return results +} + // updateHostnameState finishes a check of hostname from newState, built // from its nameservers' answers: it follows the CNAME in them, notifies // what changed since the previous check, and saves newState. @@ -790,7 +862,7 @@ func (w *Watcher) detectRecordChanges( // saved delegation, which detectNSChanges notifies when they are removed // from it. checkDomain saves a new delegation only after this runs, and // only together with the records, so the saved delegation and the -// records in prev come from the same check. +// nameservers in prev come from the same check. func (w *Watcher) detectNSDisappearances( ctx context.Context, hostname string, -- 2.54.0 From 5e2c456e85c3fa3a2d68e29d72e11b03828462b9 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 14:48:17 +0200 Subject: [PATCH 85/92] notify: the log names a notification endpoint, not its webhook URL (closes #288) With debug logging on, each delivery logged the full Slack, Mattermost or ntfy URL, whose path or query lets anyone who has it post to the channel or topic. The two debug lines now give the endpoint's name and the title; sendSlack is told which of Slack or Mattermost it sends to. A webhook URL that does not parse gave a startup error quoting the whole URL; it now gives only the reason. Errors from a failed request come from the transport, not from an HTTP client, and carry no part of the path or query; a test now checks that too. Model: opus-5-5 --- TODO.md | 2 + internal/notify/delivery_test.go | 175 +++++++++++++++++++++++++++++++ internal/notify/export_test.go | 5 +- internal/notify/notify.go | 30 ++++-- internal/notify/notify_test.go | 17 +++ 5 files changed, 219 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 4fb2e39..60e6c82 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-15: the log names a notification endpoint (`slack`, `mattermost`, + `ntfy`) and never shows its URL's path or query (closes #288). - 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering gets an NS Failure for each, then an NS Recovery (closes #284). - 2026-10-13: an IPv6 address is written in brackets before its port, as in diff --git a/internal/notify/delivery_test.go b/internal/notify/delivery_test.go index 064af7b..8d699d7 100644 --- a/internal/notify/delivery_test.go +++ b/internal/notify/delivery_test.go @@ -6,10 +6,12 @@ import ( "encoding/json" "errors" "io" + "log/slog" "maps" "net/http" "net/http/httptest" "net/url" + "slices" "strings" "sync" "testing" @@ -38,6 +40,13 @@ const ( prioUrgent = "urgent" ) +// Endpoint names, as the log gives them, used across multiple tests. +const ( + endpointNtfy = "ntfy" + endpointSlack = "slack" + endpointMattermost = "mattermost" +) + // testHost is the hostname used in request construction tests. const testHost = "example.com" @@ -1094,6 +1103,172 @@ func TestSendNotificationErrorNamesEndpoint(t *testing.T) { } } +// ── webhook URLs kept out of the log ────────────────────── + +// webhookPathAndQuery follows the server's address in every +// endpoint's URL in the tests below. A webhook URL's path or query +// lets anyone post to the channel or topic, so neither word marked +// secret in it may reach the log. +const webhookPathAndQuery = "/hooks/secret-in-path?token=secret-in-query" + +// assertNoWebhookSecret fails the test if text holds either word +// marked secret in webhookPathAndQuery. +func assertNoWebhookSecret(t *testing.T, text string) { + t.Helper() + + for _, secret := range []string{"secret-in-path", "secret-in-query"} { + if strings.Contains(text, secret) { + t.Errorf("%q from the webhook URL is in:\n%s", secret, text) + } + } +} + +// logRecord is the part of a JSON log line the tests below read. +type logRecord struct { + Level string `json:"level"` + Msg string `json:"msg"` + Endpoint string `json:"endpoint"` + Title string `json:"title"` + Error string `json:"error"` +} + +// parseLog decodes each line of a JSON log. +func parseLog(t *testing.T, logs string) []logRecord { + t.Helper() + + var records []logRecord + + for line := range strings.Lines(logs) { + var record logRecord + + err := json.Unmarshal([]byte(line), &record) + if err != nil { + t.Fatalf("decoding log line %q: %v", line, err) + } + + records = append(records, record) + } + + return records +} + +// sendToEachEndpoint sends one notification with the given title to +// ntfy, Slack and Mattermost, each at baseURL followed by +// webhookPathAndQuery, and retries a failed delivery once. When +// every delivery has finished it returns the JSON log written, +// debug lines included. +func sendToEachEndpoint(t *testing.T, baseURL, title string) string { + t.Helper() + + target, err := url.Parse(baseURL + webhookPathAndQuery) + if err != nil { + t.Fatalf("parsing webhook URL: %v", err) + } + + svc, logs := newLoggingService(http.DefaultTransport) + svc.SetNtfyURL(target) + svc.SetSlackWebhookURL(target) + svc.SetMattermostWebhookURL(target) + svc.SetSleepFunc(instantSleep) + svc.SetRetryConfig(notify.RetryConfig{ + MaxRetries: 1, + BaseDelay: time.Millisecond, + MaxDelay: time.Millisecond, + }) + + svc.SendNotification(context.Background(), title, "m", prioInfo) + + waitForCondition(t, func() bool { + return svc.OutstandingDeliveries() == 0 + }) + + return logs.String() +} + +// TestSendNotificationDebugLogOmitsWebhookURL checks that, with debug +// logging on, a delivery to each endpoint logs the endpoint's name and +// the title, and no part of the URL's path or query. +func TestSendNotificationDebugLogOmitsWebhookURL(t *testing.T) { + t.Parallel() + + const title = "Port Change" + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + ) + defer srv.Close() + + logs := sendToEachEndpoint(t, srv.URL, title) + + assertNoWebhookSecret(t, logs) + + got := map[string]string{} + + for _, record := range parseLog(t, logs) { + if record.Level == slog.LevelDebug.String() { + got[record.Endpoint] = record.Title + } + } + + want := map[string]string{ + endpointNtfy: title, + endpointSlack: title, + endpointMattermost: title, + } + + if !maps.Equal(got, want) { + t.Errorf("debug lines' endpoint and title = %v, want %v", got, want) + } +} + +// TestSendNotificationErrorLogOmitsWebhookURL checks that a delivery +// to each endpoint that cannot connect logs the transport's error, +// which names the refused connection, on its retry line and on its +// final failure line, and that no line holds any part of the URL's +// path or query. +func TestSendNotificationErrorLogOmitsWebhookURL(t *testing.T) { + t.Parallel() + + // Closed before anything is sent, so every attempt fails to + // connect. + srv := httptest.NewServer(http.NotFoundHandler()) + srv.Close() + + logs := sendToEachEndpoint(t, srv.URL, "t") + + assertNoWebhookSecret(t, logs) + + // For each endpoint, the messages of its lines whose error names + // the refused connection, in the order they were logged. + got := map[string][]string{} + + for _, record := range parseLog(t, logs) { + if strings.Contains(record.Error, "connection refused") { + got[record.Endpoint] = append( + got[record.Endpoint], record.Msg, + ) + } + } + + lines := []string{ + "notification delivery failed, retrying", + "failed to send notification after retries", + } + want := map[string][]string{ + endpointNtfy: lines, + endpointSlack: lines, + endpointMattermost: lines, + } + + if !maps.EqualFunc(got, want, slices.Equal) { + t.Errorf("lines whose error names the refused connection = %v, "+ + "want %v", got, want) + } +} + // ── SlackPayload JSON marshaling ────────────────────────── func TestSlackPayloadJSON(t *testing.T) { diff --git a/internal/notify/export_test.go b/internal/notify/export_test.go index 52c3b3d..1324e3c 100644 --- a/internal/notify/export_test.go +++ b/internal/notify/export_test.go @@ -81,7 +81,8 @@ func (svc *Service) SendNtfy( return svc.sendNtfy(ctx, topicURL, title, message, priority) } -// SendSlack exports sendSlack for testing. +// SendSlack exports sendSlack for testing. It sends as the +// "slack" endpoint. func (svc *Service) SendSlack( ctx context.Context, webhookURL *url.URL, @@ -89,7 +90,7 @@ func (svc *Service) SendSlack( title, message, priority string, ) error { return svc.sendSlack( - ctx, webhookURL, failed, title, message, priority, + ctx, webhookURL, "slack", failed, title, message, priority, ) } diff --git a/internal/notify/notify.go b/internal/notify/notify.go index ff9026a..6ed09ac 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -56,6 +56,15 @@ func IsAllowedScheme(scheme string) bool { func ValidateWebhookURL(raw string) (*url.URL, error) { u, err := url.ParseRequestURI(raw) if err != nil { + // A *url.Error quotes the whole URL, and this error is + // logged at startup. A webhook URL's path or query lets + // anyone post to the channel or topic, so keep only the + // reason. + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return nil, fmt.Errorf("invalid URL: %w", err) } @@ -277,7 +286,7 @@ func (svc *Service) dispatchSlack( svc.dispatch(ctx, "slack", func(c context.Context) error { return svc.sendSlack( - c, svc.slackWebhookURL, ErrSlackFailed, + c, svc.slackWebhookURL, "slack", ErrSlackFailed, title, message, priority, ) }) @@ -295,8 +304,8 @@ func (svc *Service) dispatchMattermost( ctx, "mattermost", func(c context.Context) error { return svc.sendSlack( - c, svc.mattermostWebhookURL, ErrMattermostFailed, - title, message, priority, + c, svc.mattermostWebhookURL, "mattermost", + ErrMattermostFailed, title, message, priority, ) }, ) @@ -307,9 +316,11 @@ func (svc *Service) sendNtfy( topicURL *url.URL, title, message, priority string, ) error { + // The topic URL is left out of the log: it lets anyone who has + // it read and post to the topic. svc.log.Debug( "sending ntfy notification", - "topic", topicURL.String(), + "endpoint", "ntfy", "title", title, ) @@ -372,12 +383,15 @@ type SlackAttachment struct { } // sendSlack posts to a Slack or Mattermost incoming webhook, which -// take the same payload. An HTTP error status is returned wrapped in -// failed, ErrSlackFailed or ErrMattermostFailed, so the error names -// the endpoint. +// take the same payload. endpoint, "slack" or "mattermost", names +// the webhook in the log in place of its URL, which lets anyone who +// has it post to the channel. An HTTP error status is returned +// wrapped in failed, ErrSlackFailed or ErrMattermostFailed, so the +// error names the endpoint. func (svc *Service) sendSlack( ctx context.Context, webhookURL *url.URL, + endpoint string, failed error, title, message, priority string, ) error { @@ -388,7 +402,7 @@ func (svc *Service) sendSlack( svc.log.Debug( "sending webhook notification", - "url", webhookURL.String(), + "endpoint", endpoint, "title", title, ) diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 3ac34dd..d0e49a0 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -79,6 +79,23 @@ func TestValidateWebhookURLInvalid(t *testing.T) { } } +// TestValidateWebhookURLErrorOmitsURL checks that the error for a +// webhook URL that does not parse, here because a carriage return +// was left at its end, leaves out the URL's path and query: the +// error is logged at startup. +func TestValidateWebhookURLErrorOmitsURL(t *testing.T) { + t.Parallel() + + _, err := notify.ValidateWebhookURL( + "https://hooks.example.com" + webhookPathAndQuery + "\r", + ) + if err == nil { + t.Fatal("expected an error for a URL ending in a carriage return") + } + + assertNoWebhookSecret(t, err.Error()) +} + func TestIsAllowedScheme(t *testing.T) { t.Parallel() -- 2.54.0 From dee69c47807ed9ec6c3eedb918aa26ba404acbbb Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 15:48:15 +0200 Subject: [PATCH 86/92] resolver: a nameserver is asked at each of its addresses in turn (closes #285) A nameserver's queries went only to the first IPv4 address its name resolves to, so one unreachable address made it fail whenever that address came first in the answer. Each record type is now asked at the addresses one at a time until one gives a usable reply, and the next type is asked first at the address that gave it. A type that fails is logged with the reason at each address, and a nameserver is reported as timed out only when every query timed out; one given no address has failed. The README's DNS Resolution Strategy says which addresses are asked. Model: opus-5-5 --- README.md | 30 +-- TODO.md | 2 + internal/resolver/errors.go | 4 + internal/resolver/export_test.go | 11 ++ internal/resolver/iterative.go | 87 ++++++--- internal/resolver/iterative_internal_test.go | 87 ++++----- internal/resolver/resolver_test.go | 181 ++++++++++++++++++- 7 files changed, 311 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index dd94301..0c3ab0a 100644 --- a/README.md +++ b/README.md @@ -133,18 +133,19 @@ notification endpoint set, changes show only on the dashboard; see - Queries **each** authoritative nameserver independently for **all** record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. - Each record type is a query of its own. When a nameserver answers some types - but the query for another gets no usable reply (no reply after two tries, an - error reply such as SERVFAIL, a referral, or a reply too large for UDP whose - retry over TCP fails), the failure is logged with the reason, and the type is - listed in the nameserver's `failedTypes` and keeps the records saved for the - nameserver by the previous check. On that check those records are not compared - with the other nameservers', so no record change or inconsistency is reported - for the type; on the next check they are compared with the nameserver's answer - as usual. When the previous check did not know the type's records either, - because the nameserver was new or failing then or the type was already listed - in `unknownTypes`, the type is also listed in `unknownTypes` and left out of - every comparison until it answers. A nameserver none of whose queries got a - usable reply has failed (see NS query failure below). + but the query for another gets no usable reply at any of its addresses (no + reply after two tries, an error reply such as SERVFAIL, a referral, or a reply + too large for UDP whose retry over TCP fails), the failure is logged with the + reason, and the type is listed in the nameserver's `failedTypes` and keeps the + records saved for the nameserver by the previous check. On that check those + records are not compared with the other nameservers', so no record change or + inconsistency is reported for the type; on the next check they are compared + with the nameserver's answer as usual. When the previous check did not know + the type's records either, because the nameserver was new or failing then or + the type was already listed in `unknownTypes`, the type is also listed in + `unknownTypes` and left out of every comparison until it answers. A nameserver + none of whose queries got a usable reply has failed (see NS query failure + below). - Stores results **per nameserver**. The state for a hostname is not a merged view — it is a map from nameserver to record set. - DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in @@ -549,6 +550,11 @@ name's nameservers and in the lookup of a nameserver's own address. Such a lookup can need others in turn; lookups go at most three deep, one inside another, so delegations that point at each other still end. +In step 4 each record type is asked at the IPv4 addresses the nameserver's name +resolves to, one at a time, until one gives a usable reply; the next type is +asked first at that address. A type's query fails only when none of them gives +one, and the nameserver has failed only when that is so for every type. + This approach ensures: - Independence from any upstream resolver's cache or filtering. diff --git a/TODO.md b/TODO.md index 60e6c82..1834d71 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-15: a nameserver is asked at each of its IPv4 addresses in turn, so it + has failed only when none of them gives a usable reply (closes #285). - 2026-10-15: the log names a notification endpoint (`slack`, `mattermost`, `ntfy`) and never shows its URL's path or query (closes #288). - 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index 8c53adb..a9b109d 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -36,6 +36,10 @@ var ( // so whether the name has addresses is unknown. ErrNoNameserverAnswered = errors.New("no nameserver answered") + // ErrNoNameserverAddress is returned when a nameserver is to be + // asked and no address for it was given. + ErrNoNameserverAddress = errors.New("nameserver has no address") + // ErrUnusableReply is returned when a server replied with an // error such as SERVFAIL, or with a referral that leads no // closer to the name asked about. diff --git a/internal/resolver/export_test.go b/internal/resolver/export_test.go index 23471b8..58ea51b 100644 --- a/internal/resolver/export_test.go +++ b/internal/resolver/export_test.go @@ -96,6 +96,17 @@ func (r *Resolver) QueryServers( return r.queryServers(ctx, servers, zone, name, qtype) } +// QueryTypes exports queryTypes for testing. +func (r *Resolver) QueryTypes( + ctx context.Context, + nsHostname string, + nsIPs []string, + hostname string, + qtypes []uint16, +) (*NameserverResponse, error) { + return r.queryTypes(ctx, nsHostname, nsIPs, hostname, qtypes) +} + // QueryEachNS exports queryEachNS for testing. func (r *Resolver) QueryEachNS( ctx context.Context, diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index e5689f7..7e5d0e4 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -708,8 +708,9 @@ func (r *Resolver) QueryNameserver( return r.queryNameserver(ctx, nsHostname, hostname, recordTypes()) } -// queryNameserver queries a specific nameserver for the record types -// in qtypes and builds a NameserverResponse. +// queryNameserver queries a specific nameserver, at the IPv4 addresses +// its name resolves to, for the record types in qtypes and builds a +// NameserverResponse. func (r *Resolver) queryNameserver( ctx context.Context, nsHostname string, @@ -727,7 +728,7 @@ func (r *Resolver) queryNameserver( hostname = dns.Fqdn(hostname) - return r.queryTypes(ctx, nsHostname, nsIPs[0], hostname, qtypes) + return r.queryTypes(ctx, nsHostname, nsIPs, hostname, qtypes) } // QueryNameserverIP queries a nameserver by its IP address directly, @@ -744,23 +745,33 @@ func (r *Resolver) QueryNameserverIP( hostname = dns.Fqdn(hostname) - return r.queryTypes(ctx, nsHostname, nsIP, hostname, recordTypes()) + return r.queryTypes( + ctx, nsHostname, []string{nsIP}, hostname, recordTypes(), + ) } +// queryTypes asks the nameserver nsHostname, at its addresses nsIPs, +// about hostname's records of each type in qtypes and builds a +// NameserverResponse. With no address there is nothing to ask, and it +// returns ErrNoNameserverAddress. func (r *Resolver) queryTypes( ctx context.Context, nsHostname string, - nsIP string, + nsIPs []string, hostname string, qtypes []uint16, ) (*NameserverResponse, error) { + if len(nsIPs) == 0 { + return nil, fmt.Errorf("%w: %s", ErrNoNameserverAddress, nsHostname) + } + resp := &NameserverResponse{ Nameserver: nsHostname, Records: make(map[string][]string), Status: StatusOK, } - state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp) + state := r.queryEachType(ctx, nsIPs, hostname, qtypes, resp) classifyResponse(resp, state) return resp, nil @@ -778,28 +789,37 @@ type queryState struct { answered bool } -// queryEachType asks the nameserver at nsIP about hostname once for each -// record type in qtypes, and lists in resp.FailedTypes the types whose -// query got no usable reply, logging each with the reason unless ctx was -// cancelled: shutdown cancels it, and a query it cut short did not fail. +// queryEachType asks the nameserver about hostname's records of each type +// in qtypes, at its addresses nsIPs in turn until one gives a usable +// reply, and lists in resp.FailedTypes the types that got none at any of +// them, logging each with the reasons unless ctx was cancelled: shutdown +// cancels it, and a query it cut short did not fail. func (r *Resolver) queryEachType( ctx context.Context, - nsIP string, + nsIPs []string, hostname string, qtypes []uint16, resp *NameserverResponse, ) queryState { var state queryState + // Reordered below; the caller's list is left as it was. + nsIPs = slices.Clone(nsIPs) + for _, qtype := range qtypes { if checkCtx(ctx) != nil { break } - err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state) + answeredAt, err := r.querySingleType( + ctx, nsIPs, hostname, qtype, resp, &state, + ) if err == nil { state.answered = true + // The next type is asked first at the address that answered. + nsIPs[0], nsIPs[answeredAt] = nsIPs[answeredAt], nsIPs[0] + continue } @@ -833,20 +853,30 @@ func (r *Resolver) queryEachType( return state } -// querySingleType asks the nameserver at nsIP about hostname's records -// of type qtype. It returns nil when the nameserver answered: with -// records, with none, or with NXDOMAIN; otherwise it returns why not. +// querySingleType asks the nameserver about hostname's records of type +// qtype at its addresses nsIPs, of which there is at least one, one at a +// time, until one answers: with records, with none, or with NXDOMAIN. +// It returns the index in nsIPs of the address that answered; when none +// did, it returns why each did not, each reason naming its address. func (r *Resolver) querySingleType( ctx context.Context, - nsIP string, + nsIPs []string, hostname string, qtype uint16, resp *NameserverResponse, state *queryState, -) error { - msg, err := r.queryDNS(ctx, nsIP, hostname, qtype) - if err != nil { +) (int, error) { + var errs []error + + for i, nsIP := range nsIPs { + msg, err := r.queryDNS(ctx, nsIP, hostname, qtype) + switch { + case err == nil: + err = readReply(msg, resp, state) + if err != nil { + err = fmt.Errorf("query %s @%s: %w", hostname, nsIP, err) + } case isTimeout(err): state.gotTimeout = true case errors.Is(err, ErrRefused): @@ -855,10 +885,14 @@ func (r *Resolver) querySingleType( state.netErr = err } - return err + if err == nil { + return i, nil + } + + errs = append(errs, err) } - return readReply(msg, resp, state) + return 0, errors.Join(errs...) } // readReply adds to resp the records in msg, a nameserver's reply to a @@ -952,17 +986,15 @@ func isTimeout(err error) bool { } // classifyResponse sets the nameserver's status. One that answered no -// record type has failed, and Error says why; one that answered some has -// the status of those answers. It has no data only when every type +// record type has failed, and Error says why; it says all queries timed +// out only when every query, at every address, did. One that answered +// some has the status of those answers. It has no data only when every type // answered with no records: a type in FailedTypes may have records, so a // nameserver with one stays ok. func classifyResponse(resp *NameserverResponse, state queryState) { switch { case state.gotNXDomain && !state.hasRecords: resp.Status = StatusNXDomain - case state.gotTimeout && !state.answered: - resp.Status = StatusTimeout - resp.Error = "all queries timed out" case state.gotErrorReply && !state.answered: resp.Status = StatusError resp.Error = "server returned " + state.errorReply @@ -975,6 +1007,9 @@ func classifyResponse(resp *NameserverResponse, state queryState) { case state.gotReferral && !state.answered: resp.Status = StatusError resp.Error = "server returned a referral" + case state.gotTimeout && !state.answered: + resp.Status = StatusTimeout + resp.Error = "all queries timed out" // An NXDOMAIN reply with no records was taken by the first case. case !state.hasRecords && len(resp.FailedTypes) == 0: resp.Status = StatusNoData diff --git a/internal/resolver/iterative_internal_test.go b/internal/resolver/iterative_internal_test.go index c756bb0..f0f399c 100644 --- a/internal/resolver/iterative_internal_test.go +++ b/internal/resolver/iterative_internal_test.go @@ -15,8 +15,10 @@ import ( // that answered some record types, even with no records, has not failed // when its query for another type got no usable reply, whatever the // reason, and is ok, not nodata: that type may have records. One whose -// every query got none has failed. Only one whose every type answered -// with no records is nodata. +// every query got none has failed, and its error says all queries timed +// out only when every one did: a reply such as a refusal, or a network +// error, at one of its addresses is given instead. Only one whose every +// type answered with no records is nodata. func TestClassifyResponse(t *testing.T) { t.Parallel() @@ -27,63 +29,46 @@ func TestClassifyResponse(t *testing.T) { wantStatus string wantError string }{ - { - "every type answered with no records", + {"every type answered with no records", queryState{answered: true}, - nil, - StatusNoData, "", - }, - { - "some types answered with no records, another timed out", + nil, StatusNoData, ""}, + {"some types answered with no records, another timed out", queryState{answered: true, gotTimeout: true}, - []string{"A"}, - StatusOK, "", - }, - { - "some types answered with no records, another got SERVFAIL", - queryState{ - answered: true, gotErrorReply: true, errorReply: "SERVFAIL", - }, - []string{"A"}, - StatusOK, "", - }, - { - "some types answered with no records, another was refused", + []string{"A"}, StatusOK, ""}, + {"some types answered with no records, another got SERVFAIL", + queryState{answered: true, gotErrorReply: true, errorReply: "SERVFAIL"}, + []string{"A"}, StatusOK, ""}, + {"some types answered with no records, another was refused", queryState{answered: true, gotRefused: true}, - []string{"A"}, - StatusOK, "", - }, - { - "some types answered with no records, another got a network error", + []string{"A"}, StatusOK, ""}, + {"some types answered with no records, another got a network error", queryState{answered: true, netErr: syscall.ECONNREFUSED}, - []string{"A"}, - StatusOK, "", - }, - { - "some types answered with no records, another's reply was " + - "truncated and its retry over TCP failed", + []string{"A"}, StatusOK, ""}, + {"some types answered with no records, another's reply was " + + "truncated and its retry over TCP failed", queryState{answered: true, netErr: ErrTruncated}, - []string{"TXT"}, - StatusOK, "", - }, - { - "some types answered with no records, another got a referral", + []string{"TXT"}, StatusOK, ""}, + {"some types answered with no records, another got a referral", queryState{answered: true, gotReferral: true}, - []string{"A"}, - StatusOK, "", - }, - { - "every query timed out", + []string{"A"}, StatusOK, ""}, + {"every query timed out", queryState{gotTimeout: true}, - []string{"A", "AAAA", "CNAME"}, - StatusTimeout, "all queries timed out", - }, - { - "every query got NOTIMP", + []string{"A", "AAAA", "CNAME"}, StatusTimeout, "all queries timed out"}, + {"every query got NOTIMP", queryState{gotErrorReply: true, errorReply: "NOTIMP"}, - []string{"A", "AAAA", "CNAME"}, - StatusError, "server returned NOTIMP", - }, + []string{"A", "AAAA", "CNAME"}, StatusError, "server returned NOTIMP"}, + {"every query got SERVFAIL at one address, timed out at another", + queryState{gotErrorReply: true, errorReply: "SERVFAIL", gotTimeout: true}, + []string{"A"}, StatusError, "server returned SERVFAIL"}, + {"every query was refused at one address, timed out at another", + queryState{gotRefused: true, gotTimeout: true}, + []string{"A"}, StatusError, "server returned REFUSED"}, + {"every query got a network error at one address, timed out at another", + queryState{netErr: syscall.ECONNREFUSED, gotTimeout: true}, + []string{"A"}, StatusError, "network error: connection refused"}, + {"every query got a referral at one address, timed out at another", + queryState{gotReferral: true, gotTimeout: true}, + []string{"A"}, StatusError, "server returned a referral"}, } for _, tt := range tests { diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 1c535e7..9bd98ed 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -360,6 +360,124 @@ func TestQueryNameserver_ZoneDelegatedWithoutAddresses(t *testing.T) { assert.Equal(t, resolver.StatusOK, resp.Status) } +// TestQueryTypes_FirstAddressNeverAnswers asks a google.com nameserver +// about google.com's A and AAAA records at a list of addresses that puts +// 192.0.2.1, where nothing answers, before the nameserver's own. The +// nameserver answers at its own address, so it is ok, with its records. +// Each attempt has time for one round of tries at 192.0.2.1, two tries +// of two seconds, and not for two, so AAAA must be asked first at the +// address that answered A. +func TestQueryTypes_FirstAddressNeverAnswers(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + ns := findOneNSForDomain(t, r, "google.com") + nsIPs := append( + []string{noAnswerAddress}, liveResolveNSIPs(t, r, []string{ns}, 1)..., + ) + + var resp *resolver.NameserverResponse + + livednstest.Retry( + t, + "QueryTypes("+ns+" at "+strings.Join(nsIPs, ", ")+", google.com)", + func(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 6*time.Second) + defer cancel() + + var err error + + resp, err = r.QueryTypes( + ctx, ns, nsIPs, "google.com.", + []uint16{dns.TypeA, dns.TypeAAAA}, + ) + if err != nil { + return err + } + + if len(resp.FailedTypes) > 0 { + return fmt.Errorf( + "%w: %s gave no usable reply for %s", + livednstest.ErrNoAnswer, ns, + strings.Join(resp.FailedTypes, ", "), + ) + } + + return nil + }, + ) + + assert.Equal(t, resolver.StatusOK, resp.Status) + assert.NotEmpty(t, resp.Records["A"]) + assert.NotEmpty(t, resp.Records["AAAA"]) +} + +// TestQueryTypes_EveryAddressFails asks a google.com nameserver about +// cloudflare.com, a zone it does not serve, at 192.0.2.1, where nothing +// answers, and then at its own address, which refuses. The nameserver +// has failed with the refusal, not a timeout, and the warning about the +// type gives the reason at each address. +func TestQueryTypes_EveryAddressFails(t *testing.T) { + t.Parallel() + + var logs bytes.Buffer + + r := resolver.NewFromLogger(slog.New(slog.NewTextHandler(&logs, nil))) + ns := findOneNSForDomain(t, r, "google.com") + nsIPs := append( + []string{noAnswerAddress}, liveResolveNSIPs(t, r, []string{ns}, 1)..., + ) + + var resp *resolver.NameserverResponse + + livednstest.Retry( + t, + "QueryTypes("+ns+" at "+strings.Join(nsIPs, ", ")+", cloudflare.com)", + func(ctx context.Context) error { + logs.Reset() + + var err error + + resp, err = r.QueryTypes( + ctx, ns, nsIPs, "cloudflare.com.", []uint16{dns.TypeA}, + ) + if err != nil { + return err + } + + // A timeout or a network error at its own address is no + // reply at all. + if resp.Status == resolver.StatusTimeout || + strings.HasPrefix(resp.Error, "network error") { + return fmt.Errorf( + "%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error, + ) + } + + return nil + }, + ) + + assert.Equal(t, resolver.StatusError, resp.Status) + assert.Equal(t, "server returned REFUSED", resp.Error) + assert.Contains(t, logs.String(), "@"+noAnswerAddress+": ") + assert.Contains(t, logs.String(), "@"+nsIPs[1]+": ") +} + +// TestQueryTypes_NoAddress asks a nameserver given no address. There is +// nothing to ask it at, so it has failed, with an error that says it has +// no address; it is not ok. +func TestQueryTypes_NoAddress(t *testing.T) { + t.Parallel() + + resp, err := newTestResolver(t).QueryTypes( + t.Context(), "ns1.example.net.", nil, "www.example.net.", + []uint16{dns.TypeA}, + ) + require.ErrorIs(t, err, resolver.ErrNoNameserverAddress) + assert.Nil(t, resp) +} + func TestQueryNameserver_AAAA(t *testing.T) { t.Parallel() @@ -424,8 +542,9 @@ func TestQueryNameserver_TXT(t *testing.T) { // nameserver about google.com with a resolver whose retries over TCP // fail. google.com's TXT records do not fit in a reply over UDP, so TXT // is reported as failed, holding none of the records that fit, and -// logged with the reason, while the nameserver, which answered the other -// types, is ok. +// logged with the reason, which names the address whose reply was +// truncated, while the nameserver, which answered the other types, is +// ok. func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) { t.Parallel() @@ -442,6 +561,13 @@ func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) { assert.Contains(t, logs.String(), "hostname=google.com. nameserver="+ns+" type=TXT error=", ) + + nsIPs := liveResolveNSIPs(t, newTestResolver(t), []string{ns}, 1) + for _, ip := range nsIPs { + assert.Contains(t, logs.String(), + "@"+ip+": "+resolver.ErrTruncated.Error(), + ) + } } func TestQueryNameserver_NXDomain(t *testing.T) { @@ -495,6 +621,57 @@ func TestQueryNameserver_Refused(t *testing.T) { assert.Equal(t, "server returned REFUSED", resp.Error) } +// TestQueryNameserver_EveryAddressRefuses asks ada.ns.cloudflare.com, +// whose name has several IPv4 addresses, about google.com, a zone it +// does not serve, which it refuses. A record type is asked at each of +// the nameserver's addresses before it fails, so the warning about it +// names every one. +func TestQueryNameserver_EveryAddressRefuses(t *testing.T) { + t.Parallel() + + const ns = "ada.ns.cloudflare.com." + + var logs bytes.Buffer + + r := resolver.NewFromLogger(slog.New(slog.NewTextHandler(&logs, nil))) + nsIPs := liveResolveNSIPs(t, r, []string{ns}, 2) + + var resp *resolver.NameserverResponse + + livednstest.Retry( + t, + "QueryNameserver("+ns+", google.com)", + func(ctx context.Context) error { + logs.Reset() + + var err error + + resp, err = r.QueryNameserver(ctx, ns, "google.com") + if err != nil { + return err + } + + // A timeout or a network error at every address is no + // reply at all. + if resp.Status == resolver.StatusTimeout || + strings.HasPrefix(resp.Error, "network error") { + return fmt.Errorf( + "%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error, + ) + } + + return nil + }, + ) + + assert.Equal(t, resolver.StatusError, resp.Status) + assert.Equal(t, "server returned REFUSED", resp.Error) + + for _, ip := range nsIPs { + assert.Contains(t, logs.String(), "@"+ip+": ") + } +} + // TestQueryServers_RecursiveResolverRefused passes a public recursive // resolver to QueryServers as the server of google.com. These resolvers // refuse a query that does not ask for recursion and answer one that -- 2.54.0 From caa3aa3c12e545d71d0a3b41ae56fb6fa0068836 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 16:14:20 +0200 Subject: [PATCH 87/92] notify: a reply other than 2xx is a failed delivery (closes #287) A notification endpoint that answered with a redirect was counted as delivered: the request is not sent through a client, so the redirect is never followed, and only a status of 400 or more counted as a failure. Now any status outside 200-299 fails the delivery for ntfy, Slack and Mattermost, so it is retried and logged like the others. Model: opus-5-5 --- README.md | 2 +- TODO.md | 2 + internal/notify/delivery_test.go | 64 ++++++++++++++++++-------------- internal/notify/notify.go | 19 ++++++---- internal/notify/retry_test.go | 30 +++++++++++++++ 5 files changed, 81 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 0c3ab0a..21758c0 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,7 @@ includes: Each endpoint is sent each notification on its own, in the background. A delivery that fails (a network error, no reply within 10 seconds, or an HTTP -status of 400 or more) is retried up to 5 times: the first retry after about 1 +status other than 2xx) is retried up to 5 times: the first retry after about 1 second, each wait after that twice as long up to 60 seconds, every wait varied at random by up to 25%. A delivery still failing after that is logged and dropped. diff --git a/TODO.md b/TODO.md index 1834d71..368aa6c 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-15: a notification endpoint that answers with a redirect, or any + status other than 2xx, is a failed delivery, retried and logged (closes #287). - 2026-10-15: a nameserver is asked at each of its IPv4 addresses in turn, so it has failed only when none of them gives a usable reply (closes #285). - 2026-10-15: the log names a notification endpoint (`slack`, `mattermost`, diff --git a/internal/notify/delivery_test.go b/internal/notify/delivery_test.go index 8d699d7..61a0e15 100644 --- a/internal/notify/delivery_test.go +++ b/internal/notify/delivery_test.go @@ -1185,6 +1185,42 @@ func sendToEachEndpoint(t *testing.T, baseURL, title string) string { return logs.String() } +// assertEachEndpointRetriedThenFailed fails the test unless, for each +// of ntfy, Slack and Mattermost, the lines of logs whose error +// contains reason are the retry line and then the final failure line. +func assertEachEndpointRetriedThenFailed( + t *testing.T, logs, reason string, +) { + t.Helper() + + // For each endpoint, the messages of its lines whose error + // contains reason, in the order they were logged. + got := map[string][]string{} + + for _, record := range parseLog(t, logs) { + if strings.Contains(record.Error, reason) { + got[record.Endpoint] = append( + got[record.Endpoint], record.Msg, + ) + } + } + + lines := []string{ + "notification delivery failed, retrying", + "failed to send notification after retries", + } + want := map[string][]string{ + endpointNtfy: lines, + endpointSlack: lines, + endpointMattermost: lines, + } + + if !maps.EqualFunc(got, want, slices.Equal) { + t.Errorf("lines whose error contains %q = %v, want %v", + reason, got, want) + } +} + // TestSendNotificationDebugLogOmitsWebhookURL checks that, with debug // logging on, a delivery to each endpoint logs the endpoint's name and // the title, and no part of the URL's path or query. @@ -1240,33 +1276,7 @@ func TestSendNotificationErrorLogOmitsWebhookURL(t *testing.T) { logs := sendToEachEndpoint(t, srv.URL, "t") assertNoWebhookSecret(t, logs) - - // For each endpoint, the messages of its lines whose error names - // the refused connection, in the order they were logged. - got := map[string][]string{} - - for _, record := range parseLog(t, logs) { - if strings.Contains(record.Error, "connection refused") { - got[record.Endpoint] = append( - got[record.Endpoint], record.Msg, - ) - } - } - - lines := []string{ - "notification delivery failed, retrying", - "failed to send notification after retries", - } - want := map[string][]string{ - endpointNtfy: lines, - endpointSlack: lines, - endpointMattermost: lines, - } - - if !maps.EqualFunc(got, want, slices.Equal) { - t.Errorf("lines whose error names the refused connection = %v, "+ - "want %v", got, want) - } + assertEachEndpointRetriedThenFailed(t, logs, "connection refused") } // ── SlackPayload JSON marshaling ────────────────────────── diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 6ed09ac..e17e615 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -25,9 +25,6 @@ import ( // HTTP client timeout. const httpClientTimeout = 10 * time.Second -// HTTP status code thresholds. -const httpStatusClientError = 400 - // Sentinel errors for notification failures. var ( // ErrNtfyFailed indicates the ntfy request failed. @@ -344,7 +341,10 @@ func (svc *Service) sendNtfy( defer func() { _ = resp.Body.Close() }() - if resp.StatusCode >= httpStatusClientError { + // Only a 2xx reply means ntfy took the notification. A + // redirect is not followed, so it is a failure too. + if resp.StatusCode < http.StatusOK || + resp.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf( "%w: status %d", ErrNtfyFailed, resp.StatusCode, @@ -385,9 +385,9 @@ type SlackAttachment struct { // sendSlack posts to a Slack or Mattermost incoming webhook, which // take the same payload. endpoint, "slack" or "mattermost", names // the webhook in the log in place of its URL, which lets anyone who -// has it post to the channel. An HTTP error status is returned -// wrapped in failed, ErrSlackFailed or ErrMattermostFailed, so the -// error names the endpoint. +// has it post to the channel. A reply status other than 2xx is +// returned wrapped in failed, ErrSlackFailed or ErrMattermostFailed, +// so the error names the endpoint. func (svc *Service) sendSlack( ctx context.Context, webhookURL *url.URL, @@ -437,7 +437,10 @@ func (svc *Service) sendSlack( defer func() { _ = resp.Body.Close() }() - if resp.StatusCode >= httpStatusClientError { + // Only a 2xx reply means the webhook took the notification. A + // redirect is not followed, so it is a failure too. + if resp.StatusCode < http.StatusOK || + resp.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf( "%w: status %d", failed, resp.StatusCode, diff --git a/internal/notify/retry_test.go b/internal/notify/retry_test.go index 4d47897..2fd9f58 100644 --- a/internal/notify/retry_test.go +++ b/internal/notify/retry_test.go @@ -536,3 +536,33 @@ func TestSendNotificationPermanentFailureLogsError( return attempts >= 3 }) } + +// TestSendNotificationRedirectFails checks that a 302 reply is a +// failed delivery at each endpoint: the server is sent the first +// attempt and the retry, and each endpoint logs a retry line and +// then the final failure line, both naming the 302. +func TestSendNotificationRedirectFails(t *testing.T) { + t.Parallel() + + var requests atomic.Int64 + + srv := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Redirect(w, r, "/moved", http.StatusFound) + }), + ) + defer srv.Close() + + logs := sendToEachEndpoint(t, srv.URL, "t") + + // ntfy, Slack and Mattermost each send once and retry once. + const wantRequests = 3 * 2 + + if sent := requests.Load(); sent != wantRequests { + t.Errorf("server got %d requests, want %d", sent, wantRequests) + } + + assertEachEndpointRetriedThenFailed(t, logs, "status 302") +} -- 2.54.0 From db3b09c5d8379f1686570d311ef7a13453394c38 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 17:01:52 +0200 Subject: [PATCH 88/92] handlers: a directory under /s/ answers 404, not a listing (closes #286) http.FileServer answered /s/ and /s/css/ with an HTML listing of the embedded files, and /s/css with a redirect to that listing. HandleStatic in internal/handlers now looks the name up in the embedded files first and answers 404 when it names a directory; files are served as before. The routes mount it under /s. Model: opus-5-5 --- README.md | 1 + TODO.md | 2 ++ internal/handlers/static.go | 36 ++++++++++++++++++++++++++++++ internal/server/routes.go | 10 +-------- internal/server/routes_test.go | 40 ++++++++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 internal/handlers/static.go diff --git a/README.md b/README.md index 21758c0..49e220a 100644 --- a/README.md +++ b/README.md @@ -910,6 +910,7 @@ internal/ templates/dashboard.html Dashboard template (embedded) status.go /api/v1/status healthcheck.go Health check handler + static.go Static assets under /s/ server/ server.go HTTP server lifecycle routes.go Route definitions diff --git a/TODO.md b/TODO.md index 368aa6c..c6e8b06 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-15: a request under `/s/` that names a directory, such as `/s/` or + `/s/css`, answers 404 instead of a listing of its files (closes #286). - 2026-10-15: a notification endpoint that answers with a redirect, or any status other than 2xx, is a failed delivery, retried and logged (closes #287). - 2026-10-15: a nameserver is asked at each of its IPv4 addresses in turn, so it diff --git a/internal/handlers/static.go b/internal/handlers/static.go new file mode 100644 index 0000000..5a7bc12 --- /dev/null +++ b/internal/handlers/static.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "io/fs" + "net/http" + "strings" + + "sneak.berlin/go/dnswatcher/static" +) + +// HandleStatic returns the handler for the embedded static assets. It +// is mounted under /s, with that prefix stripped from the path. A +// request that names a directory answers 404: http.FileServer would +// answer it with a listing of the directory, or with a redirect to +// that listing when the trailing slash is missing. +func (h *Handlers) HandleStatic() http.HandlerFunc { + files := http.FileServer(http.FS(static.Static)) + + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + // fs.Stat refuses a name that is empty or ends in a slash, + // so /s/ and /s/css/ answer 404 here along with /s/css. + name := strings.TrimPrefix(request.URL.Path, "/") + + info, err := fs.Stat(static.Static, name) + if err != nil || info.IsDir() { + http.NotFound(writer, request) + + return + } + + files.ServeHTTP(writer, request) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 805ff21..52778ee 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -8,8 +8,6 @@ import ( "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" - - "sneak.berlin/go/dnswatcher/static" ) // requestTimeout is the maximum duration for handling a request. @@ -47,13 +45,7 @@ func (s *Server) SetupRoutes() { public.Get("/", s.handlers.HandleDashboard()) // Static assets (embedded CSS/JS) - public.Mount( - "/s", - http.StripPrefix( - "/s", - http.FileServer(http.FS(static.Static)), - ), - ) + public.Mount("/s", http.StripPrefix("/s", s.handlers.HandleStatic())) // Health check (standard well-known path) public.Get( diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index f8f9c47..5513a9c 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -1,6 +1,7 @@ package server_test import ( + "mime" "net/http" "net/http/httptest" "testing" @@ -288,3 +289,42 @@ func TestMetricsRateLimitComesBeforeAuth(t *testing.T) { t.Errorf("another address: status = %d, want 200", rec.Code) } } + +// TestStaticDirectoriesAnswerNotFound checks that a request naming a +// directory of the static assets, with or without the trailing slash, +// answers 404 rather than a listing of it or a redirect to one, and +// that the stylesheet is still served as CSS. +func TestStaticDirectoriesAnswerNotFound(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + + srv := routedServer(t) + + for _, path := range []string{"/s", "/s/", "/s/css", "/s/css/"} { + rec := serve(srv, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, path, nil, + )) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET %s: status = %d, want 404", path, rec.Code) + } + } + + rec := serve(srv, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/s/css/tailwind.min.css", nil, + )) + + if rec.Code != http.StatusOK { + t.Errorf("GET stylesheet: status = %d, want 200", rec.Code) + } + + contentType := rec.Header().Get("Content-Type") + + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "text/css" { + t.Errorf( + "GET stylesheet: Content-Type = %q, want text/css", + contentType, + ) + } +} -- 2.54.0 From f6b7aefcbfeadb6eac2b171ad7491d231935a153 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 18:01:51 +0200 Subject: [PATCH 89/92] docs: TESTING.md constraints match the resolver and the test-time policy (closes #289) The Constraints list named a query timeout calibrated to round-trip time and a limited root-server fan-out; the resolver has neither. It now gives the 2-second timeout and the second try over UDP, says a zone's servers are asked one at a time in a random order, and gives the 60-second cap and 20-second target for make test from REPO_POLICIES.md. The line on flaky failures now says, as the README does, that a flaky live test is fixed with robustness, never with mocks or skip flags. Model: opus-5-5 --- TESTING.md | 16 +++++++++------- TODO.md | 4 ++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/TESTING.md b/TESTING.md index d216a73..c256ba3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -19,16 +19,18 @@ works correctly in production. ### Constraints - Tests hit real DNS infrastructure and require network access -- Test duration depends on network conditions; timeout tuning keeps the suite - within the 60-second target -- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) plus - processing margin -- Root server fan-out is limited to reduce parallel query load +- Test duration depends on network conditions. `make test` must finish in under + 60 seconds, the hard cap in `REPO_POLICIES.md`; under 20 seconds is the target +- Each query to a nameserver has a 2-second timeout, and a query over UDP that + gets no reply is sent once more (`queryTimeoutDuration` and `maxRetries` in + `internal/resolver/iterative.go`) +- In the walk to a name's nameservers, the servers of each zone are asked one at + a time in a random order, as the README's DNS Resolution Strategy describes - Live lookups that expect an answer go through `internal/livednstest`, which limits how many run at once in a test binary and retries a lookup that got none -- Flaky failures from transient network issues are acceptable and should be - investigated as potential resolver bugs, not papered over with mocks or skip +- A flaky live test is a robustness problem, fixed with retries with backoff, + several independent nameservers or longer timeouts, never with mocks or skip flags ### What NOT to do diff --git a/TODO.md b/TODO.md index c6e8b06..ba34bd0 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,10 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-15: `TESTING.md` Constraints give the resolver's 2-second query + timeout and second try, say a zone's servers are asked one at a time in a + random order, give the 60-second cap and 20-second target for `make test`, and + say a flaky live test is fixed with robustness, not with mocks (closes #289). - 2026-10-15: a request under `/s/` that names a directory, such as `/s/` or `/s/css`, answers 404 instead of a listing of its files (closes #286). - 2026-10-15: a notification endpoint that answers with a redirect, or any -- 2.54.0 From 06ae3571657215cc8158eaf7a4675a3b324234be Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 18:31:07 +0200 Subject: [PATCH 90/92] resolver: drop the unused timeout multiplier and the comment claiming it (closes #296) The resolver declared timeoutMultiplier and passed each try's number to exchangeWithTimeout, which threw it away with a comment saying the client config grew the timeout. Nothing did: every try waits the same 2-second queryTimeoutDuration. The constant goes, and exchangeWithTimeout is folded into its one caller, tryExchange, whose comment now says the timeout is fixed. Behaviour is unchanged. Model: opus-5-5 --- TODO.md | 3 +++ internal/resolver/iterative.go | 22 ++++------------------ 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/TODO.md b/TODO.md index ba34bd0..9375cbd 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,9 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-15: the resolver no longer says its query timeout grows on each try; + every try waits the same 2 seconds, and the unused `timeoutMultiplier` is gone + (closes #296). - 2026-10-15: `TESTING.md` Constraints give the resolver's 2-second query timeout and second try, say a zone's servers are asked one at a time in a random order, give the 60-second cap and 20-second target for `make test`, and diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 7e5d0e4..4d495f7 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -19,7 +19,6 @@ const ( queryTimeoutDuration = 2 * time.Second maxRetries = 2 maxDelegation = 20 - timeoutMultiplier = 2 // maxLookupDepth is how many lookups of nameserver addresses may be // under way one inside another. Looking up a nameserver's address @@ -62,19 +61,8 @@ func checkCtx(ctx context.Context) error { return nil } -func (r *Resolver) exchangeWithTimeout( - ctx context.Context, - msg *dns.Msg, - addr string, - attempt int, -) (*dns.Msg, error) { - _ = attempt // timeout escalation handled by client config - - resp, _, err := r.client.ExchangeContext(ctx, msg, addr) - - return resp, err -} - +// tryExchange sends msg to addr over UDP, up to maxRetries times, until +// a reply comes. Every try waits the same queryTimeoutDuration. func (r *Resolver) tryExchange( ctx context.Context, msg *dns.Msg, @@ -84,14 +72,12 @@ func (r *Resolver) tryExchange( var err error - for attempt := range maxRetries { + for range maxRetries { if checkCtx(ctx) != nil { return nil, ErrContextCanceled } - resp, err = r.exchangeWithTimeout( - ctx, msg, addr, attempt, - ) + resp, _, err = r.client.ExchangeContext(ctx, msg, addr) if err == nil { break } -- 2.54.0 From adc81311914c51fd8d7645993e4bbaa282fc232f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 19:48:08 +0200 Subject: [PATCH 91/92] resolver: a query cut short by the caller's context records no reason (closes #298) When the caller's context ends, the next query returns ErrContextCanceled before anything is sent. querySingleType recorded that as a network error, which outranks a timeout, so a nameserver that had only timed out could be reported as "network error: context canceled". It now records no reason for it. A try the context's deadline cuts short ends as a timeout. When the context has ended before a further try, tryExchange now returns that try's error instead of ErrContextCanceled, so such a nameserver is reported as timed out, not as answering. Model: opus-5-5 --- TODO.md | 4 ++ internal/resolver/iterative.go | 15 ++++- internal/resolver/iterative_internal_test.go | 32 ++++++++++ internal/resolver/resolver_test.go | 64 ++++++++++++++++++-- 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 9375cbd..4a5fdf8 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,10 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-15: a query its caller's context ends before it is sent records no + reason about the nameserver, and a try the context's deadline cuts short is a + timeout, so a nameserver whose only failures were timeouts is `timeout`, not a + network error or `ok` (closes #298). - 2026-10-15: the resolver no longer says its query timeout grows on each try; every try waits the same 2 seconds, and the unused `timeoutMultiplier` is gone (closes #296). diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 4d495f7..9156373 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -62,7 +62,10 @@ func checkCtx(ctx context.Context) error { } // tryExchange sends msg to addr over UDP, up to maxRetries times, until -// a reply comes. Every try waits the same queryTimeoutDuration. +// a reply comes. Every try waits the same queryTimeoutDuration, or less +// when ctx's deadline comes first. When ctx has ended before a further +// try, it returns the error of the try already made, such as a timeout, +// or ErrContextCanceled when none was made. func (r *Resolver) tryExchange( ctx context.Context, msg *dns.Msg, @@ -74,7 +77,11 @@ func (r *Resolver) tryExchange( for range maxRetries { if checkCtx(ctx) != nil { - return nil, ErrContextCanceled + if err == nil { + return nil, ErrContextCanceled + } + + return nil, err } resp, _, err = r.client.ExchangeContext(ctx, msg, addr) @@ -863,6 +870,10 @@ func (r *Resolver) querySingleType( if err != nil { err = fmt.Errorf("query %s @%s: %w", hostname, nsIP, err) } + case errors.Is(err, ErrContextCanceled): + // The caller's context ended before the query was sent. + // That says nothing about the nameserver, so no reason is + // recorded. case isTimeout(err): state.gotTimeout = true case errors.Is(err, ErrRefused): diff --git a/internal/resolver/iterative_internal_test.go b/internal/resolver/iterative_internal_test.go index f0f399c..a804f33 100644 --- a/internal/resolver/iterative_internal_test.go +++ b/internal/resolver/iterative_internal_test.go @@ -1,6 +1,8 @@ package resolver import ( + "context" + "log/slog" "strconv" "syscall" "testing" @@ -132,3 +134,33 @@ func TestReadReply(t *testing.T) { }) } } + +// TestQuerySingleType_ContextEnded asks a nameserver about one record +// type with a context that has already ended, so no query is sent. A +// query the context ended before it was sent says nothing about the +// nameserver and records no reason: a nameserver whose earlier query +// timed out is still reported as timed out, not as a network error. +func TestQuerySingleType_ContextEnded(t *testing.T) { + t.Parallel() + + r := NewFromLogger(slog.New(slog.DiscardHandler)) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + resp := &NameserverResponse{Records: map[string][]string{}, Status: StatusOK} + + // The query for an earlier record type timed out. + state := queryState{gotTimeout: true} + + _, err := r.querySingleType( + ctx, []string{"192.0.2.1"}, "example.com.", dns.TypeA, resp, &state, + ) + require.ErrorIs(t, err, ErrContextCanceled) + assert.Equal(t, queryState{gotTimeout: true}, state) + + classifyResponse(resp, state) + + assert.Equal(t, StatusTimeout, resp.Status) + assert.Equal(t, "all queries timed out", resp.Error) +} diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 9bd98ed..9d105b7 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -1380,10 +1380,10 @@ func TestQueryNameserverIP_Timeout(t *testing.T) { r := newTestResolver(t) // Nothing answers at 192.0.2.1, a documentation address. The - // resolver tries each query twice, and the first try gives up - // after two seconds. A deadline that ends during the first try - // makes the status vary from run to run between error and - // timeout, so the deadline must outlast the first try. + // resolver tries each query twice, and each try gives up after two + // seconds. This deadline ends during the second try of the first + // record type; TestQueryNameserverIP_DeadlineDuringFirstTry covers + // one that ends during the first. ctx, cancel := context.WithTimeout( context.Background(), 3*time.Second, ) @@ -1399,6 +1399,62 @@ func TestQueryNameserverIP_Timeout(t *testing.T) { assert.NotEmpty(t, resp.Error) } +// TestQueryNameserverIP_DeadlineDuringFirstTry gives a query to +// 192.0.2.1, where nothing answers, a deadline that ends during its +// first try. That try ends at the deadline as a timeout, so the +// nameserver is reported as timed out, not as answering. +func TestQueryNameserverIP_DeadlineDuringFirstTry(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + // Well under the two seconds one try waits. + ctx, cancel := context.WithTimeout( + context.Background(), 500*time.Millisecond, + ) + t.Cleanup(cancel) + + resp, err := r.QueryNameserverIP( + ctx, "unreachable.test.", "192.0.2.1", + "example.com", + ) + require.NoError(t, err) + + assert.Equal(t, resolver.StatusTimeout, resp.Status) + assert.NotEmpty(t, resp.Error) +} + +// TestQueryNameserverIP_CancelDuringFirstTry cancels the context while +// the first try of a query to 192.0.2.1, where nothing answers, waits +// for a reply. That try's timeout is all that is learned about the +// nameserver, so it is reported as timed out, not as answering. +func TestQueryNameserverIP_CancelDuringFirstTry(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + + // The first try starts at once and waits two seconds for a reply; + // the cancel comes a second in. The DNS client cuts a try short at + // the context's deadline but not at a cancel, so the try still + // waits its full two seconds and ends as a timeout a second after + // the cancel. By then the context has certainly ended, so no second + // try is made. With a deadline instead, the try ends at the moment + // the context does, and whether a second try is made depends on + // which the resolver sees first. + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + time.AfterFunc(time.Second, cancel) + + resp, err := r.QueryNameserverIP( + ctx, "unreachable.test.", "192.0.2.1", + "example.com", + ) + require.NoError(t, err) + + assert.Equal(t, resolver.StatusTimeout, resp.Status) + assert.NotEmpty(t, resp.Error) +} + // TestQueryNameserverIP_CancelledLogsNothing cancels the context while // a query to 192.0.2.1, where nothing answers, is waiting for a reply, // as shutdown does. The query was cut short, not failed, so nothing is -- 2.54.0 From b23cf43abf21eae30a2696fb021e7fd1353fa7cd Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 16 Oct 2026 12:26:52 +0200 Subject: [PATCH 92/92] resolver: a reply marked recursion available from a root server's address stops the lookup (closes #300) dnswatcher never asks for recursion and a root server never offers it, so a reply marked recursion available from a root server's address came from a resolver on the network answering in its place. The first such reply, a refusal included, now stops the lookup at once with "this network intercepts DNS queries", naming the address, whether or not that resolver had the name cached. queryDNS returns a refused reply along with ErrRefused so the mark can be seen; a refusal without the mark is handled as before. On such a network dnswatcher and its tests fail with that error, by design, as the owner ruled on the issue. Model: opus-5-5 --- README.md | 12 ++++++ TODO.md | 4 ++ internal/resolver/errors.go | 10 +++-- internal/resolver/iterative.go | 28 +++++++++--- internal/resolver/resolver_test.go | 68 ++++++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 49e220a..7fd92fa 100644 --- a/README.md +++ b/README.md @@ -561,6 +561,18 @@ This approach ensures: - Ability to detect split-horizon or inconsistent responses across authoritative servers. +A root server never offers recursion, so a reply marked "recursion available" +from a root server's address came from a recursive resolver on the network +answering in its place, as on a network where a router rule, a VPN or a DNS +filter sends all DNS to one resolver. The first such reply from a root server's +address, a refusal included, stops the lookup at once with the error "this +network intercepts DNS queries", naming that address, and no other root server +is asked. A lookup that every root server refuses without that mark fails with +the same error. Every lookup starts at the root servers, so on such a network +every lookup fails with that error, whether or not that resolver has the name +cached, and dnswatcher's tests fail there too. That is by design: nothing asks +again another way or otherwise works around the interception. + A watched name's records are stored as its nameservers return them, CNAME included. When they return a CNAME and no address, the chain of every CNAME target they gave is followed (with a depth limit to prevent loops) to the A and diff --git a/TODO.md b/TODO.md index 4a5fdf8..f2674ea 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,10 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-16: the first reply marked "recursion available" from a root server's + address, a refusal included, stops the lookup with "this network intercepts + DNS queries", so on a network that sends DNS to a resolver dnswatcher and its + tests fail, by design (closes #300). - 2026-10-15: a query its caller's context ends before it is sent records no reason about the nameserver, and a try the context's deadline cuts short is a timeout, so a nameserver whose only failures were timeouts is `timeout`, not a diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index a9b109d..5cf5b46 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -53,9 +53,13 @@ var ( "reply truncated and its retry over TCP failed", ) - // ErrIntercepted is returned when every root server refused a - // query. Root servers refuse no query, so the refusals came from - // something on the network answering in their place. + // ErrIntercepted is returned when a root server's address gave a + // reply marked recursion available, a refusal included, which ends + // the lookup at once, or when every root server refused a query. + // Root servers do neither, so those replies came from something on + // the network answering in their place. On such a network + // dnswatcher fails with it, by design: nothing works around the + // interception. ErrIntercepted = errors.New("this network intercepts DNS queries") // ErrCNAMEDepthExceeded is returned when a CNAME chain diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 9156373..cfca76f 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -115,7 +115,8 @@ func (r *Resolver) retryTCP( } // queryDNS sends a DNS query to a specific server IP, never asking it -// for recursion. A reply of REFUSED is returned as ErrRefused. +// for recursion. A reply of REFUSED is returned together with +// ErrRefused, so that the caller can still read the reply's flags. func (r *Resolver) queryDNS( ctx context.Context, serverIP string, @@ -139,7 +140,7 @@ func (r *Resolver) queryDNS( } if resp.Rcode == dns.RcodeRefused { - return nil, fmt.Errorf( + return resp, fmt.Errorf( "query %s @%s: %w", name, serverIP, ErrRefused, ) } @@ -284,10 +285,15 @@ func shuffled( } // queryServers asks servers, the servers of zone, about name in a random -// order until one gives a usable reply. A server that times out, refuses -// or gives a reply that is not usable is passed over for the next. When -// every server refused, the error says so, and when they are the root -// servers it is ErrIntercepted. +// order until one gives a usable reply. When they are the root servers, +// the first reply marked recursion available, a refusal included, ends +// the lookup with ErrIntercepted, naming the address it came from, and +// no other root server is asked: root servers offer no recursion, so +// that reply came from something on the network answering in their +// place. Otherwise a server that times out, refuses or gives a reply +// that is not usable is passed over for the next. When every server +// refused, the error says so, and when they are the root servers it is +// ErrIntercepted. func (r *Resolver) queryServers( ctx context.Context, servers []string, @@ -304,7 +310,17 @@ func (r *Resolver) queryServers( return nil, ErrContextCanceled } + // queryDNS returns a refusal together with its reply, so a + // refusal's mark is checked here too. resp, err := r.queryDNS(ctx, ip, name, qtype) + if resp != nil && zone == "." && resp.RecursionAvailable { + return nil, fmt.Errorf( + "query %s @%s: reply marked recursion available, "+ + "so not from a root server: %w", + name, ip, ErrIntercepted, + ) + } + if err == nil && !usableReply(resp, zone, name) { err = fmt.Errorf( "query %s @%s: %w", name, ip, ErrUnusableReply, diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 9d105b7..f67fb06 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -850,6 +850,74 @@ func TestQueryServers_NotEveryRootServerRefused(t *testing.T) { require.NotContains(t, err.Error(), "refused a query for") } +// TestQueryServers_RecursiveResolversAsRootServers passes public +// recursive resolvers to QueryServers as the servers of the root zone, +// as a network that sends every DNS query to a resolver would. Their +// replies are marked recursion available, which no root server's is: +// SERVFAIL, REFUSED, or an answer from a resolver's cache, as 1.1.1.1 +// gives for com., which would be used if a root server gave it. The +// first such reply ends the lookup with ErrIntercepted, naming the +// resolver that gave it. In the first group the replies differ; every +// resolver in the second refuses a query that does not ask for +// recursion, as 9.9.9.9 does, so there a refusal ends the lookup +// instead of being passed over. Each group has resolvers of four +// operators, and a resolver that gives no reply is passed over for the +// next, so one of them answering is enough. +func TestQueryServers_RecursiveResolversAsRootServers(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + groups := [][]string{ + {"1.1.1.1", "8.8.8.8", "9.9.9.9", "208.67.222.222"}, + {"64.6.64.6", "185.222.222.222", "4.2.2.1", "94.140.14.14"}, + } + + for _, resolvers := range groups { + for _, name := range []string{"com.", "desec.io."} { + var err error + + livednstest.Retry( + t, + "QueryServers("+strings.Join(resolvers, ", ")+ + " as root servers, "+name+")", + func(ctx context.Context) error { + _, err = r.QueryServers( + ctx, resolvers, ".", name, dns.TypeNS, + ) + + // When no resolver gave a reply marked recursion + // available, none may have replied at all, so the + // attempt is tried again. + if err != nil && !strings.Contains( + err.Error(), "marked recursion available", + ) { + return fmt.Errorf( + "%w: %w", livednstest.ErrNoAnswer, err, + ) + } + + return nil + }, + ) + + require.ErrorIs(t, err, resolver.ErrIntercepted) + + // The error names the resolver whose reply ended the + // lookup. + messages := make([]string, 0, len(resolvers)) + for _, ip := range resolvers { + messages = append(messages, + "query "+name+" @"+ip+": reply marked recursion "+ + "available, so not from a root server: "+ + "this network intercepts DNS queries", + ) + } + + require.Contains(t, messages, err.Error()) + } + } +} + func TestQueryNameserver_RecordsSorted(t *testing.T) { t.Parallel() -- 2.54.0