1 Commits
Author SHA1 Message Date
clawbot bb75020f1f watcher tests: far fewer live queries, longer live attempts (closes #214)
check / check (push) Successful in 1m34s
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 and desec.io, whose nameservers
are in zones with two nameservers, not cloudflare.com and example.com,
whose nameserver addresses are looked up at 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
2026-10-02 03:38:07 +00:00
58 changed files with 777 additions and 5500 deletions
+9 -75
View File
@@ -1,75 +1,9 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with .git/
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross bin/
# `/` and an unprefixed pattern is anchored at the context root. Every node_modules/
# depth-independent pattern therefore needs `**/`, or `config/.env` and # No .md may be excluded: Dockerfile.fmt checks every document with
# `certs/server.key` still ship while this file reads as solved. Only # prettier, and an exclusion here would drop a file from that check while
# genuinely root-anchored entries go unprefixed. Never transplant these # prettier still reports every file it was handed clean.
# into .gitignore, where `**/` is wrong. LICENSE
# .editorconfig
# Matching is case-sensitive, so secrets use character ranges rather .gitignore
# 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
-3
View File
@@ -10,6 +10,3 @@ insert_final_newline = true
[Makefile] [Makefile]
indent_style = tab indent_style = tab
[*.go]
indent_style = tab
+1 -9
View File
@@ -1,17 +1,9 @@
name: check name: check
on: [push] 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: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-28
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# script/cibuild needs no token, so none is left in .git/config.
with:
persist-credentials: false
- run: script/cibuild - run: script/cibuild
+2 -52
View File
@@ -1,57 +1,7 @@
# 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/ bin/
node_modules/
vendor/ vendor/
data/ data/
.env
*.exe *.exe
/dnswatcher /dnswatcher
-1
View File
@@ -17,7 +17,6 @@ linters:
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
+4 -1
View File
@@ -1,2 +1,5 @@
bin/
data/
node_modules/ node_modules/
yarn.lock .claude/
static/css/tailwind.min.css
+30 -84
View File
@@ -1,102 +1,48 @@
# Lint phase, built alone by script/lint. The linter is invoked directly # Lint stage - fast feedback on lint issues, before the build starts.
# rather than through `make lint`, which is itself a docker build and # The linter is invoked directly rather than through `make lint`: that
# would recurse into a daemon that does not exist in a build step. # target shells out to `docker build -f Dockerfile.lint`, and there is
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-10-06 # no docker daemon inside a docker build. For the same reason this stage
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint # 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
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN script/fmt-check-go
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# Test phase, built alone by script/test. -race needs cgo and so a C # Build stage
# compiler, which the Debian Go image ships and the alpine one does not. # script/cibuild and script/docker name this stage in --no-cache-filter.
# 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 # golang 1.25-alpine, 2026-02-28
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder 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 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 WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# The VERSION build arg when one is given (script/docker and # Run the tests - build fails if any test fails
# script/cibuild pass one), otherwise `git describe --tags --always` on RUN make test
# 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 /src/bin/dnswatcher ./cmd/dnswatcher/
# Runtime stage, and the last one: a plain `docker build .` builds this # Build the binary. .dockerignore leaves out .git, so `git describe` in
# stage's chain and nothing else. # 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 # alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
+55
View File
@@ -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/ /
+29
View File
@@ -0,0 +1,29 @@
# 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 ./...
+4 -12
View File
@@ -1,13 +1,9 @@
.PHONY: all bootstrap setup build version lint fmt fmt-check test check clean hooks docker .PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker
BINARY := dnswatcher BINARY := dnswatcher
# VERSION given on the command line (`make build VERSION=...`) or in the # `make build VERSION=...` overrides this; the Dockerfile does so, as the
# environment wins over what `git describe` says of this checkout. An # image has no .git to describe.
# empty one counts as not given; `override` is what replaces an empty VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
# command-line value.
ifeq ($(VERSION),)
override VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
endif
LDFLAGS := -X main.Version=$(VERSION) LDFLAGS := -X main.Version=$(VERSION)
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
@@ -25,10 +21,6 @@ setup:
build: build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher
# Prints the version `make build` stamps.
version:
@echo "$(VERSION)"
test: test:
@script/test @script/test
+66 -200
View File
@@ -73,37 +73,14 @@ notification endpoint set, changes show only on the dashboard; see
to discover all authoritative nameservers (NS records) for each domain. to discover all authoritative nameservers (NS records) for each domain.
- Queries **every** discovered authoritative nameserver independently. - Queries **every** discovered authoritative nameserver independently.
- Stores the domain's NS record set, as its parent zone's servers delegate it, - 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. The set is and the IPv4 and IPv6 addresses each nameserver's name resolves to.
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: - Any change triggers a notification:
- NS added to or removed from that set. A domain that had nameservers on the - NS added to or removed from that set.
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 - NS address change: a nameserver that stays in the set resolves to
different addresses than on the previous check. A nameserver added or different addresses than on the previous check. A nameserver added or
removed gets only the NS change notification. When the lookup of a removed gets only the NS change notification. When the lookup of a
nameserver's addresses fails or finds none, its previous addresses are nameserver's addresses fails or finds none, its previous addresses are
kept and nothing is sent. The lookup fails when no nameserver it asks kept and nothing is sent.
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
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.
### DNS Hostname Monitoring (Subdomains) ### DNS Hostname Monitoring (Subdomains)
@@ -111,40 +88,19 @@ notification endpoint set, changes show only on the dashboard; see
via the Public Suffix List). via the Public Suffix List).
- Every **1 hour** by default, 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 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). The its last two labels (a name under `co.uk`, or in a delegated subdomain).
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 - Queries **each** authoritative nameserver independently for **all** record
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. 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 - Stores results **per nameserver**. The state for a hostname is not a merged
view — it is a map from nameserver to record set. view — it is a map from nameserver to record set.
- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in - 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 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 any letter case. TXT and CAA values keep their letter case; they are not
lower-cased. lower-cased.
- Any observable change in any nameserver's response triggers a notification, - 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 This includes:
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS`. This includes:
- **Record change**: A nameserver returns different records than it did on - **Record change**: A nameserver returns different records than it did on
the previous check (additions, removals, value changes). For a domain or the previous check (additions, removals, value changes).
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 - **NS query failure**: A nameserver that previously responded becomes
unreachable (timeout, SERVFAIL, REFUSED, network error). This is distinct unreachable (timeout, SERVFAIL, REFUSED, network error). This is distinct
from "responded with no records": a nameserver that answers NXDOMAIN or from "responded with no records": a nameserver that answers NXDOMAIN or
@@ -163,42 +119,24 @@ notification endpoint set, changes show only on the dashboard; see
they keep disagreeing, including after a restart. A nameserver that was they keep disagreeing, including after a restart. A nameserver that was
not in the previous check (newly added, or back after dropping out), or 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 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 answers. If a pair agrees again and later disagrees, the alert is sent
nameserver failed on the previous check. If a pair agrees again and later 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
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 ### TCP Port Monitoring
- For every configured domain and hostname, constructs a deduplicated list of - For every configured domain and hostname, constructs a deduplicated list of
the IPv4 and IPv6 addresses in the A and AAAA records its authoritative the IPv4 and IPv6 addresses in the A and AAAA records its authoritative
nameservers returned. When they returned a CNAME and no address, the CNAME nameservers returned. A CNAME is not followed: a name whose CNAME points into
chain is followed and the addresses at its end are used, and a change in those another zone usually has no addresses here, so its ports and certificate are
is notified as a CNAME address change. When the nameservers gave different not checked.
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. - Checks TCP connectivity on ports **80** and **443** for each IP address.
- Every **1 hour** by default, re-checks all ports. - Every **1 hour** by default, re-checks all ports.
- Any change in port availability triggers a notification: - Any change in port availability triggers a notification:
- Port transitioned from open to closed (or vice versa). - Port transitioned from open to closed (or vice versa).
- New IP appeared (from DNS change): its port state is recorded without a - New IP appeared (from DNS change): its port state is recorded without a
port notification; the DNS change notification shows the new address. A port notification; the DNS change notification shows the new address.
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; - IP disappeared (from DNS change) — noted in the DNS change notification;
port state for that IP is removed. A domain or hostname listed in port state for that IP is removed. When none of a name's nameservers
`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 answered, its addresses are not known, so the port state saved for them is
kept. kept.
@@ -220,9 +158,7 @@ notification endpoint set, changes show only on the dashboard; see
**Every observable state change produces a notification.** dnswatcher is **Every observable state change produces a notification.** dnswatcher is
designed as a real-time change feed — degradations, failures, recoveries, and designed as a real-time change feed — degradations, failures, recoveries, and
routine changes are all reported equally. A domain or hostname listed in routine changes are all reported equally.
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
notification.
Supported notification backends: Supported notification backends:
@@ -235,21 +171,17 @@ Supported notification backends:
All configured endpoints receive every notification. Notification content All configured endpoints receive every notification. Notification content
includes: includes:
- **DNS record changes**: Which hostname or domain, which nameserver, what - **DNS record changes**: Which hostname, which nameserver, what record type,
record type, old values, new values. old values, new values.
- **DNS NS changes**: Which domain, which nameservers were added/removed. - **DNS NS changes**: Which domain, which nameservers were added/removed.
- **NS address changes**: Which domain, which nameserver, its old and new - **NS address changes**: Which domain, which nameserver, its old and new
addresses. addresses.
- **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, - **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
REFUSED, network error), which hostname/domain affected. REFUSED, network error), which hostname/domain affected.
- **NS recoveries**: Which nameserver recovered, which hostname/domain. - **NS recoveries**: Which nameserver recovered, which hostname/domain.
- **NS inconsistencies**: Which nameservers disagree, what each one returned, - **NS inconsistencies**: Which nameservers disagree, what each one returned,
which hostname or domain affected. which hostname affected.
- **Port changes**: Which IP:port, its new state, and the domains and the - **Port changes**: Which IP:port, its new state, all associated hostnames.
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 - **TLS expiry warnings**: Expiry date and days remaining, CN, associated
hostname and IP. hostname and IP.
- **TLS certificate changes**: Old and new CN and issuer, associated hostname - **TLS certificate changes**: Old and new CN and issuer, associated hostname
@@ -276,14 +208,6 @@ clears them.
false-positive change notifications. false-positive change notifications.
- State is written atomically (write to temp file, then rename) to prevent - State is written atomically (write to temp file, then rename) to prevent
corruption. 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 ### Web Dashboard
@@ -291,15 +215,10 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
(`/`). It displays: (`/`). 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, or "does not exist" for a - **Domains** with their discovered nameservers.
domain whose parent zone's servers answered NXDOMAIN, and each domain's own - **Hostnames** with per-nameserver DNS records and status.
records per nameserver and status, shown as a hostname's are. - **Ports** with open/closed state and associated hostnames.
- **Hostnames** with per-nameserver DNS records and status. For a nameserver - **TLS certificates** with CN, issuer, expiry, and status.
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
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), - **Recent alerts** (last 100 notifications sent since the process started),
displayed in reverse chronological order. displayed in reverse chronological order.
@@ -326,16 +245,6 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
| `GET /api/v1/status` | Current monitoring state | | `GET /api/v1/status` | Current monitoring state |
| `GET /metrics` | Prometheus metrics, see below | | `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). 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 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 `/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, 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 process, and counts of `/metrics` requests); dnswatcher records no metrics of
@@ -422,12 +331,11 @@ following precedence (highest to lowest):
### Environment Variables ### Environment Variables
| Variable | Description | Default | | Variable | Description | Default |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------- |
| `PORT` | HTTP listen port | `8080` | | `PORT` | HTTP listen port | `8080` |
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` | | `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` | | `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` | | `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_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` | | `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` | | `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` |
@@ -504,12 +412,7 @@ 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 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 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 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. When it gives addresses all of them are looked up, so that each can be asked.
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: This approach ensures:
@@ -517,11 +420,9 @@ This approach ensures:
- Ability to detect split-horizon or inconsistent responses across authoritative - Ability to detect split-horizon or inconsistent responses across authoritative
servers. servers.
A watched name's records are stored as its nameservers return them, CNAME CNAME chains are followed (with a depth limit to prevent loops) only to find the
included. When they return a CNAME and no address, the chain of every CNAME addresses of nameservers. A watched name's records are stored as its nameservers
target they gave is followed (with a depth limit to prevent loops) to the A and return them, CNAME included, without following it.
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 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. resolver: the HTTP client looks up the webhook's or Sentry's host name with it.
@@ -532,8 +433,7 @@ 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 The state file (`DATA_DIR/state.json`) contains the complete monitoring
snapshot. Hostname records are stored **per authoritative nameserver**, not as a snapshot. Hostname records are stored **per authoritative nameserver**, not as a
merged view, to enable inconsistency detection. `hostnames` also holds each merged view, to enable inconsistency detection.
domain's own records, under the domain's name.
```json ```json
{ {
@@ -569,7 +469,6 @@ domain's own records, under the domain's name.
"lastChecked": "2026-02-19T12:00:00Z" "lastChecked": "2026-02-19T12:00:00Z"
} }
}, },
"cnameAddresses": [],
"lastChecked": "2026-02-19T12:00:00Z" "lastChecked": "2026-02-19T12:00:00Z"
} }
}, },
@@ -603,7 +502,7 @@ reachability:
| Status | Meaning | | Status | Meaning |
| ------- | -------------------------------------------------------- | | ------- | -------------------------------------------------------- |
| `ok` | Query succeeded, records are current except as below | | `ok` | Query succeeded, records are current |
| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) | | `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
A nameserver that answers NXDOMAIN or with no records has status `ok` and empty A nameserver that answers NXDOMAIN or with no records has status `ok` and empty
@@ -612,33 +511,12 @@ nameservers, has status `error`, empty `records`, and the reason in `error`. A
certificate entry whose TLS connection or handshake failed likewise has status certificate entry whose TLS connection or handshake failed likewise has status
`error`, the reason in `error`, and the certificate fields left empty or zero. `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 `nameserverAddresses` lists, by nameserver, the sorted addresses its name
resolves to. A state file without it loads, and the next check fills it in resolves to. A state file without it loads, and the next check fills it in
without a notification. without a notification.
A domain entry has `"nxdomain": true` when the domain's parent zone's servers A port entry in the older format, with one `hostname` instead of the `hostnames`
answered NXDOMAIN, that it does not exist. Its `nameservers` and list, loads as a list of that one name.
`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
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
of the `hostnames` list, loads as a list of that one name.
--- ---
@@ -656,42 +534,47 @@ provide:
- `script/setup` — make a fresh clone ready for development: bootstrap plus the - `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run the test suite (race detector, coverage) by building the - `script/test` — run the test suite (race detector, coverage). Caching is
`Dockerfile`'s test phase, which queries live DNS. `-count=1` keeps Go's test waived for testing, exactly as it is for linting: `-count=1` forces every
result cache out. Failures are rerun with `-v` automatically, and the build 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. fails even if that rerun passes.
- `script/lint` — run golangci-lint by building the `Dockerfile`'s lint phase, - `script/lint` — run golangci-lint, always inside Docker: it builds
on the digest-pinned `golangci-lint` image, so a successful build is a clean `Dockerfile.lint`, which COPYs the repo into the digest-pinned `golangci-lint`
lint. The linter is never installed or run on the host, and Docker is the only image and lints as a build step, so a successful build is a clean lint. The
prerequisite. 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 - `script/fmt` — format all code (gofmt -s, goimports) and all Markdown
(prettier). goimports runs with `go run` at a pinned commit, never from your (prettier). goimports runs with `go run` at a pinned commit, never from your
`PATH`. prettier runs inside Docker, in a stage of the `Dockerfile` on a `PATH`. prettier runs inside Docker, built from `Dockerfile.fmt` on a
digest-pinned node image, at the version pinned by `package.json` and digest-pinned node image, at the version pinned by `package.json` and
`yarn.lock`; it is never installed on the host. `yarn.lock`; it is never installed on the host.
- `script/fmt-check` — check formatting (read-only) with the same tools, failing - `script/fmt-check` — check formatting (read-only) with the same tools, failing
on any file `script/fmt` would change: gofmt and goimports on the host, on any file `script/fmt` would change. It runs the two scripts below.
prettier inside Docker - `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/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`, with - `script/docker` — build the Docker image tagged via `script/projectname`, with
the version from `git describe` passed as `--build-arg VERSION` `--no-cache-filter=lint,builder` so the lint stage and the builder stage,
- `script/cibuild` — CI entrypoint: `script/bootstrap`, then `script/check`, which runs the tests, run on every invocation, and with the version from
then the image build `script/docker` does, which runs the lint and test phases `git describe` passed as `--build-arg VERSION`
again - `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`
- `script/precommit` — run by the git pre-commit hook; `go mod tidy` guard, then - `script/precommit` — run by the git pre-commit hook; `go mod tidy` guard, then
`script/check` `script/check`
- `script/install-precommit` — install the git pre-commit hook - `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 ## Building
```sh ```sh
make build # Build binary to bin/dnswatcher 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 lint # Run golangci-lint in Docker (requires docker)
make fmt # Format code and Markdown (requires docker) make fmt # Format code and Markdown (requires docker)
make check # Run all checks (test, lint, fmt-check) make check # Run all checks (test, lint, fmt-check)
@@ -701,25 +584,13 @@ make clean # Remove build artifacts
### Build-Time Variables ### Build-Time Variables
`make build` sets the version with `-ldflags "-X main.Version=..."`, taking it `make build` sets the version with `-ldflags "-X main.Version=..."`, taking it
from `VERSION` when given on the command line (`make build VERSION=1.2.3`) or in from `git describe --tags --always --dirty`, or from `VERSION` when given on the
the environment, otherwise from `git describe --tags --always --dirty`, and command line (`make build VERSION=1.2.3`). The version appears in the startup
`dev` without git metadata. An empty `VERSION` counts as not given. The version log and in the health check response.
appears in the startup log and in the health check response.
The image takes it from `git describe --tags --always` on the `.git` the build The Docker image has no `.git`, so the `Dockerfile` takes the version as
context carries, so a plain `docker build .` of a clone stamps the commit it was `--build-arg VERSION`. `make docker` passes it; a plain `docker build` passes
built from; a clone without tags stamps the short commit. A clone made with none, and that image reports `dev`.
`--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`.
--- ---
@@ -775,8 +646,7 @@ docker run -d \
1. **Startup**: Check that the data directory can be written, and exit with an 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 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 with empty state (first check will establish baseline without triggering
change notifications). Remove from the state the names no longer in change notifications).
`DNSWATCHER_TARGETS` (see State Management).
2. **Initial check**: Immediately perform all DNS, port, and TLS checks on 2. **Initial check**: Immediately perform all DNS, port, and TLS checks on
startup. startup.
3. **Periodic checks** (DNS always runs first): 3. **Periodic checks** (DNS always runs first):
@@ -788,13 +658,9 @@ docker run -d \
- Port and TLS checks use the IP addresses found by the DNS phase that - 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 immediately precedes them. When that phase cannot find a name's
nameservers at all, the addresses an earlier check saved for the name are nameservers at all, the addresses an earlier check saved for the name are
used. When it cannot follow a name's CNAME chain, or none of the name's used.
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, 4. **On change detection**: Send notifications to all configured endpoints,
update in-memory state, persist to disk. A record change or inconsistency for update in-memory state, persist to disk.
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, 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 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 in-flight notification deliveries to complete. Both waits share the fx
+86 -349
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-04 last_modified: 2026-08-07
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -60,28 +60,17 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image repo root and runs `docker build .`; the Gitea workflow calls it. Four further
with the version; the Gitea workflow calls it. **`script/cibuild` runs scripts are our own extensions to the standard: `script/check` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
nothing else, while `script/fmt-check` runs the formatter on the host: on a what the git pre-commit hook runs, and it calls `script/check`;
pristine checkout with nothing installed the run dies there, after the `script/install-precommit` installs the git pre-commit hook (the `make hooks`
containerised gates have passed. **The bootstrap alone is not enough**: target shims to it); and `script/projectname` (literally that filename) simply
`script/bootstrap` installs node and yarn under nvm and leaves neither on the outputs the project's name. Scripts that need the name call
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host `script/projectname` — e.g. `script/docker` assembles its image tag from it —
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore so those scripts stay byte-identical across all repos. Repo-type-specific
source nvm for the pinned node version before invoking it, exactly as pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/bootstrap`'s own install step does. A runner carrying nothing but `script/precommit`, not in the hook itself. Model scripts are at
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/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -100,198 +89,87 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by contributor should be able to understand the entire development workflow by
reading the Makefile. reading the Makefile.
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a - Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
`lint` phase and a `test` phase, with the final stage depending on both so the as a build step so the build fails if the branch is not green. For non-server
image cannot be built unless they pass. For non-server repos the final stage repos, the Dockerfile should bring up a development environment and run
brings up a development environment; for server repos it is the runtime image. `make check`. For server repos, `make check` should run as an early build
The gate phases and the build stage start from their pinned base images and stage before the final image is assembled. Dockerfiles install development
install what those images lack either inline, as the canonical Go `Dockerfile` prerequisites by running `script/bootstrap` rather than duplicating installs
below does for `git`, or by running `script/bootstrap`, as the `prompts` inline; COPY `script/` and the dependency manifests (`package.json` +
repo's own `Dockerfile` does for its yarn packages. The development `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
environment stage installs development prerequisites by running layer stays cached until dependencies change.
`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.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is - **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
no separate lint file. `script/lint` and `script/test` each build one phase repos use a multistage build where linting runs in an independent stage based
and nothing else: 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.
```sh The standard pattern for a Go repo Dockerfile is:
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 ```dockerfile
# Lint phase # Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN make fmt-check
RUN make lint
# Test phase. -race needs cgo and so a C compiler, which the Debian Go # Build stage
# image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
# 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 # golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder 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 WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN make test
# The VERSION build arg when one is given, otherwise ARG VERSION=dev
# `git describe --tags --always` on the .git in the build context. With RUN CGO_ENABLED=0 go build -trimpath \
# .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}" \ -ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/ -o /app ./cmd/app/
# Runtime stage, and the last one # Runtime stage
FROM alpine@sha256:... FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"] ENTRYPOINT ["app"]
``` ```
Key points: Key points:
- The lint phase uses the `golangci/golangci-lint` image directly (it has - The lint stage uses the `golangci/golangci-lint` image directly (it
both Go and the linter), so nothing needs installing. includes both Go and the linter), so there is no need to install the
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only linter separately.
purpose is the ordering edge. BuildKit runs stages in parallel by default, - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
and a stage nothing depends on is not built at all, so without these two a stage dependency. BuildKit runs stages in parallel by default; without
lines a red gate would not fail the build. this line, the build stage would not wait for lint to finish and a lint
- Keep the runtime stage last, and if you add a stage after it, give it the failure might not fail the overall build.
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 - If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint phase must (e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting, install them The lint stage should not depend on the actual build output — it exists to
in the lint phase. The `golangci/golangci-lint` image is Debian-based and fail fast.
has no `apk`, so install with `apt-get` under the Debian package name - If the project requires CGO or system libraries for linting (e.g.
(`libvips-dev`, where alpine says `vips-dev`), and delete the package `vips-dev`), install them in the lint stage with `apk add`.
lists in the same `RUN`, so the layer does not keep them: - The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
```dockerfile artifacts or heavier dependencies.
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 - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step. runs `script/cibuild` (which runs `docker build .`) on push. Since the
That script bootstraps, runs the gate phases, and then builds the image, so a Dockerfile already runs `make check`, a successful build implies all checks
successful run means every check passed; a bare `docker build .` does not pass.
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 - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -315,17 +193,15 @@ style conventions are in separate documents:
suite that exceeds it fails. Under 20 seconds is the target. A suite between 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 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 improvement bug against that repo. Add a 90-second timeout to the test
invocation (`go test -timeout 90s`). The backstop deliberately sits above the invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately
hard cap so that it catches a genuinely hung test rather than a merely slow sits above the hard cap so that it catches a genuinely hung test rather than a
one. merely slow one.
- **The test command should use the conditional verbose rerun pattern.** Run - **`make test` should use the conditional verbose rerun pattern.** Run tests
tests without `-v` (verbose) first. If tests fail, automatically rerun with without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
`-v` to show full output. This keeps CI logs and `docker build` output clean show full output. This keeps CI logs and `docker build` output clean on
on success (just package/suite summaries) while providing full diagnostic success (just package/suite summaries) while providing full diagnostic detail
detail on failure (every test case, every assertion). The command lives in the on failure (every test case, every assertion). The general shell pattern:
`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 ```makefile
test: test:
@@ -338,26 +214,11 @@ style conventions are in separate documents:
```makefile ```makefile
test: test:
@go test -count=1 -timeout 90s -race -cover ./... || \ @go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; } go test -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: Python example:
```makefile ```makefile
@@ -383,84 +244,10 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions. must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` Fetch the standard `.gitignore` from
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
setting up a new repo. These patterns are written to `.gitignore`'s own a new repo.
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 - **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the bundles, minified output, generated assets) must never be committed to the
@@ -476,56 +263,12 @@ style conventions are in separate documents:
- Make all changes on a feature branch. You can do whatever you want on a - Make all changes on a feature branch. You can do whatever you want on a
feature branch. feature branch.
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must - `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
_NEVER_ be modified by an agent: fetch it from manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The
byte-identical, so that no repo can quietly loosen its own linting. Linter canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed
configuration changes are made to the canonical copy in the `prompts` repo and commit-pinned via
reach consuming repos by re-vendoring; an agent may open a PR against `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
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 <tool>; 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 <package>@<commit hash>`). 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 - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -639,14 +382,12 @@ style conventions are in separate documents:
settings. settings.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
and language-specific config). Everything else goes in a subdirectory. language-specific config). Everything else goes in a subdirectory. Canonical
Canonical subdirectory names: subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose - `cmd/` — Go command entrypoints
body is a single call into `internal/` or `pkg/`, no project logic in
`cmd/`
- `configs/` — configuration templates and examples - `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform) - `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root) - `docs/` — documentation and markdown (README.md stays in root)
@@ -673,7 +414,3 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - 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.
+3 -3
View File
@@ -38,7 +38,7 @@ works correctly in production.
responses responses
- **Do not add `-short` flags** to skip slow tests - **Do not add `-short` flags** to skip slow tests
- **Do not increase `-timeout`** to hide hanging queries - **Do not increase `-timeout`** to hide hanging queries
- **Do not remove `-count=1` from the test phase of the `Dockerfile`** — Go's - **Do not remove `-count=1` from `script/test`** — Go's test cache replays a
test cache replays a previous run's output without querying anything, so a previous run's output without querying anything, so a cached pass is not
cached pass is not evidence that live resolution works evidence that live resolution works
- **Do not modify linter configuration** to suppress findings - **Do not modify linter configuration** to suppress findings
-50
View File
@@ -19,56 +19,6 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # 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).
- 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
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
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
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
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
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
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
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
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
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
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 - 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). 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 - 2026-10-02: the resolver tries root servers, and every other server list it
-45
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"slices"
"strings" "strings"
"time" "time"
@@ -36,10 +35,6 @@ var ErrInvalidInterval = errors.New(
"interval must be a positive duration such as 30m or 1h", "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. // Params contains dependencies for Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -55,7 +50,6 @@ type Config struct {
DataDir string DataDir string
Domains []string Domains []string
Hostnames []string Hostnames []string
SkipRecordNotifications []string
SlackWebhook string SlackWebhook string
MattermostWebhook string MattermostWebhook string
NtfyTopic string NtfyTopic string
@@ -109,7 +103,6 @@ func setupViper(name string) {
viper.SetDefault("DEBUG", false) viper.SetDefault("DEBUG", false)
viper.SetDefault("DATA_DIR", "/var/lib/"+name) viper.SetDefault("DATA_DIR", "/var/lib/"+name)
viper.SetDefault("TARGETS", "") viper.SetDefault("TARGETS", "")
viper.SetDefault("SKIP_RECORD_NOTIFICATIONS", "")
viper.SetDefault("SLACK_WEBHOOK", "") viper.SetDefault("SLACK_WEBHOOK", "")
viper.SetDefault("MATTERMOST_WEBHOOK", "") viper.SetDefault("MATTERMOST_WEBHOOK", "")
viper.SetDefault("NTFY_TOPIC", "") viper.SetDefault("NTFY_TOPIC", "")
@@ -154,20 +147,12 @@ func buildConfig(
return nil, err return nil, err
} }
skipRecordNotifications, err := parseSkipRecordNotifications(
domains, hostnames,
)
if err != nil {
return nil, err
}
cfg := &Config{ cfg := &Config{
Port: viper.GetInt("PORT"), Port: viper.GetInt("PORT"),
Debug: viper.GetBool("DEBUG"), Debug: viper.GetBool("DEBUG"),
DataDir: viper.GetString("DATA_DIR"), DataDir: viper.GetString("DATA_DIR"),
Domains: domains, Domains: domains,
Hostnames: hostnames, Hostnames: hostnames,
SkipRecordNotifications: skipRecordNotifications,
SlackWebhook: viper.GetString("SLACK_WEBHOOK"), SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"), MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
NtfyTopic: viper.GetString("NTFY_TOPIC"), NtfyTopic: viper.GetString("NTFY_TOPIC"),
@@ -219,36 +204,6 @@ func parseAndValidateTargets() ([]string, []string, error) {
return domains, hostnames, nil 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 { func parseCSV(input string) []string {
if input == "" { if input == "" {
return nil return nil
-28
View File
@@ -57,7 +57,6 @@ func TestNew_DefaultValues(t *testing.T) {
assert.Empty(t, cfg.MetricsUsername) assert.Empty(t, cfg.MetricsUsername)
assert.Empty(t, cfg.MetricsPassword) assert.Empty(t, cfg.MetricsPassword)
assert.False(t, cfg.SendTestNotification) assert.False(t, cfg.SendTestNotification)
assert.Empty(t, cfg.SkipRecordNotifications)
} }
func TestNew_EnvironmentOverrides(t *testing.T) { func TestNew_EnvironmentOverrides(t *testing.T) {
@@ -236,33 +235,6 @@ func TestNew_TargetsWithTrailingComma(t *testing.T) {
"trailing comma should be ignored") "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) { func TestNew_CustomDNSIntervalDuration(t *testing.T) {
viper.Reset() viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com") t.Setenv("DNSWATCHER_TARGETS", "example.com")
+4 -39
View File
@@ -1,14 +1,11 @@
package handlers package handlers
import ( import (
"cmp"
"embed" "embed"
"fmt" "fmt"
"html/template" "html/template"
"maps"
"math" "math"
"net/http" "net/http"
"slices"
"strings" "strings"
"time" "time"
@@ -43,16 +40,9 @@ func newDashboardTemplate() *template.Template {
) )
} }
// dashboardData is the data passed to the dashboard template. Hostnames // dashboardData is the data passed to the dashboard template.
// and DomainRecords split the records in Snapshot.Hostnames, which also
// 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 { type dashboardData struct {
Snapshot state.Snapshot Snapshot state.Snapshot
Hostnames map[string]*state.HostnameState
DomainRecords map[string]*state.HostnameState
Ports map[string]*statusPortInfo
Alerts []notify.AlertEntry Alerts []notify.AlertEntry
StateAge string StateAge string
GeneratedAt string GeneratedAt string
@@ -68,13 +58,9 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
) { ) {
snap := h.state.GetSnapshot() snap := h.state.GetSnapshot()
alerts := h.notifyHistory.Recent() alerts := h.notifyHistory.Recent()
hostnames, domainRecords := splitHostnames(snap)
data := dashboardData{ data := dashboardData{
Snapshot: snap, Snapshot: snap,
Hostnames: hostnames,
DomainRecords: domainRecords,
Ports: buildPorts(snap),
Alerts: alerts, Alerts: alerts,
StateAge: relTime(snap.LastUpdated), StateAge: relTime(snap.LastUpdated),
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"), GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
@@ -136,37 +122,16 @@ func joinStrings(items []string, sep string) string {
} }
// formatRecords formats a map of record type → values into a // formatRecords formats a map of record type → values into a
// compact display string. Record types are listed in the order the // compact display string.
// 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 { func formatRecords(records map[string][]string) string {
if len(records) == 0 { if len(records) == 0 {
return "-" 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 var parts []string
for _, rtype := range rtypes { for rtype, values := range records {
for _, v := range records[rtype] { for _, v := range values {
parts = append(parts, rtype+": "+v) parts = append(parts, rtype+": "+v)
} }
} }
-193
View File
@@ -1,8 +1,6 @@
package handlers_test package handlers_test
import ( import (
"regexp"
"strings"
"testing" "testing"
"time" "time"
@@ -80,194 +78,3 @@ func TestFormatRecords(t *testing.T) {
t.Errorf("unexpected format: %q", got) t.Errorf("unexpected format: %q", got)
} }
} }
// 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()
for row := range strings.SplitSeq(page, "<tr") {
if strings.Contains(row, name) {
return row
}
}
t.Fatalf("dashboard has no row containing %q", name)
return ""
}
// TestDashboardShowsFailureReasons checks that the dashboard shows the
// reason in the row of a failed nameserver and of a failed certificate,
// and not in the row of a nameserver that answered.
func TestDashboardShowsFailureReasons(t *testing.T) {
t.Parallel()
page := get(t, newHandlersWithFailures(t).HandleDashboard())
if !strings.Contains(dashboardRow(t, page, failedNS), nsFailureReason) {
t.Errorf("row of %s does not show %q", failedNS, nsFailureReason)
}
if strings.Contains(dashboardRow(t, page, answeringNS), nsFailureReason) {
t.Errorf("row of %s shows %q", answeringNS, nsFailureReason)
}
if !strings.Contains(dashboardRow(t, page, certKey), certFailedReason) {
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, "<section") {
words := strings.Join(strings.Fields(section), " ")
if strings.Contains(words, "> "+heading+" </h2>") {
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 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 2
// Hostnames 1".
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ")
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, "<th" or "<td".
func rowCells(row string, tag string) []string {
tags := regexp.MustCompile(`<[^>]*>`)
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</th>"), "<th")
cells := rowCells(dashboardRow(t, ports, sharedPort), "<td")
if len(cells) != len(headings) {
t.Fatalf("row of %s has cells %q under headings %q",
sharedPort, cells, headings)
}
under := make(map[string]string)
for i, heading := range headings {
under[heading] = cells[i]
}
if under["Domains"] != testDomain {
t.Errorf("row of %s lists %q under Domains, want %q",
sharedPort, under["Domains"], testDomain)
}
if under["Hostnames"] != testHostname {
t.Errorf("row of %s lists %q under Hostnames, want %q",
sharedPort, under["Hostnames"], testHostname)
}
}
+18 -82
View File
@@ -9,13 +9,8 @@ import (
) )
// statusDomainInfo holds status information for a monitored domain. // 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. NXDomain is true when
// the domain's parent zone's servers answered that it does not exist.
type statusDomainInfo struct { type statusDomainInfo struct {
Nameservers []string `json:"nameservers"` Nameservers []string `json:"nameservers"`
RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"`
NXDomain bool `json:"nxdomain"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
@@ -23,7 +18,6 @@ type statusDomainInfo struct {
type statusHostnameNSInfo struct { type statusHostnameNSInfo struct {
Records map[string][]string `json:"records"` Records map[string][]string `json:"records"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
@@ -34,11 +28,8 @@ type statusHostnameInfo struct {
} }
// statusPortInfo holds status information for a monitored port. // statusPortInfo holds status information for a monitored port.
// Domains and Hostnames list the apex domains and the hostnames that
// resolve to its address.
type statusPortInfo struct { type statusPortInfo struct {
Open bool `json:"open"` Open bool `json:"open"`
Domains []string `json:"domains"`
Hostnames []string `json:"hostnames"` Hostnames []string `json:"hostnames"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
@@ -50,7 +41,6 @@ type statusCertificateInfo struct {
NotAfter time.Time `json:"notAfter"` NotAfter time.Time `json:"notAfter"`
SubjectAlternativeNames []string `json:"subjectAlternativeNames"` SubjectAlternativeNames []string `json:"subjectAlternativeNames"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
@@ -104,44 +94,21 @@ func buildStatusResponse(
LastUpdated: snap.LastUpdated, LastUpdated: snap.LastUpdated,
Domains: make(map[string]*statusDomainInfo), Domains: make(map[string]*statusDomainInfo),
Hostnames: make(map[string]*statusHostnameInfo), Hostnames: make(map[string]*statusHostnameInfo),
Ports: make(map[string]*statusPortInfo),
Certificates: make(map[string]*statusCertificateInfo), Certificates: make(map[string]*statusCertificateInfo),
} }
hostnames, domainRecords := splitHostnames(snap) buildDomains(snap, resp)
buildHostnames(snap, resp)
buildDomains(snap, domainRecords, resp) buildPorts(snap, resp)
buildHostnames(hostnames, resp)
resp.Ports = buildPorts(snap)
buildCertificates(snap, resp) buildCertificates(snap, resp)
buildCounts(resp) buildCounts(resp)
return resp 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( func buildDomains(
snap state.Snapshot, snap state.Snapshot,
domainRecords map[string]*state.HostnameState,
resp *statusResponse, resp *statusResponse,
) { ) {
for name, ds := range snap.Domains { for name, ds := range snap.Domains {
@@ -149,37 +116,22 @@ func buildDomains(
copy(ns, ds.Nameservers) copy(ns, ds.Nameservers)
sort.Strings(ns) sort.Strings(ns)
records := make(map[string]*statusHostnameNSInfo)
if hs, ok := domainRecords[name]; ok {
records = nameserverInfo(hs)
}
resp.Domains[name] = &statusDomainInfo{ resp.Domains[name] = &statusDomainInfo{
Nameservers: ns, Nameservers: ns,
RecordsByNameserver: records,
NXDomain: ds.NXDomain,
LastChecked: ds.LastChecked, LastChecked: ds.LastChecked,
} }
} }
} }
func buildHostnames( func buildHostnames(
hostnames map[string]*state.HostnameState, snap state.Snapshot,
resp *statusResponse, resp *statusResponse,
) { ) {
for name, hs := range hostnames { for name, hs := range snap.Hostnames {
resp.Hostnames[name] = &statusHostnameInfo{ info := &statusHostnameInfo{
Nameservers: nameserverInfo(hs), Nameservers: make(map[string]*statusHostnameNSInfo),
LastChecked: hs.LastChecked, LastChecked: hs.LastChecked,
} }
}
}
// nameserverInfo copies each nameserver's answer saved in hs.
func nameserverInfo(
hs *state.HostnameState,
) map[string]*statusHostnameNSInfo {
info := make(map[string]*statusHostnameNSInfo)
for ns, nsState := range hs.RecordsByNameserver { for ns, nsState := range hs.RecordsByNameserver {
recs := make(map[string][]string, len(nsState.Records)) recs := make(map[string][]string, len(nsState.Records))
@@ -189,47 +141,32 @@ func nameserverInfo(
recs[rtype] = copied recs[rtype] = copied
} }
info[ns] = &statusHostnameNSInfo{ info.Nameservers[ns] = &statusHostnameNSInfo{
Records: recs, Records: recs,
Status: nsState.Status, Status: nsState.Status,
Error: nsState.Error,
LastChecked: nsState.LastChecked, LastChecked: nsState.LastChecked,
} }
} }
return info resp.Hostnames[name] = info
}
} }
// buildPorts returns the port entries saved in snap. A port entry func buildPorts(
// saves apex domains with its hostnames; they are told apart as in snap state.Snapshot,
// splitHostnames, by a domain entry in snap.Domains. resp *statusResponse,
func buildPorts(snap state.Snapshot) map[string]*statusPortInfo { ) {
ports := make(map[string]*statusPortInfo, len(snap.Ports))
for key, ps := range snap.Ports { for key, ps := range snap.Ports {
domains := []string{} hostnames := make([]string, len(ps.Hostnames))
hostnames := []string{} copy(hostnames, ps.Hostnames)
for _, name := range ps.Hostnames {
if _, isDomain := snap.Domains[name]; isDomain {
domains = append(domains, name)
} else {
hostnames = append(hostnames, name)
}
}
sort.Strings(domains)
sort.Strings(hostnames) sort.Strings(hostnames)
ports[key] = &statusPortInfo{ resp.Ports[key] = &statusPortInfo{
Open: ps.Open, Open: ps.Open,
Domains: domains,
Hostnames: hostnames, Hostnames: hostnames,
LastChecked: ps.LastChecked, LastChecked: ps.LastChecked,
} }
} }
return ports
} }
func buildCertificates( func buildCertificates(
@@ -246,7 +183,6 @@ func buildCertificates(
NotAfter: cs.NotAfter, NotAfter: cs.NotAfter,
SubjectAlternativeNames: sans, SubjectAlternativeNames: sans,
Status: cs.Status, Status: cs.Status,
Error: cs.Error,
LastChecked: cs.LastChecked, LastChecked: cs.LastChecked,
} }
} }
-309
View File
@@ -1,309 +0,0 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"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/notify"
"sneak.berlin/go/dnswatcher/internal/state"
)
// The state the handler tests serve: www.example.com has one nameserver
// that answered and one whose query failed, and its certificate check
// failed. example.net is an apex domain, whose own records are saved
// with the hostnames' records, as the watcher saves them. Both names
// resolve to domainAddress, whose port 443 entry lists them.
// missingDomain is an apex domain whose parent zone's servers answered
// that it does not exist, saved with no nameservers and no records.
const (
missingDomain = "does-not-exist.example"
testHostname = "www.example.com"
answeringNS = "ns1.example.com."
failedNS = "ns2.example.com."
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"
sharedPort = domainAddress + ":443"
)
// newHandlersWithFailures builds real Handlers whose state holds the
// entries described above.
func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
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)
}
notifier, err := notify.New(fxtest.NewLifecycle(t), notify.Params{
Logger: log,
Config: &config.Config{},
})
if err != nil {
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)
}
setTestState(st)
hnd, err := handlers.New(nil, handlers.Params{
Logger: log,
Globals: glob,
State: st,
Notify: notifier,
})
if err != nil {
t.Fatalf("handlers.New: %v", err)
}
return hnd
}
// setTestState sets the entries described above in st.
func setTestState(st *state.State) {
now := time.Now()
st.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
answeringNS: {
Records: map[string][]string{
"A": {"192.0.2.1", domainAddress},
},
Status: "ok",
LastChecked: now,
},
failedNS: {
Records: map[string][]string{},
Status: "error",
Error: nsFailureReason,
LastChecked: now,
},
},
LastChecked: now,
})
st.SetCertificateState(certKey, &state.CertificateState{
Status: "error",
Error: certFailedReason,
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,
})
st.SetPortState(sharedPort, &state.PortState{
Open: true,
Hostnames: []string{testDomain, testHostname},
LastChecked: now,
})
st.SetDomainState(missingDomain, &state.DomainState{
Nameservers: []string{},
NXDomain: true,
LastChecked: now,
})
}
// get serves one GET request to handler and returns the response body.
func get(t *testing.T, handler http.HandlerFunc) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
return rec.Body.String()
}
// TestStatusGivesFailureReasons checks that /api/v1/status gives the
// reason for a failed nameserver entry and a failed certificate entry,
// and no error for a nameserver that answered.
func TestStatusGivesFailureReasons(t *testing.T) {
t.Parallel()
body := get(t, newHandlersWithFailures(t).HandleStatus())
var resp struct {
Hostnames map[string]struct {
Nameservers map[string]map[string]any `json:"nameservers"`
} `json:"hostnames"`
Certificates map[string]map[string]any `json:"certificates"`
}
err := json.Unmarshal([]byte(body), &resp)
if err != nil {
t.Fatalf("decoding response: %v", err)
}
nameservers := resp.Hostnames[testHostname].Nameservers
got := nameservers[failedNS]["error"]
if got != nsFailureReason {
t.Errorf("failed nameserver error = %v, want %q",
got, nsFailureReason)
}
_, has := nameservers[answeringNS]["error"]
if has {
t.Errorf("answering nameserver has an error field: %v",
nameservers[answeringNS])
}
got = resp.Certificates[certKey]["error"]
if got != certFailedReason {
t.Errorf("failed certificate error = %v, want %q",
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)
}
}
// TestStatusMarksDomainThatDoesNotExist checks that /api/v1/status sets
// nxdomain for a domain that does not exist, with no nameservers or
// records, and not for a domain that exists.
func TestStatusMarksDomainThatDoesNotExist(t *testing.T) {
t.Parallel()
body := get(t, newHandlersWithFailures(t).HandleStatus())
var resp struct {
Domains map[string]struct {
Nameservers []string `json:"nameservers"`
RecordsByNameserver map[string]any `json:"recordsByNameserver"`
NXDomain bool `json:"nxdomain"`
} `json:"domains"`
}
err := json.Unmarshal([]byte(body), &resp)
if err != nil {
t.Fatalf("decoding response: %v", err)
}
missing := resp.Domains[missingDomain]
if !missing.NXDomain || len(missing.Nameservers) != 0 ||
len(missing.RecordsByNameserver) != 0 {
t.Errorf("domain %s = %+v, want nxdomain and nothing else",
missingDomain, missing)
}
if resp.Domains[testDomain].NXDomain {
t.Errorf("domain %s has nxdomain set", testDomain)
}
}
// TestStatusPortsTellDomainsFromHostnames checks that a port entry in
// /api/v1/status lists an apex domain in domains and a hostname in
// hostnames when both resolve to its address.
func TestStatusPortsTellDomainsFromHostnames(t *testing.T) {
t.Parallel()
body := get(t, newHandlersWithFailures(t).HandleStatus())
var resp struct {
Ports map[string]struct {
Domains []string `json:"domains"`
Hostnames []string `json:"hostnames"`
} `json:"ports"`
}
err := json.Unmarshal([]byte(body), &resp)
if err != nil {
t.Fatalf("decoding response: %v", err)
}
port := resp.Ports[sharedPort]
if !slices.Equal(port.Domains, []string{testDomain}) {
t.Errorf("port %s domains = %v, want [%s]",
sharedPort, port.Domains, testDomain)
}
if !slices.Equal(port.Hostnames, []string{testHostname}) {
t.Errorf("port %s hostnames = %v, want [%s]",
sharedPort, port.Hostnames, testHostname)
}
}
+38 -78
View File
@@ -39,7 +39,7 @@
Hostnames Hostnames
</div> </div>
<div class="text-2xl font-bold text-teal-400 mt-1"> <div class="text-2xl font-bold text-teal-400 mt-1">
{{ len .Hostnames }} {{ len .Snapshot.Hostnames }}
</div> </div>
</div> </div>
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4"> <div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
@@ -84,11 +84,7 @@
{{ $name }} {{ $name }}
</td> </td>
<td class="py-2 px-3 text-slate-400 break-all"> <td class="py-2 px-3 text-slate-400 break-all">
{{ if $ds.NXDomain }}
<span class="text-red-400">does not exist</span>
{{ else }}
{{ joinStrings $ds.Nameservers ", " }} {{ joinStrings $ds.Nameservers ", " }}
{{ end }}
</td> </td>
<td class="py-2 px-3 text-slate-500 whitespace-nowrap"> <td class="py-2 px-3 text-slate-500 whitespace-nowrap">
{{ relTime $ds.LastChecked }} {{ relTime $ds.LastChecked }}
@@ -98,24 +94,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{{ if .DomainRecords }}
<div class="overflow-x-auto mt-4">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-slate-500 uppercase tracking-wider">
<th class="py-2 px-3">Domain</th>
<th class="py-2 px-3">NS</th>
<th class="py-2 px-3">Status</th>
<th class="py-2 px-3">Records</th>
<th class="py-2 px-3">Checked</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{{ template "records" .DomainRecords }}
</tbody>
</table>
</div>
{{ end }}
{{ else }} {{ else }}
<p class="text-slate-600 italic text-xs"> <p class="text-slate-600 italic text-xs">
No domains configured. No domains configured.
@@ -130,7 +108,7 @@
> >
Hostnames Hostnames
</h2> </h2>
{{ if .Hostnames }} {{ if .Snapshot.Hostnames }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-left text-xs"> <table class="w-full text-left text-xs">
<thead> <thead>
@@ -143,7 +121,39 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-800"> <tbody class="divide-y divide-slate-800">
{{ template "records" .Hostnames }} {{ range $name, $hs := .Snapshot.Hostnames }}
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
<tr class="hover:bg-surface-800/50">
<td class="py-2 px-3 text-slate-200 font-medium">
{{ $name }}
</td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ $ns }}
</td>
<td class="py-2 px-3">
{{ if eq $nsr.Status "ok" }}
<span
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
>ok</span
>
{{ else }}
<span
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
>{{ $nsr.Status }}</span
>
{{ end }}
</td>
<td
class="py-2 px-3 text-slate-400 break-all max-w-xs"
>
{{ formatRecords $nsr.Records }}
</td>
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
{{ relTime $nsr.LastChecked }}
</td>
</tr>
{{ end }}
{{ end }}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -161,20 +171,19 @@
> >
Ports Ports
</h2> </h2>
{{ if .Ports }} {{ if .Snapshot.Ports }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-left text-xs"> <table class="w-full text-left text-xs">
<thead> <thead>
<tr class="text-slate-500 uppercase tracking-wider"> <tr class="text-slate-500 uppercase tracking-wider">
<th class="py-2 px-3">Address</th> <th class="py-2 px-3">Address</th>
<th class="py-2 px-3">State</th> <th class="py-2 px-3">State</th>
<th class="py-2 px-3">Domains</th>
<th class="py-2 px-3">Hostnames</th> <th class="py-2 px-3">Hostnames</th>
<th class="py-2 px-3">Checked</th> <th class="py-2 px-3">Checked</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-800"> <tbody class="divide-y divide-slate-800">
{{ range $key, $ps := .Ports }} {{ range $key, $ps := .Snapshot.Ports }}
<tr class="hover:bg-surface-800/50"> <tr class="hover:bg-surface-800/50">
<td class="py-2 px-3 text-slate-200 font-medium"> <td class="py-2 px-3 text-slate-200 font-medium">
{{ $key }} {{ $key }}
@@ -192,9 +201,6 @@
> >
{{ end }} {{ end }}
</td> </td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ joinStrings $ps.Domains ", " }}
</td>
<td class="py-2 px-3 text-slate-400 break-all"> <td class="py-2 px-3 text-slate-400 break-all">
{{ joinStrings $ps.Hostnames ", " }} {{ joinStrings $ps.Hostnames ", " }}
</td> </td>
@@ -252,11 +258,6 @@
> >
{{ end }} {{ end }}
</td> </td>
{{ if $cs.Error }}
<td colspan="3" class="py-2 px-3 text-red-400 break-all">
<div class="max-w-xs">{{ $cs.Error }}</div>
</td>
{{ else }}
<td class="py-2 px-3 text-slate-200"> <td class="py-2 px-3 text-slate-200">
{{ $cs.CommonName }} {{ $cs.CommonName }}
</td> </td>
@@ -284,7 +285,6 @@
{{ end }} {{ end }}
{{ end }} {{ end }}
</td> </td>
{{ end }}
<td class="py-2 px-3 text-slate-500 whitespace-nowrap"> <td class="py-2 px-3 text-slate-500 whitespace-nowrap">
{{ relTime $cs.LastChecked }} {{ relTime $cs.LastChecked }}
</td> </td>
@@ -363,48 +363,8 @@
class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8" class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8"
> >
dnswatcher &middot; monitoring {{ len .Snapshot.Domains }} domains + dnswatcher &middot; monitoring {{ len .Snapshot.Domains }} domains +
{{ len .Hostnames }} hostnames {{ len .Snapshot.Hostnames }} hostnames
</div> </div>
</div> </div>
</body> </body>
</html> </html>
{{/* ---- One row per nameserver of each name in the map it is given ---- */}}
{{ define "records" }}
{{ range $name, $hs := . }}
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
<tr class="hover:bg-surface-800/50">
<td class="py-2 px-3 text-slate-200 font-medium">
{{ $name }}
</td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ $ns }}
</td>
<td class="py-2 px-3">
{{ if eq $nsr.Status "ok" }}
<span
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
>ok</span
>
{{ else }}
<span
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
>{{ $nsr.Status }}</span
>
{{ end }}
</td>
<td
class="py-2 px-3 text-slate-400 break-all max-w-xs"
>
{{ if $nsr.Error }}
<span class="text-red-400">{{ $nsr.Error }}</span>
{{ else }}
{{ formatRecords $nsr.Records }}
{{ end }}
</td>
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
{{ relTime $nsr.LastChecked }}
</td>
</tr>
{{ end }}
{{ end }}
{{ end }}
+3 -69
View File
@@ -6,11 +6,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -415,8 +413,7 @@ func sendSlackInfo(
svc *notify.Service, target *url.URL, svc *notify.Service, target *url.URL,
) error { ) error {
return svc.SendSlack( return svc.SendSlack(
context.Background(), target, notify.ErrSlackFailed, context.Background(), target, "t", "m", prioInfo,
"t", "m", prioInfo,
) )
} }
@@ -509,7 +506,6 @@ func TestSendSlackPayloadFields(t *testing.T) {
err := svc.SendSlack( err := svc.SendSlack(
context.Background(), context.Background(),
webhookURL, webhookURL,
notify.ErrSlackFailed,
"Alert Title", "Alert Title",
"Alert body text", "Alert body text",
"warning", "warning",
@@ -612,8 +608,7 @@ func TestSendSlackAllColors(t *testing.T) {
err := svc.SendSlack( err := svc.SendSlack(
context.Background(), context.Background(),
webhookURL, notify.ErrSlackFailed, webhookURL, "t", "m", tc.priority,
"t", "m", tc.priority,
) )
if err != nil { if err != nil {
t.Fatalf("SendSlack error: %v", err) t.Fatalf("SendSlack error: %v", err)
@@ -664,8 +659,7 @@ func TestSendSlackNetworkError(t *testing.T) {
) )
err := svc.SendSlack( err := svc.SendSlack(
context.Background(), webhookURL, notify.ErrSlackFailed, context.Background(), webhookURL, "t", "m", "info",
"t", "m", "info",
) )
if err == nil { if err == nil {
t.Fatal("expected error for network failure") t.Fatal("expected error for network failure")
@@ -1034,66 +1028,6 @@ 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 ────────────────────────── // ── SlackPayload JSON marshaling ──────────────────────────
func TestSlackPayloadJSON(t *testing.T) { func TestSlackPayloadJSON(t *testing.T) {
+1 -2
View File
@@ -85,11 +85,10 @@ func (svc *Service) SendNtfy(
func (svc *Service) SendSlack( func (svc *Service) SendSlack(
ctx context.Context, ctx context.Context,
webhookURL *url.URL, webhookURL *url.URL,
failed error,
title, message, priority string, title, message, priority string,
) error { ) error {
return svc.sendSlack( return svc.sendSlack(
ctx, webhookURL, failed, title, message, priority, ctx, webhookURL, title, message, priority,
) )
} }
+3 -9
View File
@@ -277,8 +277,7 @@ func (svc *Service) dispatchSlack(
svc.dispatch(ctx, "slack", func(c context.Context) error { svc.dispatch(ctx, "slack", func(c context.Context) error {
return svc.sendSlack( return svc.sendSlack(
c, svc.slackWebhookURL, ErrSlackFailed, c, svc.slackWebhookURL, title, message, priority,
title, message, priority,
) )
}) })
} }
@@ -295,7 +294,7 @@ func (svc *Service) dispatchMattermost(
ctx, "mattermost", ctx, "mattermost",
func(c context.Context) error { func(c context.Context) error {
return svc.sendSlack( return svc.sendSlack(
c, svc.mattermostWebhookURL, ErrMattermostFailed, c, svc.mattermostWebhookURL,
title, message, priority, title, message, priority,
) )
}, },
@@ -371,14 +370,9 @@ type SlackAttachment struct {
Text string `json:"text"` 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( func (svc *Service) sendSlack(
ctx context.Context, ctx context.Context,
webhookURL *url.URL, webhookURL *url.URL,
failed error,
title, message, priority string, title, message, priority string,
) error { ) error {
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
@@ -426,7 +420,7 @@ func (svc *Service) sendSlack(
if resp.StatusCode >= httpStatusClientError { if resp.StatusCode >= httpStatusClientError {
return fmt.Errorf( return fmt.Errorf(
"%w: status %d", "%w: status %d",
failed, resp.StatusCode, ErrSlackFailed, resp.StatusCode,
) )
} }
+1 -3
View File
@@ -115,9 +115,7 @@ func (svc *Service) deliverWithRetry(
"endpoint", endpoint, "endpoint", endpoint,
"attempt", attempt+1, "attempt", attempt+1,
"maxAttempts", cfg.MaxRetries+1, "maxAttempts", cfg.MaxRetries+1,
// As text: the JSON log writes a time.Duration as "retryIn", delay,
// bare nanoseconds.
"retryIn", delay.String(),
"error", lastErr, "error", lastErr,
) )
-45
View File
@@ -2,7 +2,6 @@ package notify_test
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -190,50 +189,6 @@ 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( func TestDeliverWithRetryRespectsContextCancellation(
t *testing.T, t *testing.T,
) { ) {
+1 -3
View File
@@ -193,9 +193,7 @@ func (c *Checker) checkConnection(
c.log.Debug( c.log.Debug(
"port check succeeded", "port check succeeded",
"target", target, "target", target,
// As text: the JSON log writes a time.Duration as bare "latency", latency,
// nanoseconds.
"latency", latency.String(),
) )
return &PortResult{ return &PortResult{
-22
View File
@@ -10,10 +10,6 @@ var (
"no authoritative nameservers found", "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 // ErrNoNameserverAnswered is returned when every nameserver
// asked about a name timed out, failed or returned a referral, // asked about a name timed out, failed or returned a referral,
// so whether the name has addresses is unknown. // so whether the name has addresses is unknown.
@@ -26,30 +22,12 @@ var (
"reply is an error or a referral that leads no closer", "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.
ErrIntercepted = errors.New("this network intercepts DNS queries")
// ErrCNAMEDepthExceeded is returned when a CNAME chain // ErrCNAMEDepthExceeded is returned when a CNAME chain
// exceeds MaxCNAMEDepth. // exceeds MaxCNAMEDepth.
ErrCNAMEDepthExceeded = errors.New( ErrCNAMEDepthExceeded = errors.New(
"CNAME chain depth exceeded", "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 // ErrContextCanceled wraps context cancellation for the
// resolver's iterative queries. // resolver's iterative queries.
ErrContextCanceled = errors.New("context canceled") ErrContextCanceled = errors.New("context canceled")
+2 -89
View File
@@ -2,70 +2,15 @@ package resolver
import ( import (
"context" "context"
"log/slog"
"time"
"github.com/miekg/dns" "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
}
// 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. // ExtractRecordValue exports extractRecordValue for testing.
func ExtractRecordValue(rr dns.RR) string { func ExtractRecordValue(rr dns.RR) string {
return extractRecordValue(rr) 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. // UsableReply exports usableReply for testing.
func UsableReply(resp *dns.Msg, zone string, name string) bool { func UsableReply(resp *dns.Msg, zone string, name string) bool {
return usableReply(resp, zone, name) return usableReply(resp, zone, name)
@@ -83,17 +28,6 @@ func CollectIPs(
return collectIPs(results) 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. // QueryEachNS exports queryEachNS for testing.
func (r *Resolver) QueryEachNS( func (r *Resolver) QueryEachNS(
ctx context.Context, ctx context.Context,
@@ -103,33 +37,12 @@ func (r *Resolver) QueryEachNS(
return r.queryEachNS(ctx, nameservers, hostname, recordTypes()) return r.queryEachNS(ctx, nameservers, hostname, recordTypes())
} }
// ResolveNSIPs exports resolveNSIPs for testing, looking each name up // ResolveNSIPs exports resolveNSIPs for testing.
// as a lookup that no other lookup started.
func (r *Resolver) ResolveNSIPs( func (r *Resolver) ResolveNSIPs(
ctx context.Context, ctx context.Context,
nsNames []string, nsNames []string,
) []string { ) []string {
ips, _ := r.resolveNSIPs(ctx, nsNames, 1) return r.resolveNSIPs(ctx, nsNames)
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. // RootServerList exports rootServerList for testing.
+93 -321
View File
@@ -8,7 +8,6 @@ import (
"net" "net"
"slices" "slices"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -20,16 +19,6 @@ const (
maxRetries = 2 maxRetries = 2
maxDelegation = 20 maxDelegation = 20
timeoutMultiplier = 2 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. // ErrRefused is returned when a DNS server refuses a query.
@@ -100,9 +89,6 @@ func (r *Resolver) tryExchange(
return resp, err 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( func (r *Resolver) retryTCP(
ctx context.Context, ctx context.Context,
msg *dns.Msg, msg *dns.Msg,
@@ -121,8 +107,9 @@ func (r *Resolver) retryTCP(
return resp return resp
} }
// queryDNS sends a DNS query to a specific server IP, never asking it // queryDNS sends a DNS query to a specific server IP.
// for recursion. A reply of REFUSED is returned as ErrRefused. // Tries non-recursive first, falls back to recursive on
// REFUSED (handles DNS interception environments).
func (r *Resolver) queryDNS( func (r *Resolver) queryDNS(
ctx context.Context, ctx context.Context,
serverIP string, serverIP string,
@@ -145,13 +132,26 @@ func (r *Resolver) queryDNS(
return nil, fmt.Errorf("query %s @%s: %w", name, serverIP, err) return nil, fmt.Errorf("query %s @%s: %w", name, serverIP, err)
} }
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 { if resp.Rcode == dns.RcodeRefused {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"query %s @%s: %w", name, serverIP, ErrRefused, "query %s @%s: %w", name, serverIP, ErrRefused,
) )
} }
}
return r.retryTCP(ctx, msg, addr, resp), nil resp = r.retryTCP(ctx, msg, addr, resp)
return resp, nil
} }
func extractNSSet(rrs []dns.RR) []string { func extractNSSet(rrs []dns.RR) []string {
@@ -204,12 +204,6 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
return ips 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( func (r *Resolver) followDelegation(
ctx context.Context, ctx context.Context,
domain string, domain string,
@@ -218,15 +212,13 @@ func (r *Resolver) followDelegation(
// servers are the root servers, the servers of zone ".". // servers are the root servers, the servers of zone ".".
zone := "." zone := "."
var withoutAddresses []string
for range maxDelegation { for range maxDelegation {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
resp, err := r.queryZone( resp, err := r.queryServers(
ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0, ctx, servers, zone, domain, dns.TypeNS,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -240,15 +232,10 @@ func (r *Resolver) followDelegation(
// An authoritative reply comes from the servers of the zone // An authoritative reply comes from the servers of the zone
// domain is in; it is not a referral, even when its authority // domain is in; it is not a referral, even when its authority
// section lists that zone's NS records. Without NS records in // section lists that zone's NS records. Without NS records in
// the answer, domain has no nameservers of its own: it does // the answer, domain is not the zone's apex and has no
// not exist, when the reply is NXDOMAIN, or else it is not the // nameservers of its own.
// zone's apex.
if resp.Authoritative && resp.Rcode == dns.RcodeNameError {
return nil, ErrNXDomain
}
if resp.Authoritative { if resp.Authoritative {
return []string{}, nil return nil, ErrNoNameservers
} }
authNS := extractNSSet(resp.Ns) authNS := extractNSSet(resp.Ns)
@@ -256,7 +243,18 @@ func (r *Resolver) followDelegation(
return r.resolveNSIterative(ctx, domain) return r.resolveNSIterative(ctx, domain)
} }
servers, withoutAddresses = referralNameservers(resp) 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
zone = referralZone(resp) zone = referralZone(resp)
} }
@@ -281,9 +279,7 @@ func shuffled(
// queryServers asks servers, the servers of zone, about name in a random // 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 // 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 // or gives a reply that is not usable is passed over for the next.
// every server refused, the error says so, and when they are the root
// servers it is ErrIntercepted.
func (r *Resolver) queryServers( func (r *Resolver) queryServers(
ctx context.Context, ctx context.Context,
servers []string, servers []string,
@@ -293,8 +289,6 @@ func (r *Resolver) queryServers(
) (*dns.Msg, error) { ) (*dns.Msg, error) {
var lastErr error var lastErr error
refused := 0
for _, ip := range shuffled(servers, rand.Shuffle) { for _, ip := range shuffled(servers, rand.Shuffle) {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
@@ -311,44 +305,19 @@ func (r *Resolver) queryServers(
return resp, nil return resp, nil
} }
if errors.Is(err, ErrRefused) {
refused++
}
lastErr = err 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) 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 // 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 // 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 // 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 // zone that name is in: a server that refers it back to zone, up or
// sideways does not serve zone as it should. // sideways does not serve zone as it should.
func usableReply(resp *dns.Msg, zone string, name string) bool { func usableReply(resp *dns.Msg, zone string, name string) bool {
if isErrorReply(resp) { if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError {
return false return false
} }
@@ -389,116 +358,29 @@ func nsSetFrom(resp *dns.Msg, domain string) []string {
return extractNSSet(resp.Answer) return extractNSSet(resp.Answer)
} }
// 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)
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 {
return resp, nil
}
}
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 // resolveNSIPs returns the addresses of every nameserver in nsNames
// whose name resolves, each looked up at depth (see resolveARecord). // whose name resolves, for a referral that carries none. The walk can
// The walk can then go on to the zone's other nameservers when one // then go on to the zone's other nameservers when one gives no usable
// gives no usable reply. When none resolves and the depth limit // reply.
// stopped one of the lookups, it returns that lookup's error.
func (r *Resolver) resolveNSIPs( func (r *Resolver) resolveNSIPs(
ctx context.Context, ctx context.Context,
nsNames []string, nsNames []string,
depth int, ) []string {
) ([]string, error) { var ips []string
var (
ips []string
limitErr error
)
for _, ns := range nsNames { for _, ns := range nsNames {
resolved, err := r.resolveARecord(ctx, ns, depth) resolved, err := r.resolveARecord(ctx, ns)
if err == nil {
switch {
case err == nil:
ips = append(ips, resolved...) ips = append(ips, resolved...)
case errors.Is(err, ErrLookupDepthExceeded):
limitErr = err
} }
} }
if len(ips) > 0 { return ips
return ips, nil
}
return nil, limitErr
} }
// resolveNSIterative queries for NS records using iterative // resolveNSIterative queries for NS records using iterative
// resolution as a fallback when followDelegation finds no // resolution as a fallback when followDelegation finds no
// authoritative answer in the delegation chain. Its result means what // authoritative answer in the delegation chain.
// followDelegation's does.
func (r *Resolver) resolveNSIterative( func (r *Resolver) resolveNSIterative(
ctx context.Context, ctx context.Context,
domain string, domain string,
@@ -528,16 +410,6 @@ func (r *Resolver) resolveNSIterative(
return nsNames, nil 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. // Follow delegation.
authNS := extractNSSet(resp.Ns) authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 { if len(authNS) == 0 {
@@ -558,14 +430,11 @@ func (r *Resolver) resolveNSIterative(
return nil, ErrNoNameservers return nil, ErrNoNameservers
} }
// resolveARecord resolves a hostname, a nameserver's name, to IPv4 // resolveARecord resolves a hostname to IPv4 addresses using
// addresses using iterative resolution through the delegation chain. // 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( func (r *Resolver) resolveARecord(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
depth int,
) ([]string, error) { ) ([]string, error) {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
@@ -575,16 +444,13 @@ func (r *Resolver) resolveARecord(
servers := rootServerList() servers := rootServerList()
zone := "." zone := "."
var withoutAddresses []string
for range maxDelegation { for range maxDelegation {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
resp, err := r.queryZone( resp, err := r.queryServers(
ctx, servers, withoutAddresses, zone, hostname, dns.TypeA, ctx, servers, zone, hostname, dns.TypeA,
depth,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -611,7 +477,17 @@ func (r *Resolver) resolveARecord(
break break
} }
servers, withoutAddresses = referralNameservers(resp) 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
zone = referralZone(resp) zone = referralZone(resp)
} }
@@ -623,23 +499,12 @@ func (r *Resolver) resolveARecord(
// FindAuthoritativeNameservers traces the delegation chain from // FindAuthoritativeNameservers traces the delegation chain from
// root servers to discover all authoritative nameservers for the // root servers to discover all authoritative nameservers for the
// given domain, as the delegation from its parent zone's servers lists // given domain, as the delegation from its parent zone's servers lists
// them. When the servers asked answer that the name has no delegation // them. For a name that is not a zone apex it tries each
// of its own, or does not exist, it tries each parent name in turn, so // parent name in turn, so it returns the nameservers of the zone the
// it returns the nameservers of the zone the name is in. When they do // name is in.
// not answer, it returns the error and tries no parent name.
func (r *Resolver) FindAuthoritativeNameservers( func (r *Resolver) FindAuthoritativeNameservers(
ctx context.Context, ctx context.Context,
domain string, 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) { ) ([]string, error) {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
@@ -655,12 +520,10 @@ func (r *Resolver) findAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "." candidate := strings.Join(labels[i:], ".") + "."
nsNames, err := r.followDelegation(ctx, candidate, servers) nsNames, err := r.followDelegation(
if err != nil && !errors.Is(err, ErrNXDomain) { ctx, candidate, rootServerList(),
return nil, err )
} if err == nil && len(nsNames) > 0 {
if len(nsNames) > 0 {
sort.Strings(nsNames) sort.Strings(nsNames)
return nsNames, nil return nsNames, nil
@@ -708,7 +571,7 @@ func (r *Resolver) queryNameserver(
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
nsIPs, err := r.resolveARecord(ctx, nsHostname, 1) nsIPs, err := r.resolveARecord(ctx, nsHostname)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolving NS %s: %w", nsHostname, err) return nil, fmt.Errorf("resolving NS %s: %w", nsHostname, err)
} }
@@ -756,20 +619,14 @@ func (r *Resolver) queryTypes(
type queryState struct { type queryState struct {
gotNXDomain bool gotNXDomain bool
gotErrorReply bool gotSERVFAIL bool
errorReply string // its code, such as SERVFAIL, or number if unnamed
gotRefused bool gotRefused bool
gotTimeout bool gotTimeout bool
gotReferral bool gotReferral bool
netErr error netErr error
hasRecords bool 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( func (r *Resolver) queryEachType(
ctx context.Context, ctx context.Context,
nsIP string, nsIP string,
@@ -784,34 +641,7 @@ func (r *Resolver) queryEachType(
break break
} }
err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state) 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 { for k := range resp.Records {
@@ -821,9 +651,6 @@ func (r *Resolver) queryEachType(
return state 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( func (r *Resolver) querySingleType(
ctx context.Context, ctx context.Context,
nsIP string, nsIP string,
@@ -831,7 +658,7 @@ func (r *Resolver) querySingleType(
qtype uint16, qtype uint16,
resp *NameserverResponse, resp *NameserverResponse,
state *queryState, state *queryState,
) error { ) {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype) msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil { if err != nil {
switch { switch {
@@ -843,70 +670,37 @@ func (r *Resolver) querySingleType(
state.netErr = err state.netErr = err
} }
return err return
} }
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 { if msg.Rcode == dns.RcodeNameError {
state.gotNXDomain = true state.gotNXDomain = true
return nil return
} }
if isErrorReply(msg) { if msg.Rcode == dns.RcodeServerFailure {
state.gotErrorReply = true state.gotSERVFAIL = true
code, named := dns.RcodeToString[msg.Rcode] return
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 // 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 // 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 // about the name's records. A server named in the delegation that
// does not hold the zone may send one. // 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 && if !msg.Authoritative && len(msg.Answer) == 0 &&
len(extractNSSet(msg.Ns)) > 0 { len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true state.gotReferral = true
return fmt.Errorf("server returned a referral: %w", ErrUnusableReply) return
}
// 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) collectAnswerRecords(msg, resp, state)
return nil
} }
// 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( func collectAnswerRecords(
msg *dns.Msg, msg *dns.Msg,
resp *NameserverResponse, resp *NameserverResponse,
@@ -919,12 +713,9 @@ func collectAnswerRecords(
} }
typeName := dns.TypeToString[rr.Header().Rrtype] typeName := dns.TypeToString[rr.Header().Rrtype]
if !slices.Contains(resp.Records[typeName], val) {
resp.Records[typeName] = append( resp.Records[typeName] = append(
resp.Records[typeName], val, resp.Records[typeName], val,
) )
}
state.hasRecords = true state.hasRecords = true
} }
} }
@@ -939,32 +730,26 @@ func isTimeout(err error) bool {
return false 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. 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) { func classifyResponse(resp *NameserverResponse, state queryState) {
switch { switch {
case state.gotNXDomain && !state.hasRecords: case state.gotNXDomain && !state.hasRecords:
resp.Status = StatusNXDomain resp.Status = StatusNXDomain
case state.gotTimeout && !state.answered: case state.gotTimeout && !state.hasRecords:
resp.Status = StatusTimeout resp.Status = StatusTimeout
resp.Error = "all queries timed out" resp.Error = "all queries timed out"
case state.gotErrorReply && !state.answered: case state.gotSERVFAIL && !state.hasRecords:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned " + state.errorReply resp.Error = "server returned SERVFAIL"
case state.gotRefused && !state.answered: case state.gotRefused && !state.hasRecords:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned REFUSED" resp.Error = "server returned REFUSED"
case state.netErr != nil && !state.answered: case state.netErr != nil && !state.hasRecords:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "network error: " + state.netErr.Error() resp.Error = "network error: " + state.netErr.Error()
case state.gotReferral && !state.answered: case state.gotReferral && !state.hasRecords:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned a referral" resp.Error = "server returned a referral"
// An NXDOMAIN reply with no records was taken by the first case. case !state.hasRecords && !state.gotNXDomain:
case !state.hasRecords && len(resp.FailedTypes) == 0:
resp.Status = StatusNoData resp.Status = StatusNoData
} }
} }
@@ -1053,22 +838,12 @@ func (r *Resolver) queryEachNS(
return results, nil return results, nil
} }
// LookupNS returns the NS record set of a domain, as the delegation from // LookupNS returns the NS record set for a domain.
// 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( func (r *Resolver) LookupNS(
ctx context.Context, ctx context.Context,
domain string, domain string,
) ([]string, error) { ) ([]string, error) {
if checkCtx(ctx) != nil { return r.FindAuthoritativeNameservers(ctx, domain)
return nil, ErrContextCanceled
}
return r.followDelegation(
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
)
} }
// LookupAllRecords performs iterative resolution to find all DNS // LookupAllRecords performs iterative resolution to find all DNS
@@ -1132,11 +907,9 @@ func (r *Resolver) resolveIPWithCNAME(
} }
// collectIPs returns the addresses in the nameservers' answers and the // collectIPs returns the addresses in the nameservers' answers and the
// first CNAME target among them. A nameserver whose query for one of the // first CNAME target among them. It returns ErrNoNameserverAnswered when
// types failed gave only part of the addresses, and is left out. It // every nameserver timed out, failed or returned a referral: that is not
// returns ErrNoNameserverAnswered when every nameserver timed out, // a name with no addresses.
// failed, returned a referral or was left out: that is not a name with
// no addresses.
func collectIPs( func collectIPs(
results map[string]*NameserverResponse, results map[string]*NameserverResponse,
) ([]string, string, error) { ) ([]string, string, error) {
@@ -1149,8 +922,7 @@ func collectIPs(
answered := false answered := false
for _, resp := range results { for _, resp := range results {
if resp.Status == StatusTimeout || resp.Status == StatusError || if resp.Status == StatusTimeout || resp.Status == StatusError {
len(resp.FailedTypes) > 0 {
continue continue
} }
@@ -1,149 +0,0 @@
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 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
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},
[]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",
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",
queryState{answered: true, netErr: ErrTruncated},
[]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",
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",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
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)
})
}
}
// 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)
})
}
}
-51
View File
@@ -43,25 +43,6 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
assert.Empty(t, ips) 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 ( const (
// exampleCom is the zone most cases of TestUsableReply and // exampleCom is the zone most cases of TestUsableReply and
// TestNSSetFrom are about, and wwwExampleCom a name in it. // TestNSSetFrom are about, and wwwExampleCom a name in it.
@@ -257,38 +238,6 @@ 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 // TestShuffled shuffles the root servers with many seeds. Every order
// must hold each root server once, so each is tried before a // must hold each root server once, so each is tried before a
// resolution fails; each root server must come first for some seed, so // resolution fails; each root server must come first for some seed, so
+2 -20
View File
@@ -187,8 +187,8 @@ func liveFindAuthoritative(
return out return out
} }
// liveLookupNS looks up the NS record set of domain, a domain that has // liveLookupNS is liveFindAuthoritative through the LookupNS entry
// one, retrying until the delegation chain can be walked. // point, so that both entry points stay independently exercised.
func liveLookupNS( func liveLookupNS(
t *testing.T, t *testing.T,
r *resolver.Resolver, r *resolver.Resolver,
@@ -226,17 +226,11 @@ func liveLookupNS(
// liveQueryNameserver queries one nameserver, retrying while that // liveQueryNameserver queries one nameserver, retrying while that
// nameserver fails to answer. NXDOMAIN and NODATA are answers and // nameserver fails to answer. NXDOMAIN and NODATA are answers and
// are returned to the caller to assert on. // 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( func liveQueryNameserver(
t *testing.T, t *testing.T,
r *resolver.Resolver, r *resolver.Resolver,
nameserver string, nameserver string,
hostname string, hostname string,
types ...string,
) *resolver.NameserverResponse { ) *resolver.NameserverResponse {
t.Helper() t.Helper()
@@ -266,18 +260,6 @@ 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 out = resp
return nil return nil
-4
View File
@@ -31,13 +31,9 @@ type Params struct {
} }
// NameserverResponse holds one nameserver's response for a query. // 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 { type NameserverResponse struct {
Nameserver string Nameserver string
Records map[string][]string Records map[string][]string
FailedTypes []string
Status string Status string
Error string Error string
} }
+4 -539
View File
@@ -1,9 +1,7 @@
package resolver_test package resolver_test
import ( import (
"bytes"
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
@@ -13,7 +11,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -25,13 +22,6 @@ import (
// Test helpers // 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 { func newTestResolver(t *testing.T) *resolver.Resolver {
t.Helper() t.Helper()
@@ -95,47 +85,6 @@ func TestFindAuthoritativeNameservers_Subdomain(
assert.Equal(t, fromZone, fromHost) 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( func TestFindAuthoritativeNameservers_ReturnsSorted(
t *testing.T, t *testing.T,
) { ) {
@@ -212,112 +161,6 @@ func TestResolveNSIPs_EveryNameserver(t *testing.T) {
assert.ElementsMatch(t, want, got) 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 // QueryNameserver tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -327,7 +170,7 @@ func TestQueryNameserver_BasicA(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "google.com") ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(t, r, ns, "www.google.com", "A", "CNAME") resp := liveQueryNameserver(t, r, ns, "www.google.com")
require.NotNil(t, resp) require.NotNil(t, resp)
@@ -341,26 +184,12 @@ 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) { func TestQueryNameserver_AAAA(t *testing.T) {
t.Parallel() t.Parallel()
r := newTestResolver(t) r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "cloudflare.com") ns := findOneNSForDomain(t, r, "cloudflare.com")
resp := liveQueryNameserver(t, r, ns, "cloudflare.com", "AAAA") resp := liveQueryNameserver(t, r, ns, "cloudflare.com")
aaaaRecords := resp.Records["AAAA"] aaaaRecords := resp.Records["AAAA"]
require.NotEmpty(t, aaaaRecords, require.NotEmpty(t, aaaaRecords,
@@ -380,7 +209,7 @@ func TestQueryNameserver_MX(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "google.com") ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(t, r, ns, "google.com", "MX") resp := liveQueryNameserver(t, r, ns, "google.com")
mxRecords := resp.Records["MX"] mxRecords := resp.Records["MX"]
require.NotEmpty(t, mxRecords, require.NotEmpty(t, mxRecords,
@@ -393,7 +222,7 @@ func TestQueryNameserver_TXT(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "google.com") ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(t, r, ns, "google.com", "TXT") resp := liveQueryNameserver(t, r, ns, "google.com")
txtRecords := resp.Records["TXT"] txtRecords := resp.Records["TXT"]
require.NotEmpty(t, txtRecords, require.NotEmpty(t, txtRecords,
@@ -415,30 +244,6 @@ 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) { func TestQueryNameserver_NXDomain(t *testing.T) {
t.Parallel() t.Parallel()
@@ -490,184 +295,6 @@ func TestQueryNameserver_Refused(t *testing.T) {
assert.Equal(t, "server returned REFUSED", resp.Error) assert.Equal(t, "server returned REFUSED", resp.Error)
} }
// 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)
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
// 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) { func TestQueryNameserver_RecordsSorted(t *testing.T) {
t.Parallel() t.Parallel()
@@ -872,145 +499,6 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
assert.Equal(t, fromFind, fromLookup) 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.
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.")
}
// 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 // ResolveIPAddresses tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -1195,29 +683,6 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
assert.NotEmpty(t, resp.Error) 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 // TestCollectIPs_NoNameserverAnswered takes the response of a
// nameserver at 192.0.2.1, where nothing answers, as // nameserver at 192.0.2.1, where nothing answers, as
// TestQueryNameserverIP_Timeout does. Addresses collected from // TestQueryNameserverIP_Timeout does. Addresses collected from
+1 -96
View File
@@ -8,7 +8,6 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"sync" "sync"
"time" "time"
@@ -38,39 +37,24 @@ type Params struct {
// DomainState holds the monitoring state for an apex domain. // DomainState holds the monitoring state for an apex domain.
// NameserverAddresses holds the sorted addresses each nameserver's name // NameserverAddresses holds the sorted addresses each nameserver's name
// resolves to, by nameserver name. A state file written before it // resolves to, by nameserver name. A state file written before it
// existed loads with it nil. NXDomain is true when the domain's parent // existed loads with it nil.
// zone's servers answered that it does not exist; it then has no
// nameservers.
type DomainState struct { type DomainState struct {
Nameservers []string `json:"nameservers"` Nameservers []string `json:"nameservers"`
NameserverAddresses map[string][]string `json:"nameserverAddresses"` NameserverAddresses map[string][]string `json:"nameserverAddresses"`
NXDomain bool `json:"nxdomain,omitempty"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
// NameserverRecordState holds one NS's response for a hostname. // 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 { type NameserverRecordState struct {
Records map[string][]string `json:"records"` Records map[string][]string `json:"records"`
FailedTypes []string `json:"failedTypes,omitempty"`
UnknownTypes []string `json:"unknownTypes,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
// HostnameState holds per-nameserver monitoring state for a hostname. // 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 { type HostnameState struct {
RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"` RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"`
CNAMEAddresses []string `json:"cnameAddresses"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
@@ -132,8 +116,6 @@ type CertificateState struct {
} }
// Snapshot is the complete monitoring state persisted to disk. // 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 { type Snapshot struct {
Version int `json:"version"` Version int `json:"version"`
LastUpdated time.Time `json:"lastUpdated"` LastUpdated time.Time `json:"lastUpdated"`
@@ -214,19 +196,6 @@ func (s *State) Load() error {
return fmt.Errorf("parsing state file: %w", err) 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.snapshot = &snapshot
s.log.Info("loaded state from disk", "path", path) s.log.Info("loaded state from disk", "path", path)
@@ -301,27 +270,6 @@ func (s *State) GetDomainState(
return ds, ok 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. // SetHostnameState updates the state for a hostname.
func (s *State) SetHostnameState( func (s *State) SetHostnameState(
hostname string, hostname string,
@@ -345,28 +293,6 @@ func (s *State) GetHostnameState(
return hs, ok 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. // SetPortState updates the state for a port.
func (s *State) SetPortState(key string, ps *PortState) { func (s *State) SetPortState(key string, ps *PortState) {
s.mu.Lock() s.mu.Lock()
@@ -429,27 +355,6 @@ func (s *State) GetCertificateState(
return cs, ok 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 // checkDataDirWritable creates the data directory if needed, then writes
// and removes the temp file that Save uses. It runs at startup so that an // 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 // unwritable directory stops the process, instead of the process running
-197
View File
@@ -188,203 +188,6 @@ 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,
)
}
}
}
// 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.
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)
}
}
// 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. // TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle.
func TestSaveLoadRoundTrip_Hostnames(t *testing.T) { func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
t.Parallel() t.Parallel()
-72
View File
@@ -1,13 +1,10 @@
package watcher_test package watcher_test
import ( import (
"bytes"
"context" "context"
"log/slog" "log/slog"
"reflect" "reflect"
"strings"
"testing" "testing"
"time"
"sneak.berlin/go/dnswatcher/internal/portcheck" "sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/resolver"
@@ -82,72 +79,3 @@ func TestCancelledCheckSavesNothing(t *testing.T) {
t.Errorf("sent %v, want no notifications", notifications) 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)
}
}
-372
View File
@@ -1,372 +0,0 @@
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,
)
}
}
// 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 {
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)
}
}
+2 -49
View File
@@ -10,8 +10,7 @@ import (
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
) )
// NewForTest creates a Watcher without fx for unit testing. A nil cfg // NewForTest creates a Watcher without fx for unit testing.
// is an empty configuration.
func NewForTest( func NewForTest(
cfg *config.Config, cfg *config.Config,
st *state.State, st *state.State,
@@ -20,10 +19,6 @@ func NewForTest(
tc TLSChecker, tc TLSChecker,
n Notifier, n Notifier,
) *Watcher { ) *Watcher {
if cfg == nil {
cfg = &config.Config{}
}
return &Watcher{ return &Watcher{
log: slog.Default(), log: slog.Default(),
config: cfg, config: cfg,
@@ -36,12 +31,6 @@ 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. // NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing.
func NewlyDisagreeingPairs( func NewlyDisagreeingPairs(
prev, current *state.HostnameState, prev, current *state.HostnameState,
@@ -49,21 +38,6 @@ func NewlyDisagreeingPairs(
return newlyDisagreeingPairs(prev, current) 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. // DetectHostnameChanges exports detectHostnameChanges for testing.
func (w *Watcher) DetectHostnameChanges( func (w *Watcher) DetectHostnameChanges(
ctx context.Context, ctx context.Context,
@@ -83,15 +57,6 @@ func (w *Watcher) ResolveNameserverAddresses(
return w.resolveNameserverAddresses(ctx, nameservers, prev) 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. // DetectNSAddressChanges exports detectNSAddressChanges for testing.
func (w *Watcher) DetectNSAddressChanges( func (w *Watcher) DetectNSAddressChanges(
ctx context.Context, ctx context.Context,
@@ -101,17 +66,6 @@ func (w *Watcher) DetectNSAddressChanges(
w.detectNSAddressChanges(ctx, domain, prev, current) w.detectNSAddressChanges(ctx, domain, prev, current)
} }
// MaybeSendTestNotification exports maybeSendTestNotification for
// testing.
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. // CheckAllPorts exports checkAllPorts for testing.
func (w *Watcher) CheckAllPorts(ctx context.Context) { func (w *Watcher) CheckAllPorts(ctx context.Context) {
w.checkAllPorts(ctx) w.checkAllPorts(ctx)
@@ -125,8 +79,7 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
// BuildHostnameState exports buildHostnameState for testing. // BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState( func BuildHostnameState(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
prev *state.HostnameState,
now time.Time, now time.Time,
) *state.HostnameState { ) *state.HostnameState {
return buildHostnameState(results, prev, now) return buildHostnameState(results, now)
} }
-364
View File
@@ -1,364 +0,0 @@
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],
)
}
}
})
}
}
-55
View File
@@ -183,58 +183,3 @@ 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)
}
}
+1 -3
View File
@@ -11,9 +11,7 @@ import (
// DNSResolver performs iterative DNS resolution. // DNSResolver performs iterative DNS resolution.
type DNSResolver interface { type DNSResolver interface {
// LookupNS returns a domain's NS record set, as its parent zone's // LookupNS discovers authoritative nameservers for a domain.
// servers delegate it: empty when they answer that it has none, and
// resolver.ErrNXDomain when they answer that it does not exist.
LookupNS( LookupNS(
ctx context.Context, ctx context.Context,
domain string, domain string,
-215
View File
@@ -1,215 +0,0 @@
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"
)
// 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],
)
}
}
}
// 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)
}
}
// 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)
}
}
+3 -3
View File
@@ -201,7 +201,7 @@ func TestNameserverThatNeverAnswers(t *testing.T) {
} }
hs := watcher.BuildHostnameState( hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{nsA: resp}, nil, time.Now(), map[string]*resolver.NameserverResponse{nsA: resp}, time.Now(),
) )
got := hs.RecordsByNameserver[nsA] got := hs.RecordsByNameserver[nsA]
@@ -256,7 +256,7 @@ func TestNameserverThatAnswersNXDOMAIN(t *testing.T) {
} }
hs := watcher.BuildHostnameState( hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(), map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
) )
got := hs.RecordsByNameserver[ns] got := hs.RecordsByNameserver[ns]
@@ -320,7 +320,7 @@ func TestNameserverThatRefuses(t *testing.T) {
} }
hs := watcher.BuildHostnameState( hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(), map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
) )
got := hs.RecordsByNameserver[ns] got := hs.RecordsByNameserver[ns]
-224
View File
@@ -1,224 +0,0 @@
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)
}
})
}
}
@@ -1,107 +0,0 @@
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))
}
}
+59 -483
View File
@@ -2,10 +2,8 @@ package watcher
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"slices" "slices"
"sort" "sort"
"strings" "strings"
@@ -125,13 +123,10 @@ func (w *Watcher) Run(ctx context.Context) {
"watcher starting", "watcher starting",
"domains", len(w.config.Domains), "domains", len(w.config.Domains),
"hostnames", len(w.config.Hostnames), "hostnames", len(w.config.Hostnames),
// As text: the JSON log writes a time.Duration as bare "dnsInterval", w.config.DNSInterval,
// nanoseconds. "tlsInterval", w.config.TLSInterval,
"dnsInterval", w.config.DNSInterval.String(),
"tlsInterval", w.config.TLSInterval.String(),
) )
w.cleanupRemovedTargets()
w.RunOnce(ctx) w.RunOnce(ctx)
w.maybeSendTestNotification(ctx) w.maybeSendTestNotification(ctx)
@@ -202,59 +197,6 @@ 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 // runDNSChecks performs DNS resolution for all configured domains
// and hostnames, updating state with freshly resolved records. // and hostnames, updating state with freshly resolved records.
// This must complete before port or TLS checks run so those // This must complete before port or TLS checks run so those
@@ -269,36 +211,13 @@ 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( func (w *Watcher) checkDomain(
ctx context.Context, ctx context.Context,
domain string, domain string,
) { ) {
nameservers, err := w.resolver.LookupNS(ctx, domain) 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 { if err != nil {
w.logFailedLookup( w.log.Error(
ctx,
"failed to lookup NS", "failed to lookup NS",
"domain", domain, "domain", domain,
"error", err, "error", err,
@@ -330,22 +249,31 @@ func (w *Watcher) checkDomain(
w.state.SetDomainState(domain, &state.DomainState{ w.state.SetDomainState(domain, &state.DomainState{
Nameservers: nameservers, Nameservers: nameservers,
NameserverAddresses: addresses, NameserverAddresses: addresses,
NXDomain: nxdomain,
LastChecked: now, LastChecked: now,
}) })
// A domain that does not exist has no records of its own: none are // Also look up A/AAAA records for the apex domain so that
// asked for, and those saved by an earlier check are removed. // port and TLS checks (which read HostnameState) can find
if nxdomain { // the domain's IP addresses.
w.state.DeleteHostnameState(domain) results, err := w.resolver.LookupAllRecords(ctx, domain)
if err != nil {
w.log.Error(
"failed to lookup records for domain",
"domain", domain,
"error", err,
)
return return
} }
// The apex domain's records are also checked and saved as a newState := buildHostnameState(results, now)
// hostname's, so that the port and TLS checks find its addresses.
// Notifications about them name it as a domain (see nameLine). prevHS, hasPrevHS := w.state.GetHostnameState(domain)
w.checkHostname(ctx, domain) if hasPrevHS && !w.firstRun {
w.detectHostnameChanges(ctx, domain, prevHS, newState)
}
w.state.SetHostnameState(domain, newState)
} }
func (w *Watcher) detectNSChanges( func (w *Watcher) detectNSChanges(
@@ -409,8 +337,7 @@ func (w *Watcher) resolveNameserverAddresses(
continue continue
} }
w.logFailedLookup( w.log.Error(
ctx,
"no addresses found for nameserver", "no addresses found for nameserver",
"nameserver", ns, "nameserver", ns,
"error", err, "error", err,
@@ -462,8 +389,7 @@ func (w *Watcher) checkHostname(
) { ) {
results, err := w.resolver.LookupAllRecords(ctx, hostname) results, err := w.resolver.LookupAllRecords(ctx, hostname)
if err != nil { if err != nil {
w.logFailedLookup( w.log.Error(
ctx,
"failed to lookup records", "failed to lookup records",
"hostname", hostname, "hostname", hostname,
"error", err, "error", err,
@@ -472,25 +398,9 @@ func (w *Watcher) checkHostname(
return return
} }
prev, _ := w.state.GetHostnameState(hostname) newState := buildHostnameState(results, time.Now().UTC())
w.updateHostnameState(
ctx, hostname, buildHostnameState(results, prev, 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) prev, hasPrev := w.state.GetHostnameState(hostname)
w.resolveCNAMEAddresses(ctx, hostname, newState, prev)
if hasPrev && !w.firstRun { if hasPrev && !w.firstRun {
w.detectHostnameChanges(ctx, hostname, prev, newState) w.detectHostnameChanges(ctx, hostname, prev, newState)
} }
@@ -498,89 +408,12 @@ func (w *Watcher) updateHostnameState(
w.state.SetHostnameState(hostname, newState) 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 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,
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 ||
slices.Contains(nsState.FailedTypes, "A") ||
slices.Contains(nsState.FailedTypes, "AAAA") ||
slices.Contains(nsState.FailedTypes, "CNAME") {
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.logFailedLookup(
ctx,
"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 // buildHostnameState saves each nameserver's response. A nameserver
// that answered, even with NXDOMAIN or no records, is saved as ok, with // that answered, even with NXDOMAIN or no records, is saved as ok; one
// the record types whose query failed; one that timed out or failed is // that timed out or failed is saved as error with the reason, and its
// saved as error with the reason, and its empty record set is not an // empty record set is not an answer.
// answer. prev is the hostname's state from the previous check, or nil.
func buildHostnameState( func buildHostnameState(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
prev *state.HostnameState,
now time.Time, now time.Time,
) *state.HostnameState { ) *state.HostnameState {
hs := &state.HostnameState{ hs := &state.HostnameState{
@@ -592,7 +425,7 @@ func buildHostnameState(
for ns, resp := range results { for ns, resp := range results {
nsState := &state.NameserverRecordState{ nsState := &state.NameserverRecordState{
Records: maps.Clone(resp.Records), Records: resp.Records,
Status: statusOK, Status: statusOK,
LastChecked: now, LastChecked: now,
} }
@@ -601,15 +434,6 @@ func buildHostnameState(
resp.Status == resolver.StatusError { resp.Status == resolver.StatusError {
nsState.Status = statusError nsState.Status = statusError
nsState.Error = resp.Error 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 hs.RecordsByNameserver[ns] = nsState
@@ -618,27 +442,6 @@ func buildHostnameState(
return hs 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( func (w *Watcher) detectHostnameChanges(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -648,119 +451,31 @@ func (w *Watcher) detectHostnameChanges(
w.detectNSDisappearances(ctx, hostname, prev, current) w.detectNSDisappearances(ctx, hostname, prev, current)
w.detectNSFailures(ctx, hostname, prev, current) w.detectNSFailures(ctx, hostname, prev, current)
w.detectInconsistencies(ctx, hostname, prev, current) w.detectInconsistencies(ctx, hostname, prev, current)
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, and
// "Hostname: " otherwise.
func (w *Watcher) nameLine(name string) string {
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
// 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(
"%s\nOld: %s\nNew: %s",
w.nameLine(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 // detectRecordChanges compares each nameserver's records with those of
// the previous check. Only answers are compared: a nameserver that // the previous check. Only answers are compared: a nameserver that
// failed on either check has no records to compare. The records kept // failed on either check has no records to compare.
// 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. Nothing is sent for a name in SkipRecordNotifications.
func (w *Watcher) detectRecordChanges( func (w *Watcher) detectRecordChanges(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev, current *state.HostnameState, prev, current *state.HostnameState,
) { ) {
if slices.Contains(w.config.SkipRecordNotifications, hostname) {
return
}
for ns, cur := range current.RecordsByNameserver { for ns, cur := range current.RecordsByNameserver {
prevNS, ok := prev.RecordsByNameserver[ns] prevNS, ok := prev.RecordsByNameserver[ns]
if !ok || prevNS.Status != statusOK || cur.Status != statusOK { if !ok || prevNS.Status != statusOK || cur.Status != statusOK {
continue continue
} }
unknown := slices.Concat(prevNS.UnknownTypes, cur.UnknownTypes) if recordsEqual(prevNS.Records, cur.Records) {
oldRecords := withoutTypes(prevNS.Records, unknown)
newRecords := withoutTypes(cur.Records, unknown)
if recordsEqual(oldRecords, newRecords) {
continue continue
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nNameserver: %s\n%s", "Hostname: %s\nNameserver: %s\n"+
w.nameLine(hostname), ns, "Old: %v\nNew: %v",
recordDifferences( hostname, ns,
"Old", oldRecords, prevNS.Records, cur.Records,
"New", newRecords,
),
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -783,8 +498,8 @@ func (w *Watcher) detectNSDisappearances(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nNameserver: %s disappeared", "Hostname: %s\nNameserver: %s disappeared",
w.nameLine(hostname), ns, hostname, ns,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -813,8 +528,8 @@ func (w *Watcher) detectNSFailures(
switch { switch {
case prevNS.Status == statusOK && cur.Status == statusError: case prevNS.Status == statusOK && cur.Status == statusError:
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nNameserver: %s\nError: %s", "Hostname: %s\nNameserver: %s\nError: %s",
w.nameLine(hostname), ns, cur.Error, hostname, ns, cur.Error,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -825,8 +540,8 @@ func (w *Watcher) detectNSFailures(
) )
case prevNS.Status == statusError && cur.Status == statusOK: case prevNS.Status == statusError && cur.Status == statusOK:
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nNameserver: %s recovered", "Hostname: %s\nNameserver: %s recovered",
w.nameLine(hostname), ns, hostname, ns,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -839,34 +554,19 @@ 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( func (w *Watcher) detectInconsistencies(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev, current *state.HostnameState, prev, current *state.HostnameState,
) { ) {
if slices.Contains(w.config.SkipRecordNotifications, hostname) {
return
}
for _, pair := range newlyDisagreeingPairs(prev, current) { for _, pair := range newlyDisagreeingPairs(prev, current) {
ns1, ns2 := pair[0], pair[1] 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( msg := fmt.Sprintf(
"%s\n%s", "Hostname: %s\n%s: %v\n%s: %v",
w.nameLine(hostname), hostname,
recordDifferences( ns1, current.RecordsByNameserver[ns1].Records,
ns1, withoutTypes(state1.Records, failed), ns2, current.RecordsByNameserver[ns2].Records,
ns2, withoutTypes(state2.Records, failed),
),
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -883,9 +583,7 @@ func (w *Watcher) detectInconsistencies(
// except pairs where both nameservers answered in prev and already // except pairs where both nameservers answered in prev and already
// differed there. A nameserver missing from prev, or that failed there, // differed there. A nameserver missing from prev, or that failed there,
// is paired with every nameserver it differs from. A nameserver that // is paired with every nameserver it differs from. A nameserver that
// failed in current has no records to compare and is in no pair. In // failed in current has no records to compare and is in no pair.
// both checks, a record type whose query failed at either nameserver is
// not compared.
func newlyDisagreeingPairs( func newlyDisagreeingPairs(
prev, current *state.HostnameState, prev, current *state.HostnameState,
) [][2]string { ) [][2]string {
@@ -902,9 +600,9 @@ func newlyDisagreeingPairs(
for i, ns1 := range nameservers { for i, ns1 := range nameservers {
for _, ns2 := range nameservers[i+1:] { for _, ns2 := range nameservers[i+1:] {
if nameserversAgree( if recordsEqual(
current.RecordsByNameserver[ns1], current.RecordsByNameserver[ns1].Records,
current.RecordsByNameserver[ns2], current.RecordsByNameserver[ns2].Records,
) { ) {
continue continue
} }
@@ -914,7 +612,7 @@ func newlyDisagreeingPairs(
if ok1 && ok2 && if ok1 && ok2 &&
prev1.Status == statusOK && prev2.Status == statusOK && prev1.Status == statusOK && prev2.Status == statusOK &&
!nameserversAgree(prev1, prev2) { !recordsEqual(prev1.Records, prev2.Records) {
continue continue
} }
@@ -942,10 +640,8 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
} }
// Phase 3: Remove port state entries that no longer have // Phase 3: Remove port state entries that no longer have
// any hostname referencing them, and certificate entries for // any hostname referencing them.
// an address their name no longer has.
w.cleanupStalePorts(associations) w.cleanupStalePorts(associations)
w.cleanupStaleCertificates()
} }
// buildPortAssociations constructs a map from IP:port keys to // buildPortAssociations constructs a map from IP:port keys to
@@ -1029,45 +725,11 @@ 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 // noNameserverAnswered reports whether name is a configured domain or
// hostname and none of its nameservers answered on its last check. // hostname and none of its nameservers answered on its last check.
func (w *Watcher) noNameserverAnswered(name string) bool { func (w *Watcher) noNameserverAnswered(name string) bool {
if !w.isConfigured(name) { if !slices.Contains(w.config.Hostnames, name) &&
!slices.Contains(w.config.Domains, name) {
return false return false
} }
@@ -1085,9 +747,6 @@ func (w *Watcher) noNameserverAnswered(name string) bool {
return true 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 { func (w *Watcher) collectIPs(hostname string) []string {
hs, ok := w.state.GetHostnameState(hostname) hs, ok := w.state.GetHostnameState(hostname)
if !ok { if !ok {
@@ -1106,10 +765,6 @@ func (w *Watcher) collectIPs(hostname string) []string {
} }
} }
for _, ip := range hs.CNAMEAddresses {
ipSet[ip] = true
}
result := make([]string, 0, len(ipSet)) result := make([]string, 0, len(ipSet))
for ip := range ipSet { for ip := range ipSet {
result = append(result, ip) result = append(result, ip)
@@ -1156,8 +811,8 @@ func (w *Watcher) checkSinglePort(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nAddress: %s\nPort now %s", "Hosts: %s\nAddress: %s\nPort now %s",
w.portNameLines(hostnames), key, stateStr, strings.Join(hostnames, ", "), key, stateStr,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -1394,11 +1049,8 @@ func (w *Watcher) saveState() {
// maybeSendTestNotification sends a startup status notification // maybeSendTestNotification sends a startup status notification
// after the first full scan completes, if SEND_TEST_NOTIFICATION // after the first full scan completes, if SEND_TEST_NOTIFICATION
// is enabled. The message is informational, not an error or anomaly // is enabled. The message is clearly informational ("all ok")
// alert. It is written before it reaches any endpoint, so it claims // and not an error or anomaly alert.
// 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) { func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
if !w.config.SendTestNotification { if !w.config.SendTestNotification {
return return
@@ -1410,10 +1062,9 @@ func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
"dnswatcher has started and completed its initial scan.\n"+ "dnswatcher has started and completed its initial scan.\n"+
"Monitoring %d domain(s) and %d hostname(s).\n"+ "Monitoring %d domain(s) and %d hostname(s).\n"+
"Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+ "Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+
"This is a test notification, sent to every configured "+ "All notification channels are working.",
"notification endpoint.", len(snap.Domains),
len(w.config.Domains), len(snap.Hostnames),
len(w.config.Hostnames),
len(snap.Ports), len(snap.Ports),
len(snap.Certificates), len(snap.Certificates),
) )
@@ -1439,33 +1090,6 @@ func toSet(items []string) map[string]bool {
return set 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( func recordsEqual(
a, b map[string][]string, a, b map[string][]string,
) bool { ) bool {
@@ -1483,54 +1107,6 @@ func recordsEqual(
return true 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 { func sliceEqual(a, b []string) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
+4 -135
View File
@@ -312,8 +312,7 @@ func lookupNameservers(t *testing.T, name string) []string {
return nameservers return nameservers
} }
// addresses returns the A and AAAA values saved for a hostname, and the // addresses returns the A and AAAA values saved for a hostname.
// addresses saved at the end of its CNAME chain.
func addresses(hs *state.HostnameState) []string { func addresses(hs *state.HostnameState) []string {
var ips []string var ips []string
@@ -322,7 +321,7 @@ func addresses(hs *state.HostnameState) []string {
ips = append(ips, nsState.Records["AAAA"]...) ips = append(ips, nsState.Records["AAAA"]...)
} }
return append(ips, hs.CNAMEAddresses...) return ips
} }
// assertNotified checks that a notification with this title and // assertNotified checks that a notification with this title and
@@ -372,14 +371,6 @@ func TestFirstRunBaseline(t *testing.T) {
assertNoNotifications(t, deps) assertNoNotifications(t, deps)
assertStatePopulated(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( func assertNoNotifications(
@@ -482,119 +473,6 @@ 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) { func TestNSAddressChangeDetection(t *testing.T) {
t.Parallel() t.Parallel()
@@ -988,27 +866,18 @@ func TestSendTestNotification_ViaRun(t *testing.T) {
notifications := deps.notifier.getNotifications() 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 found := false
for _, n := range notifications { for _, n := range notifications {
if n.Priority == "success" && if n.Priority == "success" &&
n.Title == "✅ dnswatcher startup complete" && n.Title == "✅ dnswatcher startup complete" {
n.Message == wantMessage {
found = true found = true
} }
} }
if !found { if !found {
t.Errorf( t.Errorf(
"expected startup test notification with message %q, got: %v", "expected startup test notification, got: %v",
wantMessage,
notifications, notifications,
) )
} }
+8 -8
View File
@@ -3,12 +3,12 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. # or apk (detected in that order); assumes nothing is present.
# goimports is not installed here: script/fmt and script/fmt-check run # goimports is not installed here: script/fmt and script/fmt-check-go
# it with `go run` at a pinned commit. # run it with `go run` at a pinned commit.
# The linter is NOT installed here: golangci-lint runs via docker 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 # (script/lint), pinned by image digest, so its only prerequisite is a
# working docker. Nor is prettier: script/fmt and script/fmt-check run # working docker. Nor is prettier: script/fmt and
# it in a stage of the Dockerfile. # script/fmt-check-markdown run it in a container from Dockerfile.fmt.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -67,12 +67,12 @@ main() {
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
# Testing, linting and the markdown formatter run via docker only. # Linting and the markdown formatter run via docker only. Warn,
# Warn, don't fail: make build works without it. # don't fail: building and testing work without it.
if missing docker; then if missing docker; then
echo "bootstrap: WARNING: docker not found; install it to" \ echo "bootstrap: WARNING: docker not found; install it to" \
"run make test, make lint, make fmt, make fmt-check," \ "run make lint, make fmt, make fmt-check, make check" \
"make check and make docker." >&2 "and make docker." >&2
fi fi
go mod download go mod download
+12 -17
View File
@@ -1,10 +1,14 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. It bootstraps first: a CI runner # script/cibuild: run the CI build. The Dockerfile's lint stage runs
# checks out and runs this and nothing else, and script/fmt-check runs # the Go half of make fmt-check and golangci-lint; its builder stage
# the formatter on the host, which a pristine checkout cannot do. # runs make test and make build. The markdown half of make fmt-check
# --no-cache for the same reason as script/docker: the gate phases the # runs after that build, as its own build of Dockerfile.fmt, because
# final stage depends on are RUN steps, and a cached one is a check that # there is no docker inside a docker build.
# did not run. #
# --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.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -12,17 +16,8 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap" docker build --no-cache-filter=lint,builder .
"$SCRIPT_DIR/check" "$SCRIPT_DIR/fmt-check-markdown"
# 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 "$@" main "$@"
+8 -6
View File
@@ -1,8 +1,10 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # 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 because the gate phases the final stage depends on are RUN #
# steps, and a cached one is a check that did not run. # --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 set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -12,11 +14,11 @@ main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over # empty constant. VERSION is computed here because .dockerignore
# the version a build stage derives from the .git in the context. # excludes .git, so `git describe` in a build stage cannot find it.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache-filter=lint,builder \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
+14 -7
View File
@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# script/fmt: format all files (writes). Go with gofmt and goimports on # script/fmt: format all files (writes). Go with gofmt and goimports on
# the host, markdown with prettier in the Dockerfile's fmt stage. # the host, markdown with the prettier pinned by Dockerfile.fmt.
# #
# goimports runs with `go run` at a pinned commit, never from PATH, so # goimports runs with `go run` at a pinned commit, never from PATH, so
# every machine formats with the same version and nothing installs it. # every machine formats with the same version and nothing installs it.
@@ -8,15 +8,21 @@
# The markdown pass is a `docker build --output type=local` rather than a # 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 # `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 # a remote daemon; the formatted documents come back out of the build and
# are copied over the tree here. --output writes files and makes no # are copied over the tree here.
# image, so there is nothing to tag. #
# 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 set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" 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" GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
# Must match the export stage name in Dockerfile.fmt.
stage=fmt-out
die() { die() {
echo "script/fmt: $*" >&2 echo "script/fmt: $*" >&2
exit 1 exit 1
@@ -30,9 +36,10 @@ main() {
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dnswatcher-fmt.XXXXXX")" tmp="$(mktemp -d "${TMPDIR:-/tmp}/dnswatcher-fmt.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT INT TERM trap 'rm -rf "$tmp"' EXIT INT TERM
docker build --no-cache \ docker build \
--target fmt-out \ --target "$stage" \
--output "type=local,dest=$tmp/out" . --output "type=local,dest=$tmp/out" \
-f Dockerfile.fmt .
# An empty export means prettier was handed nothing, which must not # An empty export means prettier was handed nothing, which must not
# read as "already formatted". # read as "already formatted".
+4 -27
View File
@@ -1,37 +1,14 @@
#!/bin/sh #!/bin/sh
# script/fmt-check: check formatting (read-only). Same tools and scope # script/fmt-check: check formatting (read-only). Same tools and scope
# as script/fmt, but fails instead of writing and names what it would # as script/fmt, but fails instead of writing: the Go on the host, the
# change: the Go on the host, the markdown with prettier in the # markdown with prettier in a container.
# 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 set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" 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() { main() {
cd "$ROOT" "$SCRIPT_DIR/fmt-check-go"
files="$(gofmt -s -l .)" "$SCRIPT_DIR/fmt-check-markdown"
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 "$@" main "$@"
+31
View File
@@ -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 "$@"
+29
View File
@@ -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 "$@"
+17 -12
View File
@@ -1,23 +1,28 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter. Linting is a phase of the Dockerfile and # script/lint: run the linter. golangci-lint is never installed or run
# this builds that phase alone; the linter is never installed or run on # on the host: it runs via docker only, one way, everywhere. This
# a developer host, where a shared result cache and a host-global lock # builds Dockerfile.lint, which COPYs the repo into the digest-pinned
# make its answer untrustworthy. # golangci-lint image and lints as a build step, so a successful build
# means a clean lint.
# #
# The phase is not the last stage in the file, so it is built only when # --no-cache-filter=lint forces the lint stage (source copy + linter
# --target names it. --no-cache because a cached lint layer is a lint # run) to execute on every invocation. Without it an unchanged tree
# that did not run. The tag makes each build replace the previous image # returns success in well under a second having linted nothing. The
# instead of leaving a dangling one behind. # 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 set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ docker build \
--progress=plain \
--no-cache-filter=lint \
--target lint \ --target lint \
-t "$("$SCRIPT_DIR/projectname")-lint" . -f Dockerfile.lint \
.
} }
main "$@" main "$@"
+26 -10
View File
@@ -1,19 +1,35 @@
#!/bin/sh #!/bin/sh
# script/test: run the test suite. Testing is a phase of the Dockerfile # script/test: run the test suite.
# 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 # -count=1 disables Go's test cache, and is load-bearing here. This
# named, --no-cache because a cached test layer is a test that did not # suite queries live DNS on every run by policy (TESTING.md); a cached
# run, and a tag so each build replaces the previous image. # 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 set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ go test -count=1 -race -timeout 90s -cover ./... || {
--target test \ echo "--- Rerunning with -v for details ---" >&2
-t "$("$SCRIPT_DIR/projectname")-test" . go test -count=1 -race -timeout 90s -v ./... || true
exit 1
}
} }
main "$@" main "$@"