Files
dnswatcher/TODO.md
T
clawbot c971bf2ce5
check / check (push) Successful in 1m30s
notify: fix flaky drain timing assertions, drop false abandon warn (closes #106)
The drain tests measured elapsed time from an instant captured after
the clock they compared it against had already started, so the lower
bounds were structurally unreachable and passed only when the gap
between the two statements rounded to zero. TestDrainBoundedByContext-
Deadline failed the Docker gate outright (49.9ms against its own 50ms
deadline) and roughly 1 run in 12 locally.

- TestDrainBoundedByContextDeadline: capture start before
  context.WithTimeout, so the measured interval is a superset of the
  deadline interval and only an early return can fail the lower bound.
  The upper bound moves to a watchdog around the drain, which turns an
  unbounded drain into a prompt failure instead of a package-timeout
  hang.
- TestDrainWaitsForInFlightDelivery: same ordering fix, ahead of the
  timer that releases the held delivery.
- TestDrainWithoutDeliveriesReturnsImmediately: its 50ms ceiling was
  under the observed cost of the goroutine hop through inFlight.Wait()
  on a loaded box (57ms), and failed once in 20 runs. It now bounds the
  idle drain at 500ms, still well under the 2s deadline a stalled drain
  would hit.
- drain: an OnStop context already expired on entry with nothing
  outstanding logged a WARN about abandoning deliveries with
  abandoned=0 and closed the abandon channel for no reason. The timeout
  branch now reports at debug level when the outstanding count is zero,
  and warns only when deliveries genuinely are abandoned.
  TestDrainWithCancelledContextDoesNotWarn covers it.

Verified: script/cibuild passes; 25 consecutive cache-bypassed
make test runs under -race, all clean; make check green at 5.2s.
Both corrected assertions were confirmed non-vacuous by temporarily
breaking drain and watching them fail.
2026-09-03 17:08:21 +00:00

12 KiB

Workflow

  • branch (from main)
  • do the work in Next Step
  • move Next Step to the top of Completed Steps
  • move the top item of Future Steps into Next Step
  • commit (TODO.md changes in the same commit as the work)
  • merge to main if the branch is not protected, otherwise open a PR
  • push

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.

Next Step

Add the README sections required by policy (Description, Getting Started, Rationale, Design, TODO, License, Author) if any are still missing.

Completed Steps

  • 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-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 (owner-authorized; same file is being landed as canonical via prompts PR #24), with settings under linters.settings so the lll/funlen/cyclop/dupl thresholds apply; fixed the resulting goconst, dupl, and lll findings; the informational gomodguard deprecation warning under this config is accepted
  • 2026-07-07 Adopted scripts-to-rule-them-all: script/ entrypoints, Makefile shims, README Entrypoints section
  • 2026-02-20: iterative DNS resolver implemented; tests made hermetic with mocked DNS (origin/feature/resolver, unmerged)
  • 2026-02-20: CI actions and go install refs pinned to commit SHAs; Gitea Actions workflow for make check (origin/ci/make-check, unmerged)
  • 2026-02-20: watcher monitoring orchestrator merged to main (#8)
  • 2026-02-20: DOMAINS/HOSTNAMES unified into single TARGETS config (#11)
  • 2026-02-19: TCP port connectivity checker, made concurrent with port validation; gosec G704 SSRF findings fixed without suppression (feature branches, unmerged)
  • 2026-02-19: TLS certificate inspector with no-peer-certificates error path and IP SANs (feature branch, unmerged)
  • 2026-02-19: gosec SSRF and formatting fixes on main
  • 2026-02-19: initial scaffold with per-nameserver DNS monitoring model

Future Steps

Compliance:

  • Pin Dockerfile base images by sha256 and ensure the Docker build runs make check

Branch reconciliation:

  • Sync local checkout with origin: local main is 8 commits behind origin/main; local feature/resolver has diverged from origin/feature/resolver, which already implements the resolver
  • Merge in-flight branches to main once green: feature/resolver, ci/make-check, feature/portcheck-implementation, feature/tlscheck-implementation

Resolver (plan from untracked TODO.md; largely implemented on origin/feature/resolver, verify each item before closing):

  • Add github.com/miekg/dns dependency
  • roots.go: hardcoded IANA root server list (a through m, IPv4/IPv6), rootServers() returning ip:53 strings
  • query.go: low-level query(ctx, server, name, qtype): UDP with TCP fallback on truncation, RD=0, context respected, 5s per-query timeout, returns raw *dns.Msg
  • trace.go: iterative delegation chasing from roots: referral detection (NOERROR, empty answer, NS in authority), glue extraction with bailiwick check, out-of-bailiwick NS resolved with recursion guard, delegation depth limit (20), retry across nameservers on failure, do not chase CNAMEs inside trace
  • FindAuthoritativeNameservers: NS set via trace, sorted, FQDN normalized, trailing dot handled; must pass its 9 tests
  • QueryNameserver: resolve NS host to IPs, query A/AAAA/CNAME/MX/TXT/ SRV/CAA/NS, build NameserverResponse with status mapping (OK, NXDomain, NoData, Error), documented record formatting, sorted values, lame delegation detection; must pass its 16 tests
  • QueryAllNameservers: find NS set for parent domain (public suffix list), query all NS in parallel with bounded concurrency, return map even when all fail, context cancellation; must pass its 4 tests
  • LookupNS: thin wrapper over FindAuthoritativeNameservers, sorted, identical results; must pass its 3 tests
  • ResolveIPAddresses: collect A/AAAA from all NS, follow CNAME chains with MaxCNAMEDepth, dedupe, sort, NXDOMAIN returns empty slice with nil error; must pass its 9 tests
  • All 39 resolver tests pass, make check green, merge to main

Watcher (internal/watcher/watcher.go):

  • Scheduling loop in Run(ctx): initial check on startup, separate tickers for DNS/port and TLS intervals, persist state via state.Save() after each cycle, clean shutdown on context cancel
  • Domain check: LookupNS, compare to stored state, store silently on first run, notify with old/new NS lists on change
  • Hostname check: QueryAllNameservers, compare per-NS records; notify on record changes, NS failure, NS recovery, inconsistency detected, inconsistency resolved, empty response; store silently on first run
  • Port check: ResolveIPAddresses, check ports 80 and 443 per IP, notify on open/closed transitions, handle new and disappeared IPs
  • TLS check: for each open IP:443, CheckCertificate; notify on expiry warning, certificate change (CN/issuer/SANs), TLS failure/recovery

Port checker (internal/portcheck/portcheck.go):

  • Tests against known-open ports and RFC documentation IPs
  • CheckPort: net.DialTimeout (5s), context respected; (true, nil) open, (false, nil) closed/timeout/refused, error only for unexpected failures

TLS checker (internal/tlscheck/tlscheck.go):

  • Tests against known public HTTPS servers, verify fields populated
  • CheckCertificate: tls.Dial to specific IP:443 with hostname as SNI; extract subject CN, issuer CN and org, NotAfter, SANs; error on handshake failure

Notification service (internal/notify/notify.go, Slack/Mattermost/ntfy backends exist):

  • Structured notification types: DNS change, port change, TLS expiry, TLS change, NS failure, NS recovery, NS inconsistency
  • Per-backend formatting: Slack/Mattermost attachment colors (red failures/expiry, yellow warnings, green recoveries, blue info); ntfy priorities (urgent failures, high warnings, default changes, low recoveries); include hostname, nameserver, old/new values, timestamps

HTTP API handlers:

  • Wire *state.State and *watcher.Watcher into handler params
  • GET /api/v1/status: full state snapshot as JSON
  • GET /api/v1/domains: domain states with NS records and last-checked
  • GET /api/v1/hostnames: hostname states with per-NS record data

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