1 Commits
Author SHA1 Message Date
clawbot 30f8f05fd5 watcher tests: fewer queries per domain check, longer live attempts (closes #214)
check / check (push) Successful in 1m32s
ResolveIPAddresses, which a domain check runs for each nameserver, asked
every nameserver of the name's zone for all eight record types and read
only A, AAAA and CNAME. It now asks for those three, so a domain check
of example.com sends about a third fewer queries.

The per-attempt deadline in livednstest goes from 8 to 18 seconds. It
was sized for one lookup, while a watcher check sends over a hundred
queries in a row and on the CI runner ran past 8 seconds. Three attempts
still end under the 60-second cap.

A nameserver that answers none of the three queries now counts as not
answering even if it would have answered another type; the watcher keeps
the previous addresses either way.

Model: opus-5-5
2026-10-02 02:07:00 +00:00
4 changed files with 75 additions and 108 deletions
+2 -2
View File
@@ -19,8 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-02: watcher tests send far fewer queries and a live attempt may take - 2026-10-02: a nameserver's addresses are looked up asking only for A, AAAA and
18s; nameserver addresses are asked only for A, AAAA, CNAME (closes #214). CNAME records, and a live test attempt may take 18s, not 8s (closes #214).
- 2026-10-02: the resolver tries root servers, and every other server list it - 2026-10-02: the resolver tries root servers, and every other server list it
walks, in a random order each time, not always from the top (closes #138). walks, in a random order each time, not always from the top (closes #138).
- 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any - 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any
+4 -8
View File
@@ -39,14 +39,10 @@ const (
// AttemptTimeout bounds one attempt. It must fit the longest // AttemptTimeout bounds one attempt. It must fit the longest
// operation, a watcher check, which sends over a hundred queries one // operation, a watcher check, which sends over a hundred queries one
// after another and on a slow build host takes several times as long // after another and on a slow build host takes several times as long
// as the few seconds it takes on a fast one. An operation whose // as the few seconds it takes on a fast one. Worst case for an
// every attempt fails takes attempts * AttemptTimeout plus the // operation is attempts * AttemptTimeout plus the backoff — about 56
// backoff, about 56 seconds, after it waits for one of the // seconds, under the suite's 60-second cap and inside the 90-second
// Concurrency slots that every live operation in the test binary // `go test -timeout` backstop.
// shares. So when live DNS does not answer at all, a test binary
// with more live operations than slots runs into the 90-second
// `go test -timeout` backstop instead of each test failing on its
// own.
AttemptTimeout = 18 * time.Second AttemptTimeout = 18 * time.Second
// backoffBase is the delay after the first failed attempt; it is // backoffBase is the delay after the first failed attempt; it is
-28
View File
@@ -562,34 +562,6 @@ func TestResolveIPAddresses_CloudflareDomain(t *testing.T) {
assert.NotEmpty(t, ips) assert.NotEmpty(t, ips)
} }
// TestResolveIPAddresses_NameserverIPv4AndIPv6 looks up the addresses of
// one of cloudflare.com's nameservers, as a domain check does for each
// nameserver. That name has A and AAAA records, so both kinds of address
// come back.
func TestResolveIPAddresses_NameserverIPv4AndIPv6(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "cloudflare.com")
ips := liveResolveIPs(t, r, ns)
var ipv4, ipv6 int
for _, ip := range ips {
parsed := net.ParseIP(ip)
require.NotNil(t, parsed, "should be valid IP: %s", ip)
if parsed.To4() != nil {
ipv4++
} else {
ipv6++
}
}
assert.Positive(t, ipv4, "no IPv4 address for %s: %v", ns, ips)
assert.Positive(t, ipv6, "no IPv6 address for %s: %v", ns, ips)
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Context cancellation tests // Context cancellation tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------
+69 -70
View File
@@ -26,22 +26,18 @@ import (
// The watcher looks these names up in live DNS with the real resolver, // The watcher looks these names up in live DNS with the real resolver,
// so tests assert on what the watcher does with the answers, never on // so tests assert on what the watcher does with the answers, never on
// the records these zones publish. The nameservers of testHost and // the records these zones publish. testHost's nameservers and addresses
// testSmallDomain stay the same between a test looking them up and its // stay the same from one check to the next, which the tests that check
// check. Every query a check sends is one more that can be lost, so the // it twice rely on, and testSmallDomain's nameservers stay the same
// tests keep them few. A check asks each of a name's nameservers about // between a test looking them up and its check. A domain check looks up
// every record type, and both names have two. A domain check also looks // each nameserver's addresses, about a second per nameserver, so the
// up each nameserver's addresses at every nameserver of the zone that // tests that check a domain use testSmallDomain, which has two
// nameserver is in: testSmallDomain's nameservers are in zones with two // nameservers, and check it once. The tests that query testDomain's
// nameservers, while a domain whose nameservers are in, say, // nameservers directly do no domain check.
// cloudflare.com, which has five, makes each domain check much longer.
// A test checks a domain only when it is about domains, and checks once,
// from saved state it builds, rather than twice. The tests that query
// testDomain's nameservers directly do no domain check.
const ( const (
testDomain = "google.com" testDomain = "google.com"
testSmallDomain = "desec.io" testSmallDomain = "example.com"
testHost = "example.org" testHost = "cloudflare.com"
testIssuer = "DigiCert" testIssuer = "DigiCert"
) )
@@ -263,48 +259,55 @@ func checkOnce(
// runChecks builds a watcher, lets prepare set up the saved state and // runChecks builds a watcher, lets prepare set up the saved state and
// stand-ins it starts from, and runs its checks once against live DNS. // stand-ins it starts from, and runs its checks once against live DNS.
// When the check finds no fresh address for a name (see checkOnce), the // If change is not nil, change then alters the saved state or stand-ins
// watcher is thrown away and all of this runs again on a new one, so a // and the checks run a second time. When either check finds no fresh
// failed attempt leaves nothing behind in the saved state, the // address for a name (see checkOnce), the watcher is thrown away and
// stand-ins or the notifications. // 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( func runChecks(
t *testing.T, t *testing.T,
cfg *config.Config, cfg *config.Config,
prepare func(deps *testDeps), prepare, change func(deps *testDeps),
) (*watcher.Watcher, *testDeps) { ) *testDeps {
t.Helper() t.Helper()
var ( var deps *testDeps
w *watcher.Watcher
deps *testDeps
)
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg) w, deps = newTestWatcher(t, cfg)
if prepare != nil { if prepare != nil {
prepare(deps) prepare(deps)
} }
err := checkOnce(ctx, w, deps)
if err != nil || change == nil {
return err
}
change(deps)
return checkOnce(ctx, w, deps) return checkOnce(ctx, w, deps)
}) })
return w, deps return deps
} }
// lookupNameservers returns the nameservers live DNS lists for name, // lookupNameservers returns the nameservers live DNS lists for domain,
// for a test to save in the state its check starts from. // for a test to save in the state its check starts from.
func lookupNameservers(t *testing.T, name string) []string { func lookupNameservers(t *testing.T, domain string) []string {
t.Helper() t.Helper()
res := resolver.NewFromLogger(slog.Default()) res := resolver.NewFromLogger(slog.Default())
var nameservers []string var nameservers []string
livednstest.Retry(t, "LookupNS("+name+")", func(ctx context.Context) error { livednstest.Retry(t, "LookupNS("+domain+")", func(ctx context.Context) error {
var err error var err error
nameservers, err = res.LookupNS(ctx, name) nameservers, err = res.LookupNS(ctx, domain)
return err return err
}) })
@@ -367,7 +370,7 @@ func TestFirstRunBaseline(t *testing.T) {
cfg.Domains = []string{testSmallDomain} cfg.Domains = []string{testSmallDomain}
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
_, deps := runChecks(t, cfg, nil) deps := runChecks(t, cfg, nil, nil)
assertNoNotifications(t, deps) assertNoNotifications(t, deps)
assertStatePopulated(t, deps) assertStatePopulated(t, deps)
@@ -419,7 +422,7 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testSmallDomain} cfg.Domains = []string{testSmallDomain}
_, deps := runChecks(t, cfg, nil) deps := runChecks(t, cfg, nil, nil)
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
@@ -459,11 +462,11 @@ func TestNSChangeDetection(t *testing.T) {
cfg.Domains = []string{testSmallDomain} cfg.Domains = []string{testSmallDomain}
// The saved state lists nameservers that live DNS does not. // The saved state lists nameservers that live DNS does not.
_, deps := runChecks(t, cfg, func(deps *testDeps) { deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetDomainState(testSmallDomain, &state.DomainState{ deps.state.SetDomainState(testSmallDomain, &state.DomainState{
Nameservers: []string{oldNS1, oldNS2}, Nameservers: []string{oldNS1, oldNS2},
}) })
}) }, nil)
assertNotified(t, deps, "NS Change: "+testSmallDomain, "warning") assertNotified(t, deps, "NS Change: "+testSmallDomain, "warning")
@@ -483,7 +486,7 @@ func TestNSAddressChangeDetection(t *testing.T) {
// The saved state lists the nameservers live DNS lists, each at an // The saved state lists the nameservers live DNS lists, each at an
// address live DNS never returns. // address live DNS never returns.
_, deps := runChecks(t, cfg, func(deps *testDeps) { deps := runChecks(t, cfg, func(deps *testDeps) {
nsAddresses := make(map[string][]string, len(nameservers)) nsAddresses := make(map[string][]string, len(nameservers))
for _, ns := range nameservers { for _, ns := range nameservers {
nsAddresses[ns] = []string{oldIP} nsAddresses[ns] = []string{oldIP}
@@ -493,7 +496,7 @@ func TestNSAddressChangeDetection(t *testing.T) {
Nameservers: nameservers, Nameservers: nameservers,
NameserverAddresses: nsAddresses, NameserverAddresses: nsAddresses,
}) })
}) }, nil)
title := "NS Address Change: " + testSmallDomain title := "NS Address Change: " + testSmallDomain
ds, _ := deps.state.GetDomainState(testSmallDomain) ds, _ := deps.state.GetDomainState(testSmallDomain)
@@ -539,12 +542,12 @@ func TestNSAddedAndRemovedIsNoAddressChange(t *testing.T) {
// The saved state lists oldNS1, which live DNS does not, in place of // The saved state lists oldNS1, which live DNS does not, in place of
// the first nameserver live DNS lists, so that the check finds that // the first nameserver live DNS lists, so that the check finds that
// one added and oldNS1 removed. Only oldNS1 has addresses saved. // one added and oldNS1 removed. Only oldNS1 has addresses saved.
_, deps := runChecks(t, cfg, func(deps *testDeps) { deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetDomainState(testSmallDomain, &state.DomainState{ deps.state.SetDomainState(testSmallDomain, &state.DomainState{
Nameservers: append([]string{oldNS1}, nameservers[1:]...), Nameservers: append([]string{oldNS1}, nameservers[1:]...),
NameserverAddresses: map[string][]string{oldNS1: {oldIP}}, NameserverAddresses: map[string][]string{oldNS1: {oldIP}},
}) })
}) }, nil)
if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 1 { if n := countNotifications(deps, "NS Change: "+testSmallDomain); n != 1 {
t.Errorf("sent %d NS changes, want 1", n) t.Errorf("sent %d NS changes, want 1", n)
@@ -562,17 +565,15 @@ func TestRecordChangeDetection(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
nameservers := lookupNameservers(t, testHost) // Between the checks, save for every nameserver an address live DNS
// never returns.
// The saved state has every nameserver live DNS lists answering deps := runChecks(t, cfg, nil, func(deps *testDeps) {
// with an address live DNS never returns. hs, _ := deps.state.GetHostnameState(testHost)
_, deps := runChecks(t, cfg, func(deps *testDeps) { for _, nsState := range hs.RecordsByNameserver {
byNameserver := make(map[string]*state.NameserverRecordState) nsState.Records = map[string][]string{"A": {oldIP}}
for _, ns := range nameservers {
byNameserver[ns] = answered(map[string][]string{"A": {oldIP}})
} }
deps.state.SetHostnameState(testHost, saved(byNameserver)) deps.state.SetHostnameState(testHost, hs)
}) })
assertNotified(t, deps, "Record Change: "+testHost, "warning") assertNotified(t, deps, "Record Change: "+testHost, "warning")
@@ -584,15 +585,12 @@ func TestPortStateChange(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
w, deps := runChecks(t, cfg, nil) // Between the checks, every port closes.
deps := runChecks(t, cfg, nil, func(deps *testDeps) {
// Every port closes, and the port checks run again. They look deps.portChecker.mu.Lock()
// nothing up. deps.portChecker.closed = true
deps.portChecker.mu.Lock() deps.portChecker.mu.Unlock()
deps.portChecker.closed = true })
deps.portChecker.mu.Unlock()
w.CheckAllPorts(t.Context())
hs, _ := deps.state.GetHostnameState(testHost) hs, _ := deps.state.GetHostnameState(testHost)
assertNotified( assertNotified(
@@ -612,7 +610,7 @@ func TestTLSExpiryWarning(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
_, deps := runChecks(t, cfg, expiresInThreeDays) deps := runChecks(t, cfg, expiresInThreeDays, nil)
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning") assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
} }
@@ -784,7 +782,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// The saved state says the last check found testHost at oldIP. // The saved state says the last check found testHost at oldIP.
_, deps := runChecks(t, cfg, func(deps *testDeps) { deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetHostnameState(testHost, &state.HostnameState{ deps.state.SetHostnameState(testHost, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
oldNS1: { oldNS1: {
@@ -793,7 +791,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
}, },
}, },
}) })
}) }, nil)
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
@@ -924,20 +922,21 @@ func TestNSFailureAndRecovery(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
nameservers := lookupNameservers(t, testHost) // Between the checks, save every nameserver the first check found
// as one that did not answer, and add, as answering, one that live
// The saved state has every nameserver live DNS lists as one that // DNS does not list, which then disappears.
// did not answer, and, as answering, one that live DNS does not deps := runChecks(t, cfg, nil, func(deps *testDeps) {
// list, which then disappears. hs, _ := deps.state.GetHostnameState(testHost)
_, deps := runChecks(t, cfg, func(deps *testDeps) { for ns := range hs.RecordsByNameserver {
byNameserver := map[string]*state.NameserverRecordState{ hs.RecordsByNameserver[ns] = failed()
oldNS1: answered(map[string][]string{"A": {oldIP}}),
}
for _, ns := range nameservers {
byNameserver[ns] = failed()
} }
deps.state.SetHostnameState(testHost, saved(byNameserver)) hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{
Records: map[string][]string{"A": {oldIP}},
Status: "ok",
}
deps.state.SetHostnameState(testHost, hs)
}) })
assertNotified(t, deps, "NS Failure: "+testHost, "error") assertNotified(t, deps, "NS Failure: "+testHost, "error")