Compare commits

..
Author SHA1 Message Date
sneak a75d4d51fd tests: rename internal/livedns to livednstest and deny it outside tests (closes #164)
check / check (push) Successful in 1m38s
The live-DNS retry and concurrency limit is only for tests, but nothing
stopped program code from importing it and compiling it into the
binary. Its directory name now ends in test, and its import path is on
the test-support deny list in .golangci.yml, so make lint fails when
program code imports it. Every import and mention is updated to the new
name.

Model: opus-5-5
2026-09-29 10:44:22 +00:00
clawbot 93c1fe15e3 golangci: re-vendor the org config with gomodguard_v2 (closes #123)
check / check (push) Successful in 1m10s
The org .golangci.yml now uses gomodguard_v2 in place of the
deprecated gomodguard, which made every lint run print a deprecation
warning. The file is copied unchanged from sneak/prompts. It also 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's previous copy had no deny entries
of its own, so there were none to carry forward.

Model: opus-5-5
2026-09-29 10:27:07 +02:00
clawbot 6dd6043534 tests: remove the DNS stand-ins from the watcher and resolver tests (closes #159)
check / check (push) Successful in 1m21s
The watcher tests used a stand-in resolver and the resolver timeout test
a stand-in DNS client, against the rule that DNS is never mocked.
Watcher tests that look something up in DNS now run the real resolver
against live servers, each attempt on a new watcher. A DNS change is
tested by saving values live DNS never returns (names under .invalid,
192.0.2.1) in the state a check starts from, or by marking a real
nameserver failed. The timeout test queries 192.0.2.1, where nothing
answers. The live-DNS retry and concurrency limit moved from the
resolver tests to internal/livedns, so both packages share them.
NewFromLoggerWithClient had no other use and is gone. TESTING.md now
states the README's rule.

Model: opus-5-5
2026-09-29 08:44:07 +02:00
clawbot a93389e1a0 watcher: send the inconsistency alert once per disagreement (closes #158)
check / check (push) Successful in 1m5s
detectInconsistencies alerted for neighbouring pairs of nameservers whose
records differed, on every DNS check, for as long as they differed. It now
also takes the previous hostname state, compares every pair of nameservers,
and alerts for a pair that differs unless both were in that state and
already differed there, so a nameserver new on a check that answers
differently is reported once. The state loaded at startup is the previous
state for the first check, so a disagreement saved before a restart is not
reported again. The choice of pairs is tested on record data, and the alert
through the hostname change detection with the notifier stand-in and no
resolver. The README describes the new behaviour.

Model: opus-5-5
2026-09-29 02:29:53 +02:00
clawbot 95b017eb3e resolver: lower-case DNS names in record values (closes #157)
check / check (push) Successful in 58s
Nameservers may answer with DNS names in any letter case. For eeqj.de,
y.ns.joker.com answers in upper case while its peers answer in lower
case, so the inconsistency check fired on every cycle. extractRecordValue
now lower-cases CNAME, MX, SRV and NS targets, so the inconsistency check
and the record-change check both compare names regardless of case. A,
AAAA, TXT and CAA values are formatted as before. State saved before this
change can hold upper-case names, which report a one-time record change
on the first check after upgrading.

Model: opus-5-5
2026-09-29 00:30:29 +02:00
clawbot 1ab0b9f61d script: force lint and test to run in cibuild and docker (closes #115)
check / check (push) Successful in 59s
script/cibuild and script/docker were plain docker build. On an
unchanged tree the lint stage and the builder stage, which runs make
test, came from the layer cache, so the build passed without linting
or querying live DNS. Both scripts now pass
--no-cache-filter=lint,builder so those stages run on every build, as
script/lint already does for its own lint stage. Dependency downloads
inside those stages re-run each build. Each of the two stages in the
Dockerfile now notes that the scripts name it. README and TODO.md
updated to match.

Model: opus-4-8 (implementation); opus-5-5 (rework)
2026-09-28 23:13:38 +02:00
clawbot 19f282c8b3 server: assert timeouts on the served http.Server, not just the constructor (closes #120)
check / check (push) Successful in 6s
The timeout tests called newHTTPServer directly, so a Run that built
its http.Server inline would drop every timeout with the suite still
green. TestRunWiresSocketTimeouts wires a Server as cmd/dnswatcher
does, minus the watcher and resolver so no live DNS is touched, drives
Run with an unbindable port so it stores its http.Server and returns
without listening, and checks that server carries all four timeouts
and both required relationships. The addr/handler test, which could
not fail, is dropped.

The ReadTimeout note now says what net/http does: a request whose
headers arrive after ReadTimeout but within ReadHeaderTimeout gets a
read deadline that has already passed, so reading its body fails at
once.

Model: opus-4-8 (implementation); opus-5-5 (rework)
2026-09-28 22:01:36 +02:00
clawbot 148e47d9c0 docker: run as non-root, add health check, document upaas (closes #147)
check / check (push) Successful in 5s
The runtime image runs as uid 10001, which owns /var/lib/dnswatcher. The
working directory is /, so config loading finds no .env or dnswatcher
config file there; the binary lives in /usr/local/bin. A Docker
HEALTHCHECK probes /.well-known/healthcheck every 10 seconds with busybox
wget, well inside the 60 seconds upaas waits.

Startup now fails with an error naming the data directory when it cannot
be written, instead of running with every save failing. The check creates
the directory if needed and writes and removes the temp file Save uses;
tests cover the create and the write failing.

README gains "Running under upaas": the prod branch, host directory
setup, network and port, environment and health check.

Model: opus-5-5
2026-09-28 20:13:37 +02:00
clawbot 8aaa103956 middleware: add security response headers (closes #98)
check / check (push) Successful in 54s
Adds a SecurityHeaders middleware and registers it globally, right after the request ID middleware, so every route gets the headers, including static files, /metrics and error responses.

It sets Strict-Transport-Security (one year, includeSubDomains), a Content-Security-Policy with default-src 'self', no scripts and frame-ancestors 'none', X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy no-referrer and a Permissions-Policy that turns every listed feature off.

HSTS is sent on every response, not only over TLS: the service runs behind a TLS-terminating proxy and REPO_POLICIES.md requires the application to send it. Referrer-Policy is stricter than the policy baseline because dashboard URLs can name internal hosts.

model: claude-opus-4-8 (implementation); claude-fable-5 (commit message)
2026-09-22 00:52:48 +02:00
clawbot c2a07ce690 test: add tests for globals, healthcheck and logger (closes #110)
check / check (push) Failing after 0s
Tests for the three packages that had none, written from outside each package, each able to fail on a plausible break:

- globals: values set are read back through New, and New returns an independent copy. One sequential test function with a disclosed paralleltest suppression, because it changes shared package variables.
- healthcheck: Check returns status "ok", the documented JSON fields, an RFC3339Nano timestamp, the maintenance flag from config in both states, and version and appname from globals.
- logger: New gives a usable *slog.Logger, debug output is off by default and EnableDebugLogging turns it on.

No production code changed. The terminal output format is not asserted.

Model: opus-4-8
2026-09-21 10:05:38 +02:00
clawbot b351a2350c gomod: drop stale golang.org/x/sync indirect line (closes #132)
check / check (push) Failing after 0s
go.mod listed golang.org/x/sync v0.19.0 twice: once in the direct
require block and once as // indirect. The module is a genuine direct
dependency (internal/portcheck/portcheck.go imports
golang.org/x/sync/errgroup), so the indirect entry was redundant and
stale. script/bootstrap ends with go mod download, which dropped that
line as a side effect and left every fresh checkout with a dirty
go.mod. Running go mod tidy removes the line for good; no dependency
and no version changed, and go.sum is unaffected.

Model: opus-4-8
2026-09-21 09:44:49 +02:00
clawbot 1ffe303a6e Merge main into next, resolving TODO.md (closes #145)
check / check (push) Failing after 1s
The last milestone landed on main as a squash, so main was no longer an
ancestor of next and git merge-tree reported a TODO.md conflict: both
branches added the same block of Completed Steps entries, and next added
one more (the #99 http.Server timeouts entry). This real merge commit
makes main an ancestor of next again. TODO.md is resolved by hand to keep
every entry from both sides exactly once, which is next's version since
next's entries are a superset of main's. Every other file already equals
next. No new TODO.md entry is added, per the task brief.

Model: opus-4-8
2026-09-21 07:22:17 +00:00
clawbot fc43f893a5 server: set all four http.Server socket timeouts (#118)
check / check (push) Successful in 3m50s
The http.Server was built with only ReadHeaderTimeout set; the other three
timeouts were zero, which in net/http means no limit, so a peer could hold a
connection open past the header phase, responses had no write deadline, and
keep-alive connections were never reaped. ReadTimeout 15s, WriteTimeout 75s,
and IdleTimeout 120s now join ReadHeaderTimeout 10s as named constants.
WriteTimeout must stay above the 60s chimw.Timeout handler budget, because
net/http arms the write deadline once request headers are read; a test fails
the build if either number moves alone. The server literal moved into
newHTTPServer so the configuration can be asserted without binding a socket
(closes #99)

Model: opus-5
2026-09-09 15:17:43 +02:00
clawbotandsneak e77e206fc4 next (#136)
check / check (push) Successful in 6s
Long-lived integration branch. One commit per work unit lands here; this PR accumulates them until it is merged to `main`.

## Landed units

- **Run all linting in Docker via `Dockerfile.lint` + `script/lint`** — #134

    golangci-lint is no longer installed or run on the host. New root `Dockerfile.lint` COPYs the repo into the digest-pinned `golangci/golangci-lint:v2.12.2` image and lints as a build step, so a successful build IS a clean lint; `script/lint` is reduced to a thin wrapper that builds it. This also works where the docker daemon is remote and bind mounts are impossible.

    **Pinned digest and how it was verified.** `golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, exactly as quoted in the issue. It resolves, and it is genuinely v2.12.2:

    ```
    $ docker buildx imagetools inspect golangci/golangci-lint:v2.12.2
    Name:      docker.io/golangci/golangci-lint:v2.12.2
    MediaType: application/vnd.oci.image.index.v1+json
    Digest:    sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240

    $ docker run --rm golangci/golangci-lint@sha256:5cceeef04e...ad5240 golangci-lint --version
    golangci-lint has version 2.12.2 built with go1.26.2 from c0d3ddc9 on 2026-05-06T11:07:58Z
    ```

    The tag's index digest is the quoted digest, and the binary inside reports commit `c0d3ddc9`, matching the org's canonical pin `c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.

    **Forcing the linter to actually run.** `Dockerfile.lint` is split into a `deps` stage (base image + `go mod download`) and a `lint` stage (source copy + linter run). `script/lint` runs:

    ```
    docker build --progress=plain --no-cache-filter=lint --target lint -f Dockerfile.lint .
    ```

    Caching is explicitly waived for linting, and a cached build lints nothing, so the `lint` stage is invalidated on every invocation. The invalidation is scoped: the `deps` stage stays cached and no global cache wipe is performed. `--progress=plain` keeps the linter's own output visible.

    **`golangci-lint config verify`: deliberately NOT included.** It fetches its JSON schema over a live, unpinned HTTPS call, which would make linting network-dependent and defeat hash-pinning. Omitted for that reason, and the reason is recorded in a comment at the top of `Dockerfile.lint`.

    **`script/bootstrap`** no longer installs golangci-lint (and its pinned ref is gone); it warns non-fatally when `docker` is absent instead. The `goimports` install stays, because `script/fmt` and `script/fmt-check` still run on the host. Header comment updated accordingly.

    **Root `Dockerfile`** — required consequence, not scope creep. Its builder stage ran `make check`, which now calls `script/lint`, which shells out to `docker build`; there is no docker daemon inside a docker build, so `script/cibuild` and `script/docker` would have broken. It gains its own lint stage on the same pinned image (linter invoked directly, with a comment explaining why not `make lint`), with the builder stage depending on it via `COPY --from=lint /src/go.sum /dev/null` and running `make fmt-check`, `make test`, `make build`. The now-unneeded golangci-lint install is gone from the builder stage.

    **README** `Entrypoints` and `Building` sections now describe linting as a docker-only operation. `TODO.md` updated in the same commit.

    ### Verification

    All runs via `make` / `script/` entrypoints only.

    Two consecutive `make lint` runs on an unchanged tree, both executing the linter:

    ```
    # run 1
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 10.98 0 issues.
    #10 DONE 12.0s

    # run 2, tree untouched
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 14.63 0 issues.
    ```

    A third run shows the cache scoping is working as intended — `deps` served from cache, `lint` re-executed:

    ```
    #6 [deps 2/4] WORKDIR /src
    #6 CACHED
    #7 [deps 3/4] COPY go.mod go.sum ./
    #7 CACHED
    #8 [deps 4/4] RUN go mod download
    #8 CACHED
    #9 [lint 1/2] COPY . .
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    ```

    **Negative control.** A deliberate violation (an unused function containing an ineffectual assignment) was added to `internal/config/config.go`:

    ```
    #10 11.26 internal/config/config.go:29:2: ineffectual assignment to x (ineffassign)
    #10 11.26 internal/config/config.go:28:6: func negativeControlUnused is unused (unused)
    #10 11.26 2 issues:
    #10 ERROR: process "/bin/sh -c golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1
    ERROR: failed to build: failed to solve: process "/bin/sh -c golangci-lint run --config .golangci.yml ./..." did not complete successfully: exit code: 1
    make: *** [Makefile:26: lint] Error 1
    ```

    `make lint` exited non-zero naming both findings and their exact lines. After reverting the file, `make lint` was clean again (`0 issues.`).

    **`make check`** green end to end (test, lint, fmt-check), exit 0.

    **`script/cibuild`** green, confirming the `Dockerfile` restructure does not recurse: the lint stage ran (`#16 12.56 0 issues.`), then `#22 [builder 8/9] RUN make test` with `PASS` lines, then `#23 [builder 9/9] RUN make build`.

    ### Notes for the owner

    This supersedes two PRs you still have queued for merge, both of which tune host linting that no longer exists after this change: #128 (isolates the host golangci-lint cache and lock) and #131 (always installs the pinned lint tools in `script/bootstrap`). Neither was merged or incorporated here.

    Made moot by this change: #121 and #130.

    ### Review outcome

    Reviewed at #136 (comment) — **PASS**. Three non-blocking comment-accuracy findings were recorded there for the next touch of those files; the reviewer independently confirmed the lint gate is live by negative control from a warm cache.

- **Live DNS tests made robust instead of gated; test caps moved to the org-wide 60s/20s/90s values** — #93

    The resolver's live-DNS tests failed nondeterministically, a different subset each run. Fixed by engineering the nondeterminism out, not by routing around the network. **Nothing is mocked, faked, stubbed, recorded or replayed; there is no `-short` flag, no build tag, no skip, and no environment-tolerance for restricted egress.** Production resolver behaviour is unchanged.

    ### Root causes, all test-side

    1. **Burst fan-out at one root server.** Every test in `internal/resolver` calls `t.Parallel()` and the build hosts have many cores (48 here), so all ~35 iterative resolutions started within milliseconds of each other, and because `queryServers` walks `rootServerList()` in fixed order they all aimed their first query at `198.41.0.4`. Root servers rate-limit that, which fits the reported symptom of a different arbitrary subset failing each run.
    2. **No retry anywhere.** One dropped UDP packet in a delegation chain failed a test outright.
    3. **Unanimity assertions.** `TestQueryAllNameservers_AllReturnOK` and `_NXDomainFromAllNS` required *every* nameserver of a domain to answer — four independent chances to fail per run, with no tolerance for one being slow.

    ### What was built

    New `internal/resolver/livedns_test.go` holds all the live-DNS machinery, so `resolver_test.go` itself takes only call-site edits:

    - **Bounded live concurrency.** A package-wide semaphore (`liveConcurrency = 6`) caps how many live resolutions are in flight at once. Tests keep `t.Parallel()`; only their network work is throttled. This is the direct fix for cause 1, and the 60s budget is what makes it affordable.
    - **Retry with exponential backoff.** Three attempts per live operation, 8s deadline each, 500ms base backoff doubling. The retry predicate is deliberately **transport-level** — "did a nameserver answer at all" — and never the assertion the test is making, so a resolver that answers *incorrectly* still fails on the first attempt rather than being retried into a false green.
    - **Quorum instead of unanimity, tolerating SILENCE ONLY.** A strict majority of the discovered nameservers must answer as expected, and every individual result must additionally fall inside a closed **allowlist** of statuses the test explicitly sanctions: `ok`/`timeout`/`error` for the all-OK test, `nxdomain`/`timeout`/`error` for the NXDOMAIN test. A nameserver that stays silent is tolerated; one that answers **wrongly** is not, at any count. The allowlist is the load-bearing part — see the rework note below for why a blocklist was not enough.

    New `internal/resolver/livedns_harness_test.go` tests that machinery directly — quorum arithmetic, status counting, the allowlist, the gate's concurrency bound, per-attempt deadlines, and recovery from a transient failure. It performs no DNS resolution of any kind, so it neither mocks DNS nor depends on it.

    ### Rework after review — the quorum could not fail on a wrong answer

    The review at #136 (comment) returned **FAIL** on `9cb2c2b`, correctly. Fixed in `87bce43`.

    **The defect.** The claim above was, as first written, false for `resolver.StatusNoData`. Each test banned exactly one wrong status — `_AllReturnOK` banned only `nxdomain`, `_NXDomainFromAllNS` banned only `ok` — and `nodata` is neither. It is a **wrong answer, not silence**: `answeredCount` counted it as answered, so it did not even trigger a retry, and with a quorum of 3-of-4 a single wrong nameserver slid through undetected. The pre-change unanimity assertions would have caught it. That is robustness work quietly becoming assertion-loosening, which is exactly what this repo cannot afford.

    **The fix.** Tolerance is now an allowlist, not a blocklist of one status. New `unsanctionedStatuses()` returns every per-nameserver result whose status the caller did not explicitly sanction, and each test asserts that list is empty in addition to its quorum. A blocklist bans the one wrong answer its author thought of and silently admits everything else, including any status added to the resolver later; an allowlist fails on anything nobody sanctioned. `answeredCount` was reframed the same way — it now counts the closed set `ok`/`nxdomain`/`nodata`, so an unfamiliar status is treated as silence and can only ever cause a retry and then a loud failure, never a quiet pass.

    **Evidence — the reviewer's exact probe, re-run.** `queryEachNS` in `internal/resolver/iterative.go` was patched to force one of `google.com`'s four nameservers to return `StatusNoData` with empty records. `make test` now goes **red**, naming the offending nameserver and status:

    ```
    exit=2
    --- FAIL: TestQueryAllNameservers_AllReturnOK (1.12s)
        Error:    Should be empty, but was [ns1.google.com.=nodata]
        Messages: every nameserver must answer OK or not answer at all:
                  ns1.google.com.=nodata ns2.google.com.=ok
                  ns3.google.com.=ok ns4.google.com.=ok
    --- FAIL: TestQueryAllNameservers_NXDomainFromAllNS (1.34s)
        Error:    Should be empty, but was [ns1.google.com.=nodata]
        Messages: every nameserver must report NXDOMAIN or not answer at all:
                  ns1.google.com.=nodata ns2.google.com.=nxdomain
                  ns3.google.com.=nxdomain ns4.google.com.=nxdomain
    FAIL  sneak.berlin/go/dnswatcher/internal/resolver  1.704s
    ```

    That is the same input that returned `exit=0` with both tests **passing** under the old assertions. Probe reverted, tree clean, suite green again:

    ```
    exit=0
    ok  sneak.berlin/go/dnswatcher/internal/resolver  4.185s  coverage: 77.1% of statements
    (zero `(cached)` lines)
    ```

    Two harness tests lock the regression in without any probe: three OK plus one `nodata` (quorum satisfied, no NXDOMAIN present — the exact input that used to pass) is reported as unsanctioned, and an unknown status is neither counted as answered nor tolerated.

    Also fixed from the review: the per-attempt deadline assertion in `livedns_harness_test.go` had no lower bound, so it passed for a deadline far shorter than intended. It now asserts the remaining time exceeds `liveAttemptTimeout/2` as well.

    Deliberately **not** done in this rework, per the review and the owner: no `-count=1` in `script/test` (the test-cache issue is real but pre-existing and repo-wide, filed separately); the remaining non-DNS mocks stay for #97; `queryServers` root-ordering stays untouched under #138.

    ### The `-timeout` backstop value: 90s

    Per the ruling at sneak/prompts#41 (comment) the cap is org-wide with two tiers: **60s hard cap for CI green, 20s target, and anything between the two must be filed as an improvement bug.** The backstop is **`90s`**, matching sneak/prompts#42 and preserving the 1.5x backstop-to-cap ratio the old 20s/30s pair had. It must strictly exceed the 60s cap or the cap is unreachable — the old `-timeout 30s` would have killed a 60s-capped suite at half its allowance. Applied to `script/test`; nothing else in the repo carried the old `30s`.

    Worst case for one live operation is 3 attempts x 8s plus ~1.5s of backoff, about 26s — comfortably inside the 90s backstop even if several operations exhaust their attempts at once.

    ### `REPO_POLICIES.md` is re-vendored, not hand-edited

    The file is org-canonical, so it was **copied byte-for-byte** from `prompts/REPO_POLICIES.md` on `sneak/prompts` branch `org-wide-60s-test-cap` (commit `52b5192`) rather than reworded to approximately the same thing. Verified:

    ```
    $ cmp prompts/REPO_POLICIES.md dnswatcher/REPO_POLICIES.md && echo identical
    identical
    $ sha256sum REPO_POLICIES.md
    bcf11c312a1bee18a0e937eb412b51914411c1ab23308b8362409f3f88379ff7
    ```

    **What that byte-identity does and does not certify.** The source branch `org-wide-60s-test-cap` is an **unmerged proposal** — sneak/prompts#42 — not `prompts` `main`. So, precisely:

    - The **60s hard cap and 20s improvement-bug tier ARE the owner's ruling** (sneak/prompts#41 (comment)).
    - The **`90s` backstop is our own proposed number and is NOT ratified** (sneak/prompts#41 (comment)).
    - The vendored text is therefore **the proposed canonical text, pending** sneak/prompts#42. If that PR lands with different numbers, this file must be re-vendored to match; it should not be hand-edited here either way.

    **Known mismatch with this repo's actual state, recorded not papered over.** Re-vendoring also picked up the paragraph at `REPO_POLICIES.md:266-271` mandating that canonical golangci-lint be installed commit-pinned via `go install ...@c0d3ddc9...`. This repo does **not** comply with that mechanism: `cc86473` in this same PR made linting Docker-only, and `script/bootstrap` now installs golangci-lint nowhere. The **version and commit match** (`v2.12.2` / `c0d3ddc9`); the **installation mechanism does not**. The vendored file is org-canonical and must not be edited downstream, so this is being raised upstream for the org text to accommodate Docker-only linting rather than patched here.

    `TESTING.md`'s stale "within the 30-second target" follows to 60. That edit is deliberately a single line so it merges cleanly when #97 lands.

    ### Verification

    All runs through `make` / `script/` entrypoints only; lint runs in Docker.

    **Ten consecutive `make check` runs, all green, none served from cache.** Go's test cache will happily report `ok pkg (cached)` without executing anything, which proves nothing about nondeterminism, so every run was forced to actually execute and each log was checked for zero `(cached)` lines:

    ```
    check#1  exit=0 wall=32s resolver=2.895s fails=0 cached=0
    check#2  exit=0 wall=27s resolver=2.872s fails=0 cached=0
    check#3  exit=0 wall=47s resolver=2.729s fails=0 cached=0
    check#4  exit=0 wall=36s resolver=2.885s fails=0 cached=0
    check#5  exit=0 wall=32s resolver=2.899s fails=0 cached=0
    check#6  exit=0 (harness tests added)      fails=0 cached=0
    check#7  exit=0 wall=41s resolver=2.891s fails=0 cached=0
    check#8  exit=0 wall=36s resolver=2.871s fails=0 cached=0
    check#9  exit=0 wall=26s resolver=2.846s fails=0 cached=0
    check#10 exit=0 wall=46s resolver=2.833s fails=0 cached=0
    ```

    After the rework commit `87bce43`, `make check` green again end to end, zero `(cached)` test lines, Docker lint stage demonstrably executed rather than served from cache:

    ```
    exit=0  cached=0
    #8 [deps 4/4] RUN go mod download
    #8 CACHED
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 30.72 0 issues.
    ```

    The Docker lint stage was confirmed to execute rather than cache on each run:

    ```
    #8 [deps 4/4] RUN go mod download
    #8 CACHED
    #9 [lint 1/2] COPY . .
    #9 DONE 0.6s
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 DONE 39.7s
    ```

    **`make test` wall time: 3.6-4.1s** across three timed uncached runs (`4093ms`, `3649ms`, `3729ms`). `internal/resolver` went from 2.0s to ~2.9s — the concurrency gate's cost. That is inside the 20s target, so no improvement bug is owed under the new two-tier rule.

    **Honest note on what these runs do and do not prove.** Live DNS was healthy throughout: **no live-DNS retry fired even once**, and no flake was observed either before or after the change (six pre-change baseline runs were also clean). So these runs demonstrate the change is not itself flaky and does not slow the suite; they do **not** demonstrate recovery from a real DNS failure, because no real DNS failure occurred. The original flakiness is *not reproduced* rather than *shown fixed*. The retry path is instead proven by `TestRetryLiveRecoversFromTransientFailure`, the only source of the single `retrying in 500ms` line in each log:

    ```
    livedns_harness_test.go:90: transient: attempt 1 of 3 failed
        (no answer from live DNS), retrying in 500ms
    ```

    ### Observation, not acted on

    The single most effective remaining lever against root-server rate limiting would be to stop `queryServers` always trying `rootServerList()` in the same order, so that load spreads across all thirteen roots instead of concentrating on `a.root-servers.net`. That is **production** code and this issue scopes the work as test-side, so it was left alone rather than changed quietly. It is now tracked for the owner's decision at #138.

    ### Interaction with #97

    `TESTING.md` and `internal/resolver/resolver_test.go` auto-merge — that PR touches `resolver_test.go` only at the import block and the final timeout-test section, while this change touches the body of the file and adds two new files, and it leaves the mock-`DNSClient` timeout test at the tail of `resolver_test.go` entirely alone since removing it is that PR's job. `TODO.md` does conflict; that PR is already labelled `needs-rebase`, so this adds nothing material to its rebase.

- **Go's test cache disabled, so every `make test` actually queries live DNS** — #139

    `script/test` did not pass `-count=1`, so on an unchanged tree Go served the whole suite from cache: exit 0 in ~0.2s, every package marked `(cached)`, and not one DNS query made. This repo's suite exists to exercise live resolution on every run (`TESTING.md`), so that green asserted nothing — and it is exactly the green used as evidence that a flakiness fix works, since "run it a few times" stops being runs after the first. It had already misled two agents, each of whom forced uncached runs by hand.

    `-count=1` now disables caching on every invocation.

    **The conditional verbose rerun was missing and is added here.** `REPO_POLICIES.md` mandates it; the primary run had been unconditionally `-v`, which is the failure mode the policy exists to prevent (unreadable CI and `docker build` logs on success). Tests now run quiet, and only a failure triggers the `-v` rerun. Two properties matter and both are covered: the rerun carries `-count=1` too, so it cannot replay a cached copy of the failure it is meant to diagnose; and its exit status is discarded in favour of a forced `1`, so a flake that passes the second time cannot turn the build green — the first failure already proved the suite broken.

    **`-timeout 90s` untouched.** It is a deliberate backstop that must strictly exceed the 60s hard cap.

    **No special-casing for the Docker build**, which reaches the same script via `RUN make test`: a fresh container's test cache is empty, so `-count=1` is a no-op there, and carving out an exception would only create a second code path that could drift.

    ### Verification

    **Proven from a warm cache, not a cold one.** The suite was run first to populate the cache, and the pre-change state confirmed:

    ```
    ok  sneak.berlin/go/dnswatcher/internal/config    (cached)  coverage: 92.6% of statements
    ok  sneak.berlin/go/dnswatcher/internal/resolver  (cached)  coverage: 77.1% of statements
    ...8 of 8 packages (cached)...
    real 0m0.203s
    ```

    With the change applied to that same warm cache, three back-to-back runs on an unchanged tree, **zero `(cached)` markers** in all three:

    ```
    # run 1                                          # run 2
    ok  .../internal/config     1.045s               ok  .../internal/config     1.040s
    ok  .../internal/handlers   1.029s               ok  .../internal/handlers   1.021s
    ok  .../internal/notify     1.148s               ok  .../internal/notify     1.252s
    ok  .../internal/portcheck  1.026s               ok  .../internal/portcheck  1.025s
    ok  .../internal/resolver   3.005s               ok  .../internal/resolver   2.846s
    ok  .../internal/state      1.078s               ok  .../internal/state      1.097s
    ok  .../internal/tlscheck   1.067s               ok  .../internal/tlscheck   1.079s
    ok  .../internal/watcher    1.566s               ok  .../internal/watcher    1.544s
    real 0m4.174s                                    real 0m4.015s

    # run 3: grep -c '(cached)' => 0                 real 0m4.519s
    ```

    **Measured uncached wall time: 4.0-4.5s** (was ~0.2s served from cache). Inside the 20s target, so no improvement bug is owed under the two-tier rule at sneak/prompts#41 (comment).

    **`-count=1` composes with `-race` and `-cover`**: both still present in the primary run, and the per-package coverage percentages above are identical to the pre-change values.

    **The failure path was exercised, not assumed.** A purpose-built flaky test that fails on its first run and passes every run after (marker file kept outside the module, so the tree stays byte-identical and a cached result would be served if caching were on) was run through the script:

    ```
    exit code: 1
    --- FAIL: TestFlaky (0.00s)
    FAIL    flakeproof  0.013s
    --- Rerunning with -v for details ---
    --- PASS: TestFlaky (0.00s)
    ok      flakeproof  1.014s
    ```

    Quiet failure, verbose rerun that genuinely re-executed (it passed, so it did not replay the cached `FAIL`), and exit `1` regardless of the rerun passing. Scratch module removed afterwards.

    **`make check` green**, exit 0, with the Docker lint stage demonstrably executed rather than served from cache:

    ```
    #8  [deps 4/4] RUN go mod download
    #8  CACHED
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 21.36 0 issues.
    #10 DONE 26.2s
    ```

    `README.md` and `TESTING.md` record why the cache is waived, and `TODO.md` is updated in the same commit.

    ### Question for the owner, not filed as a defect

    The Docker lint run emits `The linter 'gomodguard' is deprecated (since v2.12.0) due to: new major version. Replaced by gomodguard_v2.` It is pre-existing and out of this issue's scope. It is not filed as an issue here because `.golangci.yml` tracks the org-canonical config, so switching to `gomodguard_v2` looks like an upstream `sneak/prompts` decision rather than a per-repo fix. Say the word and it gets filed in whichever place you consider canonical.

- **MIT `LICENSE` added; README states the licence** — #102

    The repo had no licence file at all, so publicly readable code was all-rights-reserved by default and nobody could legally use it. `LICENSE` was also the last file missing from `REPO_POLICIES.md`'s required minimum.

    MIT, by standing org policy rather than a per-repo call: any public repo lacking a licence gets MIT, and a private one with no licence is already all-rights-reserved. `sneak/dnswatcher` is public (`private: false` on the Gitea repo record).

    `README.md`'s first line now names the licence, per the Description requirement, and the License section states MIT and points at the file instead of recording the decision as pending. `TODO.md` updated in the same commit.

    ### Verification

    `LICENSE` is the canonical MIT text byte-for-byte with only the copyright line filled in (`Copyright (c) 2026 sneak`) — no clauses added, removed, reworded, or reflowed. It was not typed from memory: the file was copied from an existing verbatim MIT template on disk and only the copyright line edited (`diff` against that template shows that one line and nothing else), then the result was word-diffed against SPDX `MIT.txt` fetched from `spdx/license-list-data`, ignoring only line wrapping and the placeholder — identical.

    `make fmt` did **not** touch `LICENSE`, and cannot: `script/fmt` runs `gofmt -s -w .` and `goimports -w .` only, with no prettier or markdown step in the repo, so no exclusion was needed.

    `make check` green, exit 0. Tests executed rather than replayed (zero `(cached)` lines, `internal/resolver 3.098s`), and the Docker lint stage ran rather than cached:

    ```
    #10 [deps 4/4] RUN go mod download
    #10 CACHED
    #12 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #12 36.44 0 issues.
    #12 DONE 36.7s
    ```

- **Comment-only corrections in `script/bootstrap`, `script/cibuild`, and `Dockerfile.lint`** — #137

    Follow-up to this PR's own review. Nothing executable changed: the diff touches comment lines, one warning string, and `TODO.md`.

    - `script/bootstrap`'s header justified the pinned `goimports` install by claiming `script/fmt-check` runs it on the host. Verified against the script: `script/fmt-check` runs `gofmt -l .` and nothing else. The header now credits `script/fmt` alone. That `fmt-check` does not verify goimports at all is #119 and was deliberately left alone.
    - `script/cibuild`'s header still said the `Dockerfile` runs `make check`. It now describes the current file: lint stage runs `make fmt-check` and `golangci-lint`, builder stage runs `make test` and `make build`.
    - The `docker`-missing warning was three fragments, each re-prefixed with `bootstrap:` mid-clause. Now one sentence: `bootstrap: WARNING: docker not found; install it to run make lint and make docker.`
    - `Dockerfile.lint`'s comment explained why `golangci-lint config verify` is omitted but read as though the omission were free. It now states the residual risk: unknown top-level keys in `.golangci.yml` are silently ignored, so a mistyped or wrong-schema key lints clean while applying nothing. `config verify` was **not** added — the network-dependence reasoning stands.

    ### Verification

    `make check` green, exit 0. Tests executed rather than replayed (zero `(cached)` lines, `internal/resolver 2.820s`), Docker lint stage executed rather than cached:

    ```
    #8 [deps 4/4] RUN go mod download
    #8 CACHED
    #10 [lint 2/2] RUN golangci-lint run --config .golangci.yml ./...
    #10 13.30 0 issues.
    ```

    Comment-only confirmed by reading the whole diff: no statement, flag, or command changed anywhere.

---

## Issues closed by this merge

The commits on `next` each carry a bare `(closes #N)` in their subject, but the
references in the prose above are full URLs, which Gitea's auto-close parser
does not act on. Listing them here in bare form so the merge to `main`
definitively closes them rather than leaving them open to be re-picked up as
idle work:

Closes #93
Closes #102
Closes #134
Closes #137
Closes #139

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #136
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-09-09 14:57:55 +02:00
clawbot ae06f7e3a1 notify: drain in-flight deliveries at shutdown (closes #106)
check / check (push) Successful in 1m22s
Shutdown waits for deliveries already in flight instead of dropping them.

Reviewed and green; squashed to next by the dispatcher, dnswatcher having no manager.

Model: opus-5
2026-09-09 14:26:53 +02:00
sneak b8662b8a9c docs: correct stale script headers and record the config-verify cost (closes #137)
check / check (push) Successful in 51s
script/bootstrap credited the goimports pin to script/fmt-check, which
runs gofmt only; the header now credits script/fmt. script/cibuild
still described the Dockerfile as running make check, which stopped
being true once linting moved to its own stage. The docker-missing
warning in script/bootstrap is one sentence instead of three fragments
each carrying the bootstrap: prefix. Dockerfile.lint now states the
residual risk of skipping golangci-lint config verify: unknown
top-level keys in .golangci.yml are ignored silently, so a mistyped key
lints clean and applies nothing.

Comment and message text only; no behaviour changes.
2026-08-10 14:09:20 +00:00
sneak 168281ad60 docs: add MIT LICENSE and state the licence in the README (closes #102)
check / check (push) Has been cancelled
The repository had no licence file at all, which makes publicly readable
code all-rights-reserved by default: nobody may legally use it. That is a
1.0 blocker rather than a nicety, and `LICENSE` was the only file from
`REPO_POLICIES.md`'s required minimum still missing here.

The choice is standing org policy rather than a per-repo call: any public
repo lacking a licence gets MIT, while a private repo with no licence is
already all-rights-reserved and needs nothing. `sneak/dnswatcher` is
public, so MIT.

`LICENSE` carries the canonical MIT text byte-for-byte with only the
copyright line filled in; no clauses added, removed, reworded, or
reflowed. `README.md`'s first line now names the licence, which the
Description requirement in `REPO_POLICIES.md` calls for, and the License
section states MIT and points at the file instead of recording the
decision as pending.
2026-08-10 14:04:31 +00:00
sneak 6f6bf3a65b test: disable Go's test cache so every run queries live DNS (closes #139)
check / check (push) Successful in 1m34s
`script/test` did not pass `-count=1`, so on an unchanged tree Go
served the whole suite from its test cache: exit 0 in ~0.2s with every
package marked `(cached)` and not one DNS query made. This repo's suite
exists to exercise live resolution on every run (`TESTING.md`), so that
green asserted nothing — and it is exactly the green used as evidence
that a flakiness fix works, since "run it a few times" stops being
runs after the first.

`-count=1` now disables caching on every invocation.

The conditional verbose rerun that `REPO_POLICIES.md` mandates was
missing at the same spot and is added here rather than left broken: the
primary run had been unconditionally `-v`, which is the failure mode
the policy exists to prevent (unreadable CI and `docker build` logs on
success). Tests now run quiet, and only a failure triggers the `-v`
rerun. The rerun carries `-count=1` too, so it cannot replay a cached
copy of the failure it is meant to diagnose, and its exit status is
discarded in favour of a forced 1: the first failure already proved the
suite broken, so a flake that passes the second time must not turn the
build green.

`-timeout 90s` is untouched. It is a deliberate backstop that must
strictly exceed the 60s hard cap on suite duration.

No special-casing for the Docker build, which also reaches this script
via `RUN make test`: a fresh container's test cache is empty, so
`-count=1` changes nothing there and carving out an exception would
only create a second code path that could drift.

Verified: three back-to-back `make test` runs on an unchanged tree,
zero `(cached)` markers, ~4.0-4.5s wall each (was ~0.2s cached),
comfortably inside the 20s target with `-race` and `-cover` both still
working and coverage percentages unchanged. The rerun-and-still-fail
path was exercised against a purpose-built flaky test that fails once
then passes: quiet failure, verbose rerun that genuinely re-executed,
exit 1 regardless. `make check` green.
2026-08-10 13:48:15 +00:00
sneak 87bce43f8d test: rework live-DNS quorum unit — tolerate silence, never a wrong answer
check / check (push) Successful in 1m23s
Rework of the unit at #93
(commit 9cb2c2b), against the review at
#136 (comment).

Review of 9cb2c2b found the quorum assertions could not fail on a
class of wrong answer. Each test banned exactly one bad status —
_AllReturnOK banned only nxdomain, _NXDomainFromAllNS banned only ok
— so resolver.StatusNoData passed both. nodata is a wrong answer, not
silence, and answeredCount counted it as answered, so it did not even
trigger a retry; with a quorum of 3 of 4 a single wrong nameserver
slid through undetected. That is assertion-loosening beyond what the
quorum change requires.

Tolerance is now a closed allowlist rather than a blocklist of one
status. unsanctionedStatuses() reports every per-nameserver result
whose status the caller did not explicitly sanction: ok/timeout/error
for the all-OK test, nxdomain/timeout/error for the NXDOMAIN test.
Silence (timeout, error) is the only thing quorum exists to tolerate;
any other status, including one added to the resolver later, fails by
name. answeredCount is likewise an allowlist of ok/nxdomain/nodata, so
an unknown status counts as silence and can only cause a retry and
then a loud failure, never a quiet pass.

Two harness tests cover the regression directly: three OK plus one
nodata (quorum satisfied, no nxdomain present — the input that used
to pass) is now reported as unsanctioned, and an unknown status is
neither counted as answered nor tolerated.

Verified by re-running the reviewer's probe: queryEachNS patched to
force one of google.com's four nameservers to return StatusNoData
turns both tests red, naming the offending nameserver and status —

    --- FAIL: TestQueryAllNameservers_AllReturnOK (1.12s)
        Should be empty, but was [ns1.google.com.=nodata]
        every nameserver must answer OK or not answer at all:
        ns1.google.com.=nodata ns2.google.com.=ok ns3.google.com.=ok
        ns4.google.com.=ok
    --- FAIL: TestQueryAllNameservers_NXDomainFromAllNS (1.34s)
        Should be empty, but was [ns1.google.com.=nodata]
        every nameserver must report NXDOMAIN or not answer at all:
        ns1.google.com.=nodata ns2.google.com.=nxdomain
        ns3.google.com.=nxdomain ns4.google.com.=nxdomain

— and green with the probe reverted. Also fixes the review's nit: the
per-attempt deadline assertion had no lower bound, so it passed for a
deadline far shorter than intended.

No production code changed; DNS is still never mocked.
2026-08-10 13:33:53 +00:00
sneak 9cb2c2b7e0 test: make live DNS tests robust instead of gated (closes #93)
check / check (push) Successful in 1m18s
The resolver's live-DNS tests failed nondeterministically, a different
subset each run. Three structural causes, all test-side:

- Burst fan-out. Every test in the package is parallel and the build
  hosts have many cores, so all ~35 iterative resolutions started at
  the same instant and, because queryServers walks rootServerList() in
  fixed order, hit the same root server within milliseconds. Root
  servers rate-limit that.
- No retry anywhere. One dropped UDP packet in a delegation chain
  failed a test outright.
- Unanimity assertions. TestQueryAllNameservers_AllReturnOK and
  _NXDomainFromAllNS required every one of a domain's nameservers to
  answer, with no tolerance for one being slow.

New internal/resolver/livedns_test.go addresses each: a package-wide
gate bounds how many live resolutions are in flight at once, every
live operation gets three attempts with exponential backoff and its
own deadline, and multi-nameserver assertions now need a strict
majority rather than unanimity. The retry predicate is deliberately
transport-level -- "did a nameserver answer at all" -- never the
assertion under test, so a resolver that answers incorrectly still
fails on the first attempt. A nameserver that stays silent is
tolerated; one that answers wrongly is not.

livedns_harness_test.go tests that machinery directly: quorum
arithmetic, status counting, the gate's concurrency bound, per-attempt
deadlines, and recovery from a transient failure. It touches no DNS.

Nothing is mocked, faked, stubbed, recorded, skipped or build-tagged,
and production resolver behaviour is unchanged.

Test caps move to the new org-wide values ruled at prompts issue 41:
60s hard cap, 20s target, 90s -timeout backstop. REPO_POLICIES.md is
re-vendored byte-identical from sneak/prompts rather than hand-edited,
which also picks up the golangci-lint paragraph this copy had drifted
behind on. TESTING.md's stale 30-second target follows to 60.

#93
2026-08-10 13:09:17 +00:00
sneak cc86473410 build: run all linting in Docker via Dockerfile.lint (closes #134)
check / check (push) Successful in 1m17s
golangci-lint is no longer installed or run on the host. script/lint is
now a thin wrapper that builds the new root Dockerfile.lint, which COPYs
the repo into the digest-pinned golangci/golangci-lint:v2.12.2 image and
lints as a build step, so a successful build is a clean lint. This works
even where the docker daemon is remote and bind mounts are impossible.

Dockerfile.lint is split into a deps stage (base image, go mod download)
and a lint stage (source copy, linter run). script/lint passes
--no-cache-filter=lint so the lint stage executes on every invocation:
caching is explicitly waived for linting, and a cached build lints
nothing. The deps stage stays cached and no global cache invalidation is
performed. --progress=plain keeps the linter's own output visible.

golangci-lint config verify is deliberately omitted: it fetches its JSON
schema over a live, unpinned HTTPS call, which would make linting
network-dependent and defeat hash-pinning.

script/bootstrap no longer installs golangci-lint and warns instead when
docker is absent. The goimports install stays, since script/fmt and
script/fmt-check still run it on the host.

The root Dockerfile ran make check in its builder stage, which would now
recurse into script/lint and shell out to docker build with no daemon
available. It gains its own lint stage on the same pinned image, invoked
directly, with the builder depending on it via COPY --from=lint and
running make fmt-check, make test and make build.
2026-08-10 12:37:47 +00:00
44 changed files with 3859 additions and 962 deletions
+70 -2
View File
@@ -10,14 +10,20 @@ run:
linters:
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:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- 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:
lll:
line-length: 88
@@ -28,6 +34,68 @@ linters:
max-complexity: 15
dupl:
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.
- pkg: sneak.berlin/go/dnswatcher/internal/livednstest
desc: >-
Live-DNS test support 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:
max-issues-per-linter: 0
+52 -18
View File
@@ -1,13 +1,10 @@
# Build stage
# golang 1.25-alpine, 2026-02-28
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache git make gcc musl-dev binutils-gold
# golangci-lint v2.12.2, 2026-08-07
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5
# goimports v0.42.0
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0
# Lint stage - fast feedback on lint issues, before the build starts.
# The linter is invoked directly rather than through `make lint`: that
# target shells out to `docker build -f Dockerfile.lint`, and there is
# 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
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src
COPY go.mod go.sum ./
@@ -15,8 +12,27 @@ RUN go mod download
COPY . .
# Run all checks - build fails if any check fails
RUN make check
RUN make fmt-check
RUN golangci-lint run --config .golangci.yml ./...
# Build stage
# script/cibuild and script/docker name this stage in --no-cache-filter.
# golang 1.25-alpine, 2026-02-28
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache git make gcc musl-dev binutils-gold
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Run the tests - build fails if any test fails
RUN make test
# Build the binary
RUN make build
@@ -27,15 +43,33 @@ FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher
COPY --from=builder /src/bin/dnswatcher /app/dnswatcher
# Create data directory
RUN mkdir -p /var/lib/dnswatcher
# Run as an unprivileged user that owns the data directory. A fresh named
# volume inherits this ownership; a bind-mounted host directory must be
# owned by uid 10001 (see "Running under upaas" in README.md), or startup
# fails.
RUN addgroup -S -g 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
# Config loading also reads a `.env` file and a file named `dnswatcher`
# (any config extension, or none) from the working directory. `/` holds
# neither, so every setting comes from the environment. Do not make the
# data directory, or the binary's directory, the working directory.
WORKDIR /
USER dnswatcher
EXPOSE 8080
ENTRYPOINT ["/app/dnswatcher"]
# busybox wget (already in alpine) probes the health endpoint every 10
# 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.
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
ENTRYPOINT ["/usr/local/bin/dnswatcher"]
+29
View File
@@ -0,0 +1,29 @@
# Lint-only image: used by script/lint. golangci-lint is never run on
# the host — the repo is COPYed into the build context and the linter
# runs as a build step, so a successful build IS a clean lint. This
# also works where the docker daemon is remote and bind mounts are
# impossible.
#
# `golangci-lint config verify` is deliberately NOT run here: it
# fetches its JSON schema over a live, unpinned HTTPS call, which would
# make linting network-dependent and defeat hash-pinning. The cost of
# that: unknown top-level keys in .golangci.yml are silently ignored,
# so a mistyped or wrong-schema key lints clean while applying nothing.
#
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
WORKDIR /src
# Dependencies first, so this stage stays cached across lint runs.
COPY go.mod go.sum ./
RUN go mod download
# Everything below is invalidated on every run by the
# --no-cache-filter=lint that script/lint passes: caching is explicitly
# waived for linting, and a cached build lints nothing.
FROM deps AS lint
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sneak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+164 -20
View File
@@ -1,6 +1,6 @@
# dnswatcher
dnswatcher is a pre-1.0 Go daemon by [@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, and TLS certificates, delivering real-time change notifications via Slack, Mattermost, and ntfy webhooks.
dnswatcher is an MIT-licensed, pre-1.0 Go daemon by [@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, and TLS certificates, delivering real-time change notifications via Slack, Mattermost, and ntfy webhooks.
> ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice.
@@ -61,6 +61,10 @@ rejected.
record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
- Stores results **per nameserver**. The state for a hostname is not a
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
notification. This includes:
- **Record change**: A nameserver returns different records than it
@@ -70,8 +74,15 @@ rejected.
This is distinct from "responded with no records."
- **NS recovery**: A previously-unreachable nameserver starts
responding again.
- **Inconsistency detected**: Two nameservers that previously agreed
now return different record sets for the same hostname.
- **Inconsistency detected**: Two nameservers return different record
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
@@ -182,6 +193,67 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
| `GET /api/v1/status` | Current monitoring state |
| `GET /metrics` | Prometheus metrics (optional) |
#### Server timeouts
The HTTP server sets all four socket-level timeouts. These are compile-time
constants in `internal/server/server.go`, not configurable via environment
variables.
| Timeout | Value | Purpose |
|---------------------|-------|-----------------------------------------------|
| `ReadHeaderTimeout` | 10s | Bounds the request header read (slowloris) |
| `ReadTimeout` | 15s | Bounds the whole request read, headers + body |
| `WriteTimeout` | 75s | Bounds handler execution plus response flush |
| `IdleTimeout` | 120s | Reaps idle keep-alive connections |
These are distinct from the 60s per-request handler budget applied by
`chimw.Timeout` in `internal/server/routes.go`, which cancels the request
context but does not touch the socket. `WriteTimeout` is deliberately
larger than that budget: the write deadline is armed once request headers
are read, so a smaller value would sever the connection before a handler
using its full budget could respond. `IdleTimeout` exceeds common
Prometheus scrape intervals so the scraper reuses its connection.
### Security Headers
Every response — the dashboard, the static assets under `/s/...`, the
healthchecks, the JSON API, and `/metrics` — carries the following
headers, set by a global middleware:
| Header | Value |
|-----------------------------|---------------------------------------|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
| `Content-Security-Policy` | see below |
| `X-Frame-Options` | `DENY` |
| `X-Content-Type-Options` | `nosniff` |
| `Referrer-Policy` | `no-referrer` |
| `Permissions-Policy` | all unused browser features denied |
The content security policy is:
```
default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self';
font-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none';
form-action 'none'; frame-ancestors 'none'
```
The dashboard ships no JavaScript (the 30-second refresh is a
`<meta http-equiv="refresh">`), no inline styles, no inline event
handlers, and no images; its only subresource is the embedded stylesheet
at `/s/css/tailwind.min.css`, which `style-src 'self'` permits. The
policy therefore needs neither `unsafe-inline` nor `unsafe-eval`.
`frame-ancestors 'none'` is the primary anti-framing control, with
`X-Frame-Options: DENY` retained as the legacy fallback.
HSTS is emitted unconditionally, including over plain HTTP. dnswatcher is
expected to run behind a TLS-terminating reverse proxy, and the browser
must still be told to enforce HTTPS end to end, so the header is never
gated on whether the request itself arrived over TLS.
`Referrer-Policy: no-referrer` is stricter than the
`strict-origin-when-cross-origin` baseline: the dashboard has no
cross-origin navigation needs, and its URL may name internal hosts.
---
## Architecture
@@ -194,7 +266,8 @@ internal/
globals/globals.go Build-time variables (version)
logger/logger.go slog structured logging (TTY detection)
healthcheck/healthcheck.go Health check service
middleware/middleware.go HTTP middleware (logging, CORS, metrics auth)
middleware/middleware.go HTTP middleware (logging, CORS, security
headers, metrics auth)
handlers/handlers.go HTTP request handlers
server/
server.go HTTP server lifecycle
@@ -205,6 +278,8 @@ internal/
tlscheck/tlscheck.go TLS certificate inspector
notify/notify.go Notification service (Slack, Mattermost, ntfy)
watcher/watcher.go Main monitoring orchestrator and scheduler
livednstest/livednstest.go Retry and concurrency limit for tests
against live DNS (imported only by tests)
```
### Design Principles
@@ -218,7 +293,8 @@ internal/
- **Structured logging**: All logs use `log/slog` with JSON output in
production (TTY detection for development).
- **Graceful shutdown**: All background goroutines respect context
cancellation and the fx lifecycle.
cancellation and the fx lifecycle. In-flight notification deliveries
are drained on shutdown, bounded by the shutdown timeout.
---
@@ -380,20 +456,35 @@ standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
- `script/bootstrap` — install all dependencies (go, pinned
golangci-lint and goimports, `go mod download`)
- `script/bootstrap` — install all dependencies (go, pinned goimports,
`go mod download`). It does not install golangci-lint: see
`script/lint` below.
- `script/setup` — make a fresh clone ready for development: bootstrap
plus the git pre-commit hook
- `script/projectname` — print the project name (used for the Docker
image tag)
- `script/test` — run the test suite (race detector, coverage)
- `script/lint` — run golangci-lint
- `script/test` — run the test suite (race detector, coverage). Caching
is waived for testing, exactly as it is for linting: `-count=1`
forces every invocation to execute, because the suite queries live
DNS and a cached pass queries nothing. Failures are rerun with `-v`
automatically, and the build fails even if that rerun passes.
- `script/lint` — run golangci-lint, always inside Docker: it builds
`Dockerfile.lint`, which COPYs the repo into the digest-pinned
`golangci-lint` image and lints as a build step, so a successful
build is a clean lint. The linter is never installed or run on the
host, and Docker is the only prerequisite. Caching is waived for
linting: the lint stage is forced to execute on every run with
`--no-cache-filter`, because a cached build lints nothing.
- `script/fmt` — format all code (gofmt -s, goimports)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via
`script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/docker` — build the Docker image tagged via `script/projectname`, with
`--no-cache-filter=lint,builder` so the lint stage and the builder stage,
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`
guard, then `script/check`
- `script/install-precommit` — install the git pre-commit hook
@@ -403,7 +494,7 @@ them. We provide:
```sh
make build # Build binary to bin/dnswatcher
make test # Run tests with race detector
make lint # Run golangci-lint
make lint # Run golangci-lint in Docker (requires docker)
make fmt # Format code
make check # Run all checks (test, lint, fmt-check)
make clean # Remove build artifacts
@@ -434,11 +525,57 @@ docker run -d \
---
## Running under upaas
[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this
repository's `Dockerfile` and runs it. The app needs:
- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
`prod` pull request is a deploy.
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
the state file lives. upaas bind-mounts the host path it is given and
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
watched name and recent alert, and upaas publishes every mapped port on
all interfaces of the host
([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)). Add a
port mapping to container port `8080` only if the dashboard should be
public. Otherwise add none: set the app's Docker network in upaas to
your reverse proxy's Docker network, and the proxy reaches the app at
`upaas-` followed by the app name, port `8080`.
- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list
of the domains and hostnames to watch. dnswatcher refuses to start
without it.
- **Recommended environment:** at least one notification endpoint
(`DNSWATCHER_SLACK_WEBHOOK`, `DNSWATCHER_MATTERMOST_WEBHOOK`,
`DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the
dashboard. `DNSWATCHER_METRICS_USERNAME` and
`DNSWATCHER_METRICS_PASSWORD` serve `/metrics` behind basic auth.
- **Leave unset:** `DNSWATCHER_DATA_DIR`, which the image sets to
`/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every
setting comes from the environment; the image holds no config file.
- **Health check:** the image's own, which requests
`/.well-known/healthcheck` every 10 seconds. upaas reads the
container's health 60 seconds after a deploy and marks the deploy
failed unless it is `healthy`.
---
## Monitoring Lifecycle
1. **Startup**: Load state from disk. If no state file exists, start
with empty state (first check will establish baseline without
triggering change notifications).
1. **Startup**: Check that the data directory can be written, and exit
with an error naming it if not. Load state from disk. If no state
file exists, start with empty state (first check will establish
baseline without triggering change notifications).
2. **Initial check**: Immediately perform all DNS, port, and TLS checks
on startup.
3. **Periodic checks** (DNS always runs first):
@@ -452,8 +589,14 @@ docker run -d \
from a previous cycle.
4. **On change detection**: Send notifications to all configured
endpoints, update in-memory state, persist to disk.
5. **Shutdown**: Persist final state to disk, complete in-flight
notifications, stop gracefully.
5. **Shutdown**: Persist final state to disk, wait for in-flight
notification deliveries to complete, stop gracefully. The wait is
bounded by the fx shutdown timeout (15s by default): deliveries still
retrying against an unreachable endpoint when that expires are
abandoned, and the number abandoned is logged at warn level rather
than dropped silently. Notifications generated after shutdown has
begun are refused and logged, so a late burst cannot extend the
shutdown.
---
@@ -475,8 +618,9 @@ Viper for configuration.
## License
License has not yet been chosen for this project. Pending decision by the
author (MIT, GPL, or WTFPL).
dnswatcher is released under the MIT License, Copyright (c) 2026
[@sneak](https://sneak.berlin). See the [`LICENSE`](./LICENSE) file in the
repository root for the full text.
## Author
+14 -6
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-07-06
last_modified: 2026-08-07
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -189,8 +189,13 @@ style conventions are in separate documents:
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- `make test` must complete in under 60 seconds. That is the hard cap, and a
suite that exceeds it fails. Under 20 seconds is the target. A suite between
20 and 60 seconds is still green, but the overage must be filed as an
improvement bug against that repo. Add a 90-second timeout to the test
invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately
sits above the hard cap so that it catches a genuinely hung test rather than a
merely slow one.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
@@ -209,9 +214,9 @@ style conventions are in separate documents:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
@go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
go test -timeout 90s -race -v ./...; exit 1; }
```
Python example:
@@ -260,7 +265,10 @@ style conventions are in separate documents:
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The
canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed
commit-pinned via
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
+16 -5
View File
@@ -2,8 +2,12 @@
## DNS Resolution Tests
All resolver tests **MUST** use live queries against real DNS servers.
No mocking of the DNS client layer is permitted.
DNS is never mocked in this project, not in tests and not anywhere
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
@@ -17,18 +21,25 @@ real servers ensures the resolver works correctly in production.
- Tests hit real DNS infrastructure and require network access
- Test duration depends on network conditions; timeout tuning keeps
the suite within the 30-second target
the suite within the 60-second target
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
plus processing margin
- Root server fan-out is limited to reduce parallel query load
- Live lookups that expect an answer go through `internal/livednstest`,
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
should be investigated as potential resolver bugs, not papered over
with mocks or skip flags
### What NOT to do
- **Do not mock `DNSClient`** for resolver tests (the mock constructor
exists for unit-testing other packages that consume the resolver)
- **Do not mock, fake or stub DNS** anywhere: no stand-in `DNSClient`,
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 increase `-timeout`** to hide hanging queries
- **Do not remove `-count=1` from `script/test`** — Go's test cache
replays a previous run's output without querying anything, so a
cached pass is not evidence that live resolution works
- **Do not modify linter configuration** to suppress findings
+138 -21
View File
@@ -10,28 +10,148 @@
# Status
pre-1.0. No git tags. Core resolver work in flight on feature/resolver
(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.
pre-1.0. No git tags.
# Next Step
Policy scaffold commit: add LICENSE, REPO_POLICIES.md, .editorconfig,
.dockerignore, and .gitea/workflows/check.yml, and add the missing
fmt-check, docker, and hooks targets to the Makefile. One commit, then
confirm make check still passes.
Add the README sections required by policy (Description, Getting Started,
Rationale, Design, TODO, License, Author) if any are still missing.
# Completed Steps
- 2026-08-09: `script/bootstrap` now installs the pinned `golangci-lint`
and `goimports` unconditionally instead of only when the binary is
absent from `PATH`, so the commit pins actually take effect on
already-provisioned machines; it also warns when `PATH` resolves
either tool to a copy outside the directory `go install` writes to.
The `missing` presence check is retained for `git`, `make`, and `go`
(#117)
- 2026-09-29: the live-DNS test package is renamed `internal/livednstest` and
added to the `test-support` `deny` list in `.golangci.yml`, so `make lint`
fails when program code imports it (closes #164).
- 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/livednstest`, 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
`dnswatcher`, Docker `HEALTHCHECK`, startup fails when the data directory is
not writable, README "Running under upaas" (closes #147).
- 2026-09-21: added behavioural tests for `internal/globals`,
`internal/healthcheck`, and `internal/logger` (closes #110).
- 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync`
`// indirect` line so `script/bootstrap` leaves a clean tree (#132)
- 2026-08-10: comment-only corrections to `script/bootstrap`,
`script/cibuild`, and `Dockerfile.lint`. The `goimports` pin in
`script/bootstrap` was justified by a claim that `script/fmt-check`
runs it on the host; it does not (it runs `gofmt -l .` only), so the
header now credits `script/fmt` alone. `script/cibuild` still claimed
the `Dockerfile` runs `make check`, which stopped being true when
linting moved to its own stage; it now describes the lint stage
(`make fmt-check` plus `golangci-lint`) and the builder stage
(`make test`, `make build`). The `docker`-missing warning in
`script/bootstrap` reads as one sentence instead of three fragments
each re-prefixed with `bootstrap:`. `Dockerfile.lint` now records the
residual risk of omitting `golangci-lint config verify`: unknown
top-level keys in `.golangci.yml` are silently ignored, so a mistyped
key lints clean while applying nothing. No behaviour changed
- 2026-08-10: MIT `LICENSE` added at the repository root, closing the
last gap in `REPO_POLICIES.md`'s required-minimum file list and
removing the all-rights-reserved default that would otherwise have
shipped with a 1.0 tag. The licence choice is the standing org policy
(any public repo lacking a licence gets MIT; a private repo with no
licence is already all-rights-reserved), and this repo is public. The
file holds the canonical MIT text byte-for-byte with only the
copyright line filled in (`Copyright (c) 2026 sneak`); no clauses were
added, removed, or reflowed. `README.md`'s first line now names the
licence, as the Description requirement demands, and the License
section states MIT and points at the file instead of saying the choice
is pending. `make fmt` covers only Go sources (`gofmt -s`,
`goimports`), so it cannot reflow `LICENSE`
- 2026-08-10: the policy scaffold (`REPO_POLICIES.md`, `.editorconfig`,
`.dockerignore`, `.gitea/workflows/check.yml`, and the `fmt-check`,
`docker`, and hooks Makefile targets) is present; it landed piecemeal
across the scripts-to-rule-them-all and policy commits rather than as
the single commit this file once planned
- 2026-08-10: Go's test cache disabled for `script/test` via `-count=1`,
so every invocation actually executes. A cached pass replays an
earlier run's output without querying DNS at all, which in this repo
means the suite's entire premise goes unexercised while the run
reports green in under a second. The conditional verbose rerun that
`REPO_POLICIES.md` mandates was added at the same time (the primary
run had been unconditionally `-v`): quiet first, `-v` only on
failure, `-count=1` on both, and exit 1 forced regardless of the
rerun's result so a flake passing the second time cannot turn the
build green. `-timeout 90s` left alone as the deliberate backstop
above the 60s hard cap. Uncached suite runs ~4s, well inside the 20s
target
- 2026-08-10: live-DNS test flakiness addressed by robustness rather
than gating, per the owner's ruling on #93: new
`internal/resolver/livedns_test.go` adds a package-wide concurrency
gate (so parallel tests stop bursting at the first root server),
retry with exponential backoff on transport failures only, and
quorum instead of unanimity for multi-nameserver assertions. Quorum
tolerates silence only: every per-nameserver status must be in a
closed allowlist (`ok`/`timeout`/`error`, or
`nxdomain`/`timeout`/`error`), so a wrong answer from a minority —
`nodata` today, any status added later — fails the test instead of
sliding through under the majority. The
`make test` cap moved to the new org-wide 60s hard cap / 20s target
with a 90s `-timeout` backstop; `REPO_POLICIES.md` re-vendored
byte-identical from `sneak/prompts`. No mocks, no `-short`, no build
tags, no skips, and no change to production resolver behaviour
- 2026-08-10: all linting moved into Docker: new root `Dockerfile.lint`
on the digest-pinned `golangci/golangci-lint:v2.12.2` image,
`script/lint` reduced to a thin wrapper that builds it with
`--no-cache-filter=lint` so the linter actually executes every run,
golangci-lint install dropped from `script/bootstrap` (goimports
stays, `script/fmt` needs it on the host), and the root `Dockerfile`
given its own lint stage so its build no longer recurses through
`make check` into `script/lint`. `golangci-lint config verify` is
deliberately omitted: it fetches its schema over an unpinned live
HTTPS call
- 2026-08-09: in-flight notification deliveries are now drained at
shutdown (#106): `notify.New` registers an fx `OnStop` hook that waits
on a `sync.WaitGroup` of tracked delivery goroutines, bounded by the
`OnStop` context; on expiry the outstanding count is logged at warn
level and parked retry backoffs are released instead of being dropped
silently, and deliveries submitted after the drain begins are refused
so shutdown cannot be extended indefinitely; an `OnStop` context that
is already expired on entry with nothing outstanding drains quietly
rather than warning about deliveries that were never abandoned
- 2026-08-09: `http.Server` now sets all four socket-level timeouts
(`ReadTimeout` 15s, `ReadHeaderTimeout` 10s, `WriteTimeout` 75s,
`IdleTimeout` 120s) as named constants in `internal/server/server.go`,
closing the slowloris / unreaped-keep-alive exposure required by
`REPO_POLICIES.md` before 1.0; `WriteTimeout` is deliberately greater
than the 60s `chimw.Timeout` handler budget so that budget stays
reachable, and tests in `internal/server` pin both the non-zero
values and that relationship (#99)
- 2026-08-09: security response headers middleware
(`SecurityHeaders()` in `internal/middleware/middleware.go`)
registered globally in `internal/server/routes.go`, so HSTS, CSP,
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and
`Permissions-Policy` are set on every response including `/s/...` and
`/metrics`; the CSP needs no `unsafe-inline`/`unsafe-eval` because the
dashboard ships no JavaScript and no inline styles; HSTS is emitted
unconditionally per policy (TLS-terminating proxy in front). Remaining
1.0 hardening items — `http.Server` timeouts, request body limits,
rate limiting, CORS scoping — are tracked separately
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
org-standard v2-schema config used across the org's repos
@@ -60,8 +180,6 @@ confirm make check still passes.
Compliance:
- Add README sections required by policy (Description, Getting Started,
Rationale, Design, TODO, License, Author) if any are missing
- Pin Dockerfile base images by sha256 and ensure the Docker build runs
make check
@@ -155,6 +273,5 @@ Infrastructure notes (from untracked TODO.md):
- Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de
remote intentionally; do not "fix" it
- Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix
- Resolver tests originally used live DNS against *.dns.sneak.cloud
(required records documented in the test file header); origin now has
mocked hermetic tests, keep them hermetic
- DNS is never mocked; tests that look something up in DNS query live DNS
servers (README, "No DNS mocking. Ever.")
-1
View File
@@ -40,7 +40,6 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
+50
View File
@@ -0,0 +1,50 @@
package globals_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/globals"
)
// TestGlobals exercises the package-level version and appname
// variables through their setters and read-back via New. These are
// shared package state, so the test mutates a global and must run
// sequentially; it cannot use t.Parallel().
//
//nolint:paralleltest // mutates shared package-level globals, must run sequentially
func TestGlobals(t *testing.T) {
versions := []string{"v1.2.3", "dev", "", "v1.2.3-4-gabcdef"}
for _, want := range versions {
globals.SetVersion(want)
g, err := globals.New(nil)
require.NoError(t, err)
assert.Equal(t, want, g.Version,
"New must surface the version set by SetVersion")
}
names := []string{"dnswatcher", "other", ""}
for _, want := range names {
globals.SetAppname(want)
g, err := globals.New(nil)
require.NoError(t, err)
assert.Equal(t, want, g.Appname,
"New must surface the appname set by SetAppname")
}
// New returns a snapshot: a later SetVersion must not mutate a
// Globals handed out earlier.
globals.SetVersion("first")
g, err := globals.New(nil)
require.NoError(t, err)
globals.SetVersion("second")
assert.Equal(t, "first", g.Version,
"a Globals returned by New must not change when the "+
"package variable is set again")
}
+134
View File
@@ -0,0 +1,134 @@
package healthcheck_test
import (
"context"
"encoding/json"
"testing"
"time"
"go.uber.org/fx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/healthcheck"
"sneak.berlin/go/dnswatcher/internal/logger"
)
// recordingLifecycle is a minimal fx.Lifecycle that records the hooks
// appended to it, so healthcheck.New can be exercised through its real
// constructor without standing up a whole fx application.
type recordingLifecycle struct {
hooks []fx.Hook
}
func (l *recordingLifecycle) Append(hook fx.Hook) {
l.hooks = append(l.hooks, hook)
}
// newHealthcheck builds a Healthcheck through the real constructor and
// runs the registered OnStart hook so StartupTime is set the same way
// the fx lifecycle would set it.
func newHealthcheck(
t *testing.T,
maintenance bool,
version string,
) *healthcheck.Healthcheck {
t.Helper()
g := &globals.Globals{Appname: "dnswatcher", Version: version}
log, err := logger.New(nil, logger.Params{Globals: g})
require.NoError(t, err)
lifecycle := &recordingLifecycle{}
hc, err := healthcheck.New(lifecycle, healthcheck.Params{
Globals: g,
Config: &config.Config{MaintenanceMode: maintenance},
Logger: log,
})
require.NoError(t, err)
require.Len(t, lifecycle.hooks, 1,
"New must register exactly one lifecycle hook")
require.NotNil(t, lifecycle.hooks[0].OnStart)
require.NoError(t, lifecycle.hooks[0].OnStart(context.Background()))
return hc
}
func TestCheckStatusAndPayloadShape(t *testing.T) {
t.Parallel()
hc := newHealthcheck(t, false, "v9.9.9")
resp := hc.Check()
assert.Equal(t, "ok", resp.Status)
// The JSON shape and field names are part of the contract for the
// /health and /.well-known/healthcheck routes, so assert on the
// exact set of keys the response marshals to.
raw, err := json.Marshal(resp)
require.NoError(t, err)
var fields map[string]json.RawMessage
require.NoError(t, json.Unmarshal(raw, &fields))
wantKeys := []string{
"status",
"now",
"uptimeSeconds",
"uptimeHuman",
"version",
"appname",
"maintenanceMode",
}
assert.Len(t, fields, len(wantKeys),
"response must marshal to exactly the documented fields")
for _, key := range wantKeys {
assert.Contains(t, fields, key, "missing JSON field %q", key)
}
// The Now field is documented as RFC3339Nano; a change to the
// format constant should turn this red.
_, err = time.Parse(time.RFC3339Nano, resp.Now)
assert.NoError(t, err, "Now must be RFC3339Nano")
}
func TestCheckMaintenanceModeReflectsConfig(t *testing.T) {
t.Parallel()
tests := []struct {
name string
maintenance bool
}{
{"maintenance off", false},
{"maintenance on", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
hc := newHealthcheck(t, tt.maintenance, "test")
resp := hc.Check()
assert.Equal(t, tt.maintenance, resp.Maintenance,
"maintenanceMode must mirror Config.MaintenanceMode")
})
}
}
func TestCheckSurfacesVersionAndAppname(t *testing.T) {
t.Parallel()
hc := newHealthcheck(t, false, "surfaced-version-123")
resp := hc.Check()
assert.Equal(t, "surfaced-version-123", resp.Version,
"version from globals must appear in the payload")
assert.Equal(t, "dnswatcher", resp.Appname)
}
+121
View File
@@ -0,0 +1,121 @@
// Package livednstest 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 livednstest
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,
)
}
+103
View File
@@ -0,0 +1,103 @@
package livednstest_test
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/dnswatcher/internal/livednstest"
)
// 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
livednstest.Retry(t, "transient", func(_ context.Context) error {
attempts++
if attempts < wantAttempts {
return livednstest.ErrNoAnswer
}
return nil
})
assert.Equal(t, wantAttempts, attempts)
}
func TestRetryGivesEachAttemptADeadline(t *testing.T) {
t.Parallel()
livednstest.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, livednstest.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, livednstest.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()
_ = livednstest.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, livednstest.Concurrency,
"live queries must stay under the package-wide gate",
)
}
+66
View File
@@ -0,0 +1,66 @@
package logger_test
import (
"context"
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/logger"
)
func newTestLogger(t *testing.T) *logger.Logger {
t.Helper()
g := &globals.Globals{Appname: "dnswatcher", Version: "test"}
l, err := logger.New(nil, logger.Params{Globals: g})
require.NoError(t, err)
return l
}
// TestNewReturnsUsableLogger checks that the constructor yields a
// working *slog.Logger.
func TestNewReturnsUsableLogger(t *testing.T) {
t.Parallel()
l := newTestLogger(t)
require.NotNil(t, l.Get(), "Get must return a non-nil logger")
}
// TestDefaultLevelExcludesDebug verifies the default configuration
// logs at info: debug records are suppressed, info records pass.
func TestDefaultLevelExcludesDebug(t *testing.T) {
t.Parallel()
log := newTestLogger(t).Get()
ctx := context.Background()
assert.False(t, log.Enabled(ctx, slog.LevelDebug),
"debug must be suppressed at the default level")
assert.True(t, log.Enabled(ctx, slog.LevelInfo),
"info must be enabled at the default level")
}
// TestEnableDebugLoggingChangesLevel verifies the debug and non-debug
// configurations differ as intended: enabling debug makes debug
// records pass where they previously did not.
func TestEnableDebugLoggingChangesLevel(t *testing.T) {
t.Parallel()
l := newTestLogger(t)
log := l.Get()
ctx := context.Background()
require.False(t, log.Enabled(ctx, slog.LevelDebug),
"debug must start disabled")
l.EnableDebugLogging()
assert.True(t, log.Enabled(ctx, slog.LevelDebug),
"debug must be enabled after EnableDebugLogging")
}
+85
View File
@@ -21,6 +21,60 @@ import (
// corsMaxAge is the maximum age for CORS preflight responses.
const corsMaxAge = 300
// Security response header values applied to every response.
//
// The CSP is as strict as the dashboard allows: the template ships no
// JavaScript, no inline styles, no inline event handlers and no images,
// and its only subresource is the embedded stylesheet at
// /s/css/tailwind.min.css, which style-src 'self' permits. Neither
// unsafe-inline nor unsafe-eval is used. frame-ancestors 'none' is the
// primary anti-framing control; X-Frame-Options is the legacy fallback.
const (
// hstsValue is emitted unconditionally, including over plain HTTP,
// because the service runs behind a TLS-terminating proxy and the
// browser must still enforce HTTPS end to end.
hstsValue = "max-age=31536000; includeSubDomains"
cspValue = "default-src 'self'; " +
"script-src 'none'; " +
"style-src 'self'; " +
"img-src 'self'; " +
"font-src 'none'; " +
"connect-src 'none'; " +
"object-src 'none'; " +
"base-uri 'none'; " +
"form-action 'none'; " +
"frame-ancestors 'none'"
frameOptionsValue = "DENY"
contentTypeOptionsValue = "nosniff"
// referrerPolicyValue is stricter than the policy minimum of
// strict-origin-when-cross-origin: the dashboard has no
// cross-origin navigation needs and its URL may name internal
// hosts.
referrerPolicyValue = "no-referrer"
permissionsPolicyValue = "accelerometer=(), " +
"autoplay=(), " +
"camera=(), " +
"display-capture=(), " +
"encrypted-media=(), " +
"fullscreen=(), " +
"geolocation=(), " +
"gyroscope=(), " +
"magnetometer=(), " +
"microphone=(), " +
"midi=(), " +
"payment=(), " +
"picture-in-picture=(), " +
"publickey-credentials-get=(), " +
"screen-wake-lock=(), " +
"usb=(), " +
"xr-spatial-tracking=()"
)
// Params contains dependencies for Middleware.
type Params struct {
fx.In
@@ -186,6 +240,37 @@ func (m *Middleware) CORS() func(http.Handler) http.Handler {
})
}
// SecurityHeaders returns middleware that sets the security response
// headers required for production internet exposure on every response.
//
// The headers are set before the request reaches the next handler so
// that they are present on every response, including panics recovered
// by chi's Recoverer and timeouts produced by chi's Timeout.
func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
header := writer.Header()
header.Set("Strict-Transport-Security", hstsValue)
header.Set("Content-Security-Policy", cspValue)
header.Set("X-Frame-Options", frameOptionsValue)
header.Set(
"X-Content-Type-Options",
contentTypeOptionsValue,
)
header.Set("Referrer-Policy", referrerPolicyValue)
header.Set(
"Permissions-Policy",
permissionsPolicyValue,
)
next.ServeHTTP(writer, request)
})
}
}
// MetricsAuth returns basic auth middleware for /metrics.
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
if m.params.Config.MetricsUsername == "" {
+334
View File
@@ -0,0 +1,334 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/handlers"
"sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/middleware"
"sneak.berlin/go/dnswatcher/internal/notify"
"sneak.berlin/go/dnswatcher/internal/state"
)
// Expected security header values, spelled out literally so that any
// change to the middleware has to be made deliberately here as well.
const (
wantHSTS = "max-age=31536000; includeSubDomains"
wantCSP = "default-src 'self'; " +
"script-src 'none'; " +
"style-src 'self'; " +
"img-src 'self'; " +
"font-src 'none'; " +
"connect-src 'none'; " +
"object-src 'none'; " +
"base-uri 'none'; " +
"form-action 'none'; " +
"frame-ancestors 'none'"
wantFrameOptions = "DENY"
wantContentTypeOptions = "nosniff"
wantReferrerPolicy = "no-referrer"
wantPermissionsPolicy = "accelerometer=(), " +
"autoplay=(), " +
"camera=(), " +
"display-capture=(), " +
"encrypted-media=(), " +
"fullscreen=(), " +
"geolocation=(), " +
"gyroscope=(), " +
"magnetometer=(), " +
"microphone=(), " +
"midi=(), " +
"payment=(), " +
"picture-in-picture=(), " +
"publickey-credentials-get=(), " +
"screen-wake-lock=(), " +
"usb=(), " +
"xr-spatial-tracking=()"
)
// stylesheetPath is the only subresource the dashboard loads.
const stylesheetPath = "/s/css/tailwind.min.css"
// newTestLogger builds a logger for direct component construction.
func newTestLogger(t *testing.T) *logger.Logger {
t.Helper()
glob, err := globals.New(nil)
if err != nil {
t.Fatalf("globals.New: %v", err)
}
log, err := logger.New(nil, logger.Params{Globals: glob})
if err != nil {
t.Fatalf("logger.New: %v", err)
}
return log
}
// newTestMiddleware builds a Middleware without an fx application.
func newTestMiddleware(t *testing.T) *middleware.Middleware {
t.Helper()
glob, err := globals.New(nil)
if err != nil {
t.Fatalf("globals.New: %v", err)
}
mw, err := middleware.New(nil, middleware.Params{
Logger: newTestLogger(t),
Globals: glob,
Config: &config.Config{},
})
if err != nil {
t.Fatalf("middleware.New: %v", err)
}
return mw
}
// serveWithSecurityHeaders runs a GET through SecurityHeaders and
// returns the recorded response.
func serveWithSecurityHeaders(
t *testing.T,
target string,
handler http.Handler,
) *httptest.ResponseRecorder {
t.Helper()
mw := newTestMiddleware(t)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, target, nil,
)
mw.SecurityHeaders()(handler).ServeHTTP(rec, req)
return rec
}
// okHandler writes a trivial 200 response.
func okHandler() http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.WriteHeader(http.StatusOK)
})
}
func TestSecurityHeaders(t *testing.T) {
t.Parallel()
tests := []struct {
name string
header string
want string
}{
{
"hsts",
"Strict-Transport-Security",
wantHSTS,
},
{
"csp",
"Content-Security-Policy",
wantCSP,
},
{
"frame options",
"X-Frame-Options",
wantFrameOptions,
},
{
"content type options",
"X-Content-Type-Options",
wantContentTypeOptions,
},
{
"referrer policy",
"Referrer-Policy",
wantReferrerPolicy,
},
{
"permissions policy",
"Permissions-Policy",
wantPermissionsPolicy,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rec := serveWithSecurityHeaders(t, "/", okHandler())
got := rec.Header().Get(tt.header)
if got != tt.want {
t.Errorf(
"%s = %q, want %q",
tt.header, got, tt.want,
)
}
})
}
}
// TestSecurityHeadersCSPDirectives guards the properties the repo
// policy requires of the content security policy itself.
func TestSecurityHeadersCSPDirectives(t *testing.T) {
t.Parallel()
rec := serveWithSecurityHeaders(t, "/", okHandler())
csp := rec.Header().Get("Content-Security-Policy")
forbidden := []string{"unsafe-inline", "unsafe-eval"}
for _, directive := range forbidden {
if strings.Contains(csp, directive) {
t.Errorf("CSP must not contain %q: %q", directive, csp)
}
}
required := []string{
"default-src 'self'",
"script-src 'none'",
"style-src 'self'",
"frame-ancestors 'none'",
}
for _, directive := range required {
if !strings.Contains(csp, directive) {
t.Errorf("CSP must contain %q: %q", directive, csp)
}
}
}
// TestSecurityHeadersOnErrorResponse verifies the headers are emitted
// even when the wrapped handler fails, since they are set before the
// handler runs.
func TestSecurityHeadersOnErrorResponse(t *testing.T) {
t.Parallel()
failing := http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
http.Error(
writer,
"boom",
http.StatusInternalServerError,
)
})
rec := serveWithSecurityHeaders(t, "/api/v1/status", failing)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
if got := rec.Header().Get(
"X-Content-Type-Options",
); got != wantContentTypeOptions {
t.Errorf(
"X-Content-Type-Options = %q, want %q",
got, wantContentTypeOptions,
)
}
if got := rec.Header().Get(
"Strict-Transport-Security",
); got != wantHSTS {
t.Errorf(
"Strict-Transport-Security = %q, want %q",
got, wantHSTS,
)
}
}
// newTestHandlers builds real Handlers with empty monitoring state.
func newTestHandlers(t *testing.T) *handlers.Handlers {
t.Helper()
glob, err := globals.New(nil)
if err != nil {
t.Fatalf("globals.New: %v", err)
}
log := newTestLogger(t)
notifier, err := notify.New(fxtest.NewLifecycle(t), notify.Params{
Logger: log,
Config: &config.Config{},
})
if err != nil {
t.Fatalf("notify.New: %v", err)
}
hnd, err := handlers.New(nil, handlers.Params{
Logger: log,
Globals: glob,
State: state.NewForTest(),
Notify: notifier,
})
if err != nil {
t.Fatalf("handlers.New: %v", err)
}
return hnd
}
// TestDashboardRendersWithSecurityHeaders renders the real dashboard
// through the middleware and checks that the policy still permits the
// one stylesheet the page loads.
func TestDashboardRendersWithSecurityHeaders(t *testing.T) {
t.Parallel()
mw := newTestMiddleware(t)
hnd := newTestHandlers(t)
router := chi.NewRouter()
router.Use(mw.SecurityHeaders())
router.Get("/", hnd.HandleDashboard())
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, stylesheetPath) {
t.Errorf("dashboard does not reference %q", stylesheetPath)
}
if !strings.Contains(body, "dnswatcher") {
t.Errorf("dashboard body looks empty: %d bytes", len(body))
}
csp := rec.Header().Get("Content-Security-Policy")
if csp != wantCSP {
t.Errorf("CSP = %q, want %q", csp, wantCSP)
}
// The stylesheet is same-origin, so style-src 'self' allows it.
if !strings.Contains(csp, "style-src 'self'") {
t.Errorf("CSP would block %q: %q", stylesheetPath, csp)
}
}
+20 -4
View File
@@ -32,11 +32,27 @@ func NewRequestForTest(
// NewTestService creates a Service suitable for unit testing.
// It discards log output and uses the given transport.
func NewTestService(transport http.RoundTripper) *Service {
return &Service{
log: slog.New(slog.DiscardHandler),
transport: transport,
history: NewAlertHistory(),
return newService(slog.New(slog.DiscardHandler), transport)
}
// NewTestServiceWithLogger creates a Service that writes to the
// given handler, so tests can assert on emitted log records.
func NewTestServiceWithLogger(
transport http.RoundTripper,
handler slog.Handler,
) *Service {
return newService(slog.New(handler), transport)
}
// Drain exports drain for testing.
func (svc *Service) Drain(ctx context.Context) {
svc.drain(ctx)
}
// OutstandingDeliveries reports how many delivery goroutines
// are currently tracked as in flight.
func (svc *Service) OutstandingDeliveries() int64 {
return svc.outstanding.Load()
}
// SetNtfyURL sets the ntfy URL on a Service for testing.
+73 -56
View File
@@ -12,6 +12,8 @@ import (
"log/slog"
"net/http"
"net/url"
"sync"
"sync/atomic"
"time"
"go.uber.org/fx"
@@ -115,19 +117,41 @@ type Service struct {
history *AlertHistory
retryConfig RetryConfig
sleepFn func(time.Duration) <-chan time.Time
// Shutdown draining state. drainMu guards draining and
// serialises it against the counter increment in
// startDelivery; inFlight tracks the delivery goroutines
// themselves and outstanding mirrors its count so a timed
// out drain can report how many were abandoned.
drainMu sync.Mutex
draining bool
inFlight sync.WaitGroup
outstanding atomic.Int64
abandon chan struct{}
abandonOnce sync.Once
}
// newService builds a Service with the fields every Service
// needs regardless of how it was constructed.
func newService(
log *slog.Logger,
transport http.RoundTripper,
) *Service {
return &Service{
log: log,
transport: transport,
history: NewAlertHistory(),
abandon: make(chan struct{}),
}
}
// New creates a new notify Service.
func New(
_ fx.Lifecycle,
lifecycle fx.Lifecycle,
params Params,
) (*Service, error) {
svc := &Service{
log: params.Logger.Get(),
transport: http.DefaultTransport,
config: params.Config,
history: NewAlertHistory(),
}
svc := newService(params.Logger.Get(), http.DefaultTransport)
svc.config = params.Config
if params.Config.NtfyTopic != "" {
u, err := ValidateWebhookURL(
@@ -168,6 +192,14 @@ func New(
svc.mattermostWebhookURL = u
}
lifecycle.Append(fx.Hook{
OnStop: func(ctx context.Context) error {
svc.drain(ctx)
return nil
},
})
return svc, nil
}
@@ -194,6 +226,32 @@ func (svc *Service) SendNotification(
svc.dispatchMattermost(ctx, title, message, priority)
}
// dispatch delivers a notification to one endpoint on a
// tracked background goroutine.
//
// The delivery context is detached from ctx with
// context.WithoutCancel so that a cancelled caller does not
// kill a delivery already under way; the shutdown drain, not
// the caller, decides how long deliveries may keep running.
func (svc *Service) dispatch(
ctx context.Context,
endpoint string,
send func(context.Context) error,
) {
notifyCtx := context.WithoutCancel(ctx)
svc.startDelivery(endpoint, func() {
err := svc.deliverWithRetry(notifyCtx, endpoint, send)
if err != nil {
svc.log.Error(
"failed to send notification after retries",
"endpoint", endpoint,
"error", err,
)
}
})
}
func (svc *Service) dispatchNtfy(
ctx context.Context,
title, message, priority string,
@@ -202,26 +260,11 @@ func (svc *Service) dispatchNtfy(
return
}
go func() {
notifyCtx := context.WithoutCancel(ctx)
err := svc.deliverWithRetry(
notifyCtx, "ntfy",
func(c context.Context) error {
svc.dispatch(ctx, "ntfy", func(c context.Context) error {
return svc.sendNtfy(
c, svc.ntfyURL,
title, message, priority,
c, svc.ntfyURL, title, message, priority,
)
},
)
if err != nil {
svc.log.Error(
"failed to send ntfy notification "+
"after retries",
"error", err,
)
}
}()
})
}
func (svc *Service) dispatchSlack(
@@ -232,26 +275,11 @@ func (svc *Service) dispatchSlack(
return
}
go func() {
notifyCtx := context.WithoutCancel(ctx)
err := svc.deliverWithRetry(
notifyCtx, "slack",
func(c context.Context) error {
svc.dispatch(ctx, "slack", func(c context.Context) error {
return svc.sendSlack(
c, svc.slackWebhookURL,
title, message, priority,
c, svc.slackWebhookURL, title, message, priority,
)
},
)
if err != nil {
svc.log.Error(
"failed to send slack notification "+
"after retries",
"error", err,
)
}
}()
})
}
func (svc *Service) dispatchMattermost(
@@ -262,11 +290,8 @@ func (svc *Service) dispatchMattermost(
return
}
go func() {
notifyCtx := context.WithoutCancel(ctx)
err := svc.deliverWithRetry(
notifyCtx, "mattermost",
svc.dispatch(
ctx, "mattermost",
func(c context.Context) error {
return svc.sendSlack(
c, svc.mattermostWebhookURL,
@@ -274,14 +299,6 @@ func (svc *Service) dispatchMattermost(
)
},
)
if err != nil {
svc.log.Error(
"failed to send mattermost notification "+
"after retries",
"error", err,
)
}
}()
}
func (svc *Service) sendNtfy(
+9
View File
@@ -2,6 +2,7 @@ package notify
import (
"context"
"fmt"
"math"
"math/rand/v2"
"time"
@@ -121,6 +122,14 @@ func (svc *Service) deliverWithRetry(
select {
case <-ctx.Done():
return ctx.Err()
case <-svc.abandon:
// Shutdown drained past its deadline; stop
// sleeping rather than outlive the process.
// A nil channel (Service built without a
// constructor) simply never fires.
return fmt.Errorf(
"%w: %s", ErrDeliveryAbandoned, endpoint,
)
case <-svc.sleepFunc(delay):
}
}
+119
View File
@@ -0,0 +1,119 @@
package notify
import (
"context"
"errors"
)
// ErrDeliveryAbandoned is returned by a retry loop that was
// cut short because shutdown drained past its deadline.
var ErrDeliveryAbandoned = errors.New(
"notification delivery abandoned at shutdown",
)
// startDelivery runs fn on its own goroutine while tracking it,
// so that drain can wait for it during shutdown.
//
// The WaitGroup counter is incremented here, on the caller's
// goroutine, before the worker exists: incrementing it inside
// the worker would race with drain's Wait and could let
// shutdown sail past a delivery that had not started yet.
//
// Once draining has begun the delivery is refused outright
// rather than queued, so a steady stream of newly submitted
// notifications cannot keep extending the drain.
func (svc *Service) startDelivery(endpoint string, fn func()) {
svc.drainMu.Lock()
if svc.draining {
svc.drainMu.Unlock()
svc.log.Warn(
"notification not dispatched: shutdown in progress",
"endpoint", endpoint,
)
return
}
svc.outstanding.Add(1)
// WaitGroup.Go increments the counter synchronously, here,
// and only then starts the goroutine.
svc.inFlight.Go(func() {
// Runs before the WaitGroup counter is decremented, so
// a drain that times out reports an accurate count.
defer svc.outstanding.Add(-1)
fn()
})
svc.drainMu.Unlock()
}
// drain waits for in-flight notification deliveries to finish.
//
// It first stops accepting new deliveries, then waits until
// either every outstanding delivery has completed or ctx
// expires — whichever comes first. ctx is the context fx
// passes to the OnStop hook, so a permanently dead webhook
// cannot hang shutdown indefinitely.
//
// When the deadline arrives with deliveries still outstanding,
// the count is logged at warn level and the abandon channel is
// closed, which releases any retry loop sleeping in backoff.
// Deliveries already inside an HTTP round trip are bounded by
// the existing httpClientTimeout instead.
//
// A ctx that is already expired on entry is not by itself cause
// for alarm: if nothing is outstanding there is nothing to
// abandon, and the drain says so at debug level rather than
// warning about deliveries that do not exist.
func (svc *Service) drain(ctx context.Context) {
svc.drainMu.Lock()
svc.draining = true
svc.drainMu.Unlock()
done := make(chan struct{})
go func() {
svc.inFlight.Wait()
close(done)
}()
select {
case <-done:
svc.log.Debug(
"all in-flight notifications completed",
)
case <-ctx.Done():
// outstanding is decremented before the WaitGroup
// counter, and startDelivery can no longer add to it
// now that draining is set, so a zero here means every
// delivery really did finish. ctx expiring in that
// state (an OnStop context that was already cancelled
// on entry is the usual way) abandons nothing, so it
// must not close abandon or warn about it.
abandoned := svc.outstanding.Load()
if abandoned == 0 {
svc.log.Debug(
"all in-flight notifications completed",
)
return
}
svc.abandonOnce.Do(func() {
if svc.abandon != nil {
close(svc.abandon)
}
})
svc.log.Warn(
"shutdown deadline reached with notifications "+
"still in flight; abandoning them",
"abandoned", abandoned,
"error", ctx.Err(),
)
}
}
+531
View File
@@ -0,0 +1,531 @@
package notify_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"go.uber.org/fx"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/notify"
)
// Timings used by the drain tests. They stay in the same
// 10-100ms band as the retry tests so the suite never waits on
// a real backoff delay.
const (
// inFlightHold is how long a delivery is kept mid-request
// before the handler is released.
inFlightHold = 30 * time.Millisecond
// drainDeadline bounds a drain that is expected to time
// out.
drainDeadline = 50 * time.Millisecond
// drainSlack is the upper bound on how long a bounded
// drain may take; generous enough for a loaded CI box,
// still far below the 20s test ceiling.
drainSlack = 2 * time.Second
// settleDelay is how long to wait before asserting that
// something did *not* happen.
settleDelay = 50 * time.Millisecond
// idleDrainBound is the upper bound on a drain that has
// nothing in flight. It is deliberately far above the cost
// of the goroutine hop through inFlight.Wait() — which
// reached 57ms on a loaded box under -race with the package's
// parallel tests — and far below drainSlack, the deadline
// such a drain is given. A drain that blocked until its
// deadline instead of returning on the WaitGroup therefore
// still fails this bound, but scheduling delay alone cannot.
idleDrainBound = 500 * time.Millisecond
)
// syncBuffer is an io.Writer safe for concurrent use, so log
// output written from delivery goroutines can be inspected.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (sb *syncBuffer) Write(p []byte) (int, error) {
sb.mu.Lock()
defer sb.mu.Unlock()
return sb.buf.Write(p) //nolint:wrapcheck // test helper
}
func (sb *syncBuffer) String() string {
sb.mu.Lock()
defer sb.mu.Unlock()
return sb.buf.String()
}
// newLoggingService returns a Service writing JSON logs into
// the returned buffer.
func newLoggingService(
transport http.RoundTripper,
) (*notify.Service, *syncBuffer) {
logs := &syncBuffer{}
handler := slog.NewJSONHandler(logs, nil)
return notify.NewTestServiceWithLogger(transport, handler),
logs
}
// blockingNtfyServer returns a server whose handler signals on
// entered, waits for release, and then responds 200.
func blockingNtfyServer(
entered chan<- struct{},
release <-chan struct{},
served *atomic.Bool,
) *httptest.Server {
var once sync.Once
return httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
once.Do(func() { close(entered) })
<-release
served.Store(true)
w.WriteHeader(http.StatusOK)
}),
)
}
// TestDrainWaitsForInFlightDelivery verifies that a delivery
// already under way when shutdown starts is allowed to finish.
func TestDrainWaitsForInFlightDelivery(t *testing.T) {
t.Parallel()
var served atomic.Bool
entered := make(chan struct{})
release := make(chan struct{})
srv := blockingNtfyServer(entered, release, &served)
defer srv.Close()
topicURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
svc.SetNtfyURL(topicURL)
svc.SendNotification(
context.Background(), "t", "m", prioInfo,
)
// Make sure the delivery really is mid-request before the
// drain begins.
select {
case <-entered:
case <-time.After(drainSlack):
t.Fatal("delivery never reached the endpoint")
}
// As in TestDrainBoundedByContextDeadline: start is captured
// before the clock it is compared against, here the timer
// holding the delivery open, so elapsed covers the whole hold
// and the lower bound cannot come out short from scheduling
// delay alone.
start := time.Now()
timer := time.AfterFunc(inFlightHold, func() {
close(release)
})
defer timer.Stop()
ctx, cancel := context.WithTimeout(
context.Background(), drainSlack,
)
defer cancel()
svc.Drain(ctx)
elapsed := time.Since(start)
if !served.Load() {
t.Error(
"drain returned before the in-flight delivery " +
"completed",
)
}
if elapsed < inFlightHold {
t.Errorf(
"drain took %v, want at least %v",
elapsed, inFlightHold,
)
}
if got := svc.OutstandingDeliveries(); got != 0 {
t.Errorf("outstanding deliveries = %d, want 0", got)
}
}
// neverFires returns a channel that never delivers, standing in
// for a long backoff sleep without actually sleeping.
func neverFires(_ time.Duration) <-chan time.Time {
return make(chan time.Time)
}
// TestDrainBoundedByContextDeadline verifies that a delivery
// stuck retrying against a dead endpoint does not hold shutdown
// past the OnStop context deadline, and that the abandoned
// deliveries are logged at warn level rather than dropped
// silently.
func TestDrainBoundedByContextDeadline(t *testing.T) {
t.Parallel()
var requests atomic.Int64
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusInternalServerError)
}),
)
defer srv.Close()
topicURL, _ := url.Parse(srv.URL)
svc, logs := newLoggingService(http.DefaultTransport)
svc.SetNtfyURL(topicURL)
// Never let the backoff sleep complete: the delivery is
// parked in its retry wait until shutdown releases it.
svc.SetSleepFunc(neverFires)
svc.SetRetryConfig(notify.RetryConfig{
MaxRetries: 5,
BaseDelay: time.Hour,
MaxDelay: time.Hour,
})
svc.SendNotification(
context.Background(), "t", "m", prioError,
)
waitForCondition(t, func() bool {
return requests.Load() >= 1 &&
svc.OutstandingDeliveries() == 1
})
// start must be captured *before* the deadline clock starts,
// so that the measured interval is a superset of the deadline
// interval. Capturing it after context.WithTimeout would
// make elapsed structurally smaller than drainDeadline and
// the lower bound below unfalsifiable-by-luck: it would fail
// whenever the two statements were separated by any
// scheduling delay, and pass otherwise, regardless of what
// the drain did.
start := time.Now()
ctx, cancel := context.WithTimeout(
context.Background(), drainDeadline,
)
defer cancel()
// The upper bound is enforced by a watchdog rather than by
// measuring after the fact: a drain that is not bounded at
// all never returns here (the delivery is parked in a backoff
// that never fires), so an unbounded drain must fail this
// test promptly instead of hanging the package until the test
// binary's 30s timeout.
returned := make(chan struct{})
go func() {
defer close(returned)
svc.Drain(ctx)
}()
select {
case <-returned:
case <-time.After(drainSlack):
t.Fatalf(
"drain did not return within %v; its %v deadline "+
"did not bound it",
drainSlack, drainDeadline,
)
}
// The lower bound is the real assertion: the drain must have
// waited for its whole deadline rather than giving up on the
// outstanding delivery early. With start captured above, an
// early return is the only thing that can make it fail.
if elapsed := time.Since(start); elapsed < drainDeadline {
t.Errorf(
"drain returned after %v, before its %v deadline",
elapsed, drainDeadline,
)
}
assertAbandonLogged(t, logs.String())
// The abandoned delivery must stop retrying rather than
// outlive the drain.
waitForCondition(t, func() bool {
return svc.OutstandingDeliveries() == 0
})
}
// assertAbandonLogged checks that the drain logged the
// abandoned deliveries at warn level with a count.
func assertAbandonLogged(t *testing.T, output string) {
t.Helper()
if !strings.Contains(output, `"level":"WARN"`) {
t.Errorf(
"abandoned deliveries not logged at warn level; "+
"log output: %s",
output,
)
}
if !strings.Contains(output, `"abandoned":1`) {
t.Errorf(
"abandoned delivery count not logged; "+
"log output: %s",
output,
)
}
}
// TestDrainRefusesNewDeliveries verifies that notifications
// submitted after the drain has begun are refused and logged,
// so a stream of new work cannot extend shutdown indefinitely.
func TestDrainRefusesNewDeliveries(t *testing.T) {
t.Parallel()
var requests atomic.Int64
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusOK)
}),
)
defer srv.Close()
target, _ := url.Parse(srv.URL)
svc, logs := newLoggingService(http.DefaultTransport)
svc.SetNtfyURL(target)
svc.SetSlackWebhookURL(target)
svc.SetMattermostWebhookURL(target)
ctx, cancel := context.WithTimeout(
context.Background(), drainSlack,
)
defer cancel()
// Nothing is in flight, so this returns immediately and
// leaves the service refusing further deliveries.
svc.Drain(ctx)
for range 3 {
svc.SendNotification(
context.Background(), "t", "m", prioInfo,
)
}
time.Sleep(settleDelay)
if got := requests.Load(); got != 0 {
t.Errorf(
"%d requests reached the endpoint after drain, "+
"want 0",
got,
)
}
if got := svc.OutstandingDeliveries(); got != 0 {
t.Errorf("outstanding deliveries = %d, want 0", got)
}
output := logs.String()
if !strings.Contains(output, "shutdown in progress") {
t.Errorf(
"refused deliveries not logged; log output: %s",
output,
)
}
}
// recordingLifecycle is a minimal fx.Lifecycle that records the
// hooks appended to it, so the wiring done by notify.New can be
// inspected without standing up a whole fx application.
type recordingLifecycle struct {
hooks []fx.Hook
}
func (l *recordingLifecycle) Append(hook fx.Hook) {
l.hooks = append(l.hooks, hook)
}
// newNotifyService builds a Service through the real
// constructor, wired to the given lifecycle.
func newNotifyService(
t *testing.T,
lifecycle fx.Lifecycle,
ntfyTopic string,
) *notify.Service {
t.Helper()
g, err := globals.New(nil)
if err != nil {
t.Fatalf("globals.New: %v", err)
}
log, err := logger.New(nil, logger.Params{Globals: g})
if err != nil {
t.Fatalf("logger.New: %v", err)
}
svc, err := notify.New(lifecycle, notify.Params{
Logger: log,
Config: &config.Config{NtfyTopic: ntfyTopic},
})
if err != nil {
t.Fatalf("notify.New: %v", err)
}
return svc
}
// TestNewRegistersDrainingStopHook verifies that notify.New
// wires an OnStop hook into the fx lifecycle and that the hook
// waits for in-flight deliveries.
func TestNewRegistersDrainingStopHook(t *testing.T) {
t.Parallel()
var served atomic.Bool
entered := make(chan struct{})
release := make(chan struct{})
srv := blockingNtfyServer(entered, release, &served)
defer srv.Close()
lifecycle := &recordingLifecycle{}
svc := newNotifyService(t, lifecycle, srv.URL)
if len(lifecycle.hooks) != 1 {
t.Fatalf(
"appended %d lifecycle hooks, want 1",
len(lifecycle.hooks),
)
}
stop := lifecycle.hooks[0].OnStop
if stop == nil {
t.Fatal("lifecycle hook has no OnStop function")
}
svc.SendNotification(
context.Background(), "t", "m", prioInfo,
)
select {
case <-entered:
case <-time.After(drainSlack):
t.Fatal("delivery never reached the endpoint")
}
timer := time.AfterFunc(inFlightHold, func() {
close(release)
})
defer timer.Stop()
ctx, cancel := context.WithTimeout(
context.Background(), drainSlack,
)
defer cancel()
err := stop(ctx)
if err != nil {
t.Fatalf("OnStop returned error: %v", err)
}
if !served.Load() {
t.Error(
"OnStop returned before the in-flight delivery " +
"completed",
)
}
}
// TestDrainWithoutDeliveriesReturnsImmediately verifies the
// common case: nothing in flight, shutdown is not delayed.
func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) {
t.Parallel()
svc := notify.NewTestService(http.DefaultTransport)
// Captured before the deadline clock, as elsewhere in this
// file; for an upper bound that is the conservative
// direction, since the measured interval can then only be
// longer than the drain itself.
start := time.Now()
ctx, cancel := context.WithTimeout(
context.Background(), drainSlack,
)
defer cancel()
svc.Drain(ctx)
if elapsed := time.Since(start); elapsed > idleDrainBound {
t.Errorf(
"drain of an idle service took %v, want well "+
"under its %v deadline",
elapsed, drainSlack,
)
}
}
// TestDrainWithCancelledContextDoesNotWarn verifies that an
// OnStop context that is already dead on entry does not produce
// an "abandoning them" warning when there was nothing in flight
// to abandon. The expired context wins the select immediately,
// so only the outstanding count can tell the difference between
// a genuine timeout and a shutdown that had simply already run
// out of time with no work left.
func TestDrainWithCancelledContextDoesNotWarn(t *testing.T) {
t.Parallel()
svc, logs := newLoggingService(http.DefaultTransport)
ctx, cancel := context.WithCancel(context.Background())
cancel()
svc.Drain(ctx)
if output := logs.String(); strings.Contains(
output, `"level":"WARN"`,
) {
t.Errorf(
"drain with nothing in flight warned about "+
"abandoned deliveries; log output: %s",
output,
)
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/miekg/dns"
)
// DNSClient abstracts DNS wire-protocol exchanges so the resolver
// can be tested without hitting real nameservers.
// DNSClient sends one DNS message to a nameserver and returns the
// reply. The resolver holds one for UDP and one for TCP.
type DNSClient interface {
ExchangeContext(
ctx context.Context,
+8
View File
@@ -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)
}
+8 -5
View File
@@ -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 {
switch r := rr.(type) {
case *dns.A:
@@ -616,22 +619,22 @@ func extractRecordValue(rr dns.RR) string {
case *dns.AAAA:
return r.AAAA.String()
case *dns.CNAME:
return r.Target
return strings.ToLower(r.Target)
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:
return strings.Join(r.Txt, "")
case *dns.SRV:
return fmt.Sprintf(
"%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:
return fmt.Sprintf(
"%d %s \"%s\"", r.Flag, r.Tag, r.Value,
)
case *dns.NS:
return r.Ns
return strings.ToLower(r.Ns)
default:
return ""
}
+62
View File
@@ -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))
})
}
}
+192
View File
@@ -0,0 +1,192 @@
package resolver_test
import (
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/dnswatcher/internal/resolver"
)
// Tests for the live-DNS harness in livedns_test.go itself. These
// exercise pure logic; they perform no DNS resolution of any kind, so
// they neither mock DNS nor depend on it.
// Names for the synthetic status maps below. Nothing is ever queried
// at them: they are map keys handed to the package's pure counting
// helpers, not a stand-in for a nameserver.
const (
nsExample1 = "ns1.example."
nsExample2 = "ns2.example."
nsExample3 = "ns3.example."
nsExample4 = "ns4.example."
)
func TestLiveQuorumIsStrictMajority(t *testing.T) {
t.Parallel()
cases := map[int]int{
0: 1,
1: 1,
2: 2,
3: 2,
4: 3,
5: 3,
13: 7,
}
for total, want := range cases {
assert.Equal(
t, want, liveQuorum(total),
"liveQuorum(%d)", total,
)
}
}
func TestStatusCountingIgnoresSilentNameservers(t *testing.T) {
t.Parallel()
results := map[string]*resolver.NameserverResponse{
nsExample1: {
Nameserver: nsExample1,
Status: resolver.StatusOK,
},
nsExample2: {
Nameserver: nsExample2,
Status: resolver.StatusOK,
},
nsExample3: {
Nameserver: nsExample3,
Status: resolver.StatusTimeout,
},
nsExample4: {
Nameserver: nsExample4,
Status: resolver.StatusError,
},
}
assert.Equal(
t, 2, countStatus(results, resolver.StatusOK),
)
assert.Equal(
t, 0, countStatus(results, resolver.StatusNXDomain),
)
// Two of four answered, which is short of the quorum of
// three: this is the state that triggers a retry rather
// than an assertion failure.
assert.Equal(t, 2, answeredCount(results))
assert.Less(t, answeredCount(results), liveQuorum(len(results)))
assert.Equal(
t,
"ns1.example.=ok ns2.example.=ok "+
"ns3.example.=timeout ns4.example.=error",
describeStatuses(results),
)
}
// TestUnsanctionedStatusesRejectsWrongAnswers is the regression test
// for the defect this allowlist exists to prevent: a minority of
// nameservers answering WRONGLY while quorum keeps the suite green.
// nodata is the case that motivated it — it is a wrong answer, not
// silence, and it was previously banned by neither test.
func TestUnsanctionedStatusesRejectsWrongAnswers(t *testing.T) {
t.Parallel()
// Four nameservers, three OK and one answering nodata: a
// quorum of three is satisfied and no NXDOMAIN is present, so
// the old blocklist assertions both passed on this input.
results := map[string]*resolver.NameserverResponse{
nsExample1: {
Nameserver: nsExample1,
Status: resolver.StatusOK,
},
nsExample2: {
Nameserver: nsExample2,
Status: resolver.StatusOK,
},
nsExample3: {
Nameserver: nsExample3,
Status: resolver.StatusOK,
},
nsExample4: {
Nameserver: nsExample4,
Status: resolver.StatusNoData,
},
}
assert.GreaterOrEqual(
t,
countStatus(results, resolver.StatusOK),
liveQuorum(len(results)),
)
assert.Zero(t, countStatus(results, resolver.StatusNXDomain))
// nodata is an ANSWER, so it never triggers a retry: nothing
// but the allowlist stands between it and a false green.
assert.Equal(t, len(results), answeredCount(results))
assert.Equal(
t,
[]string{nsExample4 + "=nodata"},
unsanctionedStatuses(
results,
resolver.StatusOK,
resolver.StatusTimeout,
resolver.StatusError,
),
"nodata must be reported as an unsanctioned status",
)
}
func TestUnsanctionedStatusesToleratesSilenceOnly(t *testing.T) {
t.Parallel()
results := map[string]*resolver.NameserverResponse{
nsExample1: {
Nameserver: nsExample1,
Status: resolver.StatusNXDomain,
},
nsExample2: {
Nameserver: nsExample2,
Status: resolver.StatusTimeout,
},
nsExample3: {
Nameserver: nsExample3,
Status: resolver.StatusError,
},
}
allowed := []string{
resolver.StatusNXDomain,
resolver.StatusTimeout,
resolver.StatusError,
}
assert.Empty(
t,
unsanctionedStatuses(results, allowed...),
"timeout and error are non-answers and are tolerated",
)
// The same silent nameservers do not count towards a quorum.
assert.Equal(t, 1, answeredCount(results))
// An unknown status is treated as silence by answeredCount —
// so it retries and fails loudly — and is unsanctioned by the
// allowlist rather than quietly permitted.
const laterStatus = "some-status-added-later"
results[nsExample4] = &resolver.NameserverResponse{
Nameserver: nsExample4,
Status: laterStatus,
}
assert.Equal(t, 1, answeredCount(results))
assert.Equal(
t,
[]string{nsExample4 + "=" + laterStatus},
unsanctionedStatuses(results, allowed...),
)
}
+385
View File
@@ -0,0 +1,385 @@
package resolver_test
import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strings"
"testing"
"sneak.berlin/go/dnswatcher/internal/livednstest"
"sneak.berlin/go/dnswatcher/internal/resolver"
)
// ----------------------------------------------------------------
// Live DNS test support
// ----------------------------------------------------------------
//
// Tests that look something up in DNS query live DNS servers, never a
// stand-in; logic that works on record data may be tested on that
// data with no lookup (see TESTING.md). Each live operation below goes
// through livednstest.Retry, which bounds how many resolutions are in
// flight at once and retries an operation that got no answer (see
// package livednstest).
//
// 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.
//
// That tolerance 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.
// minNameservers is the smallest nameserver count a well-run zone is
// expected to publish.
const minNameservers = 2
// errLiveNoQuorum reports that too few of a domain's nameservers
// answered for a quorum assertion to be made.
var errLiveNoQuorum = errors.New("no nameserver quorum")
// liveQuorum is how many of total nameservers must agree for a
// multi-nameserver assertion to hold: a strict majority.
func liveQuorum(total int) int {
if total < 1 {
return 1
}
return total/2 + 1
}
// countStatus counts the responses carrying the given status.
func countStatus(
results map[string]*resolver.NameserverResponse,
status string,
) int {
n := 0
for _, resp := range results {
if resp.Status == status {
n++
}
}
return n
}
// liveAnswerStatuses is the closed set of statuses that count as a
// nameserver having ANSWERED at all, whether or not the test agrees
// with the answer. It is deliberately an allowlist: a status added
// to the resolver later is treated as silence, so it can only ever
// cause a retry and then a loud failure, never a quiet pass.
func liveAnswerStatuses() []string {
return []string{
resolver.StatusOK,
resolver.StatusNXDomain,
resolver.StatusNoData,
}
}
// answeredCount counts the nameservers that produced an answer of
// any kind, as opposed to failing or timing out.
func answeredCount(
results map[string]*resolver.NameserverResponse,
) int {
answers := liveAnswerStatuses()
n := 0
for _, resp := range results {
if slices.Contains(answers, resp.Status) {
n++
}
}
return n
}
// unsanctionedStatuses returns "nameserver=status" for every result
// whose status the caller did not explicitly sanction, sorted for a
// stable failure message. Callers pass the full closed set they will
// accept — the expected answer plus whichever non-answers (timeout,
// error) quorum is allowed to tolerate — so that any status outside
// it fails the test by name.
func unsanctionedStatuses(
results map[string]*resolver.NameserverResponse,
allowed ...string,
) []string {
offenders := make([]string, 0, len(results))
for ns, resp := range results {
if slices.Contains(allowed, resp.Status) {
continue
}
offenders = append(
offenders, fmt.Sprintf("%s=%s", ns, resp.Status),
)
}
sort.Strings(offenders)
return offenders
}
// describeStatuses renders per-nameserver statuses for use in
// assertion failure messages.
func describeStatuses(
results map[string]*resolver.NameserverResponse,
) string {
parts := make([]string, 0, len(results))
for ns, resp := range results {
parts = append(
parts, fmt.Sprintf("%s=%s", ns, resp.Status),
)
}
sort.Strings(parts)
return strings.Join(parts, " ")
}
// ----------------------------------------------------------------
// Live operation wrappers
// ----------------------------------------------------------------
// liveFindAuthoritative resolves a domain's authoritative
// nameservers, retrying until the delegation chain can be walked.
func liveFindAuthoritative(
t *testing.T,
r *resolver.Resolver,
domain string,
) []string {
t.Helper()
var out []string
livednstest.Retry(
t,
"FindAuthoritativeNameservers("+domain+")",
func(ctx context.Context) error {
ns, err := r.FindAuthoritativeNameservers(ctx, domain)
if err != nil {
return err
}
if len(ns) == 0 {
return fmt.Errorf(
"%w: %s has no nameservers",
livednstest.ErrNoAnswer, domain,
)
}
out = ns
return nil
},
)
return out
}
// liveLookupNS is liveFindAuthoritative through the LookupNS entry
// point, so that both entry points stay independently exercised.
func liveLookupNS(
t *testing.T,
r *resolver.Resolver,
domain string,
) []string {
t.Helper()
var out []string
livednstest.Retry(
t,
"LookupNS("+domain+")",
func(ctx context.Context) error {
ns, err := r.LookupNS(ctx, domain)
if err != nil {
return err
}
if len(ns) == 0 {
return fmt.Errorf(
"%w: %s has no nameservers",
livednstest.ErrNoAnswer, domain,
)
}
out = ns
return nil
},
)
return out
}
// liveQueryNameserver queries one nameserver, retrying while that
// nameserver fails to answer. NXDOMAIN and NODATA are answers and
// are returned to the caller to assert on.
func liveQueryNameserver(
t *testing.T,
r *resolver.Resolver,
nameserver string,
hostname string,
) *resolver.NameserverResponse {
t.Helper()
what := fmt.Sprintf(
"QueryNameserver(%s, %s)", nameserver, hostname,
)
var out *resolver.NameserverResponse
livednstest.Retry(
t,
what,
func(ctx context.Context) error {
resp, err := r.QueryNameserver(
ctx, nameserver, hostname,
)
if err != nil {
return err
}
if resp.Status == resolver.StatusTimeout ||
resp.Status == resolver.StatusError {
return fmt.Errorf(
"%w: %s returned %s: %s",
livednstest.ErrNoAnswer, nameserver,
resp.Status, resp.Error,
)
}
out = resp
return nil
},
)
return out
}
// liveQueryAllNameservers queries every authoritative nameserver
// for a hostname, retrying until a quorum of them has answered.
// Individual nameservers that stay silent are left in the result
// for the caller to account for.
func liveQueryAllNameservers(
t *testing.T,
r *resolver.Resolver,
hostname string,
) map[string]*resolver.NameserverResponse {
t.Helper()
var out map[string]*resolver.NameserverResponse
livednstest.Retry(
t,
"QueryAllNameservers("+hostname+")",
func(ctx context.Context) error {
results, err := r.QueryAllNameservers(ctx, hostname)
if err != nil {
return err
}
if len(results) == 0 {
return fmt.Errorf(
"%w: no nameservers queried for %s",
livednstest.ErrNoAnswer, hostname,
)
}
answered := answeredCount(results)
if answered < liveQuorum(len(results)) {
return fmt.Errorf(
"%w: %d of %d answered: %s",
errLiveNoQuorum, answered,
len(results), describeStatuses(results),
)
}
out = results
return nil
},
)
return out
}
// liveResolveIPs resolves a hostname that is expected to have
// addresses, retrying until at least one is returned.
func liveResolveIPs(
t *testing.T,
r *resolver.Resolver,
hostname string,
) []string {
t.Helper()
var out []string
livednstest.Retry(
t,
"ResolveIPAddresses("+hostname+")",
func(ctx context.Context) error {
ips, err := r.ResolveIPAddresses(ctx, hostname)
if err != nil {
return err
}
if len(ips) == 0 {
return fmt.Errorf(
"%w: no addresses for %s",
livednstest.ErrNoAnswer, hostname,
)
}
out = ips
return nil
},
)
return out
}
// liveResolveIPsAllowingEmpty resolves a hostname that may legitimately
// have no addresses, so the empty result is returned rather than
// retried. Used for names that must not exist; the corresponding
// QueryAllNameservers test is what proves the nameservers actively
// said NXDOMAIN rather than merely staying silent.
func liveResolveIPsAllowingEmpty(
t *testing.T,
r *resolver.Resolver,
hostname string,
) []string {
t.Helper()
var out []string
livednstest.Retry(
t,
"ResolveIPAddresses("+hostname+")",
func(ctx context.Context) error {
ips, err := r.ResolveIPAddresses(ctx, hostname)
if err != nil {
return err
}
out = ips
return nil
},
)
return out
}
-13
View File
@@ -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.
+106 -235
View File
@@ -10,7 +10,6 @@ import (
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -32,32 +31,17 @@ func newTestResolver(t *testing.T) *resolver.Resolver {
return resolver.NewFromLogger(log)
}
func testContext(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithTimeout(
context.Background(), 60*time.Second,
)
t.Cleanup(cancel)
return ctx
}
// findOneNSForDomain picks one authoritative nameserver to aim a
// test at. Quorum handling lives in livedns_test.go, and the live-DNS
// retry and concurrency limit in package livednstest.
func findOneNSForDomain(
t *testing.T,
r *resolver.Resolver,
ctx context.Context, //nolint:revive // test helper
domain string,
) string {
t.Helper()
nameservers, err := r.FindAuthoritativeNameservers(
ctx, domain,
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
return nameservers[0]
return liveFindAuthoritative(t, r, domain)[0]
}
// ----------------------------------------------------------------
@@ -70,13 +54,7 @@ func TestFindAuthoritativeNameservers_ValidDomain(
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
nameservers := liveFindAuthoritative(t, r, "google.com")
hasGoogleNS := false
@@ -99,13 +77,9 @@ func TestFindAuthoritativeNameservers_Subdomain(
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
nameservers := liveFindAuthoritative(t, r, "www.google.com")
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "www.google.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
assert.NotEmpty(t, nameservers)
}
func TestFindAuthoritativeNameservers_ReturnsSorted(
@@ -114,12 +88,7 @@ func TestFindAuthoritativeNameservers_ReturnsSorted(
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
nameservers := liveFindAuthoritative(t, r, "google.com")
assert.True(
t,
@@ -134,17 +103,8 @@ func TestFindAuthoritativeNameservers_Deterministic(
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
first, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
second, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
first := liveFindAuthoritative(t, r, "google.com")
second := liveFindAuthoritative(t, r, "google.com")
assert.Equal(t, first, second)
}
@@ -155,17 +115,8 @@ func TestFindAuthoritativeNameservers_TrailingDot(
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns1, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
ns2, err := r.FindAuthoritativeNameservers(
ctx, "google.com.",
)
require.NoError(t, err)
ns1 := liveFindAuthoritative(t, r, "google.com")
ns2 := liveFindAuthoritative(t, r, "google.com.")
assert.Equal(t, ns1, ns2)
}
@@ -176,13 +127,7 @@ func TestFindAuthoritativeNameservers_CloudflareDomain(
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "cloudflare.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
nameservers := liveFindAuthoritative(t, r, "cloudflare.com")
for _, ns := range nameservers {
assert.True(t, strings.HasSuffix(ns, "."),
@@ -199,13 +144,9 @@ func TestQueryNameserver_BasicA(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "google.com")
ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(t, r, ns, "www.google.com")
resp, err := r.QueryNameserver(
ctx, ns, "www.google.com",
)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, resolver.StatusOK, resp.Status)
@@ -222,13 +163,8 @@ func TestQueryNameserver_AAAA(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "cloudflare.com")
resp, err := r.QueryNameserver(
ctx, ns, "cloudflare.com",
)
require.NoError(t, err)
ns := findOneNSForDomain(t, r, "cloudflare.com")
resp := liveQueryNameserver(t, r, ns, "cloudflare.com")
aaaaRecords := resp.Records["AAAA"]
require.NotEmpty(t, aaaaRecords,
@@ -247,13 +183,8 @@ func TestQueryNameserver_MX(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns, "google.com",
)
require.NoError(t, err)
ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(t, r, ns, "google.com")
mxRecords := resp.Records["MX"]
require.NotEmpty(t, mxRecords,
@@ -265,13 +196,8 @@ func TestQueryNameserver_TXT(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns, "google.com",
)
require.NoError(t, err)
ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(t, r, ns, "google.com")
txtRecords := resp.Records["TXT"]
require.NotEmpty(t, txtRecords,
@@ -297,14 +223,10 @@ func TestQueryNameserver_NXDomain(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns,
"this-surely-does-not-exist-xyz.google.com",
ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(
t, r, ns, "this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
assert.Equal(t, resolver.StatusNXDomain, resp.Status)
}
@@ -313,13 +235,8 @@ func TestQueryNameserver_RecordsSorted(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns, "google.com",
)
require.NoError(t, err)
ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(t, r, ns, "google.com")
for recordType, values := range resp.Records {
assert.True(
@@ -336,13 +253,8 @@ func TestQueryNameserver_ResponseIncludesNameserver(
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "cloudflare.com")
resp, err := r.QueryNameserver(
ctx, ns, "cloudflare.com",
)
require.NoError(t, err)
ns := findOneNSForDomain(t, r, "cloudflare.com")
resp := liveQueryNameserver(t, r, ns, "cloudflare.com")
assert.Equal(t, ns, resp.Nameserver)
}
@@ -353,14 +265,10 @@ func TestQueryNameserver_EmptyRecordsOnNXDomain(
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns,
"this-surely-does-not-exist-xyz.google.com",
ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(
t, r, ns, "this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
totalRecords := 0
for _, values := range resp.Records {
@@ -374,18 +282,9 @@ func TestQueryNameserver_TrailingDotHandling(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp1, err := r.QueryNameserver(
ctx, ns, "google.com",
)
require.NoError(t, err)
resp2, err := r.QueryNameserver(
ctx, ns, "google.com.",
)
require.NoError(t, err)
ns := findOneNSForDomain(t, r, "google.com")
resp1 := liveQueryNameserver(t, r, ns, "google.com")
resp2 := liveQueryNameserver(t, r, ns, "google.com.")
assert.Equal(t, resp1.Status, resp2.Status)
}
@@ -398,15 +297,9 @@ func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
results := liveQueryAllNameservers(t, r, "google.com")
results, err := r.QueryAllNameservers(
ctx, "google.com",
)
require.NoError(t, err)
require.NotEmpty(t, results)
assert.GreaterOrEqual(t, len(results), 2)
assert.GreaterOrEqual(t, len(results), minNameservers)
for ns, resp := range results {
assert.Equal(t, ns, resp.Nameserver)
@@ -417,20 +310,37 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
results := liveQueryAllNameservers(t, r, "google.com")
results, err := r.QueryAllNameservers(
ctx, "google.com",
// A quorum, not unanimity: one authoritative server being
// slow or rate-limiting us is a property of the live
// internet, not a resolver defect.
assert.GreaterOrEqual(
t,
countStatus(results, resolver.StatusOK),
liveQuorum(len(results)),
"a quorum of nameservers should answer OK: %s",
describeStatuses(results),
)
require.NoError(t, err)
for ns, resp := range results {
assert.Equal(
t, resolver.StatusOK, resp.Status,
"NS %s should return OK", ns,
// Quorum tolerates SILENCE only. Every individual result must
// be either the expected answer or a non-answer: ok, timeout
// or error, and nothing else. Stated as a closed allowlist so
// that a wrong answer no one thought to ban — nxdomain and
// nodata today, any status added later — fails here rather
// than sliding through under the quorum.
assert.Empty(
t,
unsanctionedStatuses(
results,
resolver.StatusOK,
resolver.StatusTimeout,
resolver.StatusError,
),
"every nameserver must answer OK or not answer at all: %s",
describeStatuses(results),
)
}
}
func TestQueryAllNameservers_NXDomainFromAllNS(
t *testing.T,
@@ -438,20 +348,34 @@ func TestQueryAllNameservers_NXDomainFromAllNS(
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
results, err := r.QueryAllNameservers(
ctx,
"this-surely-does-not-exist-xyz.google.com",
results := liveQueryAllNameservers(
t, r, "this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
for ns, resp := range results {
assert.Equal(
t, resolver.StatusNXDomain, resp.Status,
"NS %s should return nxdomain", ns,
assert.GreaterOrEqual(
t,
countStatus(results, resolver.StatusNXDomain),
liveQuorum(len(results)),
"a quorum of nameservers should report NXDOMAIN: %s",
describeStatuses(results),
)
// Silence is tolerated; any actual answer other than NXDOMAIN
// is not. Closed allowlist for the same reason as above: a
// server answering `ok` or `nodata` for a name that must not
// exist is a wrong answer, not a slow one.
assert.Empty(
t,
unsanctionedStatuses(
results,
resolver.StatusNXDomain,
resolver.StatusTimeout,
resolver.StatusError,
),
"every nameserver must report NXDOMAIN or not answer "+
"at all: %s",
describeStatuses(results),
)
}
}
// ----------------------------------------------------------------
@@ -462,11 +386,7 @@ func TestLookupNS_ValidDomain(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
require.NotEmpty(t, nameservers)
nameservers := liveLookupNS(t, r, "google.com")
for _, ns := range nameservers {
assert.True(t, strings.HasSuffix(ns, "."),
@@ -479,10 +399,7 @@ func TestLookupNS_Sorted(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
nameservers := liveLookupNS(t, r, "google.com")
assert.True(t, sort.StringsAreSorted(nameservers))
}
@@ -491,15 +408,8 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
fromLookup, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
fromFind, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
fromLookup := liveLookupNS(t, r, "google.com")
fromFind := liveFindAuthoritative(t, r, "google.com")
assert.Equal(t, fromFind, fromLookup)
}
@@ -512,11 +422,7 @@ func TestResolveIPAddresses_ReturnsIPs(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
require.NotEmpty(t, ips)
ips := liveResolveIPs(t, r, "google.com")
for _, ip := range ips {
parsed := net.ParseIP(ip)
@@ -530,10 +436,7 @@ func TestResolveIPAddresses_Deduplicated(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
ips := liveResolveIPs(t, r, "google.com")
seen := make(map[string]bool)
@@ -547,10 +450,7 @@ func TestResolveIPAddresses_Sorted(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
ips := liveResolveIPs(t, r, "google.com")
assert.True(t, sort.StringsAreSorted(ips))
}
@@ -561,13 +461,10 @@ func TestResolveIPAddresses_NXDomainReturnsEmpty(
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(
ctx,
"this-surely-does-not-exist-xyz.google.com",
ips := liveResolveIPsAllowingEmpty(
t, r, "this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
assert.Empty(t, ips)
}
@@ -575,11 +472,9 @@ func TestResolveIPAddresses_CloudflareDomain(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ips := liveResolveIPs(t, r, "cloudflare.com")
ips, err := r.ResolveIPAddresses(ctx, "cloudflare.com")
require.NoError(t, err)
require.NotEmpty(t, ips)
assert.NotEmpty(t, ips)
}
// ----------------------------------------------------------------
@@ -630,21 +525,18 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
func TestQueryNameserverIP_Timeout(t *testing.T) {
t.Parallel()
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
r := resolver.NewFromLoggerWithClient(
log, &timeoutClient{},
)
r := newTestResolver(t)
// 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(
context.Background(), 10*time.Second,
context.Background(), 3*time.Second,
)
t.Cleanup(cancel)
// Query any IP — the client always returns a timeout error.
resp, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1",
"example.com",
@@ -655,27 +547,6 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
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) {
t.Parallel()
+24
View File
@@ -0,0 +1,24 @@
package server
import (
"net/http"
"time"
)
// RequestTimeout exports the handler execution budget applied by
// chimw.Timeout in SetupRoutes, so tests can assert the relationship
// between it and the server's WriteTimeout.
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
View File
@@ -21,6 +21,7 @@ func (s *Server) SetupRoutes() {
// Global middleware
s.router.Use(chimw.Recoverer)
s.router.Use(chimw.RequestID)
s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.Logging())
s.router.Use(s.mw.CORS())
s.router.Use(chimw.Timeout(requestTimeout))
+64 -7
View File
@@ -33,8 +33,52 @@ type Params struct {
// shutdownTimeout is how long to wait for graceful shutdown.
const shutdownTimeout = 30 * time.Second
// readHeaderTimeout is the max duration for reading request headers.
const readHeaderTimeout = 10 * time.Second
// Socket-level timeouts for the HTTP server.
//
// These bound time spent on the connection itself and are a distinct
// control from the per-request handler budget enforced by
// chimw.Timeout(requestTimeout) in routes.go: that one cancels the
// request context after requestTimeout but never touches the socket,
// so without the values below a peer can hold a connection open
// forever (slowloris, unreaped keep-alives).
//
// The one hard constraint between the two controls is
// writeTimeout > requestTimeout. net/http arms the write deadline
// once the request headers have been read, so on a plaintext
// connection it covers handler execution AND the response flush. If
// writeTimeout were <= requestTimeout the server would sever the
// connection before a handler that legitimately consumed its full
// budget could emit anything, making the 60s budget unreachable in
// practice. The margin between them is the response-flush allowance.
//
// The only clients of this service are browsers loading the dashboard
// and a Prometheus scraper; the values are sized for those.
const (
// readHeaderTimeout is the max duration for reading request
// headers.
readHeaderTimeout = 10 * time.Second
// readTimeout bounds reading the entire request, headers plus
// body. Every route here is a GET with no body, so this only
// ever needs to cover headers; the extra 5s over
// readHeaderTimeout is slack, not a real allowance, and keeps a
// body dribbled one byte at a time from holding the read side
// open indefinitely.
readTimeout = 15 * time.Second
// writeTimeout must exceed the requestTimeout handler budget
// (60s) per the note above. The 15s difference is the allowance
// for flushing a completed response to a slow client.
writeTimeout = 75 * time.Second
// idleTimeout reaps keep-alive connections between requests. It
// is deliberately longer than the common Prometheus scrape
// intervals (15s/30s/60s) so the scraper reuses its connection
// rather than reconnecting every cycle, while a browser tab
// left open on the dashboard stops occupying a connection
// within two minutes of going quiet.
idleTimeout = 120 * time.Second
)
// Server is the HTTP server.
type Server struct {
@@ -76,16 +120,29 @@ func New(
return srv, nil
}
// newHTTPServer builds the listening http.Server with every
// socket-level timeout set. All four are set deliberately: a zero
// value in net/http means "no limit", not "some default".
func newHTTPServer(
listenAddr string,
handler http.Handler,
) *http.Server {
return &http.Server{
Addr: listenAddr,
Handler: handler,
ReadTimeout: readTimeout,
ReadHeaderTimeout: readHeaderTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
}
}
// Run starts the HTTP server.
func (s *Server) Run() {
s.SetupRoutes()
listenAddr := fmt.Sprintf(":%d", s.port)
s.httpServer = &http.Server{
Addr: listenAddr,
Handler: s,
ReadHeaderTimeout: readHeaderTimeout,
}
s.httpServer = newHTTPServer(listenAddr, s)
s.log.Info("http server starting", "addr", listenAddr)
+130
View File
@@ -0,0 +1,130 @@
package server_test
import (
"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/state"
)
// buildServer wires a *server.Server exactly as cmd/dnswatcher does,
// minus the watcher/resolver subtree that would touch live DNS. fx
// builds the object graph but the lifecycle is never started, so no
// OnStart hook runs and nothing listens or resolves. The caller must
// 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),
)
err := app.Err()
if err != nil {
t.Fatalf("building server graph: %v", err)
}
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(
"ReadHeaderTimeout must be non-zero, got %v",
hs.ReadHeaderTimeout,
)
}
if hs.WriteTimeout <= 0 {
t.Errorf("WriteTimeout must be non-zero, got %v", hs.WriteTimeout)
}
if hs.IdleTimeout <= 0 {
t.Errorf("IdleTimeout must be non-zero, got %v", hs.IdleTimeout)
}
if hs.WriteTimeout <= server.RequestTimeout {
t.Errorf(
"WriteTimeout (%v) must exceed handler budget (%v)",
hs.WriteTimeout,
server.RequestTimeout,
)
}
if hs.ReadTimeout < hs.ReadHeaderTimeout {
t.Errorf(
"ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)",
hs.ReadTimeout,
hs.ReadHeaderTimeout,
)
}
if hs.Handler != srv {
t.Errorf(
"Run wired handler %T, want the *server.Server",
hs.Handler,
)
}
}
+29
View File
@@ -148,6 +148,11 @@ func New(
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
err := state.checkDataDirWritable()
if err != nil {
return err
}
return state.Load()
},
OnStop: func(_ context.Context) error {
@@ -345,3 +350,27 @@ func (s *State) GetCertificateState(
return cs, ok
}
// checkDataDirWritable creates the data directory if needed, then writes
// and removes the temp file that Save uses. It runs at startup so that an
// unwritable directory stops the process, instead of the process running
// with every save failing and only logged.
func (s *State) checkDataDirWritable() error {
dir := s.config.DataDir
tmpPath := s.config.StatePath() + ".tmp"
err := os.MkdirAll(dir, dirPermissions)
if err == nil {
err = os.WriteFile(tmpPath, nil, filePermissions)
}
if err == nil {
err = os.Remove(tmpPath)
}
if err != nil {
return fmt.Errorf("data directory %s is not writable: %w", dir, err)
}
return nil
}
+107
View File
@@ -4,10 +4,16 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/state"
)
@@ -493,6 +499,107 @@ func TestSaveWritePermissionError(t *testing.T) {
}
}
// startState builds a State through the real constructor and runs its
// startup hook against dataDir, returning the startup error.
func startState(t *testing.T, dataDir string) error {
t.Helper()
g, err := globals.New(nil)
if err != nil {
t.Fatalf("globals.New: %v", err)
}
log, err := logger.New(nil, logger.Params{Globals: g})
if err != nil {
t.Fatalf("logger.New: %v", err)
}
lifecycle := fxtest.NewLifecycle(t)
_, err = state.New(lifecycle, state.Params{
Logger: log,
Config: &config.Config{DataDir: dataDir},
})
if err != nil {
t.Fatalf("state.New: %v", err)
}
return lifecycle.Start(t.Context())
}
// TestStartupFailsWhenDataDirNotWritable verifies that startup stops
// with an error naming the data directory when it cannot be written.
// The directory's parent is a regular file, which also fails as root.
func TestStartupFailsWhenDataDirNotWritable(t *testing.T) {
t.Parallel()
parent := filepath.Join(t.TempDir(), "file")
err := os.WriteFile(parent, nil, 0o600)
if err != nil {
t.Fatalf("writing file: %v", err)
}
dataDir := filepath.Join(parent, "data")
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)
}
}
// 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
// data directory and leaves nothing behind in it.
func TestStartupCreatesDataDir(t *testing.T) {
t.Parallel()
dataDir := filepath.Join(t.TempDir(), "data")
err := startState(t, dataDir)
if err != nil {
t.Fatalf("startup error: %v", err)
}
entries, err := os.ReadDir(dataDir)
if err != nil {
t.Fatalf("reading data directory: %v", err)
}
if len(entries) != 0 {
t.Errorf("startup left %d entries in the data directory", len(entries))
}
}
// TestPortStateUnmarshalJSON_NewFormat verifies deserialization of the
// current multi-hostname format.
func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) {
+25
View File
@@ -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)
}
+182
View File
@@ -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
View File
@@ -366,7 +366,7 @@ func (w *Watcher) detectHostnameChanges(
) {
w.detectRecordChanges(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(
@@ -448,22 +448,11 @@ func (w *Watcher) detectNSDisappearances(
func (w *Watcher) detectInconsistencies(
ctx context.Context,
hostname string,
prev *state.HostnameState,
current map[string]map[string][]string,
) {
nameservers := make([]string, 0, len(current))
for ns := range current {
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
}
for _, pair := range newlyDisagreeingPairs(prev, current) {
ns1, ns2 := pair[0], pair[1]
msg := fmt.Sprintf(
"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) {
// Phase 1: Build current IP:port → hostname associations
// from fresh DNS data.
+245 -474
View File
@@ -2,151 +2,77 @@ package watcher_test
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"sync"
"testing"
"time"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/livednstest"
"sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/tlscheck"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// errNotFound is returned when mock data is missing.
var errNotFound = errors.New("not found")
// Fixture values shared across tests.
// The watcher looks these names up in live DNS with the real resolver,
// so tests assert on what the watcher does with the answers, never on
// the records these zones publish. testHost's nameservers and addresses
// stay the same from one check to the next, which the tests that check
// it twice rely on.
const (
testDomain = "example.com"
testHost = "www.example.com"
testNS1 = "ns1.example.com."
testNS2 = "ns2.example.com."
testIPv4 = "93.184.216.34"
testIP = "1.2.3.4"
testDomain = "google.com"
testHost = "cloudflare.com"
testIssuer = "DigiCert"
)
// --- Mock implementations ---
type mockResolver struct {
mu sync.Mutex
nsRecords map[string][]string
allRecords map[string]map[string]map[string][]string
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,
// 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"
)
}
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
}
// --- Stand-ins for the port checker, TLS checker and notifier ---
//
// DNS has none: the watchers built here use the real resolver (see
// TESTING.md).
// mockPortChecker reports every port open until closed is set.
type mockPortChecker struct {
mu sync.Mutex
results map[string]bool
err error
closed bool
calls int
}
func (m *mockPortChecker) CheckPort(
_ context.Context,
address string,
port int,
_ string,
_ int,
) (*portcheck.PortResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls++
if m.err != nil {
return nil, m.err
}
key := fmt.Sprintf("%s:%d", address, port)
open := m.results[key]
return &portcheck.PortResult{Open: open}, nil
return &portcheck.PortResult{Open: !m.closed}, nil
}
// mockTLSChecker returns a certificate for the requested hostname that
// expires at notAfter.
type mockTLSChecker struct {
mu sync.Mutex
certs map[string]*tlscheck.CertificateInfo
err error
notAfter time.Time
calls int
}
func (m *mockTLSChecker) CheckCertificate(
_ context.Context,
ip string,
_ string,
hostname string,
) (*tlscheck.CertificateInfo, error) {
m.mu.Lock()
@@ -154,20 +80,12 @@ func (m *mockTLSChecker) CheckCertificate(
m.calls++
if m.err != nil {
return nil, m.err
}
key := fmt.Sprintf("%s:%s", ip, hostname)
cert, ok := m.certs[key]
if !ok {
return nil, fmt.Errorf(
"%w: cert for %s", errNotFound, key,
)
}
return cert, nil
return &tlscheck.CertificateInfo{
CommonName: hostname,
Issuer: testIssuer,
NotAfter: m.notAfter,
SubjectAlternativeNames: []string{hostname},
}, nil
}
type notification struct {
@@ -205,10 +123,9 @@ func (m *mockNotifier) getNotifications() []notification {
return result
}
// --- Helper to build a Watcher for testing ---
// --- Helpers to build a Watcher and run its checks against live DNS ---
type testDeps struct {
resolver *mockResolver
portChecker *mockPortChecker
tlsChecker *mockTLSChecker
notifier *mockNotifier
@@ -223,16 +140,9 @@ func newTestWatcher(
t.Helper()
deps := &testDeps{
resolver: &mockResolver{
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),
},
portChecker: &mockPortChecker{},
tlsChecker: &mockTLSChecker{
certs: make(map[string]*tlscheck.CertificateInfo),
notAfter: time.Now().Add(90 * 24 * time.Hour),
},
notifier: &mockNotifier{},
config: cfg,
@@ -243,7 +153,7 @@ func newTestWatcher(
w := watcher.NewForTest(
deps.config,
deps.state,
deps.resolver,
resolver.NewFromLogger(slog.Default()),
deps.portChecker,
deps.tlsChecker,
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, livednstest.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
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg)
if prepare != nil {
prepare(deps)
}
err := checkOnce(ctx, w, deps)
if err != nil || change == nil {
return err
}
change(deps)
return checkOnce(ctx, w, deps)
})
return deps
}
// 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) {
t.Parallel()
@@ -270,51 +296,12 @@ func TestFirstRunBaseline(t *testing.T) {
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps)
w.RunOnce(t.Context())
deps := runChecks(t, cfg, nil, nil)
assertNoNotifications(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(
t *testing.T,
deps *testDeps,
@@ -361,26 +348,7 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
w, deps := newTestWatcher(t, cfg)
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())
deps := runChecks(t, cfg, nil, nil)
snap := deps.state.GetSnapshot()
@@ -419,50 +387,18 @@ func TestNSChangeDetection(t *testing.T) {
cfg := defaultTestConfig(t)
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{
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
assertNotified(t, deps, "NS Change: "+testDomain, "warning")
ctx := t.Context()
w.RunOnce(ctx)
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")
ds, _ := deps.state.GetDomainState(testDomain)
if slices.Contains(ds.Nameservers, oldNS1) {
t.Errorf("saved nameservers not updated: %v", ds.Nameservers)
}
}
@@ -472,40 +408,18 @@ func TestRecordChangeDetection(t *testing.T) {
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}},
// 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.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()
w.RunOnce(ctx)
deps.state.SetHostnameState(testHost, hs)
})
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")
}
assertNotified(t, deps, "Record Change: "+testHost, "warning")
}
func TestPortStateChange(t *testing.T) {
@@ -514,38 +428,23 @@ func TestPortStateChange(t *testing.T) {
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
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(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testHost,
},
}
ctx := t.Context()
w.RunOnce(ctx)
// Between the checks, every port closes.
deps := runChecks(t, cfg, nil, func(deps *testDeps) {
deps.portChecker.mu.Lock()
deps.portChecker.results["1.2.3.4:443"] = false
deps.portChecker.closed = true
deps.portChecker.mu.Unlock()
})
w.RunOnce(ctx)
notifications := deps.notifier.getNotifications()
if len(notifications) == 0 {
t.Error("expected notification for port state change")
hs, _ := deps.state.GetHostnameState(testHost)
assertNotified(
t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning",
)
}
// expiresInThreeDays makes the TLS checker return certificates that
// expire within the seven-day warning period.
func expiresInThreeDays(deps *testDeps) {
deps.tlsChecker.notAfter = time.Now().Add(3 * 24 * time.Hour)
}
func TestTLSExpiryWarning(t *testing.T) {
@@ -554,49 +453,9 @@ func TestTLSExpiryWarning(t *testing.T) {
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg)
deps := runChecks(t, cfg, expiresInThreeDays, nil)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
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,
)
}
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
}
func TestTLSExpiryWarningDedup(t *testing.T) {
@@ -606,50 +465,25 @@ func TestTLSExpiryWarningDedup(t *testing.T) {
cfg.Hostnames = []string{testHost}
cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg)
title := "TLS Expiry Warning: " + testHost
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
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,
},
// The second check comes within the TLS interval of the first,
// so it must not warn again.
var warnings int
deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) {
warnings = countNotifications(deps, title)
})
if warnings == 0 {
t.Fatal("expected expiry warnings from the first check")
}
ctx := t.Context()
// 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 {
got := countNotifications(deps, title)
if got != warnings {
t.Errorf(
"expected exactly 1 expiry warning (dedup), got %d",
expiryCount,
"expected %d expiry warnings (dedup), got %d",
warnings, got,
)
}
}
@@ -657,21 +491,12 @@ func TestTLSExpiryWarningDedup(t *testing.T) {
func TestGracefulShutdown(t *testing.T) {
t.Parallel()
// No domains or hostnames: stopping does not involve DNS.
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.DNSInterval = 100 * time.Millisecond
cfg.TLSInterval = 100 * time.Millisecond
w, deps := 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
w, _ := newTestWatcher(t, cfg)
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) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg)
setupHostnameIP(deps, testHost, "10.0.0.1")
ctx := t.Context()
w.RunOnce(ctx)
// The saved state says the last check found testHost at oldIP.
deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetHostnameState(testHost, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
oldNS1: {
Records: map[string][]string{"A": {oldIP}},
Status: "ok",
},
},
})
}, nil)
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.
updateHostnameIP(deps, testHost, "10.0.0.2")
w.RunOnce(ctx)
snap = deps.state.GetSnapshot()
if _, ok := snap.Ports["10.0.0.2:80"]; !ok {
t.Error("port check used stale DNS: missing 10.0.0.2:80")
// Port and TLS checks must use the addresses this check found.
for _, ip := range addresses(snap.Hostnames[testHost]) {
if _, ok := snap.Ports[ip+":80"]; !ok {
t.Error("port check used stale DNS: missing " + ip + ":80")
}
certKey := "10.0.0.2:443:www.example.com"
certKey := ip + ":443:" + testHost
if _, ok := snap.Certificates[certKey]; !ok {
t.Error("TLS check used stale DNS: missing " + certKey)
}
}
}
func TestSendTestNotification_Enabled(t *testing.T) {
t.Parallel()
// No domains or hostnames: the startup notification does not
// involve DNS.
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = true
w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps)
w.RunOnce(t.Context())
@@ -797,14 +584,11 @@ func TestSendTestNotification_ViaRun(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = true
cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps)
ctx, cancel := context.WithCancel(t.Context())
@@ -844,14 +628,11 @@ func TestSendTestNotification_Disabled(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = false
cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps)
ctx, cancel := context.WithCancel(t.Context())
@@ -884,32 +665,22 @@ func TestNSFailureAndRecovery(t *testing.T) {
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
testNS2: {"A": {testIP}},
// 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.ipAddresses[testHost] = []string{
testIP,
hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{
Records: map[string][]string{"A": {oldIP}},
Status: "ok",
}
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)
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")
}
assertNotified(t, deps, "NS Failure: "+testHost, "error")
assertNotified(t, deps, "NS Recovery: "+testHost, "success")
}
+20 -47
View File
@@ -1,20 +1,19 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Base tooling (git, make, go) comes from nix, apt, brew, or
# apk (detected in that order) and is installed only when absent;
# assumes nothing is present. golangci-lint and goimports are always
# (re)installed via `go install` at the same pinned commits the
# Dockerfile uses (never "latest") -- a presence check cannot tell the
# pinned build from an arbitrary one already on PATH, so guarding them
# would make the pins inert. Idempotent either way: running this twice
# succeeds both times and leaves the same result.
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present.
# goimports is installed via `go install` at a pinned commit (never
# "latest") because script/fmt runs it on the host; script/fmt-check
# does not (it runs gofmt only).
# The linter is NOT installed here: golangci-lint runs via docker only
# (script/lint), pinned by image digest, so its only prerequisite is a
# working docker.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-08-07 (same pins as the Dockerfile)
# golangci-lint v2.12.2
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# Pinned version, 2026-08-07 (same pin as the Dockerfile)
# goimports v0.42.0
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
@@ -65,31 +64,6 @@ missing() {
! command -v "$1" >/dev/null 2>&1
}
# go_bin_dir: directory `go install` writes binaries to.
go_bin_dir() {
gobin="$(go env GOBIN)"
if [ -n "$gobin" ]; then
echo "$gobin"
else
echo "$(go env GOPATH)/bin"
fi
}
# warn_if_shadowed <tool> <dir>: the pinned build was just installed
# into <dir>. If PATH resolves <tool> anywhere else, that other copy is
# what `make lint` and `make fmt` will actually run, and it is not the
# pinned version. Warn loudly rather than failing, since the fix is the
# user's PATH and not anything this script can do.
warn_if_shadowed() {
resolved="$(command -v "$1" 2>/dev/null || true)"
if [ "$resolved" != "$2/$1" ]; then
echo "bootstrap: WARNING: installed pinned $1 to $2/$1, but PATH" >&2
echo "bootstrap: WARNING: resolves $1 to ${resolved:-(not on PATH)};" >&2
echo "bootstrap: WARNING: put $2 first on PATH or lint results will" >&2
echo "bootstrap: WARNING: not match CI." >&2
fi
}
main() {
cd "$ROOT"
@@ -97,17 +71,16 @@ main() {
if missing make; then pkg_install gnumake make make make; fi
if missing go; then pkg_install go golang go go; fi
# Lint/format tools, pinned via go install. These are installed
# unconditionally: `command -v` only proves *some* build is on PATH,
# and a wrong golangci-lint either cannot parse our v2-schema
# .golangci.yml at all or silently disagrees with CI. Installing at
# a fixed commit ref is idempotent and cheap with a warm module
# cache, so there is nothing to save by skipping it.
GOBIN_DIR="$(go_bin_dir)"
go install "$GOLANGCI_LINT_REF"
go install "$GOIMPORTS_REF"
warn_if_shadowed golangci-lint "$GOBIN_DIR"
warn_if_shadowed goimports "$GOBIN_DIR"
# Format tools, pinned via go install (installs into
# "$(go env GOPATH)/bin"; ensure that is on your PATH).
if missing goimports; then go install "$GOIMPORTS_REF"; fi
# Linting runs via docker only (script/lint). Warn, don't fail:
# everything except `make lint` works without it.
if missing docker; then
echo "bootstrap: WARNING: docker not found; install it to" \
"run make lint and make docker." >&2
fi
go mod download
+8 -3
View File
@@ -1,13 +1,18 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so
# a successful build implies all checks pass.
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
# make fmt-check and golangci-lint; its builder stage runs make test
# 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
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
docker build --no-cache-filter=lint,builder .
}
main "$@"
+6 -2
View File
@@ -1,6 +1,10 @@
#!/bin/sh
# 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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -8,7 +12,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
docker build --no-cache-filter=lint,builder -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"
+18 -2
View File
@@ -1,12 +1,28 @@
#!/bin/sh
# script/lint: run the linter.
# script/lint: run the linter. golangci-lint is never installed or run
# on the host: it runs via docker only, one way, everywhere. This
# builds Dockerfile.lint, which COPYs the repo into the digest-pinned
# golangci-lint image and lints as a build step, so a successful build
# means a clean lint.
#
# --no-cache-filter=lint forces the lint stage (source copy + linter
# run) to execute on every invocation. Without it an unchanged tree
# returns success in well under a second having linted nothing. The
# deps stage (base image + go mod download) stays cached, and no global
# cache invalidation is performed. --progress=plain keeps the linter's
# own output visible.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
golangci-lint run --config .golangci.yml ./...
docker build \
--progress=plain \
--no-cache-filter=lint \
--target lint \
-f Dockerfile.lint \
.
}
main "$@"
+24 -1
View File
@@ -1,12 +1,35 @@
#!/bin/sh
# script/test: run the test suite.
#
# -count=1 disables Go's test cache, and is load-bearing here. This
# suite queries live DNS on every run by policy (TESTING.md); a cached
# result is a replay of an earlier run's output with no query made at
# all. On an unchanged tree the whole suite would return success in
# under a second having resolved nothing, which makes the repeated-run
# green that is used as evidence for flakiness fixes worthless. Do not
# remove it.
#
# Conditional verbose rerun per REPO_POLICIES.md: run quiet first so
# CI and docker build logs stay readable, and rerun with -v only on
# failure. The rerun also carries -count=1 (a cached replay of the
# failure would show nothing new), and the exit status is forced to 1
# no matter how the rerun ends: the first failure already proved the
# suite broken, so a flaky test that passes the second time must not
# turn the build green.
#
# -timeout 90s is a deliberate backstop above the 60s hard cap on
# suite duration. Do not lower it.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go test -v -race -timeout 30s -cover ./...
go test -count=1 -race -timeout 90s -cover ./... || {
echo "--- Rerunning with -v for details ---" >&2
go test -count=1 -race -timeout 90s -v ./... || true
exit 1
}
}
main "$@"