check / check (push) Failing after 2m4s
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. A change in them is notified as a CNAME address change, also from or to none. A state file without the field loads them as not known (nil), so its first check sends nothing for them. When following fails, or none of the name's nameservers answered, the last check's addresses are kept. The domain check now runs the hostname check for the apex instead of a copy of it. Model: opus-5-5
239 lines
6.3 KiB
Go
239 lines
6.3 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"
|
|
)
|
|
|
|
// 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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|