5 Commits
Author SHA1 Message Date
sneak f41601dd80 watcher: follow a watched name's CNAME for port and TLS checks (closes #203)
check / check (push) Canceled after 0s
When a watched name's nameservers answer with a CNAME and no address,
the DNS check follows every target they gave with ResolveIPAddresses
and saves all addresses found as cnameAddresses in the hostname state,
so nameservers disagreeing on the target do not change them between
checks. Port and TLS checks use them. A change, also from or to none,
is notified as a CNAME address change; the first check from a state
file without them sends none. When a target cannot be followed, or
none of the name's nameservers answered, the last check's addresses
are kept. The domain check now runs the hostname check for the apex
instead of a copy of it.

Model: opus-5-5
2026-10-02 04:51:29 +00:00
clawbot 3182fc99a6 docker: a plain docker build . stamps the git version (closes #210)
check / check (push) Canceled after 0s
A plain `docker build .`, which is how upaas builds, stamped `dev`:
`.dockerignore` left out `.git` and the builder declared
`ARG VERSION=dev`. `.dockerignore` now sends `.git` without
`.git/config`, which can hold a credential, and lists no tracked file,
which git would count as deleted. `ARG VERSION` has no default. The
Makefile takes a non-empty `VERSION` from the command line or the
environment, so a build arg still wins; otherwise `git describe` runs in
the builder, which trusts the checkout whoever owns it, as a context
sent as a tar archive keeps its owners. A new `make version` prints the
version; the build fails when the context carries `.git` and it comes
out empty, `dev` or `unknown`.

Model: opus-5-5
2026-10-02 06:27:47 +02:00
clawbot 889e17459b resolver: never resend a refused query asking for recursion (closes #206)
check / check (push) Canceled after 0s
queryDNS resent a query that a server refused, this time asking for
recursion, so on a network that intercepts DNS the answers could come
from a recursive resolver without anyone knowing. A refusal is now only
a refusal, and the server is passed over for the next.

When every server of a zone refuses, the error says so. When every root
server refuses, the error is ErrIntercepted: root servers refuse no
query, so something on the network is answering in their place.
FindAuthoritativeNameservers stops at that error instead of trying each
parent name, so the watcher's log line says it.

A live test asks Quad9, which refuses a query not asking for recursion,
so that the resend cannot come back unnoticed.

Model: opus-5-5
2026-10-02 06:10:54 +02:00
clawbot a67fd20e4f ci: a push cancels its branch's older run, checkout keeps no token (closes #216)
check / check (push) Canceled after 0s
Every push queues a run on the shared runner, and a branch pushed again
left its older run queued for a head nobody needed. The workflow now puts
each branch's runs in one concurrency group with cancel-in-progress, so a
new push cancels that branch's older run, queued or running. The group is
keyed on the branch, so pushes to other branches never cancel runs on
`next` or `main`; a push to `next` itself does cancel the older `next` run.

The checkout step no longer writes the token into `.git/config`;
`script/cibuild` does not need it.

Model: opus-5-5
2026-10-02 06:07:53 +02:00
clawbot dba932c9e3 watcher tests: far fewer live queries, longer live attempts (closes #214)
check / check (push) Successful in 1m11s
A domain check looked up each nameserver's addresses by asking every
nameserver of that name's zone for all eight record types; it now asks
only for A, AAAA and CNAME, the ones it reads.

The watcher tests now check example.org instead of cloudflare.com (two
nameservers instead of five) and desec.io instead of example.com (its
nameservers are in zones with two, not cloudflare.com's five). The
record change and NS failure tests start from saved state built on one
NS lookup instead of a first full check, and the port change test runs
only the port checks again. A live test attempt may take 18 seconds,
not 8. A new live test checks that a nameserver's addresses include
IPv4 and IPv6.

Model: opus-5-5
2026-10-02 06:04:31 +02:00
19 changed files with 1058 additions and 169 deletions
+7 -7
View File
@@ -1,9 +1,9 @@
.git/ # .git is sent, without its config: the builder stage derives the version it
# stamps into the binary from it, and `git describe` does not need the config,
# which can hold a credential (a password in the remote URL, a CI token). No
# tracked file may be listed here: git in the build would see it as deleted
# and mark the version -dirty, and an excluded .md would silently drop out of
# the prettier check in Dockerfile.fmt.
.git/config
bin/ bin/
node_modules/ node_modules/
# No .md may be excluded: Dockerfile.fmt checks every document with
# prettier, and an exclusion here would drop a file from that check while
# prettier still reports every file it was handed clean.
LICENSE
.editorconfig
.gitignore
+8
View File
@@ -1,9 +1,17 @@
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-28 # 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
+25 -5
View File
@@ -24,6 +24,11 @@ FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4
RUN apk add --no-cache git make gcc musl-dev binutils-gold RUN apk add --no-cache git make gcc musl-dev binutils-gold
# A build context sent as a tar archive keeps its files' owners, and git
# refuses to read a checkout owned by another user. Trust this one
# whoever owns it.
RUN git config --system --add safe.directory /src
# Force BuildKit to run the lint stage before proceeding # 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
@@ -36,11 +41,26 @@ COPY . .
# Run the tests - build fails if any test fails # Run the tests - build fails if any test fails
RUN make test RUN make test
# Build the binary. .dockerignore leaves out .git, so `git describe` in # Version stamped into the binary: the VERSION build arg when one is
# the Makefile cannot find the version here: script/docker passes it as # given and not empty (script/docker passes one), otherwise what
# --build-arg VERSION, and a build that passes none reports `dev`. # `git describe` says of the .git in the build context, so a plain
ARG VERSION=dev # `docker build .` of a clone stamps its tag or short commit. The build
RUN make build VERSION="${VERSION}" # arg reaches make through the environment.
ARG VERSION
# A context that carries .git, as a directory or as a file, must yield a
# real version: one that is empty, `dev` or `unknown` cannot be traced
# back to a commit.
RUN version="$(make version)"; \
if [ -e .git ]; then \
case "$version" in \
"" | dev | unknown) \
echo "version is \"$version\" although the build context carries .git" >&2; \
exit 1 ;; \
esac; \
fi
RUN make build
# Runtime stage # Runtime stage
# alpine 3.21, 2026-02-28 # alpine 3.21, 2026-02-28
+2 -3
View File
@@ -30,9 +30,8 @@ COPY . .
# --config, not discovery: a .prettierrc that failed to arrive would # --config, not discovery: a .prettierrc that failed to arrive would
# otherwise leave prettier on its defaults, where proseWrap is "preserve" # otherwise leave prettier on its defaults, where proseWrap is "preserve"
# and every wrap this check exists to enforce passes. Missing the file is # 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: # a hard error instead. --no-editorconfig so that .prettierrc alone sets
# .editorconfig is not in the build context, so honouring it here and on # the style.
# a developer's machine would be two different answers.
RUN prettier --config .prettierrc --no-editorconfig --check "**/*.md" RUN prettier --config .prettierrc --no-editorconfig --check "**/*.md"
# Write path. Not a check: script/fmt builds this and takes the files. # Write path. Not a check: script/fmt builds this and takes the files.
+12 -4
View File
@@ -1,9 +1,13 @@
.PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker .PHONY: all bootstrap setup build version lint fmt fmt-check test check clean hooks docker
BINARY := dnswatcher BINARY := dnswatcher
# `make build VERSION=...` overrides this; the Dockerfile does so, as the # VERSION given on the command line (`make build VERSION=...`) or in the
# image has no .git to describe. # environment, which is how the Dockerfile's VERSION build arg arrives,
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") # wins over what `git describe` says of this checkout. An empty one counts
# as not given; `override` is what replaces an empty command-line value.
ifeq ($(VERSION),)
override VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
endif
LDFLAGS := -X main.Version=$(VERSION) 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/
@@ -21,6 +25,10 @@ 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; the Dockerfile checks it.
version:
@echo "$(VERSION)"
test: test:
@script/test @script/test
+54 -13
View File
@@ -121,14 +121,26 @@ notification endpoint set, changes show only on the dashboard; see
failed on it, and answers differently is reported on the check where it failed on it, and answers differently is reported on the check where it
answers. If a pair agrees again and later disagrees, the alert is sent answers. If a pair agrees again and later disagrees, the alert is sent
again. again.
- **CNAME address change**: The addresses at the end of a name's CNAME chain
differ from those of the previous check. They are found when its
nameservers answer with a CNAME and no address; a name that answers with
an address has none. A change from or to no addresses is sent too, as when
a name moves between A records and a CNAME. Nothing is sent when the
previous addresses were kept because a chain could not be followed or none
of the name's nameservers answered. The first check after loading a state
file without `cnameAddresses` sends nothing: it saves the addresses it
finds for the next check to compare.
### TCP Port Monitoring ### 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. A CNAME is not followed: a name whose CNAME points into nameservers returned. When they returned a CNAME and no address, the CNAME
another zone usually has no addresses here, so its ports and certificate are chain is followed and the addresses at its end are used, and a change in those
not checked. is notified as a CNAME address change. When the nameservers gave different
CNAME targets, each is followed and the addresses of all are used. When a
chain cannot be followed, or none of the name's nameservers answered, the
addresses the last check found at its end are used.
- Checks TCP connectivity on ports **80** and **443** for each IP address. - 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:
@@ -176,6 +188,8 @@ includes:
- **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, 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.
@@ -420,9 +434,11 @@ 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.
CNAME chains are followed (with a depth limit to prevent loops) only to find the A watched name's records are stored as its nameservers return them, CNAME
addresses of nameservers. A watched name's records are stored as its nameservers included. When they return a CNAME and no address, the chain of every CNAME
return them, CNAME included, without following it. target they gave is followed (with a depth limit to prevent loops) to the A and
AAAA records at its end, and the port and TLS checks use those addresses.
Nameservers' addresses are also found by following CNAME chains.
Sending a notification or a Sentry report is the one use of the system's 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.
@@ -469,6 +485,7 @@ merged view, to enable inconsistency detection.
"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"
} }
}, },
@@ -515,6 +532,14 @@ certificate entry whose TLS connection or handshake failed likewise has status
resolves to. A state file without it loads, and the next check fills it in resolves to. A state file without it loads, and the next check fills it in
without a notification. without a notification.
`cnameAddresses` lists the sorted addresses at the end of the chain of every
CNAME target a hostname's nameservers gave, found when they answered with a
CNAME and no address; it is empty when they answered with an address. When a
chain cannot be followed, or none of the name's nameservers answered, the
previous check's list is kept, or `null` when no earlier check saved one. A
state file without it loads, and the first check after that saves it without a
notification.
A port entry in the older format, with one `hostname` instead of the `hostnames` A port entry in the older format, with one `hostname` instead of the `hostnames`
list, loads as a list of that one name. list, loads as a list of that one name.
@@ -574,6 +599,7 @@ provide:
```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
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)
@@ -584,13 +610,26 @@ 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 `git describe --tags --always --dirty`, or from `VERSION` when given on the from `VERSION` when given on the command line (`make build VERSION=1.2.3`) or in
command line (`make build VERSION=1.2.3`). The version appears in the startup the environment, otherwise from `git describe --tags --always --dirty`, and
log and in the health check response. `dev` without git metadata. An empty `VERSION` counts as not given. The version
appears in the startup log and in the health check response.
The Docker image has no `.git`, so the `Dockerfile` takes the version as The image takes it the same way, from the `.git` the build context carries, so a
`--build-arg VERSION`. `make docker` passes it; a plain `docker build` passes plain `docker build .` of a clone stamps the commit it was built from; a clone
none, and that image reports `dev`. without tags stamps the short commit. A clone made with `--depth 1` carries at
most a tag on its own commit, so such a clone of an untagged commit stamps the
short commit. In a build from a directory, `.dockerignore` keeps out
`.git/config`, which `git describe` does not need and which can hold a
credential. Docker does not apply `.dockerignore` to a context sent as a tar
archive, as upaas sends it, so that context carries `.git/config` into the
build. It also keeps its files' owners, so git in the build trusts the checkout
whoever owns it. A non-empty `--build-arg VERSION=...` takes precedence;
`make docker` passes the version `git describe` gives on the host. The build
fails when the context carries `.git`, as a directory or as a file, and the
version comes out empty, `dev` or `unknown`. `.dockerignore` must list no
tracked file: git in the build would see it as deleted and mark the version
`-dirty`.
--- ---
@@ -658,7 +697,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. used. When it cannot follow a name's CNAME chain, or none of the name's
nameservers answered, the addresses an earlier check found at the end of
the chain are used.
4. **On change detection**: Send notifications to all configured endpoints, 4. **On change detection**: Send notifications to all configured endpoints,
update in-memory state, persist to disk. update in-memory state, persist to disk.
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,
+10
View File
@@ -19,6 +19,16 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-02: a watched name whose nameservers answer with a CNAME and no
address gets port and TLS checks at the end of its CNAME chain (closes #203).
- 2026-10-02: a 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
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
walks, in a random order each time, not always from the top (closes #138). walks, in a random order each time, not always from the top (closes #138).
- 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any - 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any
+12 -5
View File
@@ -36,11 +36,18 @@ const (
// before the test fails. // before the test fails.
attempts = 3 attempts = 3
// AttemptTimeout bounds one attempt. Worst case for an operation // AttemptTimeout bounds one attempt. It must fit the longest
// is attempts * AttemptTimeout plus the backoff — about 26 // operation, a watcher check, which sends over a hundred queries one
// seconds, well inside the 90-second `go test -timeout` backstop // after another and on a slow build host takes several times as long
// even when several operations exhaust their attempts. // as the few seconds it takes on a fast one. An operation whose
AttemptTimeout = 8 * time.Second // every attempt fails takes attempts * AttemptTimeout plus the
// backoff, about 56 seconds, after it waits for one of the
// Concurrency slots that every live operation in the test binary
// shares. So when live DNS does not answer at all, a test binary
// with more live operations than slots runs into the 90-second
// `go test -timeout` backstop instead of each test failing on its
// own.
AttemptTimeout = 18 * time.Second
// backoffBase is the delay after the first failed attempt; it is // backoffBase is the delay after the first failed attempt; it is
// multiplied by backoffFactor each time. // multiplied by backoffFactor each time.
+5
View File
@@ -22,6 +22,11 @@ var (
"reply is an error or a referral that leads no closer", "reply is an error or a referral that leads no closer",
) )
// ErrIntercepted is returned when every root server refused a
// query. Root servers refuse no query, so the refusals came from
// something on the network answering in their place.
ErrIntercepted = errors.New("this network intercepts DNS queries")
// ErrCNAMEDepthExceeded is returned when a CNAME chain // ErrCNAMEDepthExceeded is returned when a CNAME chain
// exceeds MaxCNAMEDepth. // exceeds MaxCNAMEDepth.
ErrCNAMEDepthExceeded = errors.New( ErrCNAMEDepthExceeded = errors.New(
+12 -1
View File
@@ -28,13 +28,24 @@ 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,
nameservers []string, nameservers []string,
hostname string, hostname string,
) (map[string]*NameserverResponse, error) { ) (map[string]*NameserverResponse, error) {
return r.queryEachNS(ctx, nameservers, hostname) return r.queryEachNS(ctx, nameservers, hostname, recordTypes())
} }
// ResolveNSIPs exports resolveNSIPs for testing. // ResolveNSIPs exports resolveNSIPs for testing.
+78 -36
View File
@@ -107,9 +107,8 @@ func (r *Resolver) retryTCP(
return resp return resp
} }
// queryDNS sends a DNS query to a specific server IP. // queryDNS sends a DNS query to a specific server IP, never asking it
// Tries non-recursive first, falls back to recursive on // for recursion. A reply of REFUSED is returned as ErrRefused.
// REFUSED (handles DNS interception environments).
func (r *Resolver) queryDNS( func (r *Resolver) queryDNS(
ctx context.Context, ctx context.Context,
serverIP string, serverIP string,
@@ -133,25 +132,12 @@ func (r *Resolver) queryDNS(
} }
if resp.Rcode == dns.RcodeRefused { if resp.Rcode == dns.RcodeRefused {
msg.RecursionDesired = true return nil, fmt.Errorf(
"query %s @%s: %w", name, serverIP, ErrRefused,
resp, err = r.tryExchange(ctx, msg, addr) )
if err != nil {
return nil, fmt.Errorf(
"query %s @%s: %w", name, serverIP, err,
)
}
if resp.Rcode == dns.RcodeRefused {
return nil, fmt.Errorf(
"query %s @%s: %w", name, serverIP, ErrRefused,
)
}
} }
resp = r.retryTCP(ctx, msg, addr, resp) return r.retryTCP(ctx, msg, addr, resp), nil
return resp, nil
} }
func extractNSSet(rrs []dns.RR) []string { func extractNSSet(rrs []dns.RR) []string {
@@ -279,7 +265,9 @@ 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. // or gives a reply that is not usable is passed over for the next. When
// every server refused, the error says so, and when they are the root
// servers it is ErrIntercepted.
func (r *Resolver) queryServers( func (r *Resolver) queryServers(
ctx context.Context, ctx context.Context,
servers []string, servers []string,
@@ -289,6 +277,8 @@ 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
@@ -305,9 +295,27 @@ 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)
} }
@@ -528,17 +536,49 @@ func (r *Resolver) FindAuthoritativeNameservers(
return nsNames, nil return nsNames, nil
} }
// The root servers would refuse every parent name too.
if errors.Is(err, ErrIntercepted) {
return nil, err
}
} }
return nil, ErrNoNameservers return nil, ErrNoNameservers
} }
// recordTypes returns the record types a nameserver is asked for when a
// name is checked.
func recordTypes() []uint16 {
return []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeCNAME,
dns.TypeMX, dns.TypeTXT, dns.TypeSRV,
dns.TypeCAA, dns.TypeNS,
}
}
// addressTypes returns the record types ResolveIPAddresses asks for,
// the only ones it reads.
func addressTypes() []uint16 {
return []uint16{dns.TypeA, dns.TypeAAAA, dns.TypeCNAME}
}
// QueryNameserver queries a specific nameserver for all record // QueryNameserver queries a specific nameserver for all record
// types and builds a NameserverResponse. // types and builds a NameserverResponse.
func (r *Resolver) QueryNameserver( func (r *Resolver) QueryNameserver(
ctx context.Context, ctx context.Context,
nsHostname string, nsHostname string,
hostname string, hostname string,
) (*NameserverResponse, error) {
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
}
// queryNameserver queries a specific nameserver for the record types
// in qtypes and builds a NameserverResponse.
func (r *Resolver) queryNameserver(
ctx context.Context,
nsHostname string,
hostname string,
qtypes []uint16,
) (*NameserverResponse, error) { ) (*NameserverResponse, error) {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
@@ -551,7 +591,7 @@ func (r *Resolver) QueryNameserver(
hostname = dns.Fqdn(hostname) hostname = dns.Fqdn(hostname)
return r.queryAllTypes(ctx, nsHostname, nsIPs[0], hostname) return r.queryTypes(ctx, nsHostname, nsIPs[0], hostname, qtypes)
} }
// QueryNameserverIP queries a nameserver by its IP address directly, // QueryNameserverIP queries a nameserver by its IP address directly,
@@ -568,14 +608,15 @@ func (r *Resolver) QueryNameserverIP(
hostname = dns.Fqdn(hostname) hostname = dns.Fqdn(hostname)
return r.queryAllTypes(ctx, nsHostname, nsIP, hostname) return r.queryTypes(ctx, nsHostname, nsIP, hostname, recordTypes())
} }
func (r *Resolver) queryAllTypes( func (r *Resolver) queryTypes(
ctx context.Context, ctx context.Context,
nsHostname string, nsHostname string,
nsIP string, nsIP string,
hostname string, hostname string,
qtypes []uint16,
) (*NameserverResponse, error) { ) (*NameserverResponse, error) {
resp := &NameserverResponse{ resp := &NameserverResponse{
Nameserver: nsHostname, Nameserver: nsHostname,
@@ -583,12 +624,6 @@ func (r *Resolver) queryAllTypes(
Status: StatusOK, Status: StatusOK,
} }
qtypes := []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeCNAME,
dns.TypeMX, dns.TypeTXT, dns.TypeSRV,
dns.TypeCAA, dns.TypeNS,
}
state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp) state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp)
classifyResponse(resp, state) classifyResponse(resp, state)
@@ -779,18 +814,19 @@ func (r *Resolver) QueryAllNameservers(
return nil, err return nil, err
} }
return r.queryEachNS(ctx, nameservers, hostname) return r.queryEachNS(ctx, nameservers, hostname, recordTypes())
} }
func (r *Resolver) queryEachNS( func (r *Resolver) queryEachNS(
ctx context.Context, ctx context.Context,
nameservers []string, nameservers []string,
hostname string, hostname string,
qtypes []uint16,
) (map[string]*NameserverResponse, error) { ) (map[string]*NameserverResponse, error) {
results := make(map[string]*NameserverResponse) results := make(map[string]*NameserverResponse)
for _, ns := range nameservers { for _, ns := range nameservers {
resp, err := r.QueryNameserver(ctx, ns, hostname) resp, err := r.queryNameserver(ctx, ns, hostname, qtypes)
// A query the context cut short says nothing about the // A query the context cut short says nothing about the
// nameserver, so it must not be returned as its failure. // nameserver, so it must not be returned as its failure.
@@ -835,9 +871,10 @@ func (r *Resolver) LookupAllRecords(
} }
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 // ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
// addresses, following CNAME chains up to MaxCNAMEDepth. When no // addresses, following CNAME chains up to MaxCNAMEDepth. It asks each
// nameserver of the name's zone answered, it returns an error rather // nameserver of the name's zone for its A, AAAA and CNAME records only.
// than no addresses. // When no nameserver of the name's zone answered, it returns an error
// rather than no addresses.
func (r *Resolver) ResolveIPAddresses( func (r *Resolver) ResolveIPAddresses(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -858,7 +895,12 @@ func (r *Resolver) resolveIPWithCNAME(
return nil, ErrCNAMEDepthExceeded return nil, ErrCNAMEDepthExceeded
} }
results, err := r.QueryAllNameservers(ctx, hostname) nameservers, err := r.FindAuthoritativeNameservers(ctx, hostname)
if err != nil {
return nil, err
}
results, err := r.queryEachNS(ctx, nameservers, hostname, addressTypes())
if err != nil { if err != nil {
return nil, err return nil, err
} }
+210
View File
@@ -11,6 +11,7 @@ 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"
@@ -295,6 +296,187 @@ func TestQueryNameserver_Refused(t *testing.T) {
assert.Equal(t, "server returned REFUSED", resp.Error) assert.Equal(t, "server returned REFUSED", resp.Error)
} }
// TestQueryNameserverIP_RecursiveResolverRefused asks Quad9, a public
// recursive resolver, about google.com at both of its addresses. Quad9
// refuses a query that does not ask for recursion and answers one that
// does. The resolver never asks for recursion, so it must be reported
// as refusing, never as answering.
func TestQueryNameserverIP_RecursiveResolverRefused(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
for _, ip := range []string{"9.9.9.9", "149.112.112.112"} {
var resp *resolver.NameserverResponse
livednstest.Retry(
t,
"QueryNameserverIP("+ip+", google.com)",
func(ctx context.Context) error {
var err error
resp, err = r.QueryNameserverIP(
ctx, ip, ip, "google.com",
)
if err != nil {
return err
}
// A timeout or a network error is no reply at all.
if resp.Status == resolver.StatusTimeout ||
strings.HasPrefix(resp.Error, "network error") {
return fmt.Errorf(
"%w: %s: %s",
livednstest.ErrNoAnswer, ip, resp.Error,
)
}
return nil
},
)
assert.Equal(t, resolver.StatusError, resp.Status, ip)
assert.Equal(t, "server returned REFUSED", resp.Error, ip)
}
}
// googleNameserverIPv4s returns the IPv4 addresses of google.com's
// nameservers, the only addresses the resolver asks servers at.
func googleNameserverIPv4s(t *testing.T, r *resolver.Resolver) []string {
t.Helper()
names := liveFindAuthoritative(t, r, "google.com")
return liveResolveNSIPs(t, r, names, len(names))
}
// TestQueryServers_EveryServerRefused asks all of google.com's
// nameservers about cloudflare.com, a zone they do not serve, which
// they all refuse. The error says every server refused; it is not
// ErrIntercepted, which only the root servers refusing shows.
func TestQueryServers_EveryServerRefused(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
servers := googleNameserverIPv4s(t, r)
var err error
livednstest.Retry(
t,
"QueryServers(google.com servers, cloudflare.com)",
func(ctx context.Context) error {
_, err = r.QueryServers(
ctx, servers, "google.com.", "cloudflare.com.",
dns.TypeNS,
)
// When not every server refused, one may have given no
// reply at all, so the attempt is tried again.
if err != nil &&
!strings.HasPrefix(err.Error(), "every server of") {
return fmt.Errorf(
"%w: %w", livednstest.ErrNoAnswer, err,
)
}
return nil
},
)
require.ErrorIs(t, err, resolver.ErrRefused)
require.NotErrorIs(t, err, resolver.ErrIntercepted)
require.EqualError(
t, err,
"every server of google.com. refused a query for "+
"cloudflare.com.: dns query refused",
)
}
// TestQueryServers_EveryRootServerRefused passes google.com's
// nameservers to QueryServers as the servers of the root zone. They
// refuse a query about cloudflare.com, as root servers would if
// something on the network answered in their place, so the error is
// ErrIntercepted.
func TestQueryServers_EveryRootServerRefused(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
servers := googleNameserverIPv4s(t, r)
var err error
livednstest.Retry(
t,
"QueryServers(google.com servers as root servers, cloudflare.com)",
func(ctx context.Context) error {
_, err = r.QueryServers(
ctx, servers, ".", "cloudflare.com.", dns.TypeNS,
)
// When not every server refused, one may have given no
// reply at all, so the attempt is tried again. Both errors
// for every server refusing say "refused a query for".
if err != nil &&
!strings.Contains(err.Error(), "refused a query for") {
return fmt.Errorf(
"%w: %w", livednstest.ErrNoAnswer, err,
)
}
return nil
},
)
require.ErrorIs(t, err, resolver.ErrIntercepted)
require.EqualError(
t, err,
"every root server refused a query for cloudflare.com.: "+
"this network intercepts DNS queries",
)
}
// TestQueryServers_NotEveryRootServerRefused passes google.com's
// nameservers and 192.0.2.1 to QueryServers as the servers of the root
// zone. The google.com nameservers refuse a query about cloudflare.com,
// but nothing answers at 192.0.2.1, a documentation address, so not
// every server refused, wherever 192.0.2.1 falls in the random order:
// the error is not ErrIntercepted and does not say every server refused.
func TestQueryServers_NotEveryRootServerRefused(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
servers := googleNameserverIPv4s(t, r)
servers = append(servers, "192.0.2.1")
var err error
livednstest.Retry(
t,
"QueryServers(google.com servers and 192.0.2.1, cloudflare.com)",
func(ctx context.Context) error {
_, err = r.QueryServers(
ctx, servers, ".", "cloudflare.com.", dns.TypeNS,
)
// An attempt that ran out of time may not have asked every
// server, so it is tried again.
if ctx.Err() != nil {
return fmt.Errorf(
"%w: %w", livednstest.ErrNoAnswer, err,
)
}
return nil
},
)
require.Error(t, err)
require.NotErrorIs(t, err, resolver.ErrIntercepted)
// Both errors for every server refusing say "refused a query for".
require.NotContains(t, err.Error(), "refused a query for")
}
func TestQueryNameserver_RecordsSorted(t *testing.T) { func TestQueryNameserver_RecordsSorted(t *testing.T) {
t.Parallel() t.Parallel()
@@ -562,6 +744,34 @@ func TestResolveIPAddresses_CloudflareDomain(t *testing.T) {
assert.NotEmpty(t, ips) assert.NotEmpty(t, ips)
} }
// TestResolveIPAddresses_NameserverIPv4AndIPv6 looks up the addresses of
// one of cloudflare.com's nameservers, as a domain check does for each
// nameserver. That name has A and AAAA records, so both kinds of address
// come back.
func TestResolveIPAddresses_NameserverIPv4AndIPv6(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "cloudflare.com")
ips := liveResolveIPs(t, r, ns)
var ipv4, ipv6 int
for _, ip := range ips {
parsed := net.ParseIP(ip)
require.NotNil(t, parsed, "should be valid IP: %s", ip)
if parsed.To4() != nil {
ipv4++
} else {
ipv6++
}
}
assert.Positive(t, ipv4, "no IPv4 address for %s: %v", ns, ips)
assert.Positive(t, ipv6, "no IPv6 address for %s: %v", ns, ips)
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Context cancellation tests // Context cancellation tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------
+5
View File
@@ -53,8 +53,13 @@ type NameserverRecordState struct {
} }
// 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"`
} }
+89
View File
@@ -188,6 +188,95 @@ func TestLoadStateFromBeforeNameserverAddresses(t *testing.T) {
} }
} }
// TestSaveLoadRoundTrip_CNAMEAddresses checks that no addresses at the
// end of a hostname's CNAME chain load as an empty list, and addresses
// that are not known load as nil: the watcher tells the two apart.
func TestSaveLoadRoundTrip_CNAMEAddresses(t *testing.T) {
t.Parallel()
dir := t.TempDir()
s := state.NewForTestWithDataDir(dir)
want := map[string][]string{
"cname.example.com": {testIP},
"none.example.com": {},
"not-known.example.com": nil,
}
for name, addresses := range want {
s.SetHostnameState(name, &state.HostnameState{
CNAMEAddresses: addresses,
})
}
err := s.Save()
if err != nil {
t.Fatalf("Save() error: %v", err)
}
loaded := state.NewForTestWithDataDir(dir)
err = loaded.Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
for name, addresses := range want {
hs, ok := loaded.GetHostnameState(name)
if !ok {
t.Fatalf("missing hostname %s", name)
}
if !reflect.DeepEqual(hs.CNAMEAddresses, addresses) {
t.Errorf(
"%s: loaded %#v, want %#v",
name, hs.CNAMEAddresses, addresses,
)
}
}
}
// TestLoadStateFromBeforeCNAMEAddresses loads a state file written
// before the addresses at the end of a hostname's CNAME chain were
// saved. They load as not known (nil), not as none.
func TestLoadStateFromBeforeCNAMEAddresses(t *testing.T) {
t.Parallel()
dir := t.TempDir()
data := []byte(`{
"version": 1,
"lastUpdated": "2026-02-19T12:00:00Z",
"hostnames": {
"www.example.com": {
"recordsByNameserver": {},
"lastChecked": "2026-02-19T12:00:00Z"
}
}
}`)
err := os.WriteFile(filepath.Join(dir, "state.json"), data, 0o600)
if err != nil {
t.Fatalf("writing state file: %v", err)
}
s := state.NewForTestWithDataDir(dir)
err = s.Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
hs, ok := s.GetHostnameState(testHostname)
if !ok {
t.Fatal("missing hostname " + testHostname)
}
if hs.CNAMEAddresses != nil {
t.Errorf("CNAME addresses: got %#v, want nil", hs.CNAMEAddresses)
}
}
// TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle. // 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()
+331
View File
@@ -0,0 +1,331 @@
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"
)
// Each name these tests look up in live DNS, and each zone a CNAME
// points into, has two nameservers, to keep queries few (see the top
// of watcher_test.go). cnameHost is a CNAME into another zone,
// readthedocs.io: its nameservers answer with the CNAME and no address.
const cnameHost = "flask.palletsprojects.com"
// TestCNAMEIntoAnotherZonePortAndTLSChecks checks cnameHost against
// live DNS. Its port and TLS checks must use the addresses at the end
// of its CNAME chain.
func TestCNAMEIntoAnotherZonePortAndTLSChecks(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{cnameHost}
_, deps := runChecks(t, cfg, nil)
snap := deps.state.GetSnapshot()
hs := snap.Hostnames[cnameHost]
if len(hs.CNAMEAddresses) == 0 {
t.Fatalf(
"%s: no addresses saved from following its CNAME; if it "+
"is no longer a CNAME into another zone, this test "+
"needs another name",
cnameHost,
)
}
for _, ip := range hs.CNAMEAddresses {
ps, ok := snap.Ports[ip+":443"]
if !ok || !slices.Contains(ps.Hostnames, cnameHost) {
t.Errorf("no port state for %s at %s:443", cnameHost, ip)
}
certKey := ip + ":443:" + cnameHost
if _, ok := snap.Certificates[certKey]; !ok {
t.Errorf("no certificate state %s", certKey)
}
}
}
// TestCNAMEThatCannotBeFollowedKeepsPrevious gives a name a CNAME to a
// target under .invalid, whose lookup fails. The addresses the previous
// check saved from following its CNAME are kept.
func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) {
t.Parallel()
w := watcher.NewForTest(
nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil,
)
current := hostnameState(map[string]map[string][]string{
nsA: cnameTo("target.example.invalid."),
})
prev := &state.HostnameState{CNAMEAddresses: []string{oldIP}}
// 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.ResolveCNAMEAddresses(ctx, host, current, prev)
return nil
})
if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) {
t.Errorf(
"saved %v, want %v",
current.CNAMEAddresses, prev.CNAMEAddresses,
)
}
}
// 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.
func followLive(
t *testing.T,
records map[string]map[string][]string,
) []string {
t.Helper()
w := watcher.NewForTest(
nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil,
)
prev := cnameState(oldIP)
var current *state.HostnameState
livednstest.Retry(t, "following CNAMEs", func(ctx context.Context) error {
current = hostnameState(records)
w.ResolveCNAMEAddresses(ctx, host, current, prev)
if slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) {
return livednstest.ErrNoAnswer
}
return nil
})
return current.CNAMEAddresses
}
// TestCNAMEAddressesOfEveryTarget gives a name's two nameservers
// different CNAME targets, as when a secondary still serves an old one.
// The addresses at the end of both are saved, whichever answer is read
// first: one.one.one.one has 1.1.1.1, and dns.adguard-dns.com has
// 94.140.14.14.
func TestCNAMEAddressesOfEveryTarget(t *testing.T) {
t.Parallel()
found := followLive(t, map[string]map[string][]string{
nsA: cnameTo("one.one.one.one."),
nsB: cnameTo("dns.adguard-dns.com."),
})
for _, ip := range []string{"1.1.1.1", "94.140.14.14"} {
if !slices.Contains(found, ip) {
t.Errorf("saved %v, want %s among them", found, ip)
}
}
}
// TestCNAMEChainEndingInNoAddressSavesEmptyList follows a CNAME to a
// name live DNS answers with NXDOMAIN. An empty list is saved, not nil,
// which would mean the addresses are not known.
func TestCNAMEChainEndingInNoAddressSavesEmptyList(t *testing.T) {
t.Parallel()
found := followLive(t, map[string]map[string][]string{
nsA: cnameTo("this-surely-does-not-exist-xyz.example.org."),
})
if found == nil || len(found) != 0 {
t.Errorf("saved %#v, want an empty list", found)
}
}
// TestCNAMEBesideAnAddressNotFollowed gives one nameserver of a name an
// address and another a CNAME. The CNAME is not followed: an empty list
// is saved, not nil, and nothing is looked up, the watcher having no
// resolver.
func TestCNAMEBesideAnAddressNotFollowed(t *testing.T) {
t.Parallel()
w := watcher.NewForTest(nil, nil, nil, nil, nil, nil)
current := hostnameState(map[string]map[string][]string{
nsA: {"A": {ip1}},
nsB: cnameTo("target.example.org."),
})
w.ResolveCNAMEAddresses(t.Context(), host, current, nil)
if current.CNAMEAddresses == nil || len(current.CNAMEAddresses) != 0 {
t.Errorf("saved %#v, want an empty list", current.CNAMEAddresses)
}
}
// TestCNAMEWhoseNameserversAllFailedKeepsPrevious checks a name none of
// whose nameservers answered. The addresses the previous check saved
// from following its CNAME are kept, and nothing is looked up: the
// watcher has no resolver.
func TestCNAMEWhoseNameserversAllFailedKeepsPrevious(t *testing.T) {
t.Parallel()
w := watcher.NewForTest(nil, nil, nil, nil, nil, nil)
current := saved(map[string]*state.NameserverRecordState{
nsA: failed(), nsB: failed(),
})
prev := cnameState(oldIP)
w.ResolveCNAMEAddresses(t.Context(), host, current, prev)
if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) {
t.Errorf(
"saved %v, want %v",
current.CNAMEAddresses, prev.CNAMEAddresses,
)
}
}
// cnameTo builds the records of a nameserver that answered with a CNAME
// to target and no address.
func cnameTo(target string) map[string][]string {
return map[string][]string{"CNAME": {target}}
}
// cnameState builds the state a check leaves behind for a name whose
// nameserver answered with a CNAME and no address, when following the
// CNAME found these addresses, which may be none.
func cnameState(addresses ...string) *state.HostnameState {
hs := hostnameState(map[string]map[string][]string{
nsA: cnameTo("target.example.org."),
})
hs.CNAMEAddresses = append([]string{}, addresses...)
return hs
}
func TestCNAMEAddressChangeAlerts(t *testing.T) {
t.Parallel()
// A state file written before the addresses were saved loads with
// them nil.
olderStateFile := cnameState()
olderStateFile.CNAMEAddresses = nil
// Each case is the state saved by the previous check and by the
// current one. The name's records are the same in both.
tests := []struct {
name string
prev, current *state.HostnameState
want int
}{
{
"same addresses",
cnameState(ip1, ip2), cnameState(ip1, ip2), 0,
},
{
"same addresses in another order",
cnameState(ip2, ip1), cnameState(ip1, ip2), 0,
},
{
"address replaced",
cnameState(ip1), cnameState(ip2), 1,
},
{
"address added",
cnameState(ip1), cnameState(ip1, ip2), 1,
},
{
"no address at the end of the chain now",
cnameState(ip1), cnameState(), 1,
},
{
"addresses at the end of the chain again",
cnameState(), cnameState(ip1), 1,
},
{
"state file from before addresses were saved",
olderStateFile, cnameState(ip1), 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
w.DetectHostnameChanges(t.Context(), host, tt.prev, tt.current)
got := len(notifier.getNotifications())
if got != tt.want {
t.Errorf("sent %d notifications, want %d", got, tt.want)
}
})
}
}
func TestCNAMEAddressChangeAlertNamesHostnameAndAddresses(t *testing.T) {
t.Parallel()
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
w.DetectHostnameChanges(
t.Context(), host, cnameState(ip1), cnameState(ip2, ip3),
)
want := notification{
Title: "CNAME Address Change: " + host,
Message: "Hostname: " + host +
"\nOld: " + ip1 + "\nNew: " + ip2 + ", " + ip3,
Priority: "warning",
}
got := notifier.getNotifications()
if len(got) != 1 || got[0] != want {
t.Errorf("sent %v, want %v", got, want)
}
}
// TestNameMovedFromARecordsToCNAMEAlerts checks a name that answers
// with an A record and then with a CNAME whose chain ends in ip2. The
// second check is notified as a CNAME address change from no addresses,
// beside the record change. Nothing is looked up: the watcher has no
// resolver.
func TestNameMovedFromARecordsToCNAMEAlerts(t *testing.T) {
t.Parallel()
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
prev := hostnameState(map[string]map[string][]string{
nsA: {"A": {ip1}},
})
w.ResolveCNAMEAddresses(t.Context(), host, prev, nil)
w.DetectHostnameChanges(t.Context(), host, prev, cnameState(ip2))
title := "CNAME Address Change: " + host
message := "Hostname: " + host + "\nOld: \nNew: " + ip2
got := notifier.getNotifications()
if !slices.ContainsFunc(got, func(n notification) bool {
return n.Title == title && n.Message == message
}) {
t.Errorf("sent %v, want %q with %q among them", got, title, message)
}
}
+9
View File
@@ -57,6 +57,15 @@ 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,
+114 -22
View File
@@ -252,28 +252,9 @@ func (w *Watcher) checkDomain(
LastChecked: now, LastChecked: now,
}) })
// Also look up A/AAAA records for the apex domain so that // The apex domain's records are also checked as a hostname's, so
// port and TLS checks (which read HostnameState) can find // that the port and TLS checks find its addresses.
// the domain's IP addresses. w.checkHostname(ctx, 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
}
newState := buildHostnameState(results, now)
prevHS, hasPrevHS := w.state.GetHostnameState(domain)
if hasPrevHS && !w.firstRun {
w.detectHostnameChanges(ctx, domain, prevHS, newState)
}
w.state.SetHostnameState(domain, newState)
} }
func (w *Watcher) detectNSChanges( func (w *Watcher) detectNSChanges(
@@ -401,6 +382,9 @@ func (w *Watcher) checkHostname(
newState := buildHostnameState(results, time.Now().UTC()) newState := buildHostnameState(results, time.Now().UTC())
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)
} }
@@ -408,6 +392,76 @@ func (w *Watcher) checkHostname(
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, and when a target cannot be
// followed, as when no nameserver of a zone in its chain answers.
func (w *Watcher) resolveCNAMEAddresses(
ctx context.Context,
hostname string,
current, prev *state.HostnameState,
) {
var prevAddresses []string
if prev != nil {
prevAddresses = prev.CNAMEAddresses
}
// Empty, not nil: nil means the addresses are not known.
current.CNAMEAddresses = []string{}
answered := false
targets := make(map[string]bool)
for _, nsState := range current.RecordsByNameserver {
if nsState.Status != statusOK {
continue
}
answered = true
if len(nsState.Records["A"]) > 0 || len(nsState.Records["AAAA"]) > 0 {
return
}
for _, target := range nsState.Records["CNAME"] {
targets[target] = true
}
}
if !answered {
current.CNAMEAddresses = prevAddresses
return
}
for target := range targets {
ips, err := w.resolver.ResolveIPAddresses(ctx, target)
if err != nil {
w.log.Error(
"failed to follow CNAME",
"hostname", hostname,
"target", target,
"error", err,
)
current.CNAMEAddresses = prevAddresses
return
}
current.CNAMEAddresses = append(current.CNAMEAddresses, ips...)
}
// Still the empty list when every chain ends in no address.
slices.Sort(current.CNAMEAddresses)
current.CNAMEAddresses = slices.Compact(current.CNAMEAddresses)
}
// buildHostnameState saves each nameserver's response. A nameserver // buildHostnameState saves each nameserver's response. A nameserver
// that answered, even with NXDOMAIN or no records, is saved as ok; one // that answered, even with NXDOMAIN or no records, is saved as ok; one
// that timed out or failed is saved as error with the reason, and its // that timed out or failed is saved as error with the reason, and its
@@ -451,6 +505,37 @@ 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)
}
// detectCNAMEAddressChanges notifies when the addresses at the end of
// hostname's CNAME chain differ from those the previous check saved,
// including a change from or to none. When the previous addresses are
// not known (nil), as on the first check after loading a state file
// written before they were saved, nothing is compared.
func (w *Watcher) detectCNAMEAddressChanges(
ctx context.Context,
hostname string,
prev, current *state.HostnameState,
) {
old, cur := prev.CNAMEAddresses, current.CNAMEAddresses
if old == nil || sliceEqual(old, cur) {
return
}
msg := fmt.Sprintf(
"Hostname: %s\nOld: %s\nNew: %s",
hostname,
strings.Join(old, ", "),
strings.Join(cur, ", "),
)
w.notify.SendNotification(
ctx,
"CNAME Address Change: "+hostname,
msg,
"warning",
)
} }
// detectRecordChanges compares each nameserver's records with those of // detectRecordChanges compares each nameserver's records with those of
@@ -747,6 +832,9 @@ 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 {
@@ -765,6 +853,10 @@ 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)
+73 -71
View File
@@ -26,18 +26,22 @@ import (
// The watcher looks these names up in live DNS with the real resolver, // The watcher looks these names up in live DNS with the real resolver,
// so tests assert on what the watcher does with the answers, never on // so tests assert on what the watcher does with the answers, never on
// the records these zones publish. testHost's nameservers and addresses // the records these zones publish. The nameservers of testHost and
// stay the same from one check to the next, which the tests that check // testSmallDomain stay the same between a test looking them up and its
// it twice rely on, and testSmallDomain's nameservers stay the same // check. Every query a check sends is one more that can be lost, so the
// between a test looking them up and its check. A domain check looks up // tests keep them few. A check asks each of a name's nameservers about
// each nameserver's addresses, about a second per nameserver, so the // every record type, and both names have two. A domain check also looks
// tests that check a domain use testSmallDomain, which has two // up each nameserver's addresses at every nameserver of the zone that
// nameservers, and check it once. The tests that query testDomain's // nameserver is in: testSmallDomain's nameservers are in zones with two
// nameservers directly do no domain check. // nameservers, while a domain whose nameservers are in, say,
// cloudflare.com, which has five, makes each domain check much longer.
// A test checks a domain only when it is about domains, and checks once,
// from saved state it builds, rather than twice. The tests that query
// testDomain's nameservers directly do no domain check.
const ( const (
testDomain = "google.com" testDomain = "google.com"
testSmallDomain = "example.com" testSmallDomain = "desec.io"
testHost = "cloudflare.com" testHost = "example.org"
testIssuer = "DigiCert" testIssuer = "DigiCert"
) )
@@ -259,55 +263,48 @@ func checkOnce(
// runChecks builds a watcher, lets prepare set up the saved state and // runChecks builds a watcher, lets prepare set up the saved state and
// stand-ins it starts from, and runs its checks once against live DNS. // stand-ins it starts from, and runs its checks once against live DNS.
// If change is not nil, change then alters the saved state or stand-ins // When the check finds no fresh address for a name (see checkOnce), the
// and the checks run a second time. When either check finds no fresh // watcher is thrown away and all of this runs again on a new one, so a
// address for a name (see checkOnce), the watcher is thrown away and // failed attempt leaves nothing behind in the saved state, the
// all of this runs again on a new one, so a failed attempt leaves // stand-ins or the notifications.
// nothing behind in the saved state, the stand-ins or the notifications.
func runChecks( func runChecks(
t *testing.T, t *testing.T,
cfg *config.Config, cfg *config.Config,
prepare, change func(deps *testDeps), prepare func(deps *testDeps),
) *testDeps { ) (*watcher.Watcher, *testDeps) {
t.Helper() t.Helper()
var deps *testDeps var (
w *watcher.Watcher
deps *testDeps
)
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg) w, deps = newTestWatcher(t, cfg)
if prepare != nil { if prepare != nil {
prepare(deps) prepare(deps)
} }
err := checkOnce(ctx, w, deps)
if err != nil || change == nil {
return err
}
change(deps)
return checkOnce(ctx, w, deps) return checkOnce(ctx, w, deps)
}) })
return deps return w, deps
} }
// lookupNameservers returns the nameservers live DNS lists for domain, // lookupNameservers returns the nameservers live DNS lists for name,
// for a test to save in the state its check starts from. // for a test to save in the state its check starts from.
func lookupNameservers(t *testing.T, domain string) []string { func lookupNameservers(t *testing.T, name string) []string {
t.Helper() t.Helper()
res := resolver.NewFromLogger(slog.Default()) res := resolver.NewFromLogger(slog.Default())
var nameservers []string var nameservers []string
livednstest.Retry(t, "LookupNS("+domain+")", func(ctx context.Context) error { livednstest.Retry(t, "LookupNS("+name+")", func(ctx context.Context) error {
var err error var err error
nameservers, err = res.LookupNS(ctx, domain) nameservers, err = res.LookupNS(ctx, name)
return err return err
}) })
@@ -315,7 +312,8 @@ func lookupNameservers(t *testing.T, domain string) []string {
return nameservers return nameservers
} }
// addresses returns the A and AAAA values saved for a hostname. // addresses returns the A and AAAA values saved for a hostname, and the
// addresses saved at the end of its CNAME chain.
func addresses(hs *state.HostnameState) []string { func addresses(hs *state.HostnameState) []string {
var ips []string var ips []string
@@ -324,7 +322,7 @@ func addresses(hs *state.HostnameState) []string {
ips = append(ips, nsState.Records["AAAA"]...) ips = append(ips, nsState.Records["AAAA"]...)
} }
return ips return append(ips, hs.CNAMEAddresses...)
} }
// assertNotified checks that a notification with this title and // assertNotified checks that a notification with this title and
@@ -370,7 +368,7 @@ func TestFirstRunBaseline(t *testing.T) {
cfg.Domains = []string{testSmallDomain} cfg.Domains = []string{testSmallDomain}
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
deps := runChecks(t, cfg, nil, nil) _, deps := runChecks(t, cfg, nil)
assertNoNotifications(t, deps) assertNoNotifications(t, deps)
assertStatePopulated(t, deps) assertStatePopulated(t, deps)
@@ -422,7 +420,7 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testSmallDomain} cfg.Domains = []string{testSmallDomain}
deps := runChecks(t, cfg, nil, nil) _, deps := runChecks(t, cfg, nil)
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
@@ -462,11 +460,11 @@ func TestNSChangeDetection(t *testing.T) {
cfg.Domains = []string{testSmallDomain} cfg.Domains = []string{testSmallDomain}
// The saved state lists nameservers that live DNS does not. // The saved state lists nameservers that live DNS does not.
deps := runChecks(t, cfg, func(deps *testDeps) { _, deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetDomainState(testSmallDomain, &state.DomainState{ deps.state.SetDomainState(testSmallDomain, &state.DomainState{
Nameservers: []string{oldNS1, oldNS2}, Nameservers: []string{oldNS1, oldNS2},
}) })
}, nil) })
assertNotified(t, deps, "NS Change: "+testSmallDomain, "warning") assertNotified(t, deps, "NS Change: "+testSmallDomain, "warning")
@@ -486,7 +484,7 @@ func TestNSAddressChangeDetection(t *testing.T) {
// The saved state lists the nameservers live DNS lists, each at an // The saved state lists the nameservers live DNS lists, each at an
// address live DNS never returns. // address live DNS never returns.
deps := runChecks(t, cfg, func(deps *testDeps) { _, deps := runChecks(t, cfg, func(deps *testDeps) {
nsAddresses := make(map[string][]string, len(nameservers)) nsAddresses := make(map[string][]string, len(nameservers))
for _, ns := range nameservers { for _, ns := range nameservers {
nsAddresses[ns] = []string{oldIP} nsAddresses[ns] = []string{oldIP}
@@ -496,7 +494,7 @@ func TestNSAddressChangeDetection(t *testing.T) {
Nameservers: nameservers, Nameservers: nameservers,
NameserverAddresses: nsAddresses, NameserverAddresses: nsAddresses,
}) })
}, nil) })
title := "NS Address Change: " + testSmallDomain title := "NS Address Change: " + testSmallDomain
ds, _ := deps.state.GetDomainState(testSmallDomain) ds, _ := deps.state.GetDomainState(testSmallDomain)
@@ -542,12 +540,12 @@ func TestNSAddedAndRemovedIsNoAddressChange(t *testing.T) {
// The saved state lists oldNS1, which live DNS does not, in place of // The saved state lists oldNS1, which live DNS does not, in place of
// the first nameserver live DNS lists, so that the check finds that // the first nameserver live DNS lists, so that the check finds that
// one added and oldNS1 removed. Only oldNS1 has addresses saved. // one added and oldNS1 removed. Only oldNS1 has addresses saved.
deps := runChecks(t, cfg, func(deps *testDeps) { _, deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetDomainState(testSmallDomain, &state.DomainState{ deps.state.SetDomainState(testSmallDomain, &state.DomainState{
Nameservers: append([]string{oldNS1}, nameservers[1:]...), Nameservers: append([]string{oldNS1}, nameservers[1:]...),
NameserverAddresses: map[string][]string{oldNS1: {oldIP}}, NameserverAddresses: map[string][]string{oldNS1: {oldIP}},
}) })
}, nil) })
if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 1 { if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 1 {
t.Errorf("sent %d NS changes, want 1", n) t.Errorf("sent %d NS changes, want 1", n)
@@ -565,15 +563,17 @@ func TestRecordChangeDetection(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// Between the checks, save for every nameserver an address live DNS nameservers := lookupNameservers(t, testHost)
// never returns.
deps := runChecks(t, cfg, nil, func(deps *testDeps) { // The saved state has every nameserver live DNS lists answering
hs, _ := deps.state.GetHostnameState(testHost) // with an address live DNS never returns.
for _, nsState := range hs.RecordsByNameserver { _, deps := runChecks(t, cfg, func(deps *testDeps) {
nsState.Records = map[string][]string{"A": {oldIP}} byNameserver := make(map[string]*state.NameserverRecordState)
for _, ns := range nameservers {
byNameserver[ns] = answered(map[string][]string{"A": {oldIP}})
} }
deps.state.SetHostnameState(testHost, hs) deps.state.SetHostnameState(testHost, saved(byNameserver))
}) })
assertNotified(t, deps, "Record Change: "+testHost, "warning") assertNotified(t, deps, "Record Change: "+testHost, "warning")
@@ -585,12 +585,15 @@ func TestPortStateChange(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// Between the checks, every port closes. w, deps := runChecks(t, cfg, nil)
deps := runChecks(t, cfg, nil, func(deps *testDeps) {
deps.portChecker.mu.Lock() // Every port closes, and the port checks run again. They look
deps.portChecker.closed = true // nothing up.
deps.portChecker.mu.Unlock() deps.portChecker.mu.Lock()
}) deps.portChecker.closed = true
deps.portChecker.mu.Unlock()
w.CheckAllPorts(t.Context())
hs, _ := deps.state.GetHostnameState(testHost) hs, _ := deps.state.GetHostnameState(testHost)
assertNotified( assertNotified(
@@ -610,7 +613,7 @@ func TestTLSExpiryWarning(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
deps := runChecks(t, cfg, expiresInThreeDays, nil) _, deps := runChecks(t, cfg, expiresInThreeDays)
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning") assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
} }
@@ -782,7 +785,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// The saved state says the last check found testHost at oldIP. // The saved state says the last check found testHost at oldIP.
deps := runChecks(t, cfg, func(deps *testDeps) { _, deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetHostnameState(testHost, &state.HostnameState{ deps.state.SetHostnameState(testHost, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
oldNS1: { oldNS1: {
@@ -791,7 +794,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
}, },
}, },
}) })
}, nil) })
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
@@ -922,21 +925,20 @@ func TestNSFailureAndRecovery(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// Between the checks, save every nameserver the first check found nameservers := lookupNameservers(t, testHost)
// as one that did not answer, and add, as answering, one that live
// DNS does not list, which then disappears. // The saved state has every nameserver live DNS lists as one that
deps := runChecks(t, cfg, nil, func(deps *testDeps) { // did not answer, and, as answering, one that live DNS does not
hs, _ := deps.state.GetHostnameState(testHost) // list, which then disappears.
for ns := range hs.RecordsByNameserver { _, deps := runChecks(t, cfg, func(deps *testDeps) {
hs.RecordsByNameserver[ns] = failed() byNameserver := map[string]*state.NameserverRecordState{
oldNS1: answered(map[string][]string{"A": {oldIP}}),
}
for _, ns := range nameservers {
byNameserver[ns] = failed()
} }
hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{ deps.state.SetHostnameState(testHost, saved(byNameserver))
Records: map[string][]string{"A": {oldIP}},
Status: "ok",
}
deps.state.SetHostnameState(testHost, hs)
}) })
assertNotified(t, deps, "NS Failure: "+testHost, "error") assertNotified(t, deps, "NS Failure: "+testHost, "error")
+2 -2
View File
@@ -14,8 +14,8 @@ 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. VERSION is computed here because .dockerignore # empty constant. The VERSION build arg takes precedence over what
# excludes .git, so `git describe` in a build stage cannot find it. # the build would derive from the .git in its context.
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-filter=lint,builder \ docker build --no-cache-filter=lint,builder \