check / check (push) Canceled after 0s
The resolver lists in FailedTypes each record type whose query to a nameserver got no usable reply (none after two tries, an error reply, a referral, or a truncated reply whose TCP retry failed), and logs it with the reason unless shutdown cut it short. A nameserver that answered no type has failed, as before. The watcher saves such a type in failedTypes, keeping the previous check's records, leaves it out of the comparison with other nameservers on that check, and compares the kept records with the next answer. With nothing to keep, it is also in unknownTypes and not compared until it answers. Change messages leave out what was not compared. A nameserver whose A, AAAA or CNAME query failed is no answer when following a CNAME or resolving addresses. Model: opus-5-5
373 lines
10 KiB
Go
373 lines
10 KiB
Go
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)
|
|
}
|
|
}
|