2 Commits
Author SHA1 Message Date
sneak f513d7d4c2 watcher: follow a watched name's CNAME for port and TLS checks (closes #203)
check / check (push) Successful in 1m16s
When a watched name's nameservers answer with a CNAME and no address,
the DNS check follows every target they gave with ResolveIPAddresses
and saves all addresses found as cnameAddresses in the hostname state,
so nameservers disagreeing on the target do not change them between
checks. Port and TLS checks use them. A change, also from or to none,
is notified as a CNAME address change; the first check from a state
file without them sends none. When a target cannot be followed, or
none of the name's nameservers answered, the last check's addresses
are kept. The domain check now runs the hostname check for the apex
instead of a copy of it.

Model: opus-5-5
2026-10-02 05:54:25 +00:00
clawbot a18803ff28 resolver tests: retry an answer missing the record type read (closes #218)
check / check (push) Canceled after 0s
QueryNameserver sends one query per record type. When only the AAAA or
MX query was lost, the answer still had status ok, liveQueryNameserver
did not retry it, and the test found no AAAA or MX records.
liveQueryNameserver now takes the record types a test reads and, through
livednstest, retries an answer that holds records of none of them. The
A, AAAA, MX and TXT tests name theirs. A resolver that loses a type for
good still fails, after the last attempt instead of the first.

Model: opus-5-5
2026-10-02 07:52:44 +02:00
6 changed files with 73 additions and 17 deletions
+2
View File
@@ -21,6 +21,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 2026-10-02: a watched name whose nameservers answer with a CNAME and no - 2026-10-02: a watched name whose nameservers answer with a CNAME and no
address gets port and TLS checks at the end of its CNAME chain (closes #203). address gets port and TLS checks at the end of its CNAME chain (closes #203).
- 2026-10-02: a resolver test that reads one record type from a nameserver's
answer asks again when that type is missing from it (closes #218).
- 2026-10-02: a plain `docker build .` of a clone stamps its tag or short - 2026-10-02: a plain `docker build .` of a clone stamps its tag or short
commit, not `dev`: the build context now carries `.git` (closes #210). commit, not `dev`: the build context now carries `.git` (closes #210).
- 2026-10-02: a query a server refuses is not resent asking for recursion, and - 2026-10-02: a query a server refuses is not resent asking for recursion, and
+18
View File
@@ -226,11 +226,17 @@ func liveLookupNS(
// liveQueryNameserver queries one nameserver, retrying while that // liveQueryNameserver queries one nameserver, retrying while that
// nameserver fails to answer. NXDOMAIN and NODATA are answers and // nameserver fails to answer. NXDOMAIN and NODATA are answers and
// are returned to the caller to assert on. // are returned to the caller to assert on.
//
// QueryNameserver sends one query per record type, so one lost query
// leaves its type out of an answer that is otherwise fine. A test names
// in types the record types it reads; an answer holding records of none
// of them is retried too.
func liveQueryNameserver( func liveQueryNameserver(
t *testing.T, t *testing.T,
r *resolver.Resolver, r *resolver.Resolver,
nameserver string, nameserver string,
hostname string, hostname string,
types ...string,
) *resolver.NameserverResponse { ) *resolver.NameserverResponse {
t.Helper() t.Helper()
@@ -260,6 +266,18 @@ func liveQueryNameserver(
) )
} }
hasRecords := func(recordType string) bool {
return len(resp.Records[recordType]) > 0
}
if len(types) > 0 && !slices.ContainsFunc(types, hasRecords) {
return fmt.Errorf(
"%w: %s returned no %s records",
livednstest.ErrNoAnswer, nameserver,
strings.Join(types, " or "),
)
}
out = resp out = resp
return nil return nil
+4 -4
View File
@@ -171,7 +171,7 @@ func TestQueryNameserver_BasicA(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "google.com") ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(t, r, ns, "www.google.com") resp := liveQueryNameserver(t, r, ns, "www.google.com", "A", "CNAME")
require.NotNil(t, resp) require.NotNil(t, resp)
@@ -190,7 +190,7 @@ func TestQueryNameserver_AAAA(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "cloudflare.com") ns := findOneNSForDomain(t, r, "cloudflare.com")
resp := liveQueryNameserver(t, r, ns, "cloudflare.com") resp := liveQueryNameserver(t, r, ns, "cloudflare.com", "AAAA")
aaaaRecords := resp.Records["AAAA"] aaaaRecords := resp.Records["AAAA"]
require.NotEmpty(t, aaaaRecords, require.NotEmpty(t, aaaaRecords,
@@ -210,7 +210,7 @@ func TestQueryNameserver_MX(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "google.com") ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(t, r, ns, "google.com") resp := liveQueryNameserver(t, r, ns, "google.com", "MX")
mxRecords := resp.Records["MX"] mxRecords := resp.Records["MX"]
require.NotEmpty(t, mxRecords, require.NotEmpty(t, mxRecords,
@@ -223,7 +223,7 @@ func TestQueryNameserver_TXT(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "google.com") ns := findOneNSForDomain(t, r, "google.com")
resp := liveQueryNameserver(t, r, ns, "google.com") resp := liveQueryNameserver(t, r, ns, "google.com", "TXT")
txtRecords := resp.Records["TXT"] txtRecords := resp.Records["TXT"]
require.NotEmpty(t, txtRecords, require.NotEmpty(t, txtRecords,
+22 -12
View File
@@ -47,35 +47,45 @@ func TestCNAMEIntoAnotherZonePortAndTLSChecks(t *testing.T) {
} }
} }
// TestCNAMEThatCannotBeFollowedKeepsPrevious gives a name a CNAME to a // TestCNAMEThatCannotBeFollowedKeepsPrevious runs a check of a name, not
// target under .invalid, whose lookup fails. The addresses the previous // the watcher's first, from the point where its records have been looked
// check saved from following its CNAME are kept. // up: they hold a CNAME to a target under .invalid, whose lookup fails.
// The previous check found the same records, and oldIP at the end of the
// CNAME. The check must keep oldIP and send nothing.
func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) { func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) {
t.Parallel() t.Parallel()
w := watcher.NewForTest( w, deps := newTestWatcher(t, defaultTestConfig(t))
nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil, w.SetFirstRun(false)
)
current := hostnameState(map[string]map[string][]string{ records := map[string]map[string][]string{
nsA: cnameTo("target.example.invalid."), nsA: cnameTo("target.example.invalid."),
}) }
prev := &state.HostnameState{CNAMEAddresses: []string{oldIP}}
prev := hostnameState(records)
prev.CNAMEAddresses = []string{oldIP}
deps.state.SetHostnameState(host, prev)
// The result is the same whether or not live DNS answers, so the // The result is the same whether or not live DNS answers, so the
// lookup is not retried. // lookup is not retried.
_ = livednstest.Run(func(ctx context.Context) error { _ = livednstest.Run(func(ctx context.Context) error {
w.ResolveCNAMEAddresses(ctx, host, current, prev) w.UpdateHostnameState(ctx, host, hostnameState(records))
return nil return nil
}) })
if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) { hs, _ := deps.state.GetHostnameState(host)
if !slices.Equal(hs.CNAMEAddresses, prev.CNAMEAddresses) {
t.Errorf( t.Errorf(
"saved %v, want %v", "saved %v, want %v",
current.CNAMEAddresses, prev.CNAMEAddresses, hs.CNAMEAddresses, prev.CNAMEAddresses,
) )
} }
notifications := deps.notifier.getNotifications()
if len(notifications) != 0 {
t.Errorf("sent %v, want no notifications", notifications)
}
} }
// followLive follows in live DNS the CNAMEs in a name's records, built // followLive follows in live DNS the CNAMEs in a name's records, built
+15
View File
@@ -38,6 +38,21 @@ func NewlyDisagreeingPairs(
return newlyDisagreeingPairs(prev, current) return newlyDisagreeingPairs(prev, current)
} }
// SetFirstRun sets whether the watcher is on its first check, in which
// nothing is compared with the previous check. NewForTest's watcher is.
func (w *Watcher) SetFirstRun(firstRun bool) {
w.firstRun = firstRun
}
// UpdateHostnameState exports updateHostnameState for testing.
func (w *Watcher) UpdateHostnameState(
ctx context.Context,
hostname string,
newState *state.HostnameState,
) {
w.updateHostnameState(ctx, hostname, newState)
}
// DetectHostnameChanges exports detectHostnameChanges for testing. // DetectHostnameChanges exports detectHostnameChanges for testing.
func (w *Watcher) DetectHostnameChanges( func (w *Watcher) DetectHostnameChanges(
ctx context.Context, ctx context.Context,
+12 -1
View File
@@ -379,8 +379,19 @@ func (w *Watcher) checkHostname(
return return
} }
newState := buildHostnameState(results, time.Now().UTC()) w.updateHostnameState(
ctx, hostname, buildHostnameState(results, time.Now().UTC()),
)
}
// updateHostnameState finishes a check of hostname from newState, built
// from its nameservers' answers: it follows the CNAME in them, notifies
// what changed since the previous check, and saves newState.
func (w *Watcher) updateHostnameState(
ctx context.Context,
hostname string,
newState *state.HostnameState,
) {
prev, hasPrev := w.state.GetHostnameState(hostname) prev, hasPrev := w.state.GetHostnameState(hostname)
w.resolveCNAMEAddresses(ctx, hostname, newState, prev) w.resolveCNAMEAddresses(ctx, hostname, newState, prev)