watcher: follow a watched name's CNAME for port and TLS checks (closes #203)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
When a watched name's nameservers answer with a CNAME and no address, the DNS check follows every target they gave with ResolveIPAddresses and saves all addresses found as cnameAddresses in the hostname state, so nameservers disagreeing on the target do not change them between checks. Port and TLS checks use them. A change, also from or to none, is notified as a CNAME address change; the first check from a state file without them sends none. When a target cannot be followed, 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
This commit is contained in:
@@ -121,14 +121,26 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
failed on it, and answers differently is reported on the check where it
|
failed on it, and answers differently is reported on the check where it
|
||||||
answers. If a pair agrees again and later disagrees, the alert is sent
|
answers. If a pair agrees again and later disagrees, the alert is sent
|
||||||
again.
|
again.
|
||||||
|
- **CNAME address change**: The addresses at the end of a name's CNAME chain
|
||||||
|
differ from those of the previous check. They are found when its
|
||||||
|
nameservers answer with a CNAME and no address; a name that answers with
|
||||||
|
an address has none. A change from or to no addresses is sent too, as when
|
||||||
|
a name moves between A records and a CNAME. Nothing is sent when the
|
||||||
|
previous addresses were kept because a chain could not be followed or none
|
||||||
|
of the name's nameservers answered. The first check after loading a state
|
||||||
|
file without `cnameAddresses` sends nothing: it saves the addresses it
|
||||||
|
finds for the next check to compare.
|
||||||
|
|
||||||
### TCP Port Monitoring
|
### TCP Port Monitoring
|
||||||
|
|
||||||
- For every configured domain and hostname, constructs a deduplicated list of
|
- For every configured domain and hostname, constructs a deduplicated list of
|
||||||
the IPv4 and IPv6 addresses in the A and AAAA records its authoritative
|
the IPv4 and IPv6 addresses in the A and AAAA records its authoritative
|
||||||
nameservers returned. A CNAME is not followed: a name whose CNAME points into
|
nameservers returned. When they returned a CNAME and no address, the CNAME
|
||||||
another zone usually has no addresses here, so its ports and certificate are
|
chain is followed and the addresses at its end are used, and a change in those
|
||||||
not checked.
|
is notified as a CNAME address change. When the nameservers gave different
|
||||||
|
CNAME targets, each is followed and the addresses of all are used. When a
|
||||||
|
chain cannot be followed, or none of the name's nameservers answered, the
|
||||||
|
addresses the last check found at its end are used.
|
||||||
- Checks TCP connectivity on ports **80** and **443** for each IP address.
|
- Checks TCP connectivity on ports **80** and **443** for each IP address.
|
||||||
- Every **1 hour** by default, re-checks all ports.
|
- Every **1 hour** by default, re-checks all ports.
|
||||||
- Any change in port availability triggers a notification:
|
- Any change in port availability triggers a notification:
|
||||||
@@ -176,6 +188,8 @@ includes:
|
|||||||
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
||||||
- **NS address changes**: Which domain, which nameserver, its old and new
|
- **NS address changes**: Which domain, which nameserver, its old and new
|
||||||
addresses.
|
addresses.
|
||||||
|
- **CNAME address changes**: Which hostname, the old and new addresses at the
|
||||||
|
end of its CNAME chain.
|
||||||
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
|
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
|
||||||
REFUSED, network error), which hostname/domain affected.
|
REFUSED, network error), which hostname/domain affected.
|
||||||
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
||||||
@@ -420,9 +434,11 @@ This approach ensures:
|
|||||||
- Ability to detect split-horizon or inconsistent responses across authoritative
|
- Ability to detect split-horizon or inconsistent responses across authoritative
|
||||||
servers.
|
servers.
|
||||||
|
|
||||||
CNAME chains are followed (with a depth limit to prevent loops) only to find the
|
A watched name's records are stored as its nameservers return them, CNAME
|
||||||
addresses of nameservers. A watched name's records are stored as its nameservers
|
included. When they return a CNAME and no address, the chain of every CNAME
|
||||||
return them, CNAME included, without following it.
|
target they gave 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 also found by following CNAME chains.
|
||||||
|
|
||||||
Sending a notification or a Sentry report is the one use of the system's
|
Sending a notification or a Sentry report is the one use of the system's
|
||||||
resolver: the HTTP client looks up the webhook's or Sentry's host name with it.
|
resolver: the HTTP client looks up the webhook's or Sentry's host name with it.
|
||||||
@@ -469,6 +485,7 @@ merged view, to enable inconsistency detection.
|
|||||||
"lastChecked": "2026-02-19T12:00:00Z"
|
"lastChecked": "2026-02-19T12:00:00Z"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cnameAddresses": [],
|
||||||
"lastChecked": "2026-02-19T12:00:00Z"
|
"lastChecked": "2026-02-19T12:00:00Z"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -515,6 +532,14 @@ certificate entry whose TLS connection or handshake failed likewise has status
|
|||||||
resolves to. A state file without it loads, and the next check fills it in
|
resolves to. A state file without it loads, and the next check fills it in
|
||||||
without a notification.
|
without a notification.
|
||||||
|
|
||||||
|
`cnameAddresses` lists the sorted addresses at the end of the chain of every
|
||||||
|
CNAME target a hostname's nameservers gave, found when they answered with a
|
||||||
|
CNAME and no address; it is empty when they answered with an address. When a
|
||||||
|
chain cannot be followed, or none of the name's nameservers answered, the
|
||||||
|
previous check's list is kept, or `null` when no earlier check saved one. A
|
||||||
|
state file without it loads, and the first check after that saves it without a
|
||||||
|
notification.
|
||||||
|
|
||||||
A port entry in the older format, with one `hostname` instead of the `hostnames`
|
A port entry in the older format, with one `hostname` instead of the `hostnames`
|
||||||
list, loads as a list of that one name.
|
list, loads as a list of that one name.
|
||||||
|
|
||||||
@@ -672,7 +697,9 @@ docker run -d \
|
|||||||
- Port and TLS checks use the IP addresses found by the DNS phase that
|
- Port and TLS checks use the IP addresses found by the DNS phase that
|
||||||
immediately precedes them. When that phase cannot find a name's
|
immediately precedes them. When that phase cannot find a name's
|
||||||
nameservers at all, the addresses an earlier check saved for the name are
|
nameservers at all, the addresses an earlier check saved for the name are
|
||||||
used.
|
used. When it cannot follow a name's CNAME chain, or none of the name's
|
||||||
|
nameservers answered, the addresses an earlier check found at the end of
|
||||||
|
the chain are used.
|
||||||
4. **On change detection**: Send notifications to all configured endpoints,
|
4. **On change detection**: Send notifications to all configured endpoints,
|
||||||
update in-memory state, persist to disk.
|
update in-memory state, persist to disk.
|
||||||
5. **Shutdown**: The watcher stops checking and saves the final state to disk,
|
5. **Shutdown**: The watcher stops checking and saves the final state to disk,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-02: 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-02: a plain `docker build .` of a clone stamps its tag or short
|
- 2026-10-02: a plain `docker build .` of a clone stamps its tag or short
|
||||||
commit, not `dev`: the build context now carries `.git` (closes #210).
|
commit, not `dev`: the build context now carries `.git` (closes #210).
|
||||||
- 2026-10-02: a query a server refuses is not resent asking for recursion, and
|
- 2026-10-02: a query a server refuses is not resent asking for recursion, and
|
||||||
|
|||||||
@@ -53,8 +53,13 @@ type NameserverRecordState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HostnameState holds per-nameserver monitoring state for a hostname.
|
// 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. It is nil when they are not known: a
|
||||||
|
// state file written before it existed loads with it nil.
|
||||||
type HostnameState struct {
|
type HostnameState struct {
|
||||||
RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"`
|
RecordsByNameserver map[string]*NameserverRecordState `json:"recordsByNameserver"`
|
||||||
|
CNAMEAddresses []string `json:"cnameAddresses"`
|
||||||
LastChecked time.Time `json:"lastChecked"`
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,95 @@ func TestLoadStateFromBeforeNameserverAddresses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSaveLoadRoundTrip_CNAMEAddresses checks that no addresses at the
|
||||||
|
// end of a hostname's CNAME chain load as an empty list, and addresses
|
||||||
|
// that are not known load as nil: the watcher tells the two apart.
|
||||||
|
func TestSaveLoadRoundTrip_CNAMEAddresses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := state.NewForTestWithDataDir(dir)
|
||||||
|
|
||||||
|
want := map[string][]string{
|
||||||
|
"cname.example.com": {testIP},
|
||||||
|
"none.example.com": {},
|
||||||
|
"not-known.example.com": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, addresses := range want {
|
||||||
|
s.SetHostnameState(name, &state.HostnameState{
|
||||||
|
CNAMEAddresses: addresses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Save()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Save() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded := state.NewForTestWithDataDir(dir)
|
||||||
|
|
||||||
|
err = loaded.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, addresses := range want {
|
||||||
|
hs, ok := loaded.GetHostnameState(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing hostname %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(hs.CNAMEAddresses, addresses) {
|
||||||
|
t.Errorf(
|
||||||
|
"%s: loaded %#v, want %#v",
|
||||||
|
name, hs.CNAMEAddresses, addresses,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadStateFromBeforeCNAMEAddresses loads a state file written
|
||||||
|
// before the addresses at the end of a hostname's CNAME chain were
|
||||||
|
// saved. They load as not known (nil), not as none.
|
||||||
|
func TestLoadStateFromBeforeCNAMEAddresses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
data := []byte(`{
|
||||||
|
"version": 1,
|
||||||
|
"lastUpdated": "2026-02-19T12:00:00Z",
|
||||||
|
"hostnames": {
|
||||||
|
"www.example.com": {
|
||||||
|
"recordsByNameserver": {},
|
||||||
|
"lastChecked": "2026-02-19T12:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
err := os.WriteFile(filepath.Join(dir, "state.json"), data, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing state file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := state.NewForTestWithDataDir(dir)
|
||||||
|
|
||||||
|
err = s.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hs, ok := s.GetHostnameState(testHostname)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing hostname " + testHostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hs.CNAMEAddresses != nil {
|
||||||
|
t.Errorf("CNAME addresses: got %#v, want nil", hs.CNAMEAddresses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle.
|
// TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle.
|
||||||
func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
|
func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each name these tests look up in live DNS, and each zone a CNAME
|
||||||
|
// points into, has two nameservers, to keep queries few (see the top
|
||||||
|
// of watcher_test.go). cnameHost is a CNAME into another zone,
|
||||||
|
// readthedocs.io: its nameservers answer with the CNAME and no address.
|
||||||
|
const cnameHost = "flask.palletsprojects.com"
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
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: cnameTo("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.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,15 @@ func (w *Watcher) ResolveNameserverAddresses(
|
|||||||
return w.resolveNameserverAddresses(ctx, nameservers, prev)
|
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.
|
// DetectNSAddressChanges exports detectNSAddressChanges for testing.
|
||||||
func (w *Watcher) DetectNSAddressChanges(
|
func (w *Watcher) DetectNSAddressChanges(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
+114
-22
@@ -252,28 +252,9 @@ func (w *Watcher) checkDomain(
|
|||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Also look up A/AAAA records for the apex domain so that
|
// The apex domain's records are also checked as a hostname's, so
|
||||||
// port and TLS checks (which read HostnameState) can find
|
// that the port and TLS checks find its addresses.
|
||||||
// the domain's IP addresses.
|
w.checkHostname(ctx, domain)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) detectNSChanges(
|
func (w *Watcher) detectNSChanges(
|
||||||
@@ -401,6 +382,9 @@ func (w *Watcher) checkHostname(
|
|||||||
newState := buildHostnameState(results, time.Now().UTC())
|
newState := buildHostnameState(results, time.Now().UTC())
|
||||||
|
|
||||||
prev, hasPrev := w.state.GetHostnameState(hostname)
|
prev, hasPrev := w.state.GetHostnameState(hostname)
|
||||||
|
|
||||||
|
w.resolveCNAMEAddresses(ctx, hostname, newState, prev)
|
||||||
|
|
||||||
if hasPrev && !w.firstRun {
|
if hasPrev && !w.firstRun {
|
||||||
w.detectHostnameChanges(ctx, hostname, prev, newState)
|
w.detectHostnameChanges(ctx, hostname, prev, newState)
|
||||||
}
|
}
|
||||||
@@ -408,6 +392,76 @@ func (w *Watcher) checkHostname(
|
|||||||
w.state.SetHostnameState(hostname, newState)
|
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, and an empty list otherwise. Every CNAME
|
||||||
|
// target the nameservers gave is followed with ResolveIPAddresses and
|
||||||
|
// the addresses found for all of them are saved, so nameservers that
|
||||||
|
// disagree on the target do not change the result from check to check.
|
||||||
|
// The addresses saved in prev, which may be nil, are kept when none of
|
||||||
|
// the name's nameservers answered, and when a target cannot be
|
||||||
|
// followed, as when no nameserver of a zone in its chain answers.
|
||||||
|
func (w *Watcher) resolveCNAMEAddresses(
|
||||||
|
ctx context.Context,
|
||||||
|
hostname string,
|
||||||
|
current, prev *state.HostnameState,
|
||||||
|
) {
|
||||||
|
var prevAddresses []string
|
||||||
|
if prev != nil {
|
||||||
|
prevAddresses = prev.CNAMEAddresses
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty, not nil: nil means the addresses are not known.
|
||||||
|
current.CNAMEAddresses = []string{}
|
||||||
|
|
||||||
|
answered := false
|
||||||
|
targets := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, nsState := range current.RecordsByNameserver {
|
||||||
|
if nsState.Status != statusOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
answered = true
|
||||||
|
|
||||||
|
if len(nsState.Records["A"]) > 0 || len(nsState.Records["AAAA"]) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range nsState.Records["CNAME"] {
|
||||||
|
targets[target] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !answered {
|
||||||
|
current.CNAMEAddresses = prevAddresses
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for target := range targets {
|
||||||
|
ips, err := w.resolver.ResolveIPAddresses(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
w.log.Error(
|
||||||
|
"failed to follow CNAME",
|
||||||
|
"hostname", hostname,
|
||||||
|
"target", target,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
current.CNAMEAddresses = prevAddresses
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current.CNAMEAddresses = append(current.CNAMEAddresses, ips...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still the empty list when every chain ends in no address.
|
||||||
|
slices.Sort(current.CNAMEAddresses)
|
||||||
|
current.CNAMEAddresses = slices.Compact(current.CNAMEAddresses)
|
||||||
|
}
|
||||||
|
|
||||||
// buildHostnameState saves each nameserver's response. A nameserver
|
// buildHostnameState saves each nameserver's response. A nameserver
|
||||||
// that answered, even with NXDOMAIN or no records, is saved as ok; one
|
// 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
|
// that timed out or failed is saved as error with the reason, and its
|
||||||
@@ -451,6 +505,37 @@ func (w *Watcher) detectHostnameChanges(
|
|||||||
w.detectNSDisappearances(ctx, hostname, prev, current)
|
w.detectNSDisappearances(ctx, hostname, prev, current)
|
||||||
w.detectNSFailures(ctx, hostname, prev, current)
|
w.detectNSFailures(ctx, hostname, prev, current)
|
||||||
w.detectInconsistencies(ctx, hostname, prev, current)
|
w.detectInconsistencies(ctx, hostname, prev, current)
|
||||||
|
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectCNAMEAddressChanges notifies when the addresses at the end of
|
||||||
|
// hostname's CNAME chain differ from those the previous check saved,
|
||||||
|
// including a change from or to none. When the previous addresses are
|
||||||
|
// not known (nil), as on the first check after loading a state file
|
||||||
|
// written before they were saved, nothing is compared.
|
||||||
|
func (w *Watcher) detectCNAMEAddressChanges(
|
||||||
|
ctx context.Context,
|
||||||
|
hostname string,
|
||||||
|
prev, current *state.HostnameState,
|
||||||
|
) {
|
||||||
|
old, cur := prev.CNAMEAddresses, current.CNAMEAddresses
|
||||||
|
if old == nil || sliceEqual(old, cur) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"Hostname: %s\nOld: %s\nNew: %s",
|
||||||
|
hostname,
|
||||||
|
strings.Join(old, ", "),
|
||||||
|
strings.Join(cur, ", "),
|
||||||
|
)
|
||||||
|
|
||||||
|
w.notify.SendNotification(
|
||||||
|
ctx,
|
||||||
|
"CNAME Address Change: "+hostname,
|
||||||
|
msg,
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectRecordChanges compares each nameserver's records with those of
|
// detectRecordChanges compares each nameserver's records with those of
|
||||||
@@ -747,6 +832,9 @@ func (w *Watcher) noNameserverAnswered(name string) bool {
|
|||||||
return true
|
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 {
|
func (w *Watcher) collectIPs(hostname string) []string {
|
||||||
hs, ok := w.state.GetHostnameState(hostname)
|
hs, ok := w.state.GetHostnameState(hostname)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -765,6 +853,10 @@ func (w *Watcher) collectIPs(hostname string) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, ip := range hs.CNAMEAddresses {
|
||||||
|
ipSet[ip] = true
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]string, 0, len(ipSet))
|
result := make([]string, 0, len(ipSet))
|
||||||
for ip := range ipSet {
|
for ip := range ipSet {
|
||||||
result = append(result, ip)
|
result = append(result, ip)
|
||||||
|
|||||||
@@ -312,7 +312,8 @@ func lookupNameservers(t *testing.T, name string) []string {
|
|||||||
return nameservers
|
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 {
|
func addresses(hs *state.HostnameState) []string {
|
||||||
var ips []string
|
var ips []string
|
||||||
|
|
||||||
@@ -321,7 +322,7 @@ func addresses(hs *state.HostnameState) []string {
|
|||||||
ips = append(ips, nsState.Records["AAAA"]...)
|
ips = append(ips, nsState.Records["AAAA"]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ips
|
return append(ips, hs.CNAMEAddresses...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertNotified checks that a notification with this title and
|
// assertNotified checks that a notification with this title and
|
||||||
|
|||||||
Reference in New Issue
Block a user