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