From 3472c8f4c1612691b8a246b58f74170d6faefde4 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 23:47:49 +0000 Subject: [PATCH] watcher: follow a watched name's CNAME for port and TLS checks (closes #203) When a watched name's nameservers answer with a CNAME and no address, the DNS check asks ResolveIPAddresses for the name, which looks it up again and follows the chain, and saves the addresses at its end in the hostname state as cnameAddresses. The port and TLS checks use them. Before, only the A and AAAA records in the answers were used, so a CNAME into another zone got no port or TLS checks. When following fails, the addresses the last check saved are kept. The domain check now runs the hostname check for the apex instead of a copy of it. Model: opus-5-5 --- README.md | 18 +++++-- TODO.md | 2 + internal/state/state.go | 4 ++ internal/watcher/cname_test.go | 87 ++++++++++++++++++++++++++++++++ internal/watcher/export_test.go | 9 ++++ internal/watcher/watcher.go | 80 +++++++++++++++++++++-------- internal/watcher/watcher_test.go | 5 +- 7 files changed, 177 insertions(+), 28 deletions(-) create mode 100644 internal/watcher/cname_test.go diff --git a/README.md b/README.md index d1ec4db..7d33440 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,11 @@ notification endpoint set, changes show only on the dashboard; see ### TCP Port Monitoring - For every configured domain and hostname, constructs a deduplicated list of - all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain resolution - across all authoritative nameservers. + the IPv4 and IPv6 addresses in the A and AAAA records its authoritative + nameservers returned. When they returned a CNAME and no address, the CNAME + chain is followed and the addresses at its end are used; a change in those + sends no notification of its own. When the chain cannot be followed, the + addresses the last check found at its end are used. - Checks TCP connectivity on ports **80** and **443** for each IP address. - Every **1 hour**, re-checks all ports. - Any change in port availability triggers a notification: @@ -389,8 +392,11 @@ This approach ensures: servers. - Visibility into the full delegation chain. -For hostname monitoring, the resolver follows CNAME chains (with a depth limit -to prevent loops) before collecting terminal A/AAAA records. +A watched name's records are stored as its nameservers return them, CNAME +included. When they return a CNAME and no address, the CNAME chain is followed +(with a depth limit to prevent loops) to the A and AAAA records at its end, and +the port and TLS checks use those addresses. Nameservers' addresses are found +the same way. --- @@ -478,6 +484,10 @@ nameservers, has status `error`, empty `records`, and the reason in `error`. resolves to. A state file without it loads, and the next check fills it in without a notification. +`cnameAddresses` lists the sorted addresses at the end of a hostname's CNAME +chain, found when its nameservers answered with a CNAME and no address. It is +left out otherwise. + --- ## Entrypoints diff --git a/TODO.md b/TODO.md index 596779e..88839e5 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: 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). - 2026-10-01: README has Getting Started, Rationale and TODO sections, and its Architecture section is now Design, in the order policy sets (closes #173). - 2026-10-01: a zone's server that answers SERVFAIL or a referral leading no diff --git a/internal/state/state.go b/internal/state/state.go index a8af3f9..ba9033d 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -53,8 +53,12 @@ type NameserverRecordState struct { } // HostnameState holds per-nameserver monitoring state for a hostname. +// CNAMEAddresses holds the sorted addresses at the end of the name's +// CNAME chain, found when its nameservers answered with a CNAME and no +// address; it is empty otherwise. type HostnameState struct { RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"` + CNAMEAddresses []string `json:"cnameAddresses,omitempty"` LastChecked time.Time `json:"lastChecked"` } diff --git a/internal/watcher/cname_test.go b/internal/watcher/cname_test.go new file mode 100644 index 0000000..d32f0a8 --- /dev/null +++ b/internal/watcher/cname_test.go @@ -0,0 +1,87 @@ +package watcher_test + +import ( + "context" + "log/slog" + "slices" + "testing" + + "sneak.berlin/go/dnswatcher/internal/livednstest" + "sneak.berlin/go/dnswatcher/internal/resolver" + "sneak.berlin/go/dnswatcher/internal/state" + "sneak.berlin/go/dnswatcher/internal/watcher" +) + +// cnameHost is a CNAME into another zone: its nameservers answer with +// the CNAME and no address. +const cnameHost = "www.python.org" + +// TestCNAMEIntoAnotherZonePortAndTLSChecks checks cnameHost against +// live DNS. Its port and TLS checks must use the addresses at the end +// of its CNAME chain. +func TestCNAMEIntoAnotherZonePortAndTLSChecks(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Hostnames = []string{cnameHost} + + deps := runChecks(t, cfg, nil, nil) + + snap := deps.state.GetSnapshot() + hs := snap.Hostnames[cnameHost] + + if len(hs.CNAMEAddresses) == 0 { + t.Fatalf( + "%s: no addresses saved from following its CNAME; if it "+ + "is no longer a CNAME into another zone, this test "+ + "needs another name", + cnameHost, + ) + } + + for _, ip := range hs.CNAMEAddresses { + ps, ok := snap.Ports[ip+":443"] + if !ok || !slices.Contains(ps.Hostnames, cnameHost) { + t.Errorf("no port state for %s at %s:443", cnameHost, ip) + } + + certKey := ip + ":443:" + cnameHost + if _, ok := snap.Certificates[certKey]; !ok { + t.Errorf("no certificate state %s", certKey) + } + } +} + +// TestCNAMEThatCannotBeFollowedKeepsPrevious gives a name under +// .invalid, whose lookup fails, answers with a CNAME and no address. +// The addresses the previous check saved from following its CNAME are +// kept. +func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) { + t.Parallel() + + const name = "www.example.invalid" + + w := watcher.NewForTest( + nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil, + ) + + current := hostnameState(map[string]map[string][]string{ + nsA: {"CNAME": {"target.example.invalid."}}, + }) + prev := &state.HostnameState{CNAMEAddresses: []string{oldIP}} + + // The result is the same whether or not live DNS answers, so the + // lookup is not retried. + _ = livednstest.Run(func(ctx context.Context) error { + w.ResolveCNAMEAddresses(ctx, name, current, prev) + + return nil + }) + + if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) { + t.Errorf( + "saved %v, want %v", + current.CNAMEAddresses, prev.CNAMEAddresses, + ) + } +} diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 557e787..f2ffae2 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -58,6 +58,15 @@ func (w *Watcher) ResolveNameserverAddresses( return w.resolveNameserverAddresses(ctx, nameservers, prev) } +// ResolveCNAMEAddresses exports resolveCNAMEAddresses for testing. +func (w *Watcher) ResolveCNAMEAddresses( + ctx context.Context, + hostname string, + current, prev *state.HostnameState, +) { + w.resolveCNAMEAddresses(ctx, hostname, current, prev) +} + // DetectNSAddressChanges exports detectNSAddressChanges for testing. func (w *Watcher) DetectNSAddressChanges( ctx context.Context, diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2df33f4..20d090f 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -256,28 +256,9 @@ func (w *Watcher) checkDomain( LastChecked: now, }) - // Also look up A/AAAA records for the apex domain so that - // port and TLS checks (which read HostnameState) can find - // the domain's IP addresses. - results, err := w.resolver.LookupAllRecords(ctx, domain) - if err != nil { - w.log.Error( - "failed to lookup records for domain", - "domain", domain, - "error", err, - ) - - return - } - - newState := buildHostnameState(results, now) - - prevHS, hasPrevHS := w.state.GetHostnameState(domain) - if hasPrevHS && !w.firstRun { - w.detectHostnameChanges(ctx, domain, prevHS, newState) - } - - w.state.SetHostnameState(domain, newState) + // The apex domain's records are also checked as a hostname's, so + // that the port and TLS checks find its addresses. + w.checkHostname(ctx, domain) } func (w *Watcher) detectNSChanges( @@ -405,6 +386,9 @@ func (w *Watcher) checkHostname( newState := buildHostnameState(results, time.Now().UTC()) prev, hasPrev := w.state.GetHostnameState(hostname) + + w.resolveCNAMEAddresses(ctx, hostname, newState, prev) + if hasPrev && !w.firstRun { w.detectHostnameChanges(ctx, hostname, prev, newState) } @@ -412,6 +396,51 @@ func (w *Watcher) checkHostname( w.state.SetHostnameState(hostname, newState) } +// resolveCNAMEAddresses saves in current the addresses at the end of +// hostname's CNAME chain, when the nameservers' answers in current hold +// a CNAME and no address. ResolveIPAddresses looks the name up again +// and follows the chain. When it fails, as when no nameserver of a zone +// in the chain answers, the addresses saved in prev, which may be nil, +// are kept. +func (w *Watcher) resolveCNAMEAddresses( + ctx context.Context, + hostname string, + current, prev *state.HostnameState, +) { + hasCNAME := false + + for _, nsState := range current.RecordsByNameserver { + if len(nsState.Records["A"]) > 0 || len(nsState.Records["AAAA"]) > 0 { + return + } + + if len(nsState.Records["CNAME"]) > 0 { + hasCNAME = true + } + } + + if !hasCNAME { + return + } + + ips, err := w.resolver.ResolveIPAddresses(ctx, hostname) + if err != nil { + w.log.Error( + "failed to follow CNAME", + "hostname", hostname, + "error", err, + ) + + if prev != nil { + current.CNAMEAddresses = prev.CNAMEAddresses + } + + return + } + + current.CNAMEAddresses = ips +} + // buildHostnameState saves each nameserver's response. A nameserver // that answered, even with NXDOMAIN or no records, is saved as ok; one // that timed out or failed is saved as error with the reason, and its @@ -751,6 +780,9 @@ func (w *Watcher) noNameserverAnswered(name string) bool { return true } +// collectIPs returns the addresses saved for hostname: those in its +// nameservers' A and AAAA records, and those at the end of its CNAME +// chain. func (w *Watcher) collectIPs(hostname string) []string { hs, ok := w.state.GetHostnameState(hostname) if !ok { @@ -769,6 +801,10 @@ func (w *Watcher) collectIPs(hostname string) []string { } } + for _, ip := range hs.CNAMEAddresses { + ipSet[ip] = true + } + result := make([]string, 0, len(ipSet)) for ip := range ipSet { result = append(result, ip) diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index c166a3e..4b52083 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -315,7 +315,8 @@ func lookupNameservers(t *testing.T, domain string) []string { return nameservers } -// addresses returns the A and AAAA values saved for a hostname. +// addresses returns the A and AAAA values saved for a hostname, and the +// addresses saved at the end of its CNAME chain. func addresses(hs *state.HostnameState) []string { var ips []string @@ -324,7 +325,7 @@ func addresses(hs *state.HostnameState) []string { ips = append(ips, nsState.Records["AAAA"]...) } - return ips + return append(ips, hs.CNAMEAddresses...) } // assertNotified checks that a notification with this title and