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 a CNAME to a // target under .invalid, whose lookup fails. The addresses the previous // check saved from following its CNAME are kept. func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) { t.Parallel() 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, host, current, prev) return nil }) if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) { t.Errorf( "saved %v, want %v", current.CNAMEAddresses, prev.CNAMEAddresses, ) } } // followLive follows in live DNS the CNAMEs in a name's records, built // from records, and returns the addresses saved for the name. The // previous check saved oldIP, which is kept when a target cannot be // followed; that is retried. func followLive( t *testing.T, records map[string]map[string][]string, ) []string { t.Helper() w := watcher.NewForTest( nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil, ) prev := cnameState(oldIP) var current *state.HostnameState livednstest.Retry(t, "following CNAMEs", func(ctx context.Context) error { current = hostnameState(records) w.ResolveCNAMEAddresses(ctx, host, current, prev) if slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) { return livednstest.ErrNoAnswer } return nil }) return current.CNAMEAddresses } // TestCNAMEAddressesOfEveryTarget gives a name's two nameservers // different CNAME targets, as when a secondary still serves an old one. // The addresses at the end of both are saved, whichever answer is read // first: one.one.one.one has 1.1.1.1, and dns.google has 8.8.8.8. func TestCNAMEAddressesOfEveryTarget(t *testing.T) { t.Parallel() found := followLive(t, map[string]map[string][]string{ nsA: {"CNAME": {"one.one.one.one."}}, nsB: {"CNAME": {"dns.google."}}, }) for _, ip := range []string{"1.1.1.1", "8.8.8.8"} { if !slices.Contains(found, ip) { t.Errorf("saved %v, want %s among them", found, ip) } } } // TestCNAMEChainEndingInNoAddressSavesEmptyList follows a CNAME to a // name live DNS answers with NXDOMAIN. An empty list is saved, not nil, // which would mean the addresses are not known. func TestCNAMEChainEndingInNoAddressSavesEmptyList(t *testing.T) { t.Parallel() found := followLive(t, map[string]map[string][]string{ nsA: {"CNAME": {"this-surely-does-not-exist-xyz.google.com."}}, }) if found == nil || len(found) != 0 { t.Errorf("saved %#v, want an empty list", found) } } // TestCNAMEBesideAnAddressNotFollowed gives one nameserver of a name an // address and another a CNAME. The CNAME is not followed: an empty list // is saved, not nil, and nothing is looked up, the watcher having no // resolver. func TestCNAMEBesideAnAddressNotFollowed(t *testing.T) { t.Parallel() w := watcher.NewForTest(nil, nil, nil, nil, nil, nil) current := hostnameState(map[string]map[string][]string{ nsA: {"A": {ip1}}, nsB: {"CNAME": {"target.example.org."}}, }) w.ResolveCNAMEAddresses(t.Context(), host, current, nil) if current.CNAMEAddresses == nil || len(current.CNAMEAddresses) != 0 { t.Errorf("saved %#v, want an empty list", current.CNAMEAddresses) } } // TestCNAMEWhoseNameserversAllFailedKeepsPrevious checks a name none of // whose nameservers answered. The addresses the previous check saved // from following its CNAME are kept, and nothing is looked up: the // watcher has no resolver. func TestCNAMEWhoseNameserversAllFailedKeepsPrevious(t *testing.T) { t.Parallel() w := watcher.NewForTest(nil, nil, nil, nil, nil, nil) current := saved(map[string]*state.NameserverRecordState{ nsA: failed(), nsB: failed(), }) prev := cnameState(oldIP) w.ResolveCNAMEAddresses(t.Context(), host, current, prev) if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) { t.Errorf( "saved %v, want %v", current.CNAMEAddresses, prev.CNAMEAddresses, ) } } // cnameState builds the state a check leaves behind for a name whose // nameserver answered with a CNAME and no address, when following the // CNAME found these addresses, which may be none. func cnameState(addresses ...string) *state.HostnameState { hs := hostnameState(map[string]map[string][]string{ nsA: {"CNAME": {"target.example.org."}}, }) hs.CNAMEAddresses = append([]string{}, addresses...) return hs } func TestCNAMEAddressChangeAlerts(t *testing.T) { t.Parallel() // A state file written before the addresses were saved loads with // them nil. olderStateFile := cnameState() olderStateFile.CNAMEAddresses = nil // Each case is the state saved by the previous check and by the // current one. The name's records are the same in both. tests := []struct { name string prev, current *state.HostnameState want int }{ { "same addresses", cnameState(ip1, ip2), cnameState(ip1, ip2), 0, }, { "same addresses in another order", cnameState(ip2, ip1), cnameState(ip1, ip2), 0, }, { "address replaced", cnameState(ip1), cnameState(ip2), 1, }, { "address added", cnameState(ip1), cnameState(ip1, ip2), 1, }, { "no address at the end of the chain now", cnameState(ip1), cnameState(), 1, }, { "addresses at the end of the chain again", cnameState(), cnameState(ip1), 1, }, { "state file from before addresses were saved", olderStateFile, cnameState(ip1), 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() notifier := &mockNotifier{} w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) w.DetectHostnameChanges(t.Context(), host, tt.prev, tt.current) got := len(notifier.getNotifications()) if got != tt.want { t.Errorf("sent %d notifications, want %d", got, tt.want) } }) } } func TestCNAMEAddressChangeAlertNamesHostnameAndAddresses(t *testing.T) { t.Parallel() notifier := &mockNotifier{} w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) w.DetectHostnameChanges( t.Context(), host, cnameState(ip1), cnameState(ip2, ip3), ) want := notification{ Title: "CNAME Address Change: " + host, Message: "Hostname: " + host + "\nOld: " + ip1 + "\nNew: " + ip2 + ", " + ip3, Priority: "warning", } got := notifier.getNotifications() if len(got) != 1 || got[0] != want { t.Errorf("sent %v, want %v", got, want) } } // TestNameMovedFromARecordsToCNAMEAlerts checks a name that answers // with an A record and then with a CNAME whose chain ends in ip2. The // second check is notified as a CNAME address change from no addresses, // beside the record change. Nothing is looked up: the watcher has no // resolver. func TestNameMovedFromARecordsToCNAMEAlerts(t *testing.T) { t.Parallel() notifier := &mockNotifier{} w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier) prev := hostnameState(map[string]map[string][]string{ nsA: {"A": {ip1}}, }) w.ResolveCNAMEAddresses(t.Context(), host, prev, nil) w.DetectHostnameChanges(t.Context(), host, prev, cnameState(ip2)) title := "CNAME Address Change: " + host message := "Hostname: " + host + "\nOld: \nNew: " + ip2 got := notifier.getNotifications() if !slices.ContainsFunc(got, func(n notification) bool { return n.Title == title && n.Message == message }) { t.Errorf("sent %v, want %q with %q among them", got, title, message) } }