Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f41601dd80 | ||
|
|
3182fc99a6 | ||
|
|
889e17459b | ||
|
|
a67fd20e4f | ||
|
|
dba932c9e3 |
+7
-7
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 \
|
||||||
|
|||||||
Reference in New Issue
Block a user