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
15 changed files with 482 additions and 146 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
+20 -6
View File
@@ -599,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)
@@ -609,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`.
--- ---
+8
View File
@@ -21,6 +21,14 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 2026-10-02: a watched name whose nameservers answer with a CNAME and no - 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). 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
// ---------------------------------------------------------------- // ----------------------------------------------------------------
+11 -8
View File
@@ -12,9 +12,11 @@ import (
"sneak.berlin/go/dnswatcher/internal/watcher" "sneak.berlin/go/dnswatcher/internal/watcher"
) )
// cnameHost is a CNAME into another zone: its nameservers answer with // Each name these tests look up in live DNS, and each zone a CNAME
// the CNAME and no address. // points into, has two nameservers, to keep queries few (see the top
const cnameHost = "www.python.org" // 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 // TestCNAMEIntoAnotherZonePortAndTLSChecks checks cnameHost against
// live DNS. Its port and TLS checks must use the addresses at the end // live DNS. Its port and TLS checks must use the addresses at the end
@@ -25,7 +27,7 @@ func TestCNAMEIntoAnotherZonePortAndTLSChecks(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{cnameHost} cfg.Hostnames = []string{cnameHost}
deps := runChecks(t, cfg, nil, nil) _, deps := runChecks(t, cfg, nil)
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
hs := snap.Hostnames[cnameHost] hs := snap.Hostnames[cnameHost]
@@ -118,16 +120,17 @@ func followLive(
// TestCNAMEAddressesOfEveryTarget gives a name's two nameservers // TestCNAMEAddressesOfEveryTarget gives a name's two nameservers
// different CNAME targets, as when a secondary still serves an old one. // 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 // 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.google has 8.8.8.8. // 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) { func TestCNAMEAddressesOfEveryTarget(t *testing.T) {
t.Parallel() t.Parallel()
found := followLive(t, map[string]map[string][]string{ found := followLive(t, map[string]map[string][]string{
nsA: cnameTo("one.one.one.one."), nsA: cnameTo("one.one.one.one."),
nsB: cnameTo("dns.google."), nsB: cnameTo("dns.adguard-dns.com."),
}) })
for _, ip := range []string{"1.1.1.1", "8.8.8.8"} { for _, ip := range []string{"1.1.1.1", "94.140.14.14"} {
if !slices.Contains(found, ip) { if !slices.Contains(found, ip) {
t.Errorf("saved %v, want %s among them", found, ip) t.Errorf("saved %v, want %s among them", found, ip)
} }
@@ -141,7 +144,7 @@ func TestCNAMEChainEndingInNoAddressSavesEmptyList(t *testing.T) {
t.Parallel() t.Parallel()
found := followLive(t, map[string]map[string][]string{ found := followLive(t, map[string]map[string][]string{
nsA: cnameTo("this-surely-does-not-exist-xyz.google.com."), nsA: cnameTo("this-surely-does-not-exist-xyz.example.org."),
}) })
if found == nil || len(found) != 0 { if found == nil || len(found) != 0 {
+70 -69
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
}) })
@@ -371,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)
@@ -423,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()
@@ -463,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")
@@ -487,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}
@@ -497,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)
@@ -543,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)
@@ -566,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")
@@ -586,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(
@@ -611,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")
} }
@@ -783,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: {
@@ -792,7 +794,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
}, },
}, },
}) })
}, nil) })
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
@@ -923,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 \