Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16c577d6d4 | ||
|
|
93c1fe15e3 | ||
|
|
6dd6043534 | ||
|
|
a93389e1a0 | ||
|
|
95b017eb3e | ||
|
|
1ab0b9f61d | ||
|
|
19f282c8b3 | ||
|
|
148e47d9c0 |
+66
-2
@@ -10,14 +10,20 @@ run:
|
|||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
|
enable:
|
||||||
|
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||||
|
# left to `default: all`, because it carries the module policy below.
|
||||||
|
- gomodguard_v2
|
||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
- depguard # Dependency allow/block lists
|
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
# Deprecated: the warning is attached to the old name, so it is
|
||||||
|
# silenced by disabling that name, not by enabling the successor.
|
||||||
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
settings:
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
@@ -28,6 +34,64 @@ linters:
|
|||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 100
|
threshold: 100
|
||||||
|
depguard:
|
||||||
|
# Test-support code must not be compiled into the shipped binary. A
|
||||||
|
# test-support package exists to hand a test privileges the program
|
||||||
|
# itself must never have, so a file that is not a test must not import
|
||||||
|
# one. Test files, and the files inside a package whose directory name
|
||||||
|
# ends in `test`, are where that code belongs, and are exempt.
|
||||||
|
#
|
||||||
|
# The deny list below is the one part of this file a repository is
|
||||||
|
# expected to extend, and the only part it may. depguard matches an
|
||||||
|
# import path against a list of prefixes, so it cannot be told "any path
|
||||||
|
# whose last segment ends in test"; a repository's own test-support
|
||||||
|
# packages have to be named here one at a time, by full import path,
|
||||||
|
# under a module path that differs from repository to repository. Add
|
||||||
|
# them; change nothing else.
|
||||||
|
rules:
|
||||||
|
test-support:
|
||||||
|
list-mode: lax
|
||||||
|
files:
|
||||||
|
- "$all"
|
||||||
|
- "!$test"
|
||||||
|
- "!**/*test/**"
|
||||||
|
deny:
|
||||||
|
- pkg: net/http/httptest
|
||||||
|
desc: >-
|
||||||
|
Test-support code belongs in test files and in packages whose
|
||||||
|
directory name ends in test, not in the shipped binary.
|
||||||
|
# Only decisions already recorded in the Go package defaults are
|
||||||
|
# listed here. Every entry matches the module path exactly.
|
||||||
|
gomodguard_v2:
|
||||||
|
blocked:
|
||||||
|
- module: github.com/rs/zerolog
|
||||||
|
recommendations:
|
||||||
|
- log/slog
|
||||||
|
reason: "Structured logging is stdlib log/slog."
|
||||||
|
# One entry per pre-fork module path, because the later releases
|
||||||
|
# are separate paths. A prefix match would be shorter but would
|
||||||
|
# also reach github.com/go-redis/redismock, the test double for
|
||||||
|
# the successor these entries recommend.
|
||||||
|
- module: github.com/go-redis/redis
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v7
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v8
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/sergi/go-diff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "No unified diff output; use go-udiff."
|
||||||
|
- module: github.com/hexops/gotextdiff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "Unmaintained fork; use go-udiff."
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|||||||
+12
-12
@@ -2,6 +2,7 @@
|
|||||||
# The linter is invoked directly rather than through `make lint`: that
|
# The linter is invoked directly rather than through `make lint`: that
|
||||||
# target shells out to `docker build -f Dockerfile.lint`, and there is
|
# target shells out to `docker build -f Dockerfile.lint`, and there is
|
||||||
# no docker daemon inside a docker build.
|
# no docker daemon inside a docker build.
|
||||||
|
# script/cibuild and script/docker name this stage in --no-cache-filter.
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ RUN make fmt-check
|
|||||||
RUN golangci-lint run --config .golangci.yml ./...
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
|
# script/cibuild and script/docker name this stage in --no-cache-filter.
|
||||||
# golang 1.25-alpine, 2026-02-28
|
# golang 1.25-alpine, 2026-02-28
|
||||||
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
||||||
|
|
||||||
@@ -39,18 +41,15 @@ RUN make build
|
|||||||
# alpine 3.21, 2026-02-28
|
# alpine 3.21, 2026-02-28
|
||||||
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata su-exec
|
||||||
|
|
||||||
COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher
|
COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher
|
||||||
|
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Run as an unprivileged user that owns the data directory. A fresh named
|
# dnswatcher runs as this unprivileged user. The entrypoint creates the
|
||||||
# volume inherits this ownership; a bind-mounted host directory must be
|
# data directory and gives it to this user on every start.
|
||||||
# owned by uid 10001 (see "Running under upaas" in README.md), or startup
|
|
||||||
# fails.
|
|
||||||
RUN addgroup -S -g 10001 dnswatcher \
|
RUN addgroup -S -g 10001 dnswatcher \
|
||||||
&& adduser -S -G dnswatcher -u 10001 dnswatcher \
|
&& adduser -S -G dnswatcher -u 10001 dnswatcher
|
||||||
&& mkdir -p /var/lib/dnswatcher \
|
|
||||||
&& chown dnswatcher:dnswatcher /var/lib/dnswatcher
|
|
||||||
|
|
||||||
ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
|
ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
|
||||||
|
|
||||||
@@ -60,14 +59,15 @@ ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
|
|||||||
# data directory, or the binary's directory, the working directory.
|
# data directory, or the binary's directory, the working directory.
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
USER dnswatcher
|
# No USER: the entrypoint must start as root to set up the data
|
||||||
|
# directory; it then runs dnswatcher as the dnswatcher user.
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# busybox wget (already in alpine) probes the health endpoint. The first
|
# busybox wget (already in alpine) probes the health endpoint every 10
|
||||||
# probe runs one interval after start. upaas reads the container's health
|
# seconds, so the container is healthy well before upaas reads its health
|
||||||
# 60 seconds after a deploy and fails the deploy unless it is healthy.
|
# 60 seconds after a deploy and fails the deploy unless it is healthy.
|
||||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" || exit 1
|
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/dnswatcher"]
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ rejected.
|
|||||||
record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
||||||
- Stores results **per nameserver**. The state for a hostname is not a
|
- Stores results **per nameserver**. The state for a hostname is not a
|
||||||
merged view — it is a map from nameserver to record set.
|
merged view — it is a map from nameserver to record set.
|
||||||
|
- DNS names inside record values (CNAME, MX, SRV and NS targets) are
|
||||||
|
stored in lower case, because names are case-insensitive and
|
||||||
|
nameservers may answer in any letter case. TXT and CAA values keep
|
||||||
|
their letter case; they are not lower-cased.
|
||||||
- Any observable change in any nameserver's response triggers a
|
- Any observable change in any nameserver's response triggers a
|
||||||
notification. This includes:
|
notification. This includes:
|
||||||
- **Record change**: A nameserver returns different records than it
|
- **Record change**: A nameserver returns different records than it
|
||||||
@@ -70,8 +74,15 @@ rejected.
|
|||||||
This is distinct from "responded with no records."
|
This is distinct from "responded with no records."
|
||||||
- **NS recovery**: A previously-unreachable nameserver starts
|
- **NS recovery**: A previously-unreachable nameserver starts
|
||||||
responding again.
|
responding again.
|
||||||
- **Inconsistency detected**: Two nameservers that previously agreed
|
- **Inconsistency detected**: Two nameservers return different record
|
||||||
now return different record sets for the same hostname.
|
sets for the same hostname and did not already differ on the previous
|
||||||
|
check. Every pair of nameservers is compared. The alert is sent once
|
||||||
|
for each such pair, on the check where they start to disagree, and not
|
||||||
|
again while they keep disagreeing, including after a restart. A
|
||||||
|
nameserver that was not in the previous check (newly added, or back
|
||||||
|
after dropping out) and answers differently is reported on the check
|
||||||
|
where it appears. If a pair agrees again and later disagrees, the
|
||||||
|
alert is sent again.
|
||||||
|
|
||||||
### TCP Port Monitoring
|
### TCP Port Monitoring
|
||||||
|
|
||||||
@@ -267,6 +278,8 @@ internal/
|
|||||||
tlscheck/tlscheck.go TLS certificate inspector
|
tlscheck/tlscheck.go TLS certificate inspector
|
||||||
notify/notify.go Notification service (Slack, Mattermost, ntfy)
|
notify/notify.go Notification service (Slack, Mattermost, ntfy)
|
||||||
watcher/watcher.go Main monitoring orchestrator and scheduler
|
watcher/watcher.go Main monitoring orchestrator and scheduler
|
||||||
|
livedns/livedns.go Retry and concurrency limit for tests
|
||||||
|
against live DNS (imported only by tests)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Design Principles
|
### Design Principles
|
||||||
@@ -465,9 +478,13 @@ them. We provide:
|
|||||||
- `script/fmt` — format all code (gofmt -s, goimports)
|
- `script/fmt` — format all code (gofmt -s, goimports)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
- `script/docker` — build the Docker image tagged via
|
- `script/docker` — build the Docker image tagged via `script/projectname`, with
|
||||||
`script/projectname`
|
`--no-cache-filter=lint,builder` so the lint stage and the builder stage,
|
||||||
- `script/cibuild` — CI entrypoint: plain `docker build .`
|
which runs the tests, run on every invocation
|
||||||
|
- `script/cibuild` — CI entrypoint: `docker build` with
|
||||||
|
`--no-cache-filter=lint,builder`, so the lint stage and the builder stage,
|
||||||
|
which runs the tests, run on every invocation, because a cached build lints
|
||||||
|
nothing and queries no DNS
|
||||||
- `script/precommit` — run by the git pre-commit hook; `go mod tidy`
|
- `script/precommit` — run by the git pre-commit hook; `go mod tidy`
|
||||||
guard, then `script/check`
|
guard, then `script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook
|
- `script/install-precommit` — install the git pre-commit hook
|
||||||
@@ -516,17 +533,7 @@ repository's `Dockerfile` and runs it. The app needs:
|
|||||||
- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
|
- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
|
||||||
`prod` pull request is a deploy.
|
`prod` pull request is a deploy.
|
||||||
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
|
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
|
||||||
the state file lives. upaas bind-mounts the host path it is given and
|
the state file lives.
|
||||||
does not create it. The container runs as uid 10001 and does not start
|
|
||||||
unless it can write there. Create the directory before the first
|
|
||||||
deploy:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mkdir -p /path/to/data
|
|
||||||
chown 10001:10001 /path/to/data
|
|
||||||
chmod 700 /path/to/data
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Network and port:** the dashboard is unauthenticated and shows every
|
- **Network and port:** the dashboard is unauthenticated and shows every
|
||||||
watched name and recent alert, and upaas publishes every mapped port on
|
watched name and recent alert, and upaas publishes every mapped port on
|
||||||
all interfaces of the host
|
all interfaces of the host
|
||||||
|
|||||||
+12
-4
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
## DNS Resolution Tests
|
## DNS Resolution Tests
|
||||||
|
|
||||||
All resolver tests **MUST** use live queries against real DNS servers.
|
DNS is never mocked in this project, not in tests and not anywhere
|
||||||
No mocking of the DNS client layer is permitted.
|
else; see the README section "No DNS mocking. Ever." Every test that
|
||||||
|
looks something up in DNS **MUST** query live DNS servers, never a
|
||||||
|
stand-in. Logic that works on record data, such as comparing or
|
||||||
|
formatting records, may be tested on that data directly with no
|
||||||
|
lookup.
|
||||||
|
|
||||||
### Rationale
|
### Rationale
|
||||||
|
|
||||||
@@ -21,14 +25,18 @@ real servers ensures the resolver works correctly in production.
|
|||||||
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
|
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
|
||||||
plus processing margin
|
plus processing margin
|
||||||
- Root server fan-out is limited to reduce parallel query load
|
- Root server fan-out is limited to reduce parallel query load
|
||||||
|
- Live lookups that expect an answer go through `internal/livedns`,
|
||||||
|
which limits how many run at once in a test binary and retries a
|
||||||
|
lookup that got none
|
||||||
- Flaky failures from transient network issues are acceptable and
|
- Flaky failures from transient network issues are acceptable and
|
||||||
should be investigated as potential resolver bugs, not papered over
|
should be investigated as potential resolver bugs, not papered over
|
||||||
with mocks or skip flags
|
with mocks or skip flags
|
||||||
|
|
||||||
### What NOT to do
|
### What NOT to do
|
||||||
|
|
||||||
- **Do not mock `DNSClient`** for resolver tests (the mock constructor
|
- **Do not mock, fake or stub DNS** anywhere: no stand-in `DNSClient`,
|
||||||
exists for unit-testing other packages that consume the resolver)
|
no stand-in for the watcher's `DNSResolver`, no fake DNS server, no
|
||||||
|
canned responses
|
||||||
- **Do not add `-short` flags** to skip slow tests
|
- **Do not add `-short` flags** to skip slow tests
|
||||||
- **Do not increase `-timeout`** to hide hanging queries
|
- **Do not increase `-timeout`** to hide hanging queries
|
||||||
- **Do not remove `-count=1` from `script/test`** — Go's test cache
|
- **Do not remove `-count=1` from `script/test`** — Go's test cache
|
||||||
|
|||||||
@@ -10,11 +10,7 @@
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. No git tags. Core resolver work in flight on feature/resolver
|
pre-1.0. No git tags.
|
||||||
(dirty: internal/resolver/resolver_test.go). Local checkout has diverged
|
|
||||||
from origin: origin/main is 8 commits ahead (watcher orchestrator,
|
|
||||||
unified TARGETS) and origin/feature/resolver already contains the full
|
|
||||||
iterative resolver implementation with hermetic mocked tests.
|
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
@@ -23,6 +19,38 @@ Rationale, Design, TODO, License, Author) if any are still missing.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-29: the image sets up its own data directory. Its entrypoint,
|
||||||
|
`deploy/docker-entrypoint.sh`, starts as root, creates the data directory if
|
||||||
|
needed, gives it and everything in it to the `dnswatcher` user with mode 700
|
||||||
|
on the directory, then runs dnswatcher as that user with `su-exec`. A
|
||||||
|
bind-mounted host directory no longer has to be chowned first (closes #166).
|
||||||
|
- 2026-09-29: `.golangci.yml` re-fetched unchanged from `sneak/prompts`. It
|
||||||
|
replaces the deprecated `gomodguard` with `gomodguard_v2`, so `make lint` no
|
||||||
|
longer warns about it, and turns on `depguard` with the org `test-support`
|
||||||
|
rule, which rejects `net/http/httptest` except in test files and in files
|
||||||
|
under a directory whose name ends in `test`. This repo had no `deny` entries
|
||||||
|
of its own to carry forward (closes #123).
|
||||||
|
- 2026-09-29: nothing stands in for DNS any more. Watcher tests that look
|
||||||
|
something up in DNS use the real resolver against live DNS servers and test
|
||||||
|
record and nameserver changes by preparing the saved state a check starts
|
||||||
|
from; the resolver timeout test queries an address that never answers, and
|
||||||
|
`NewFromLoggerWithClient`, used only by its stand-in client, is gone. The
|
||||||
|
live-DNS retry and concurrency limit moved to `internal/livedns`, which both
|
||||||
|
test packages use. `TESTING.md` states the README's rule (closes #159).
|
||||||
|
- 2026-09-28: the inconsistency alert is sent once, on the check where two
|
||||||
|
nameservers start to disagree or where a nameserver that disagrees first
|
||||||
|
appears, instead of on every check while they disagree, and not again after
|
||||||
|
a restart. Every pair of nameservers is compared, not only neighbours in
|
||||||
|
sorted order of name (closes #158).
|
||||||
|
- 2026-09-28: DNS names in record values (CNAME, MX, SRV and NS targets) are
|
||||||
|
lower-cased, so nameservers that answer in different letter case no longer
|
||||||
|
count as inconsistent or as a record change (closes #157).
|
||||||
|
- 2026-09-28: `script/cibuild` and `script/docker` now pass
|
||||||
|
`--no-cache-filter=lint,builder` so lint and tests run every build (closes
|
||||||
|
#115).
|
||||||
|
- 2026-09-28: the server timeout test now drives `Run` and checks the
|
||||||
|
`http.Server` it serves carries the timeouts; corrected the `ReadTimeout`
|
||||||
|
note in that test (closes #120).
|
||||||
- 2026-09-28: upaas deploy readiness — runtime image runs as unprivileged
|
- 2026-09-28: upaas deploy readiness — runtime image runs as unprivileged
|
||||||
`dnswatcher`, Docker `HEALTHCHECK`, startup fails when the data directory is
|
`dnswatcher`, Docker `HEALTHCHECK`, startup fails when the data directory is
|
||||||
not writable, README "Running under upaas" (closes #147).
|
not writable, README "Running under upaas" (closes #147).
|
||||||
@@ -247,6 +275,5 @@ Infrastructure notes (from untracked TODO.md):
|
|||||||
- Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de
|
- Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de
|
||||||
remote intentionally; do not "fix" it
|
remote intentionally; do not "fix" it
|
||||||
- Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix
|
- Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix
|
||||||
- Resolver tests originally used live DNS against *.dns.sneak.cloud
|
- DNS is never mocked; tests that look something up in DNS query live DNS
|
||||||
(required records documented in the test file header); origin now has
|
servers (README, "No DNS mocking. Ever.")
|
||||||
mocked hermetic tests, keep them hermetic
|
|
||||||
|
|||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# deploy/docker-entrypoint.sh: the Docker image's ENTRYPOINT. It runs as
|
||||||
|
# root only to give the data directory to the dnswatcher user: a host
|
||||||
|
# directory bind-mounted there keeps its host owner, often root, and may
|
||||||
|
# hold a state file left by another uid, which dnswatcher could neither
|
||||||
|
# read nor replace. dnswatcher itself always runs as the dnswatcher user.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
main() {
|
||||||
|
dir="${DNSWATCHER_DATA_DIR:-/var/lib/dnswatcher}"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
chown -R dnswatcher:dnswatcher "$dir"
|
||||||
|
chmod 700 "$dir"
|
||||||
|
exec su-exec dnswatcher /usr/local/bin/dnswatcher "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Package livedns runs the live DNS operations of tests. Tests that
|
||||||
|
// look something up in DNS query live DNS servers, never a stand-in —
|
||||||
|
// see TESTING.md. Nothing here mocks, fakes, stubs, records or replays
|
||||||
|
// DNS, and nothing here skips a test: it only changes *how* the live
|
||||||
|
// queries are issued, so that a single dropped UDP packet or one slow
|
||||||
|
// authoritative server does not turn correct code into a red build.
|
||||||
|
//
|
||||||
|
// Two mechanisms:
|
||||||
|
//
|
||||||
|
// 1. Bounded concurrency. Tests run in parallel and the build hosts
|
||||||
|
// have many cores, so without a limit every test starts its own
|
||||||
|
// iterative resolution at the same instant and they all hit the
|
||||||
|
// first root server within a few milliseconds of each other. Root
|
||||||
|
// servers rate-limit that, which shows up as a different arbitrary
|
||||||
|
// subset of tests failing on each run. Run caps how many live
|
||||||
|
// operations are in flight at once in one test binary.
|
||||||
|
//
|
||||||
|
// 2. Retry with exponential backoff. Each live operation gets several
|
||||||
|
// attempts with its own timeout. An attempt is retried when it
|
||||||
|
// obtained nothing to check, never because of what the test
|
||||||
|
// asserts about the result, so a wrong result still fails on the
|
||||||
|
// first attempt. A fault in the code under test that leaves
|
||||||
|
// nothing to check looks the same as live DNS not answering, and
|
||||||
|
// fails only after the last attempt.
|
||||||
|
package livedns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// attempts is how many times a live DNS operation is attempted
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// backoffBase is the delay after the first failed attempt; it is
|
||||||
|
// multiplied by backoffFactor each time.
|
||||||
|
backoffBase = 500 * time.Millisecond
|
||||||
|
|
||||||
|
// backoffFactor is the exponential backoff multiplier.
|
||||||
|
backoffFactor = 2
|
||||||
|
|
||||||
|
// Concurrency caps how many live operations may be in flight
|
||||||
|
// across one test binary at once.
|
||||||
|
Concurrency = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// gate bounds concurrent live operations. It has to be package scoped:
|
||||||
|
// the whole point is that it is shared by every parallel test in the
|
||||||
|
// test binary.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // package-wide live query rate limit
|
||||||
|
var gate = make(chan struct{}, Concurrency)
|
||||||
|
|
||||||
|
// ErrNoAnswer reports that a live operation produced no usable answer,
|
||||||
|
// which is retried rather than asserted on.
|
||||||
|
var ErrNoAnswer = errors.New("no answer from live DNS")
|
||||||
|
|
||||||
|
// Run executes one attempt of a live operation, holding a slot in gate
|
||||||
|
// for its duration and bounding it with its own timeout.
|
||||||
|
func Run(op func(ctx context.Context) error) error {
|
||||||
|
gate <- struct{}{}
|
||||||
|
defer func() { <-gate }()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(
|
||||||
|
context.Background(), AttemptTimeout,
|
||||||
|
)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return op(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry runs op until it reports success, retrying failures with
|
||||||
|
// exponential backoff, and fails the test if every attempt fails. op
|
||||||
|
// returns an error only for a failure to obtain an answer — never for
|
||||||
|
// an answer the test disagrees with, which belongs in an assertion so
|
||||||
|
// that it fails immediately. op stores whatever it obtained where its
|
||||||
|
// caller can find it.
|
||||||
|
func Retry(
|
||||||
|
t *testing.T,
|
||||||
|
what string,
|
||||||
|
op func(ctx context.Context) error,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var last error
|
||||||
|
|
||||||
|
backoff := backoffBase
|
||||||
|
|
||||||
|
for attempt := range attempts {
|
||||||
|
if attempt > 0 {
|
||||||
|
t.Logf(
|
||||||
|
"%s: attempt %d of %d failed (%v), "+
|
||||||
|
"retrying in %s",
|
||||||
|
what, attempt, attempts, last, backoff,
|
||||||
|
)
|
||||||
|
time.Sleep(backoff)
|
||||||
|
|
||||||
|
backoff *= backoffFactor
|
||||||
|
}
|
||||||
|
|
||||||
|
last = Run(op)
|
||||||
|
if last == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf(
|
||||||
|
"%s: all %d live attempts failed: %v",
|
||||||
|
what, attempts, last,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package livedns_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/livedns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tests for the retry and the concurrency limit themselves. They
|
||||||
|
// perform no DNS resolution of any kind.
|
||||||
|
|
||||||
|
func TestRetryRecoversFromTransientFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const wantAttempts = 2
|
||||||
|
|
||||||
|
attempts := 0
|
||||||
|
|
||||||
|
livedns.Retry(t, "transient", func(_ context.Context) error {
|
||||||
|
attempts++
|
||||||
|
|
||||||
|
if attempts < wantAttempts {
|
||||||
|
return livedns.ErrNoAnswer
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, wantAttempts, attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryGivesEachAttemptADeadline(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
livedns.Retry(t, "deadline", func(ctx context.Context) error {
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
assert.True(t, ok, "attempt should carry a deadline")
|
||||||
|
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
|
||||||
|
assert.LessOrEqual(t, remaining, livedns.AttemptTimeout)
|
||||||
|
|
||||||
|
// Lower bound too: without one this passes for a
|
||||||
|
// deadline far shorter than intended, which would
|
||||||
|
// silently turn every live attempt into an instant
|
||||||
|
// timeout.
|
||||||
|
assert.Greater(t, remaining, livedns.AttemptTimeout/2)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBoundsConcurrency(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const workers = 24
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
inFlight int
|
||||||
|
maxSeen int
|
||||||
|
)
|
||||||
|
|
||||||
|
wg.Add(workers)
|
||||||
|
|
||||||
|
for range workers {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
_ = livedns.Run(func(_ context.Context) error {
|
||||||
|
mu.Lock()
|
||||||
|
inFlight++
|
||||||
|
|
||||||
|
if inFlight > maxSeen {
|
||||||
|
maxSeen = inFlight
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
inFlight--
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.Positive(t, maxSeen)
|
||||||
|
assert.LessOrEqual(
|
||||||
|
t, maxSeen, livedns.Concurrency,
|
||||||
|
"live queries must stay under the package-wide gate",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DNSClient abstracts DNS wire-protocol exchanges so the resolver
|
// DNSClient sends one DNS message to a nameserver and returns the
|
||||||
// can be tested without hitting real nameservers.
|
// reply. The resolver holds one for UDP and one for TCP.
|
||||||
type DNSClient interface {
|
type DNSClient interface {
|
||||||
ExchangeContext(
|
ExchangeContext(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import "github.com/miekg/dns"
|
||||||
|
|
||||||
|
// ExtractRecordValue exports extractRecordValue for testing.
|
||||||
|
func ExtractRecordValue(rr dns.RR) string {
|
||||||
|
return extractRecordValue(rr)
|
||||||
|
}
|
||||||
@@ -608,7 +608,10 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractRecordValue formats a DNS RR value as a string.
|
// extractRecordValue formats a DNS RR value as a string. DNS names
|
||||||
|
// are case-insensitive and nameservers may answer in any letter case,
|
||||||
|
// so names are lower-cased to compare equal. TXT and CAA values keep
|
||||||
|
// their letter case.
|
||||||
func extractRecordValue(rr dns.RR) string {
|
func extractRecordValue(rr dns.RR) string {
|
||||||
switch r := rr.(type) {
|
switch r := rr.(type) {
|
||||||
case *dns.A:
|
case *dns.A:
|
||||||
@@ -616,22 +619,22 @@ func extractRecordValue(rr dns.RR) string {
|
|||||||
case *dns.AAAA:
|
case *dns.AAAA:
|
||||||
return r.AAAA.String()
|
return r.AAAA.String()
|
||||||
case *dns.CNAME:
|
case *dns.CNAME:
|
||||||
return r.Target
|
return strings.ToLower(r.Target)
|
||||||
case *dns.MX:
|
case *dns.MX:
|
||||||
return fmt.Sprintf("%d %s", r.Preference, r.Mx)
|
return fmt.Sprintf("%d %s", r.Preference, strings.ToLower(r.Mx))
|
||||||
case *dns.TXT:
|
case *dns.TXT:
|
||||||
return strings.Join(r.Txt, "")
|
return strings.Join(r.Txt, "")
|
||||||
case *dns.SRV:
|
case *dns.SRV:
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"%d %d %d %s",
|
"%d %d %d %s",
|
||||||
r.Priority, r.Weight, r.Port, r.Target,
|
r.Priority, r.Weight, r.Port, strings.ToLower(r.Target),
|
||||||
)
|
)
|
||||||
case *dns.CAA:
|
case *dns.CAA:
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"%d %s \"%s\"", r.Flag, r.Tag, r.Value,
|
"%d %s \"%s\"", r.Flag, r.Tag, r.Value,
|
||||||
)
|
)
|
||||||
case *dns.NS:
|
case *dns.NS:
|
||||||
return r.Ns
|
return strings.ToLower(r.Ns)
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package resolver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/resolver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rr dns.RR
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "MX target lower-cased",
|
||||||
|
rr: &dns.MX{Preference: 1, Mx: "ASPMX.L.GOOGLE.COM."},
|
||||||
|
want: "1 aspmx.l.google.com.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NS target lower-cased",
|
||||||
|
rr: &dns.NS{Ns: "x.ns.joker.COM."},
|
||||||
|
want: "x.ns.joker.com.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CNAME target lower-cased",
|
||||||
|
rr: &dns.CNAME{Target: "WWW.Example.Com."},
|
||||||
|
want: "www.example.com.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SRV target lower-cased",
|
||||||
|
rr: &dns.SRV{
|
||||||
|
Priority: 10, Weight: 5, Port: 443,
|
||||||
|
Target: "SIP.Example.Com.",
|
||||||
|
},
|
||||||
|
want: "10 5 443 sip.example.com.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TXT value keeps its case",
|
||||||
|
rr: &dns.TXT{Txt: []string{"Verify=AbC123"}},
|
||||||
|
want: "Verify=AbC123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CAA value keeps its case",
|
||||||
|
rr: &dns.CAA{Flag: 0, Tag: "issue", Value: "LetsEncrypt.org"},
|
||||||
|
want: `0 issue "LetsEncrypt.org"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want, resolver.ExtractRecordValue(tt.rr))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package resolver_test
|
package resolver_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
@@ -12,9 +9,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Tests for the live-DNS harness in livedns_test.go itself. These
|
// Tests for the live-DNS harness in livedns_test.go itself. These
|
||||||
// exercise pure logic and the retry/concurrency plumbing; they
|
// exercise pure logic; they perform no DNS resolution of any kind, so
|
||||||
// perform no DNS resolution of any kind, so they neither mock DNS
|
// they neither mock DNS nor depend on it.
|
||||||
// nor depend on it.
|
|
||||||
|
|
||||||
// Names for the synthetic status maps below. Nothing is ever queried
|
// Names for the synthetic status maps below. Nothing is ever queried
|
||||||
// at them: they are map keys handed to the package's pure counting
|
// at them: they are map keys handed to the package's pure counting
|
||||||
@@ -90,47 +86,6 @@ func TestStatusCountingIgnoresSilentNameservers(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRetryLiveRecoversFromTransientFailure(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const wantAttempts = 2
|
|
||||||
|
|
||||||
attempts := 0
|
|
||||||
|
|
||||||
retryLive(t, "transient", func(_ context.Context) error {
|
|
||||||
attempts++
|
|
||||||
|
|
||||||
if attempts < wantAttempts {
|
|
||||||
return errLiveNoAnswer
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, wantAttempts, attempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryLiveGivesEachAttemptADeadline(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
retryLive(t, "deadline", func(ctx context.Context) error {
|
|
||||||
deadline, ok := ctx.Deadline()
|
|
||||||
assert.True(t, ok, "attempt should carry a deadline")
|
|
||||||
|
|
||||||
remaining := time.Until(deadline)
|
|
||||||
|
|
||||||
assert.LessOrEqual(t, remaining, liveAttemptTimeout)
|
|
||||||
|
|
||||||
// Lower bound too: without one this passes for a
|
|
||||||
// deadline far shorter than intended, which would
|
|
||||||
// silently turn every live attempt into an instant
|
|
||||||
// timeout.
|
|
||||||
assert.Greater(t, remaining, liveAttemptTimeout/2)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnsanctionedStatusesRejectsWrongAnswers is the regression test
|
// TestUnsanctionedStatusesRejectsWrongAnswers is the regression test
|
||||||
// for the defect this allowlist exists to prevent: a minority of
|
// for the defect this allowlist exists to prevent: a minority of
|
||||||
// nameservers answering WRONGLY while quorum keeps the suite green.
|
// nameservers answering WRONGLY while quorum keeps the suite green.
|
||||||
@@ -235,50 +190,3 @@ func TestUnsanctionedStatusesToleratesSilenceOnly(t *testing.T) {
|
|||||||
unsanctionedStatuses(results, allowed...),
|
unsanctionedStatuses(results, allowed...),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunLiveBoundsConcurrency(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const workers = 24
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
wg sync.WaitGroup
|
|
||||||
inFlight int
|
|
||||||
maxSeen int
|
|
||||||
)
|
|
||||||
|
|
||||||
wg.Add(workers)
|
|
||||||
|
|
||||||
for range workers {
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
_ = runLive(func(_ context.Context) error {
|
|
||||||
mu.Lock()
|
|
||||||
inFlight++
|
|
||||||
|
|
||||||
if inFlight > maxSeen {
|
|
||||||
maxSeen = inFlight
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
inFlight--
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
assert.Positive(t, maxSeen)
|
|
||||||
assert.LessOrEqual(
|
|
||||||
t, maxSeen, liveConcurrency,
|
|
||||||
"live queries must stay under the package-wide gate",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/livedns"
|
||||||
"sneak.berlin/go/dnswatcher/internal/resolver"
|
"sneak.berlin/go/dnswatcher/internal/resolver"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,144 +17,34 @@ import (
|
|||||||
// Live DNS test support
|
// Live DNS test support
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Every test in this package resolves against the real, live DNS —
|
// Tests that look something up in DNS query live DNS servers, never a
|
||||||
// see TESTING.md. Nothing here mocks, fakes, stubs, records or
|
// stand-in; logic that works on record data may be tested on that
|
||||||
// replays DNS, and nothing here skips or gates a test: the helpers
|
// data with no lookup (see TESTING.md). Each live operation below goes
|
||||||
// below only change *how* the live queries are issued, so that a
|
// through livedns.Retry, which bounds how many resolutions are in
|
||||||
// single dropped UDP packet or one slow authoritative server does
|
// flight at once and retries an operation that got no answer (see
|
||||||
// not turn a correct resolver into a red build.
|
// package livedns).
|
||||||
//
|
//
|
||||||
// Three mechanisms, all test-side:
|
// Where an assertion spans several independent nameservers, a quorum
|
||||||
|
// is enough: a strict majority answering as expected. A server that
|
||||||
|
// fails to answer is tolerated, while a server that answers *wrongly*
|
||||||
|
// still fails the test.
|
||||||
//
|
//
|
||||||
// 1. Bounded concurrency. The package's tests are parallel and the
|
// That tolerance is expressed as an ALLOWLIST of sanctioned statuses,
|
||||||
// build hosts have many cores, so without a limit every test
|
// never as a blocklist of known-bad ones. A blocklist bans the one
|
||||||
// starts its own iterative resolution at the same instant and
|
// wrong answer its author thought of and silently admits every other
|
||||||
// they all hit the first root server in rootServerList() within
|
// status, including any added to the resolver later; an allowlist
|
||||||
// a few milliseconds of each other. Root servers rate-limit
|
// fails on anything nobody explicitly sanctioned. Silence (timeout,
|
||||||
// that, which shows up as a different arbitrary subset of tests
|
// error) is the only thing quorum exists to tolerate. A *wrong
|
||||||
// failing on each run. liveGate caps how many resolutions are
|
// answer* — nxdomain for a name that exists, ok for one that does
|
||||||
// in flight at once.
|
// not, nodata for either — is never tolerated at any count.
|
||||||
//
|
|
||||||
// 2. Retry with exponential backoff. Each live operation gets
|
|
||||||
// several attempts with its own timeout. The retry predicate is
|
|
||||||
// strictly transport-level — "did a nameserver answer at all" —
|
|
||||||
// never the assertion the test is making. A resolver that
|
|
||||||
// answers incorrectly still fails on the first attempt.
|
|
||||||
//
|
|
||||||
// 3. Quorum. Where an assertion spans several independent
|
|
||||||
// nameservers, a strict majority answering as expected is
|
|
||||||
// enough; a server that fails to answer is tolerated, while a
|
|
||||||
// server that answers *wrongly* still fails the test.
|
|
||||||
//
|
|
||||||
// The tolerance in (3) is expressed as an ALLOWLIST of sanctioned
|
|
||||||
// statuses, never as a blocklist of known-bad ones. A blocklist bans
|
|
||||||
// the one wrong answer its author thought of and silently admits
|
|
||||||
// every other status, including any added to the resolver later; an
|
|
||||||
// allowlist fails on anything nobody explicitly sanctioned. Silence
|
|
||||||
// (timeout, error) is the only thing quorum exists to tolerate. A
|
|
||||||
// *wrong answer* — nxdomain for a name that exists, ok for one that
|
|
||||||
// does not, nodata for either — is never tolerated at any count.
|
|
||||||
|
|
||||||
const (
|
// minNameservers is the smallest nameserver count a well-run zone is
|
||||||
// liveAttempts is how many times a live DNS operation is
|
// expected to publish.
|
||||||
// attempted before the test fails.
|
const minNameservers = 2
|
||||||
liveAttempts = 3
|
|
||||||
|
|
||||||
// liveAttemptTimeout bounds one attempt. Worst case for an
|
// errLiveNoQuorum reports that too few of a domain's nameservers
|
||||||
// operation is liveAttempts * liveAttemptTimeout plus the
|
// answered for a quorum assertion to be made.
|
||||||
// backoff — about 26 seconds, well inside the 90-second
|
var errLiveNoQuorum = errors.New("no nameserver quorum")
|
||||||
// `go test -timeout` backstop even when several operations
|
|
||||||
// exhaust their attempts.
|
|
||||||
liveAttemptTimeout = 8 * time.Second
|
|
||||||
|
|
||||||
// liveBackoffBase is the delay after the first failed
|
|
||||||
// attempt; it is multiplied by liveBackoffFactor each time.
|
|
||||||
liveBackoffBase = 500 * time.Millisecond
|
|
||||||
|
|
||||||
// liveBackoffFactor is the exponential backoff multiplier.
|
|
||||||
liveBackoffFactor = 2
|
|
||||||
|
|
||||||
// liveConcurrency caps how many live resolutions may be in
|
|
||||||
// flight across the whole package at once.
|
|
||||||
liveConcurrency = 6
|
|
||||||
|
|
||||||
// minNameservers is the smallest nameserver count a
|
|
||||||
// well-run zone is expected to publish.
|
|
||||||
minNameservers = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
// liveGate bounds concurrent live resolutions package-wide. It has
|
|
||||||
// to be package scoped: the whole point is that it is shared by
|
|
||||||
// every parallel test in the package.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // package-wide live query rate limit
|
|
||||||
var liveGate = make(chan struct{}, liveConcurrency)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// errLiveNoAnswer reports that a live operation produced no
|
|
||||||
// usable answer, which is retried rather than asserted on.
|
|
||||||
errLiveNoAnswer = errors.New("no answer from live DNS")
|
|
||||||
|
|
||||||
// errLiveNoQuorum reports that too few of a domain's
|
|
||||||
// nameservers answered for a quorum assertion to be made.
|
|
||||||
errLiveNoQuorum = errors.New("no nameserver quorum")
|
|
||||||
)
|
|
||||||
|
|
||||||
// runLive executes one attempt of a live operation, holding a slot
|
|
||||||
// in liveGate for its duration and bounding it with its own
|
|
||||||
// timeout.
|
|
||||||
func runLive(op func(ctx context.Context) error) error {
|
|
||||||
liveGate <- struct{}{}
|
|
||||||
defer func() { <-liveGate }()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(
|
|
||||||
context.Background(), liveAttemptTimeout,
|
|
||||||
)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return op(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// retryLive runs op until it reports success, retrying transport
|
|
||||||
// failures with exponential backoff, and fails the test if every
|
|
||||||
// attempt fails. op returns an error only for a failure to obtain
|
|
||||||
// an answer — never for an answer the test disagrees with, which
|
|
||||||
// belongs in an assertion so that it fails immediately. op stores
|
|
||||||
// whatever it obtained where its caller can find it.
|
|
||||||
func retryLive(
|
|
||||||
t *testing.T,
|
|
||||||
what string,
|
|
||||||
op func(ctx context.Context) error,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var last error
|
|
||||||
|
|
||||||
backoff := liveBackoffBase
|
|
||||||
|
|
||||||
for attempt := range liveAttempts {
|
|
||||||
if attempt > 0 {
|
|
||||||
t.Logf(
|
|
||||||
"%s: attempt %d of %d failed (%v), "+
|
|
||||||
"retrying in %s",
|
|
||||||
what, attempt, liveAttempts, last, backoff,
|
|
||||||
)
|
|
||||||
time.Sleep(backoff)
|
|
||||||
|
|
||||||
backoff *= liveBackoffFactor
|
|
||||||
}
|
|
||||||
|
|
||||||
last = runLive(op)
|
|
||||||
if last == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Fatalf(
|
|
||||||
"%s: no answer after %d live attempts: %v",
|
|
||||||
what, liveAttempts, last,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// liveQuorum is how many of total nameservers must agree for a
|
// liveQuorum is how many of total nameservers must agree for a
|
||||||
// multi-nameserver assertion to hold: a strict majority.
|
// multi-nameserver assertion to hold: a strict majority.
|
||||||
@@ -272,7 +162,7 @@ func liveFindAuthoritative(
|
|||||||
|
|
||||||
var out []string
|
var out []string
|
||||||
|
|
||||||
retryLive(
|
livedns.Retry(
|
||||||
t,
|
t,
|
||||||
"FindAuthoritativeNameservers("+domain+")",
|
"FindAuthoritativeNameservers("+domain+")",
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
@@ -284,7 +174,7 @@ func liveFindAuthoritative(
|
|||||||
if len(ns) == 0 {
|
if len(ns) == 0 {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: %s has no nameservers",
|
"%w: %s has no nameservers",
|
||||||
errLiveNoAnswer, domain,
|
livedns.ErrNoAnswer, domain,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +198,7 @@ func liveLookupNS(
|
|||||||
|
|
||||||
var out []string
|
var out []string
|
||||||
|
|
||||||
retryLive(
|
livedns.Retry(
|
||||||
t,
|
t,
|
||||||
"LookupNS("+domain+")",
|
"LookupNS("+domain+")",
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
@@ -320,7 +210,7 @@ func liveLookupNS(
|
|||||||
if len(ns) == 0 {
|
if len(ns) == 0 {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: %s has no nameservers",
|
"%w: %s has no nameservers",
|
||||||
errLiveNoAnswer, domain,
|
livedns.ErrNoAnswer, domain,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +240,7 @@ func liveQueryNameserver(
|
|||||||
|
|
||||||
var out *resolver.NameserverResponse
|
var out *resolver.NameserverResponse
|
||||||
|
|
||||||
retryLive(
|
livedns.Retry(
|
||||||
t,
|
t,
|
||||||
what,
|
what,
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
@@ -365,7 +255,7 @@ func liveQueryNameserver(
|
|||||||
resp.Status == resolver.StatusError {
|
resp.Status == resolver.StatusError {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: %s returned %s: %s",
|
"%w: %s returned %s: %s",
|
||||||
errLiveNoAnswer, nameserver,
|
livedns.ErrNoAnswer, nameserver,
|
||||||
resp.Status, resp.Error,
|
resp.Status, resp.Error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -392,7 +282,7 @@ func liveQueryAllNameservers(
|
|||||||
|
|
||||||
var out map[string]*resolver.NameserverResponse
|
var out map[string]*resolver.NameserverResponse
|
||||||
|
|
||||||
retryLive(
|
livedns.Retry(
|
||||||
t,
|
t,
|
||||||
"QueryAllNameservers("+hostname+")",
|
"QueryAllNameservers("+hostname+")",
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
@@ -404,7 +294,7 @@ func liveQueryAllNameservers(
|
|||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: no nameservers queried for %s",
|
"%w: no nameservers queried for %s",
|
||||||
errLiveNoAnswer, hostname,
|
livedns.ErrNoAnswer, hostname,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +327,7 @@ func liveResolveIPs(
|
|||||||
|
|
||||||
var out []string
|
var out []string
|
||||||
|
|
||||||
retryLive(
|
livedns.Retry(
|
||||||
t,
|
t,
|
||||||
"ResolveIPAddresses("+hostname+")",
|
"ResolveIPAddresses("+hostname+")",
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
@@ -449,7 +339,7 @@ func liveResolveIPs(
|
|||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: no addresses for %s",
|
"%w: no addresses for %s",
|
||||||
errLiveNoAnswer, hostname,
|
livedns.ErrNoAnswer, hostname,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +366,7 @@ func liveResolveIPsAllowingEmpty(
|
|||||||
|
|
||||||
var out []string
|
var out []string
|
||||||
|
|
||||||
retryLive(
|
livedns.Retry(
|
||||||
t,
|
t,
|
||||||
"ResolveIPAddresses("+hostname+")",
|
"ResolveIPAddresses("+hostname+")",
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
|
|||||||
@@ -67,17 +67,4 @@ func NewFromLogger(log *slog.Logger) *Resolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFromLoggerWithClient creates a Resolver with a custom DNS
|
|
||||||
// client, useful for testing with mock DNS responses.
|
|
||||||
func NewFromLoggerWithClient(
|
|
||||||
log *slog.Logger,
|
|
||||||
client DNSClient,
|
|
||||||
) *Resolver {
|
|
||||||
return &Resolver{
|
|
||||||
log: log,
|
|
||||||
client: client,
|
|
||||||
tcp: client,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method implementations are in iterative.go.
|
// Method implementations are in iterative.go.
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -33,8 +32,8 @@ func newTestResolver(t *testing.T) *resolver.Resolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// findOneNSForDomain picks one authoritative nameserver to aim a
|
// findOneNSForDomain picks one authoritative nameserver to aim a
|
||||||
// test at. Live-DNS retry, concurrency and quorum handling live in
|
// test at. Quorum handling lives in livedns_test.go, and the live-DNS
|
||||||
// livedns_test.go.
|
// retry and concurrency limit in package livedns.
|
||||||
func findOneNSForDomain(
|
func findOneNSForDomain(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
r *resolver.Resolver,
|
r *resolver.Resolver,
|
||||||
@@ -526,21 +525,18 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
|
|||||||
func TestQueryNameserverIP_Timeout(t *testing.T) {
|
func TestQueryNameserverIP_Timeout(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
log := slog.New(slog.NewTextHandler(
|
r := newTestResolver(t)
|
||||||
os.Stderr,
|
|
||||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
||||||
))
|
|
||||||
|
|
||||||
r := resolver.NewFromLoggerWithClient(
|
|
||||||
log, &timeoutClient{},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// Nothing answers at 192.0.2.1, a documentation address. The
|
||||||
|
// resolver tries each query twice, and the first try gives up
|
||||||
|
// after two seconds. A deadline that ends during the first try
|
||||||
|
// makes the status vary from run to run between nodata and
|
||||||
|
// timeout, so the deadline must outlast the first try.
|
||||||
ctx, cancel := context.WithTimeout(
|
ctx, cancel := context.WithTimeout(
|
||||||
context.Background(), 10*time.Second,
|
context.Background(), 3*time.Second,
|
||||||
)
|
)
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
// Query any IP — the client always returns a timeout error.
|
|
||||||
resp, err := r.QueryNameserverIP(
|
resp, err := r.QueryNameserverIP(
|
||||||
ctx, "unreachable.test.", "192.0.2.1",
|
ctx, "unreachable.test.", "192.0.2.1",
|
||||||
"example.com",
|
"example.com",
|
||||||
@@ -551,27 +547,6 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
|
|||||||
assert.NotEmpty(t, resp.Error)
|
assert.NotEmpty(t, resp.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeoutClient simulates DNS timeout errors for testing.
|
|
||||||
type timeoutClient struct{}
|
|
||||||
|
|
||||||
func (c *timeoutClient) ExchangeContext(
|
|
||||||
_ context.Context,
|
|
||||||
_ *dns.Msg,
|
|
||||||
_ string,
|
|
||||||
) (*dns.Msg, time.Duration, error) {
|
|
||||||
return nil, 0, &net.OpError{
|
|
||||||
Op: "read",
|
|
||||||
Net: "udp",
|
|
||||||
Err: &timeoutError{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type timeoutError struct{}
|
|
||||||
|
|
||||||
func (e *timeoutError) Error() string { return "i/o timeout" }
|
|
||||||
func (e *timeoutError) Timeout() bool { return true }
|
|
||||||
func (e *timeoutError) Temporary() bool { return true }
|
|
||||||
|
|
||||||
func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
|
func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewHTTPServer exports newHTTPServer for testing.
|
|
||||||
func NewHTTPServer(
|
|
||||||
listenAddr string,
|
|
||||||
handler http.Handler,
|
|
||||||
) *http.Server {
|
|
||||||
return newHTTPServer(listenAddr, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestTimeout exports the handler execution budget applied by
|
// RequestTimeout exports the handler execution budget applied by
|
||||||
// chimw.Timeout in SetupRoutes, so tests can assert the relationship
|
// chimw.Timeout in SetupRoutes, so tests can assert the relationship
|
||||||
// between it and the server's WriteTimeout.
|
// between it and the server's WriteTimeout.
|
||||||
const RequestTimeout time.Duration = requestTimeout
|
const RequestTimeout time.Duration = requestTimeout
|
||||||
|
|
||||||
|
// SetListenPort overrides the port Run binds. A test uses it to hand
|
||||||
|
// Run an unbindable port so ListenAndServe fails immediately and Run
|
||||||
|
// returns after storing its http.Server.
|
||||||
|
func SetListenPort(s *Server, port int) {
|
||||||
|
s.port = port
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPServerOf returns the http.Server that Run built and stored, so a
|
||||||
|
// test can inspect the timeouts the running server actually carries.
|
||||||
|
func HTTPServerOf(s *Server) *http.Server {
|
||||||
|
return s.httpServer
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,112 +1,130 @@
|
|||||||
package server_test
|
package server_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/config"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/handlers"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/middleware"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||||
"sneak.berlin/go/dnswatcher/internal/server"
|
"sneak.berlin/go/dnswatcher/internal/server"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// noopHandler stands in for the router; newHTTPServer only stores it.
|
// buildServer wires a *server.Server exactly as cmd/dnswatcher does,
|
||||||
func noopHandler() http.Handler {
|
// minus the watcher/resolver subtree that would touch live DNS. fx
|
||||||
return http.HandlerFunc(
|
// builds the object graph but the lifecycle is never started, so no
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
// OnStart hook runs and nothing listens or resolves. The caller must
|
||||||
w.WriteHeader(http.StatusOK)
|
// first configure viper (config.New reads it), which is also why the
|
||||||
},
|
// caller cannot run in parallel.
|
||||||
|
func buildServer(t *testing.T) *server.Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var srv *server.Server
|
||||||
|
|
||||||
|
app := fx.New(
|
||||||
|
fx.NopLogger,
|
||||||
|
fx.Provide(
|
||||||
|
globals.New,
|
||||||
|
logger.New,
|
||||||
|
config.New,
|
||||||
|
state.New,
|
||||||
|
healthcheck.New,
|
||||||
|
notify.New,
|
||||||
|
middleware.New,
|
||||||
|
handlers.New,
|
||||||
|
server.New,
|
||||||
|
),
|
||||||
|
fx.Populate(&srv),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// TestHTTPServerTimeoutsAreSet asserts that every socket-level
|
err := app.Err()
|
||||||
// timeout is configured. A zero value in net/http means "no limit",
|
if err != nil {
|
||||||
// so a refactor that silently drops one of these reintroduces the
|
t.Fatalf("building server graph: %v", err)
|
||||||
// slowloris / unreaped-keep-alive exposure this guards against.
|
|
||||||
//
|
|
||||||
// The assertions are on the configured field values only; nothing
|
|
||||||
// here measures elapsed time, so the test cannot flake on timing.
|
|
||||||
func TestHTTPServerTimeoutsAreSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
srv := server.NewHTTPServer(":8080", noopHandler())
|
|
||||||
|
|
||||||
if srv.ReadTimeout <= 0 {
|
|
||||||
t.Errorf(
|
|
||||||
"ReadTimeout must be non-zero, got %v",
|
|
||||||
srv.ReadTimeout,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if srv.ReadHeaderTimeout <= 0 {
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunWiresSocketTimeouts pins that the http.Server the running
|
||||||
|
// server actually serves — the one Run builds and hands to
|
||||||
|
// ListenAndServe — carries every socket-level timeout, plus the two
|
||||||
|
// relationships the values must satisfy.
|
||||||
|
//
|
||||||
|
// Run is driven to completion with an unbindable port: it builds and
|
||||||
|
// stores s.httpServer, then ListenAndServe fails at once and Run
|
||||||
|
// returns without ever listening. The assertions run in the same
|
||||||
|
// goroutine after Run returns, so reading s.httpServer is free of any
|
||||||
|
// data race. Nothing here measures elapsed time.
|
||||||
|
//
|
||||||
|
// ReadTimeout must be at least ReadHeaderTimeout. net/http reads the
|
||||||
|
// headers under ReadHeaderTimeout, then sets the read deadline for the
|
||||||
|
// rest of the request to ReadTimeout, counted from when it started
|
||||||
|
// reading the request. If ReadTimeout were smaller, a request whose
|
||||||
|
// headers arrived after ReadTimeout but within ReadHeaderTimeout would
|
||||||
|
// get a read deadline that had already passed, so reading its body
|
||||||
|
// would fail at once.
|
||||||
|
func TestRunWiresSocketTimeouts(t *testing.T) {
|
||||||
|
// Sets an env var and touches viper global state, so like the
|
||||||
|
// config tests it cannot use t.Parallel.
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
|
||||||
|
srv := buildServer(t)
|
||||||
|
server.SetListenPort(srv, -1)
|
||||||
|
|
||||||
|
srv.Run()
|
||||||
|
|
||||||
|
hs := server.HTTPServerOf(srv)
|
||||||
|
if hs == nil {
|
||||||
|
t.Fatal("Run did not build an http.Server")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hs.ReadTimeout <= 0 {
|
||||||
|
t.Errorf("ReadTimeout must be non-zero, got %v", hs.ReadTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hs.ReadHeaderTimeout <= 0 {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"ReadHeaderTimeout must be non-zero, got %v",
|
"ReadHeaderTimeout must be non-zero, got %v",
|
||||||
srv.ReadHeaderTimeout,
|
hs.ReadHeaderTimeout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if srv.WriteTimeout <= 0 {
|
if hs.WriteTimeout <= 0 {
|
||||||
t.Errorf(
|
t.Errorf("WriteTimeout must be non-zero, got %v", hs.WriteTimeout)
|
||||||
"WriteTimeout must be non-zero, got %v",
|
|
||||||
srv.WriteTimeout,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if srv.IdleTimeout <= 0 {
|
if hs.IdleTimeout <= 0 {
|
||||||
t.Errorf(
|
t.Errorf("IdleTimeout must be non-zero, got %v", hs.IdleTimeout)
|
||||||
"IdleTimeout must be non-zero, got %v",
|
|
||||||
srv.IdleTimeout,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// TestWriteTimeoutExceedsHandlerBudget pins the one relationship the
|
if hs.WriteTimeout <= server.RequestTimeout {
|
||||||
// values must satisfy. net/http arms the write deadline once request
|
|
||||||
// headers are read, so it covers handler execution plus the response
|
|
||||||
// flush. If WriteTimeout were not greater than the chimw.Timeout
|
|
||||||
// handler budget, the connection would be severed before a handler
|
|
||||||
// that used its full budget could respond, making that budget
|
|
||||||
// unreachable.
|
|
||||||
func TestWriteTimeoutExceedsHandlerBudget(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
srv := server.NewHTTPServer(":8080", noopHandler())
|
|
||||||
|
|
||||||
if srv.WriteTimeout <= server.RequestTimeout {
|
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"WriteTimeout (%v) must exceed handler budget (%v)",
|
"WriteTimeout (%v) must exceed handler budget (%v)",
|
||||||
srv.WriteTimeout,
|
hs.WriteTimeout,
|
||||||
server.RequestTimeout,
|
server.RequestTimeout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// TestReadTimeoutCoversHeaderTimeout asserts the read deadline for
|
if hs.ReadTimeout < hs.ReadHeaderTimeout {
|
||||||
// the whole request is at least as long as the header-only deadline;
|
|
||||||
// a smaller ReadTimeout would make ReadHeaderTimeout unreachable.
|
|
||||||
func TestReadTimeoutCoversHeaderTimeout(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
srv := server.NewHTTPServer(":8080", noopHandler())
|
|
||||||
|
|
||||||
if srv.ReadTimeout < srv.ReadHeaderTimeout {
|
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)",
|
"ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)",
|
||||||
srv.ReadTimeout,
|
hs.ReadTimeout,
|
||||||
srv.ReadHeaderTimeout,
|
hs.ReadHeaderTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hs.Handler != srv {
|
||||||
|
t.Errorf(
|
||||||
|
"Run wired handler %T, want the *server.Server",
|
||||||
|
hs.Handler,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHTTPServerAddrAndHandler covers the rest of the constructor so
|
|
||||||
// a future edit cannot drop the listen address or the handler.
|
|
||||||
func TestHTTPServerAddrAndHandler(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
srv := server.NewHTTPServer(":9999", noopHandler())
|
|
||||||
|
|
||||||
if srv.Addr != ":9999" {
|
|
||||||
t.Errorf("Addr = %q, want %q", srv.Addr, ":9999")
|
|
||||||
}
|
|
||||||
|
|
||||||
if srv.Handler == nil {
|
|
||||||
t.Error("Handler must not be nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -553,6 +553,31 @@ func TestStartupFailsWhenDataDirNotWritable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStartupFailsWhenExistingDataDirNotWritable verifies that startup
|
||||||
|
// stops when the data directory exists but the temp file that saving uses
|
||||||
|
// cannot be written in it. A directory sitting at the temp file's path
|
||||||
|
// makes that write fail, which also holds as root.
|
||||||
|
func TestStartupFailsWhenExistingDataDirNotWritable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.Mkdir(filepath.Join(dataDir, "state.json.tmp"), 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = startState(t, dataDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("startup should fail when the data directory is not writable")
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "data directory " + dataDir + " is not writable"
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("startup error %q does not contain %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestStartupCreatesDataDir verifies that startup creates a missing
|
// TestStartupCreatesDataDir verifies that startup creates a missing
|
||||||
// data directory and leaves nothing behind in it.
|
// data directory and leaves nothing behind in it.
|
||||||
func TestStartupCreatesDataDir(t *testing.T) {
|
func TestStartupCreatesDataDir(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing.
|
||||||
|
func NewlyDisagreeingPairs(
|
||||||
|
prev *state.HostnameState,
|
||||||
|
current map[string]map[string][]string,
|
||||||
|
) [][2]string {
|
||||||
|
return newlyDisagreeingPairs(prev, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectHostnameChanges exports detectHostnameChanges for testing.
|
||||||
|
func (w *Watcher) DetectHostnameChanges(
|
||||||
|
ctx context.Context,
|
||||||
|
hostname string,
|
||||||
|
prev *state.HostnameState,
|
||||||
|
current map[string]map[string][]string,
|
||||||
|
) {
|
||||||
|
w.detectHostnameChanges(ctx, hostname, prev, current)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package watcher_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
host = "www.example.net"
|
||||||
|
nsA = "a.ns.example.net."
|
||||||
|
nsB = "b.ns.example.net."
|
||||||
|
nsC = "c.ns.example.net."
|
||||||
|
ip1 = "192.0.2.1"
|
||||||
|
ip2 = "192.0.2.2"
|
||||||
|
ip3 = "192.0.2.3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hostnameState builds the state a check with these records leaves behind.
|
||||||
|
func hostnameState(
|
||||||
|
records map[string]map[string][]string,
|
||||||
|
) *state.HostnameState {
|
||||||
|
hs := &state.HostnameState{
|
||||||
|
RecordsByNameserver: make(map[string]*state.NameserverRecordState),
|
||||||
|
}
|
||||||
|
|
||||||
|
for ns, recs := range records {
|
||||||
|
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
|
||||||
|
Records: recs,
|
||||||
|
Status: "ok",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewlyDisagreeingPairs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}}
|
||||||
|
agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}}
|
||||||
|
disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}}
|
||||||
|
alert := [][2]string{{nsA, nsB}}
|
||||||
|
|
||||||
|
// b already disagrees with a and c; then c changes, so a and c,
|
||||||
|
// which agreed, now differ.
|
||||||
|
bDiffers := map[string]map[string][]string{
|
||||||
|
nsA: {"A": {ip1}}, nsB: {"A": {ip2}}, nsC: {"A": {ip1}},
|
||||||
|
}
|
||||||
|
cChanges := map[string]map[string][]string{
|
||||||
|
nsA: {"A": {ip1}}, nsB: {"A": {ip2}}, nsC: {"A": {ip3}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each case starts from the state loaded at startup and runs the
|
||||||
|
// checks in order; want[i] is what check i alerts for.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
loaded map[string]map[string][]string
|
||||||
|
checks []map[string]map[string][]string
|
||||||
|
want [][][2]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disagreement persisting across checks alerts once",
|
||||||
|
loaded: agree,
|
||||||
|
checks: []map[string]map[string][]string{disagree, disagree, disagree},
|
||||||
|
want: [][][2]string{alert, nil, nil},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disagreement starting on a later check alerts on it",
|
||||||
|
loaded: agree,
|
||||||
|
checks: []map[string]map[string][]string{agree, agree, disagree},
|
||||||
|
want: [][][2]string{nil, nil, alert},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disagreement in the loaded state does not alert",
|
||||||
|
loaded: disagree,
|
||||||
|
checks: []map[string]map[string][]string{disagree, disagree},
|
||||||
|
want: [][][2]string{nil, nil},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nameserver new on the first check and disagreeing alerts once",
|
||||||
|
loaded: onlyA,
|
||||||
|
checks: []map[string]map[string][]string{disagree, disagree},
|
||||||
|
want: [][][2]string{alert, nil},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disagreement after agreeing again alerts again",
|
||||||
|
loaded: agree,
|
||||||
|
checks: []map[string]map[string][]string{disagree, agree, disagree},
|
||||||
|
want: [][][2]string{alert, nil, alert},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new disagreement while another nameserver differs alerts",
|
||||||
|
loaded: bDiffers,
|
||||||
|
checks: []map[string]map[string][]string{cChanges, cChanges},
|
||||||
|
want: [][][2]string{{{nsA, nsC}}, nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
prev := hostnameState(tt.loaded)
|
||||||
|
|
||||||
|
for i, current := range tt.checks {
|
||||||
|
got := watcher.NewlyDisagreeingPairs(prev, current)
|
||||||
|
if !slices.Equal(got, tt.want[i]) {
|
||||||
|
t.Errorf(
|
||||||
|
"check %d: alerted for %v, want %v",
|
||||||
|
i, got, tt.want[i],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
prev = hostnameState(current)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInconsistencyAlert(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}}
|
||||||
|
agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}}
|
||||||
|
disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}}
|
||||||
|
|
||||||
|
// Each case starts from the state loaded at startup and then sees
|
||||||
|
// the nameservers disagree on three checks in a row.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
loaded map[string]map[string][]string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disagreement lasting several checks alerts once",
|
||||||
|
loaded: agree,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disagreement in the loaded state does not alert",
|
||||||
|
loaded: disagree,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nameserver new on the first check and disagreeing alerts once",
|
||||||
|
loaded: onlyA,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The hostname change detection uses only the notifier.
|
||||||
|
notifier := &mockNotifier{}
|
||||||
|
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
|
||||||
|
|
||||||
|
prev := hostnameState(tt.loaded)
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
w.DetectHostnameChanges(t.Context(), host, prev, disagree)
|
||||||
|
prev = hostnameState(disagree)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := 0
|
||||||
|
|
||||||
|
for _, n := range notifier.getNotifications() {
|
||||||
|
if n.Title == "Inconsistency: "+host {
|
||||||
|
got++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("sent %d inconsistency alerts, want %d", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-15
@@ -366,7 +366,7 @@ func (w *Watcher) detectHostnameChanges(
|
|||||||
) {
|
) {
|
||||||
w.detectRecordChanges(ctx, hostname, prev, current)
|
w.detectRecordChanges(ctx, hostname, prev, current)
|
||||||
w.detectNSDisappearances(ctx, hostname, prev, current)
|
w.detectNSDisappearances(ctx, hostname, prev, current)
|
||||||
w.detectInconsistencies(ctx, hostname, current)
|
w.detectInconsistencies(ctx, hostname, prev, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) detectRecordChanges(
|
func (w *Watcher) detectRecordChanges(
|
||||||
@@ -448,22 +448,11 @@ func (w *Watcher) detectNSDisappearances(
|
|||||||
func (w *Watcher) detectInconsistencies(
|
func (w *Watcher) detectInconsistencies(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
|
prev *state.HostnameState,
|
||||||
current map[string]map[string][]string,
|
current map[string]map[string][]string,
|
||||||
) {
|
) {
|
||||||
nameservers := make([]string, 0, len(current))
|
for _, pair := range newlyDisagreeingPairs(prev, current) {
|
||||||
for ns := range current {
|
ns1, ns2 := pair[0], pair[1]
|
||||||
nameservers = append(nameservers, ns)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(nameservers)
|
|
||||||
|
|
||||||
for i := range len(nameservers) - 1 {
|
|
||||||
ns1 := nameservers[i]
|
|
||||||
ns2 := nameservers[i+1]
|
|
||||||
|
|
||||||
if recordsEqual(current[ns1], current[ns2]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Hostname: %s\n%s: %v\n%s: %v",
|
"Hostname: %s\n%s: %v\n%s: %v",
|
||||||
@@ -481,6 +470,43 @@ func (w *Watcher) detectInconsistencies(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newlyDisagreeingPairs returns every pair of nameservers whose records
|
||||||
|
// differ in current, in sorted order of name, except pairs where both
|
||||||
|
// nameservers were in prev and already differed there. A nameserver
|
||||||
|
// missing from prev is paired with every nameserver it differs from.
|
||||||
|
func newlyDisagreeingPairs(
|
||||||
|
prev *state.HostnameState,
|
||||||
|
current map[string]map[string][]string,
|
||||||
|
) [][2]string {
|
||||||
|
nameservers := make([]string, 0, len(current))
|
||||||
|
for ns := range current {
|
||||||
|
nameservers = append(nameservers, ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(nameservers)
|
||||||
|
|
||||||
|
var pairs [][2]string
|
||||||
|
|
||||||
|
for i, ns1 := range nameservers {
|
||||||
|
for _, ns2 := range nameservers[i+1:] {
|
||||||
|
if recordsEqual(current[ns1], current[ns2]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prev1, ok1 := prev.RecordsByNameserver[ns1]
|
||||||
|
prev2, ok2 := prev.RecordsByNameserver[ns2]
|
||||||
|
|
||||||
|
if ok1 && ok2 && !recordsEqual(prev1.Records, prev2.Records) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs = append(pairs, [2]string{ns1, ns2})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairs
|
||||||
|
}
|
||||||
|
|
||||||
func (w *Watcher) checkAllPorts(ctx context.Context) {
|
func (w *Watcher) checkAllPorts(ctx context.Context) {
|
||||||
// Phase 1: Build current IP:port → hostname associations
|
// Phase 1: Build current IP:port → hostname associations
|
||||||
// from fresh DNS data.
|
// from fresh DNS data.
|
||||||
|
|||||||
+257
-486
@@ -2,151 +2,77 @@ package watcher_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/config"
|
"sneak.berlin/go/dnswatcher/internal/config"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/livedns"
|
||||||
"sneak.berlin/go/dnswatcher/internal/portcheck"
|
"sneak.berlin/go/dnswatcher/internal/portcheck"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/resolver"
|
||||||
"sneak.berlin/go/dnswatcher/internal/state"
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
"sneak.berlin/go/dnswatcher/internal/tlscheck"
|
"sneak.berlin/go/dnswatcher/internal/tlscheck"
|
||||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errNotFound is returned when mock data is missing.
|
// The watcher looks these names up in live DNS with the real resolver,
|
||||||
var errNotFound = errors.New("not found")
|
// so tests assert on what the watcher does with the answers, never on
|
||||||
|
// the records these zones publish. testHost's nameservers and addresses
|
||||||
// Fixture values shared across tests.
|
// stay the same from one check to the next, which the tests that check
|
||||||
|
// it twice rely on.
|
||||||
const (
|
const (
|
||||||
testDomain = "example.com"
|
testDomain = "google.com"
|
||||||
testHost = "www.example.com"
|
testHost = "cloudflare.com"
|
||||||
testNS1 = "ns1.example.com."
|
|
||||||
testNS2 = "ns2.example.com."
|
|
||||||
testIPv4 = "93.184.216.34"
|
|
||||||
testIP = "1.2.3.4"
|
|
||||||
testIssuer = "DigiCert"
|
testIssuer = "DigiCert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock implementations ---
|
// Saved-state values that live DNS never returns: nameserver names
|
||||||
|
// under .invalid and a documentation address.
|
||||||
|
const (
|
||||||
|
oldNS1 = "ns1.example.invalid."
|
||||||
|
oldNS2 = "ns2.example.invalid."
|
||||||
|
oldIP = "192.0.2.1"
|
||||||
|
)
|
||||||
|
|
||||||
type mockResolver struct {
|
// --- Stand-ins for the port checker, TLS checker and notifier ---
|
||||||
mu sync.Mutex
|
//
|
||||||
nsRecords map[string][]string
|
// DNS has none: the watchers built here use the real resolver (see
|
||||||
allRecords map[string]map[string]map[string][]string
|
// TESTING.md).
|
||||||
ipAddresses map[string][]string
|
|
||||||
lookupNSErr error
|
|
||||||
allRecordsErr error
|
|
||||||
resolveIPErr error
|
|
||||||
lookupNSCalls int
|
|
||||||
allRecordCalls int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockResolver) LookupNS(
|
|
||||||
_ context.Context,
|
|
||||||
domain string,
|
|
||||||
) ([]string, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
m.lookupNSCalls++
|
|
||||||
|
|
||||||
if m.lookupNSErr != nil {
|
|
||||||
return nil, m.lookupNSErr
|
|
||||||
}
|
|
||||||
|
|
||||||
ns, ok := m.nsRecords[domain]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%w: NS for %s", errNotFound, domain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ns, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockResolver) LookupAllRecords(
|
|
||||||
_ context.Context,
|
|
||||||
hostname string,
|
|
||||||
) (map[string]map[string][]string, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
m.allRecordCalls++
|
|
||||||
|
|
||||||
if m.allRecordsErr != nil {
|
|
||||||
return nil, m.allRecordsErr
|
|
||||||
}
|
|
||||||
|
|
||||||
recs, ok := m.allRecords[hostname]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%w: records for %s", errNotFound, hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return recs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockResolver) ResolveIPAddresses(
|
|
||||||
_ context.Context,
|
|
||||||
hostname string,
|
|
||||||
) ([]string, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if m.resolveIPErr != nil {
|
|
||||||
return nil, m.resolveIPErr
|
|
||||||
}
|
|
||||||
|
|
||||||
ips, ok := m.ipAddresses[hostname]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%w: IPs for %s", errNotFound, hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ips, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// mockPortChecker reports every port open until closed is set.
|
||||||
type mockPortChecker struct {
|
type mockPortChecker struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
results map[string]bool
|
closed bool
|
||||||
err error
|
calls int
|
||||||
calls int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPortChecker) CheckPort(
|
func (m *mockPortChecker) CheckPort(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
address string,
|
_ string,
|
||||||
port int,
|
_ int,
|
||||||
) (*portcheck.PortResult, error) {
|
) (*portcheck.PortResult, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
m.calls++
|
m.calls++
|
||||||
|
|
||||||
if m.err != nil {
|
return &portcheck.PortResult{Open: !m.closed}, nil
|
||||||
return nil, m.err
|
|
||||||
}
|
|
||||||
|
|
||||||
key := fmt.Sprintf("%s:%d", address, port)
|
|
||||||
open := m.results[key]
|
|
||||||
|
|
||||||
return &portcheck.PortResult{Open: open}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockTLSChecker returns a certificate for the requested hostname that
|
||||||
|
// expires at notAfter.
|
||||||
type mockTLSChecker struct {
|
type mockTLSChecker struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
certs map[string]*tlscheck.CertificateInfo
|
notAfter time.Time
|
||||||
err error
|
calls int
|
||||||
calls int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTLSChecker) CheckCertificate(
|
func (m *mockTLSChecker) CheckCertificate(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
ip string,
|
_ string,
|
||||||
hostname string,
|
hostname string,
|
||||||
) (*tlscheck.CertificateInfo, error) {
|
) (*tlscheck.CertificateInfo, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -154,20 +80,12 @@ func (m *mockTLSChecker) CheckCertificate(
|
|||||||
|
|
||||||
m.calls++
|
m.calls++
|
||||||
|
|
||||||
if m.err != nil {
|
return &tlscheck.CertificateInfo{
|
||||||
return nil, m.err
|
CommonName: hostname,
|
||||||
}
|
Issuer: testIssuer,
|
||||||
|
NotAfter: m.notAfter,
|
||||||
key := fmt.Sprintf("%s:%s", ip, hostname)
|
SubjectAlternativeNames: []string{hostname},
|
||||||
cert, ok := m.certs[key]
|
}, nil
|
||||||
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%w: cert for %s", errNotFound, key,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cert, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type notification struct {
|
type notification struct {
|
||||||
@@ -205,10 +123,9 @@ func (m *mockNotifier) getNotifications() []notification {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helper to build a Watcher for testing ---
|
// --- Helpers to build a Watcher and run its checks against live DNS ---
|
||||||
|
|
||||||
type testDeps struct {
|
type testDeps struct {
|
||||||
resolver *mockResolver
|
|
||||||
portChecker *mockPortChecker
|
portChecker *mockPortChecker
|
||||||
tlsChecker *mockTLSChecker
|
tlsChecker *mockTLSChecker
|
||||||
notifier *mockNotifier
|
notifier *mockNotifier
|
||||||
@@ -223,16 +140,9 @@ func newTestWatcher(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
deps := &testDeps{
|
deps := &testDeps{
|
||||||
resolver: &mockResolver{
|
portChecker: &mockPortChecker{},
|
||||||
nsRecords: make(map[string][]string),
|
|
||||||
allRecords: make(map[string]map[string]map[string][]string),
|
|
||||||
ipAddresses: make(map[string][]string),
|
|
||||||
},
|
|
||||||
portChecker: &mockPortChecker{
|
|
||||||
results: make(map[string]bool),
|
|
||||||
},
|
|
||||||
tlsChecker: &mockTLSChecker{
|
tlsChecker: &mockTLSChecker{
|
||||||
certs: make(map[string]*tlscheck.CertificateInfo),
|
notAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||||
},
|
},
|
||||||
notifier: &mockNotifier{},
|
notifier: &mockNotifier{},
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@@ -243,7 +153,7 @@ func newTestWatcher(
|
|||||||
w := watcher.NewForTest(
|
w := watcher.NewForTest(
|
||||||
deps.config,
|
deps.config,
|
||||||
deps.state,
|
deps.state,
|
||||||
deps.resolver,
|
resolver.NewFromLogger(slog.Default()),
|
||||||
deps.portChecker,
|
deps.portChecker,
|
||||||
deps.tlsChecker,
|
deps.tlsChecker,
|
||||||
deps.notifier,
|
deps.notifier,
|
||||||
@@ -263,6 +173,122 @@ func defaultTestConfig(t *testing.T) *config.Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkOnce runs the watcher's checks once and returns an error when a
|
||||||
|
// configured name has no hostname state saved by this check, or that
|
||||||
|
// state holds no address. Either live DNS gave no answer for the name,
|
||||||
|
// or the watcher saved no fresh result for it.
|
||||||
|
func checkOnce(
|
||||||
|
ctx context.Context,
|
||||||
|
w *watcher.Watcher,
|
||||||
|
deps *testDeps,
|
||||||
|
) error {
|
||||||
|
started := time.Now()
|
||||||
|
|
||||||
|
w.RunOnce(ctx)
|
||||||
|
|
||||||
|
names := slices.Concat(deps.config.Domains, deps.config.Hostnames)
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
hs, ok := deps.state.GetHostnameState(name)
|
||||||
|
if !ok || hs.LastChecked.Before(started) ||
|
||||||
|
len(addresses(hs)) == 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s: %w, or the watcher saved no fresh "+
|
||||||
|
"result for it",
|
||||||
|
name, livedns.ErrNoAnswer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func runChecks(
|
||||||
|
t *testing.T,
|
||||||
|
cfg *config.Config,
|
||||||
|
prepare, change func(deps *testDeps),
|
||||||
|
) *testDeps {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var deps *testDeps
|
||||||
|
|
||||||
|
livedns.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// addresses returns the A and AAAA values saved for a hostname.
|
||||||
|
func addresses(hs *state.HostnameState) []string {
|
||||||
|
var ips []string
|
||||||
|
|
||||||
|
for _, nsState := range hs.RecordsByNameserver {
|
||||||
|
ips = append(ips, nsState.Records["A"]...)
|
||||||
|
ips = append(ips, nsState.Records["AAAA"]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNotified checks that a notification with this title and
|
||||||
|
// priority was sent.
|
||||||
|
func assertNotified(
|
||||||
|
t *testing.T,
|
||||||
|
deps *testDeps,
|
||||||
|
title, priority string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
notifications := deps.notifier.getNotifications()
|
||||||
|
|
||||||
|
for _, n := range notifications {
|
||||||
|
if n.Title == title && n.Priority == priority {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Errorf(
|
||||||
|
"expected %s notification %q, got: %v",
|
||||||
|
priority, title, notifications,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countNotifications counts the notifications sent with this title.
|
||||||
|
func countNotifications(deps *testDeps, title string) int {
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for _, n := range deps.notifier.getNotifications() {
|
||||||
|
if n.Title == title {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
func TestFirstRunBaseline(t *testing.T) {
|
func TestFirstRunBaseline(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -270,51 +296,12 @@ func TestFirstRunBaseline(t *testing.T) {
|
|||||||
cfg.Domains = []string{testDomain}
|
cfg.Domains = []string{testDomain}
|
||||||
cfg.Hostnames = []string{testHost}
|
cfg.Hostnames = []string{testHost}
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
deps := runChecks(t, cfg, nil, nil)
|
||||||
setupBaselineMocks(deps)
|
|
||||||
|
|
||||||
w.RunOnce(t.Context())
|
|
||||||
|
|
||||||
assertNoNotifications(t, deps)
|
assertNoNotifications(t, deps)
|
||||||
assertStatePopulated(t, deps)
|
assertStatePopulated(t, deps)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupBaselineMocks(deps *testDeps) {
|
|
||||||
deps.resolver.nsRecords[testDomain] = []string{
|
|
||||||
testNS1,
|
|
||||||
testNS2,
|
|
||||||
}
|
|
||||||
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
|
|
||||||
testNS1: {"A": {testIPv4}},
|
|
||||||
testNS2: {"A": {testIPv4}},
|
|
||||||
}
|
|
||||||
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
|
|
||||||
testNS1: {"A": {testIPv4}},
|
|
||||||
testNS2: {"A": {testIPv4}},
|
|
||||||
}
|
|
||||||
deps.resolver.ipAddresses[testHost] = []string{
|
|
||||||
testIPv4,
|
|
||||||
}
|
|
||||||
deps.portChecker.results["93.184.216.34:80"] = true
|
|
||||||
deps.portChecker.results["93.184.216.34:443"] = true
|
|
||||||
deps.tlsChecker.certs["93.184.216.34:www.example.com"] = &tlscheck.CertificateInfo{
|
|
||||||
CommonName: testHost,
|
|
||||||
Issuer: testIssuer,
|
|
||||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
||||||
SubjectAlternativeNames: []string{
|
|
||||||
testHost,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
|
|
||||||
CommonName: testDomain,
|
|
||||||
Issuer: testIssuer,
|
|
||||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
||||||
SubjectAlternativeNames: []string{
|
|
||||||
testDomain,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertNoNotifications(
|
func assertNoNotifications(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
deps *testDeps,
|
deps *testDeps,
|
||||||
@@ -361,26 +348,7 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
|
|||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Domains = []string{testDomain}
|
cfg.Domains = []string{testDomain}
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
deps := runChecks(t, cfg, nil, nil)
|
||||||
|
|
||||||
deps.resolver.nsRecords[testDomain] = []string{
|
|
||||||
testNS1,
|
|
||||||
}
|
|
||||||
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
|
|
||||||
testNS1: {"A": {testIPv4}},
|
|
||||||
}
|
|
||||||
deps.portChecker.results["93.184.216.34:80"] = true
|
|
||||||
deps.portChecker.results["93.184.216.34:443"] = true
|
|
||||||
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
|
|
||||||
CommonName: testDomain,
|
|
||||||
Issuer: testIssuer,
|
|
||||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
||||||
SubjectAlternativeNames: []string{
|
|
||||||
testDomain,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
w.RunOnce(t.Context())
|
|
||||||
|
|
||||||
snap := deps.state.GetSnapshot()
|
snap := deps.state.GetSnapshot()
|
||||||
|
|
||||||
@@ -419,50 +387,18 @@ func TestNSChangeDetection(t *testing.T) {
|
|||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Domains = []string{testDomain}
|
cfg.Domains = []string{testDomain}
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
// The saved state lists nameservers that live DNS does not.
|
||||||
|
deps := runChecks(t, cfg, func(deps *testDeps) {
|
||||||
|
deps.state.SetDomainState(testDomain, &state.DomainState{
|
||||||
|
Nameservers: []string{oldNS1, oldNS2},
|
||||||
|
})
|
||||||
|
}, nil)
|
||||||
|
|
||||||
deps.resolver.nsRecords[testDomain] = []string{
|
assertNotified(t, deps, "NS Change: "+testDomain, "warning")
|
||||||
testNS1,
|
|
||||||
testNS2,
|
|
||||||
}
|
|
||||||
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
|
|
||||||
testNS1: {"A": {testIP}},
|
|
||||||
testNS2: {"A": {testIP}},
|
|
||||||
}
|
|
||||||
deps.portChecker.results["1.2.3.4:80"] = false
|
|
||||||
deps.portChecker.results["1.2.3.4:443"] = false
|
|
||||||
|
|
||||||
ctx := t.Context()
|
ds, _ := deps.state.GetDomainState(testDomain)
|
||||||
w.RunOnce(ctx)
|
if slices.Contains(ds.Nameservers, oldNS1) {
|
||||||
|
t.Errorf("saved nameservers not updated: %v", ds.Nameservers)
|
||||||
deps.resolver.mu.Lock()
|
|
||||||
deps.resolver.nsRecords[testDomain] = []string{
|
|
||||||
testNS1,
|
|
||||||
"ns3.example.com.",
|
|
||||||
}
|
|
||||||
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
|
|
||||||
testNS1: {"A": {testIP}},
|
|
||||||
"ns3.example.com.": {"A": {testIP}},
|
|
||||||
}
|
|
||||||
deps.resolver.mu.Unlock()
|
|
||||||
|
|
||||||
w.RunOnce(ctx)
|
|
||||||
|
|
||||||
notifications := deps.notifier.getNotifications()
|
|
||||||
if len(notifications) == 0 {
|
|
||||||
t.Error("expected notification for NS change")
|
|
||||||
}
|
|
||||||
|
|
||||||
found := false
|
|
||||||
|
|
||||||
for _, n := range notifications {
|
|
||||||
if n.Priority == "warning" {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
t.Error("expected warning-priority NS change notification")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,40 +408,18 @@ func TestRecordChangeDetection(t *testing.T) {
|
|||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Hostnames = []string{testHost}
|
cfg.Hostnames = []string{testHost}
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
// 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}}
|
||||||
|
}
|
||||||
|
|
||||||
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
|
deps.state.SetHostnameState(testHost, hs)
|
||||||
testNS1: {"A": {testIPv4}},
|
})
|
||||||
}
|
|
||||||
deps.resolver.ipAddresses[testHost] = []string{
|
|
||||||
testIPv4,
|
|
||||||
}
|
|
||||||
deps.portChecker.results["93.184.216.34:80"] = false
|
|
||||||
deps.portChecker.results["93.184.216.34:443"] = false
|
|
||||||
|
|
||||||
ctx := t.Context()
|
assertNotified(t, deps, "Record Change: "+testHost, "warning")
|
||||||
w.RunOnce(ctx)
|
|
||||||
|
|
||||||
deps.resolver.mu.Lock()
|
|
||||||
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
|
|
||||||
testNS1: {"A": {"93.184.216.35"}},
|
|
||||||
}
|
|
||||||
deps.resolver.ipAddresses[testHost] = []string{
|
|
||||||
"93.184.216.35",
|
|
||||||
}
|
|
||||||
deps.resolver.mu.Unlock()
|
|
||||||
|
|
||||||
deps.portChecker.mu.Lock()
|
|
||||||
deps.portChecker.results["93.184.216.35:80"] = false
|
|
||||||
deps.portChecker.results["93.184.216.35:443"] = false
|
|
||||||
deps.portChecker.mu.Unlock()
|
|
||||||
|
|
||||||
w.RunOnce(ctx)
|
|
||||||
|
|
||||||
notifications := deps.notifier.getNotifications()
|
|
||||||
if len(notifications) == 0 {
|
|
||||||
t.Error("expected notification for record change")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPortStateChange(t *testing.T) {
|
func TestPortStateChange(t *testing.T) {
|
||||||
@@ -514,38 +428,23 @@ func TestPortStateChange(t *testing.T) {
|
|||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Hostnames = []string{testHost}
|
cfg.Hostnames = []string{testHost}
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
// 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()
|
||||||
|
})
|
||||||
|
|
||||||
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
|
hs, _ := deps.state.GetHostnameState(testHost)
|
||||||
testNS1: {"A": {testIP}},
|
assertNotified(
|
||||||
}
|
t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning",
|
||||||
deps.resolver.ipAddresses[testHost] = []string{
|
)
|
||||||
testIP,
|
}
|
||||||
}
|
|
||||||
deps.portChecker.results["1.2.3.4:80"] = true
|
|
||||||
deps.portChecker.results["1.2.3.4:443"] = true
|
|
||||||
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
|
|
||||||
CommonName: testHost,
|
|
||||||
Issuer: testIssuer,
|
|
||||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
||||||
SubjectAlternativeNames: []string{
|
|
||||||
testHost,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := t.Context()
|
// expiresInThreeDays makes the TLS checker return certificates that
|
||||||
w.RunOnce(ctx)
|
// expire within the seven-day warning period.
|
||||||
|
func expiresInThreeDays(deps *testDeps) {
|
||||||
deps.portChecker.mu.Lock()
|
deps.tlsChecker.notAfter = time.Now().Add(3 * 24 * time.Hour)
|
||||||
deps.portChecker.results["1.2.3.4:443"] = false
|
|
||||||
deps.portChecker.mu.Unlock()
|
|
||||||
|
|
||||||
w.RunOnce(ctx)
|
|
||||||
|
|
||||||
notifications := deps.notifier.getNotifications()
|
|
||||||
if len(notifications) == 0 {
|
|
||||||
t.Error("expected notification for port state change")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTLSExpiryWarning(t *testing.T) {
|
func TestTLSExpiryWarning(t *testing.T) {
|
||||||
@@ -554,49 +453,9 @@ func TestTLSExpiryWarning(t *testing.T) {
|
|||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Hostnames = []string{testHost}
|
cfg.Hostnames = []string{testHost}
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
deps := runChecks(t, cfg, expiresInThreeDays, nil)
|
||||||
|
|
||||||
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
|
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
|
||||||
testNS1: {"A": {testIP}},
|
|
||||||
}
|
|
||||||
deps.resolver.ipAddresses[testHost] = []string{
|
|
||||||
testIP,
|
|
||||||
}
|
|
||||||
deps.portChecker.results["1.2.3.4:80"] = true
|
|
||||||
deps.portChecker.results["1.2.3.4:443"] = true
|
|
||||||
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
|
|
||||||
CommonName: testHost,
|
|
||||||
Issuer: testIssuer,
|
|
||||||
NotAfter: time.Now().Add(3 * 24 * time.Hour),
|
|
||||||
SubjectAlternativeNames: []string{
|
|
||||||
testHost,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
// First run = baseline
|
|
||||||
w.RunOnce(ctx)
|
|
||||||
|
|
||||||
// Second run should warn about expiry
|
|
||||||
w.RunOnce(ctx)
|
|
||||||
|
|
||||||
notifications := deps.notifier.getNotifications()
|
|
||||||
|
|
||||||
found := false
|
|
||||||
|
|
||||||
for _, n := range notifications {
|
|
||||||
if n.Priority == "warning" {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
t.Errorf(
|
|
||||||
"expected expiry warning, got: %v",
|
|
||||||
notifications,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTLSExpiryWarningDedup(t *testing.T) {
|
func TestTLSExpiryWarningDedup(t *testing.T) {
|
||||||
@@ -606,50 +465,25 @@ func TestTLSExpiryWarningDedup(t *testing.T) {
|
|||||||
cfg.Hostnames = []string{testHost}
|
cfg.Hostnames = []string{testHost}
|
||||||
cfg.TLSInterval = 24 * time.Hour
|
cfg.TLSInterval = 24 * time.Hour
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
title := "TLS Expiry Warning: " + testHost
|
||||||
|
|
||||||
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
|
// The second check comes within the TLS interval of the first,
|
||||||
testNS1: {"A": {testIP}},
|
// so it must not warn again.
|
||||||
}
|
var warnings int
|
||||||
deps.resolver.ipAddresses[testHost] = []string{
|
|
||||||
testIP,
|
deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) {
|
||||||
}
|
warnings = countNotifications(deps, title)
|
||||||
deps.portChecker.results["1.2.3.4:80"] = true
|
})
|
||||||
deps.portChecker.results["1.2.3.4:443"] = true
|
|
||||||
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
|
if warnings == 0 {
|
||||||
CommonName: testHost,
|
t.Fatal("expected expiry warnings from the first check")
|
||||||
Issuer: testIssuer,
|
|
||||||
NotAfter: time.Now().Add(3 * 24 * time.Hour),
|
|
||||||
SubjectAlternativeNames: []string{
|
|
||||||
testHost,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := t.Context()
|
got := countNotifications(deps, title)
|
||||||
|
if got != warnings {
|
||||||
// First run = baseline, no notifications
|
|
||||||
w.RunOnce(ctx)
|
|
||||||
|
|
||||||
// Second run should fire one expiry warning
|
|
||||||
w.RunOnce(ctx)
|
|
||||||
|
|
||||||
// Third run should NOT fire another warning (dedup)
|
|
||||||
w.RunOnce(ctx)
|
|
||||||
|
|
||||||
notifications := deps.notifier.getNotifications()
|
|
||||||
|
|
||||||
expiryCount := 0
|
|
||||||
|
|
||||||
for _, n := range notifications {
|
|
||||||
if n.Title == "TLS Expiry Warning: www.example.com" {
|
|
||||||
expiryCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if expiryCount != 1 {
|
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"expected exactly 1 expiry warning (dedup), got %d",
|
"expected %d expiry warnings (dedup), got %d",
|
||||||
expiryCount,
|
warnings, got,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -657,21 +491,12 @@ func TestTLSExpiryWarningDedup(t *testing.T) {
|
|||||||
func TestGracefulShutdown(t *testing.T) {
|
func TestGracefulShutdown(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
// No domains or hostnames: stopping does not involve DNS.
|
||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Domains = []string{testDomain}
|
|
||||||
cfg.DNSInterval = 100 * time.Millisecond
|
cfg.DNSInterval = 100 * time.Millisecond
|
||||||
cfg.TLSInterval = 100 * time.Millisecond
|
cfg.TLSInterval = 100 * time.Millisecond
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
w, _ := newTestWatcher(t, cfg)
|
||||||
|
|
||||||
deps.resolver.nsRecords[testDomain] = []string{
|
|
||||||
testNS1,
|
|
||||||
}
|
|
||||||
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
|
|
||||||
testNS1: {"A": {testIP}},
|
|
||||||
}
|
|
||||||
deps.portChecker.results["1.2.3.4:80"] = false
|
|
||||||
deps.portChecker.results["1.2.3.4:443"] = false
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
|
||||||
@@ -693,90 +518,52 @@ func TestGracefulShutdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHostnameIP(
|
|
||||||
deps *testDeps,
|
|
||||||
hostname, ip string,
|
|
||||||
) {
|
|
||||||
deps.resolver.allRecords[hostname] = map[string]map[string][]string{
|
|
||||||
testNS1: {"A": {ip}},
|
|
||||||
}
|
|
||||||
deps.portChecker.results[ip+":80"] = true
|
|
||||||
deps.portChecker.results[ip+":443"] = true
|
|
||||||
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
|
|
||||||
CommonName: hostname,
|
|
||||||
Issuer: testIssuer,
|
|
||||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
||||||
SubjectAlternativeNames: []string{hostname},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateHostnameIP(deps *testDeps, hostname, ip string) {
|
|
||||||
deps.resolver.mu.Lock()
|
|
||||||
deps.resolver.allRecords[hostname] = map[string]map[string][]string{
|
|
||||||
testNS1: {"A": {ip}},
|
|
||||||
}
|
|
||||||
deps.resolver.mu.Unlock()
|
|
||||||
|
|
||||||
deps.portChecker.mu.Lock()
|
|
||||||
deps.portChecker.results[ip+":80"] = true
|
|
||||||
deps.portChecker.results[ip+":443"] = true
|
|
||||||
deps.portChecker.mu.Unlock()
|
|
||||||
|
|
||||||
deps.tlsChecker.mu.Lock()
|
|
||||||
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
|
|
||||||
CommonName: hostname,
|
|
||||||
Issuer: testIssuer,
|
|
||||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
|
||||||
SubjectAlternativeNames: []string{hostname},
|
|
||||||
}
|
|
||||||
deps.tlsChecker.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
|
func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Hostnames = []string{testHost}
|
cfg.Hostnames = []string{testHost}
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
// The saved state says the last check found testHost at oldIP.
|
||||||
|
deps := runChecks(t, cfg, func(deps *testDeps) {
|
||||||
setupHostnameIP(deps, testHost, "10.0.0.1")
|
deps.state.SetHostnameState(testHost, &state.HostnameState{
|
||||||
|
RecordsByNameserver: map[string]*state.NameserverRecordState{
|
||||||
ctx := t.Context()
|
oldNS1: {
|
||||||
w.RunOnce(ctx)
|
Records: map[string][]string{"A": {oldIP}},
|
||||||
|
Status: "ok",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, nil)
|
||||||
|
|
||||||
snap := deps.state.GetSnapshot()
|
snap := deps.state.GetSnapshot()
|
||||||
if _, ok := snap.Ports["10.0.0.1:80"]; !ok {
|
|
||||||
t.Fatal("expected port state for 10.0.0.1:80")
|
if _, ok := snap.Ports[oldIP+":80"]; ok {
|
||||||
|
t.Error("port check used stale DNS: found " + oldIP + ":80")
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNS changes to a new IP; port and TLS must pick it up.
|
// Port and TLS checks must use the addresses this check found.
|
||||||
updateHostnameIP(deps, testHost, "10.0.0.2")
|
for _, ip := range addresses(snap.Hostnames[testHost]) {
|
||||||
|
if _, ok := snap.Ports[ip+":80"]; !ok {
|
||||||
|
t.Error("port check used stale DNS: missing " + ip + ":80")
|
||||||
|
}
|
||||||
|
|
||||||
w.RunOnce(ctx)
|
certKey := ip + ":443:" + testHost
|
||||||
|
if _, ok := snap.Certificates[certKey]; !ok {
|
||||||
snap = deps.state.GetSnapshot()
|
t.Error("TLS check used stale DNS: missing " + certKey)
|
||||||
|
}
|
||||||
if _, ok := snap.Ports["10.0.0.2:80"]; !ok {
|
|
||||||
t.Error("port check used stale DNS: missing 10.0.0.2:80")
|
|
||||||
}
|
|
||||||
|
|
||||||
certKey := "10.0.0.2:443:www.example.com"
|
|
||||||
if _, ok := snap.Certificates[certKey]; !ok {
|
|
||||||
t.Error("TLS check used stale DNS: missing " + certKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendTestNotification_Enabled(t *testing.T) {
|
func TestSendTestNotification_Enabled(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
// No domains or hostnames: the startup notification does not
|
||||||
|
// involve DNS.
|
||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Domains = []string{testDomain}
|
|
||||||
cfg.Hostnames = []string{testHost}
|
|
||||||
cfg.SendTestNotification = true
|
cfg.SendTestNotification = true
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
w, deps := newTestWatcher(t, cfg)
|
||||||
setupBaselineMocks(deps)
|
|
||||||
|
|
||||||
w.RunOnce(t.Context())
|
w.RunOnce(t.Context())
|
||||||
|
|
||||||
@@ -797,14 +584,11 @@ func TestSendTestNotification_ViaRun(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Domains = []string{testDomain}
|
|
||||||
cfg.Hostnames = []string{testHost}
|
|
||||||
cfg.SendTestNotification = true
|
cfg.SendTestNotification = true
|
||||||
cfg.DNSInterval = 24 * time.Hour
|
cfg.DNSInterval = 24 * time.Hour
|
||||||
cfg.TLSInterval = 24 * time.Hour
|
cfg.TLSInterval = 24 * time.Hour
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
w, deps := newTestWatcher(t, cfg)
|
||||||
setupBaselineMocks(deps)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
|
||||||
@@ -844,14 +628,11 @@ func TestSendTestNotification_Disabled(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Domains = []string{testDomain}
|
|
||||||
cfg.Hostnames = []string{testHost}
|
|
||||||
cfg.SendTestNotification = false
|
cfg.SendTestNotification = false
|
||||||
cfg.DNSInterval = 24 * time.Hour
|
cfg.DNSInterval = 24 * time.Hour
|
||||||
cfg.TLSInterval = 24 * time.Hour
|
cfg.TLSInterval = 24 * time.Hour
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
w, deps := newTestWatcher(t, cfg)
|
||||||
setupBaselineMocks(deps)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
|
||||||
@@ -884,32 +665,22 @@ func TestNSFailureAndRecovery(t *testing.T) {
|
|||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Hostnames = []string{testHost}
|
cfg.Hostnames = []string{testHost}
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
// Between the checks, save every nameserver the first check found
|
||||||
|
// as failed, and add, as answering, one that live DNS does not list.
|
||||||
|
deps := runChecks(t, cfg, nil, func(deps *testDeps) {
|
||||||
|
hs, _ := deps.state.GetHostnameState(testHost)
|
||||||
|
for _, nsState := range hs.RecordsByNameserver {
|
||||||
|
nsState.Status = "error"
|
||||||
|
}
|
||||||
|
|
||||||
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
|
hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{
|
||||||
testNS1: {"A": {testIP}},
|
Records: map[string][]string{"A": {oldIP}},
|
||||||
testNS2: {"A": {testIP}},
|
Status: "ok",
|
||||||
}
|
}
|
||||||
deps.resolver.ipAddresses[testHost] = []string{
|
|
||||||
testIP,
|
|
||||||
}
|
|
||||||
deps.portChecker.results["1.2.3.4:80"] = false
|
|
||||||
deps.portChecker.results["1.2.3.4:443"] = false
|
|
||||||
|
|
||||||
ctx := t.Context()
|
deps.state.SetHostnameState(testHost, hs)
|
||||||
|
})
|
||||||
|
|
||||||
w.RunOnce(ctx)
|
assertNotified(t, deps, "NS Failure: "+testHost, "error")
|
||||||
|
assertNotified(t, deps, "NS Recovery: "+testHost, "success")
|
||||||
deps.resolver.mu.Lock()
|
|
||||||
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
|
|
||||||
testNS1: {"A": {testIP}},
|
|
||||||
}
|
|
||||||
deps.resolver.mu.Unlock()
|
|
||||||
|
|
||||||
w.RunOnce(ctx)
|
|
||||||
|
|
||||||
notifications := deps.notifier.getNotifications()
|
|
||||||
if len(notifications) == 0 {
|
|
||||||
t.Error("expected notification for NS disappearance")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -1,14 +1,18 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
|
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
|
||||||
# make fmt-check and golangci-lint; its builder stage runs make test
|
# make fmt-check and golangci-lint; its builder stage runs make test
|
||||||
# and make build. A successful build implies all of those passed.
|
# and make build.
|
||||||
|
#
|
||||||
|
# --no-cache-filter=lint,builder runs both stages on every invocation;
|
||||||
|
# otherwise an unchanged tree is served from the layer cache and passes
|
||||||
|
# without linting or querying live DNS.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build .
|
docker build --no-cache-filter=lint,builder .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# Identical in all repos; the tag comes from script/projectname.
|
# The tag comes from script/projectname.
|
||||||
|
#
|
||||||
|
# --no-cache-filter=lint,builder runs the lint stage and the builder
|
||||||
|
# stage (make test) on every invocation; otherwise an unchanged tree is
|
||||||
|
# served from the layer cache without linting or querying live DNS.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -8,7 +12,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
docker build --no-cache-filter=lint,builder -t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user