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