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" ) // TestCNAMEIntoAnotherZonePortAndTLSChecks runs the port and TLS // checks on hostname state built here: the name's nameserver answered // with a CNAME into another zone, and following it found ip1. Both // checks must use ip1. They look nothing up, so the watcher has no // resolver. func TestCNAMEIntoAnotherZonePortAndTLSChecks(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) cfg.Hostnames = []string{host} deps := newTestDeps(t, cfg) w := watcher.NewForTest( cfg, deps.state, nil, deps.portChecker, deps.tlsChecker, deps.notifier, ) deps.state.SetHostnameState(host, cnameState(ip1)) w.CheckAllPorts(t.Context()) w.RunTLSChecks(t.Context()) snap := deps.state.GetSnapshot() ps, ok := snap.Ports[ip1+":443"] if !ok || !slices.Contains(ps.Hostnames, host) { t.Errorf("no port state for %s at %s:443", host, ip1) } certKey := ip1 + ":443:" + host if _, ok := snap.Certificates[certKey]; !ok { t.Errorf("no certificate state %s", certKey) } } // TestCNAMEThatCannotBeFollowedKeepsPrevious runs a check of a name, not // the watcher's first, from the point where its records have been looked // 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) { t.Parallel() w, deps := newTestWatcher(t, defaultTestConfig(t)) w.SetFirstRun(false) records := map[string]map[string][]string{ nsA: cnameTo("target.example.invalid."), } 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 // lookup is not retried. _ = livednstest.Run(func(ctx context.Context) error { w.UpdateHostnameState(ctx, host, hostnameState(records)) return nil }) hs, _ := deps.state.GetHostnameState(host) if !slices.Equal(hs.CNAMEAddresses, prev.CNAMEAddresses) { t.Errorf( "saved %v, want %v", 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 // 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. The tests point CNAMEs only at names in // zones with two nameservers, to keep queries few (see the top of // watcher_test.go). 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.adguard-dns.com has // 94.140.14.14. func TestCNAMEAddressesOfEveryTarget(t *testing.T) { t.Parallel() found := followLive(t, map[string]map[string][]string{ nsA: cnameTo("one.one.one.one."), nsB: cnameTo("dns.adguard-dns.com."), }) for _, ip := range []string{"1.1.1.1", "94.140.14.14"} { 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: cnameTo("this-surely-does-not-exist-xyz.example.org."), }) 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: cnameTo("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, ) } } // TestCNAMEWhoseAddressQueryFailedKeepsPrevious checks a name whose // nameserver answered, but whose query for A, AAAA or CNAME failed with // nothing kept for it. That is not an answer with no address: the // addresses the previous check saved from following its CNAME are kept, // and nothing is looked up, the watcher having no resolver. func TestCNAMEWhoseAddressQueryFailedKeepsPrevious(t *testing.T) { t.Parallel() for _, rtype := range []string{"A", "AAAA", "CNAME"} { t.Run(rtype, func(t *testing.T) { t.Parallel() w := watcher.NewForTest(nil, nil, nil, nil, nil, nil) current := saved(map[string]*state.NameserverRecordState{ nsA: { Records: map[string][]string{}, FailedTypes: []string{rtype}, UnknownTypes: []string{rtype}, Status: "ok", }, }) 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, ) } }) } } // cnameTo builds the records of a nameserver that answered with a CNAME // to target and no address. func cnameTo(target string) map[string][]string { return map[string][]string{"CNAME": {target}} } // 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: cnameTo("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) } }