1 Commits
Author SHA1 Message Date
sneak cd9618ea28 watcher: follow a watched name's CNAME for port and TLS checks (closes #203)
check / check (push) Failing after 37s
When a watched name's nameservers answer with a CNAME and no address,
the DNS check follows every CNAME target they gave with
ResolveIPAddresses and saves the addresses found for all of them in the
hostname state as cnameAddresses, so nameservers that disagree on the
target do not change them from check to check. 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 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
2026-10-02 02:08:31 +00:00
3 changed files with 139 additions and 50 deletions
+19 -17
View File
@@ -126,10 +126,10 @@ notification endpoint set, changes show only on the dashboard; see
nameservers answer with a CNAME and no address; a name that answers with 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 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 a name moves between A records and a CNAME. Nothing is sent when the
previous addresses were kept because the chain could not be followed or previous addresses were kept because a chain could not be followed or none
none of the name's nameservers answered. The first check after loading a of the name's nameservers answered. The first check after loading a state
state file without `cnameAddresses` sends nothing: it saves the addresses file without `cnameAddresses` sends nothing: it saves the addresses it
it finds for the next check to compare. finds for the next check to compare.
### TCP Port Monitoring ### TCP Port Monitoring
@@ -137,9 +137,10 @@ notification endpoint set, changes show only on the dashboard; see
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. When they returned a CNAME and no address, the CNAME nameservers returned. When they returned a CNAME and no address, the CNAME
chain is followed and the addresses at its end are used, and a change in those chain is followed and the addresses at its end are used, and a change in those
is notified as a CNAME address change. When the chain cannot be followed, or is notified as a CNAME address change. When the nameservers gave different
none of the name's nameservers answered, the addresses the last check found at CNAME targets, each is followed and the addresses of all are used. When a
its end are used. 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:
@@ -434,10 +435,10 @@ This approach ensures:
servers. servers.
A watched name's records are stored as its nameservers return them, CNAME A watched name's records are stored as its nameservers return them, CNAME
included. When they return a CNAME and no address, the CNAME chain is followed included. When they return a CNAME and no address, the chain of every CNAME
(with a depth limit to prevent loops) to the A and AAAA records at its end, and target they gave is followed (with a depth limit to prevent loops) to the A and
the port and TLS checks use those addresses. Nameservers' addresses are found AAAA records at its end, and the port and TLS checks use those addresses.
the same way. 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.
@@ -531,12 +532,13 @@ 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 a hostname's CNAME `cnameAddresses` lists the sorted addresses at the end of the chain of every
chain, found when its nameservers answered with a CNAME and no address; it is CNAME target a hostname's nameservers gave, found when they answered with a
empty when they answered with an address. When the chain cannot be followed, or CNAME and no address; it is empty when they answered with an address. When a
none of the name's nameservers answered, the previous check's list is kept, or chain cannot be followed, or none of the name's nameservers answered, the
`null` when no earlier check saved one. A state file without it loads, and the previous check's list is kept, or `null` when no earlier check saved one. A
first check after that saves it without a notification. 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.
+91 -7
View File
@@ -52,15 +52,12 @@ func TestCNAMEIntoAnotherZonePortAndTLSChecks(t *testing.T) {
} }
} }
// TestCNAMEThatCannotBeFollowedKeepsPrevious gives a name under // TestCNAMEThatCannotBeFollowedKeepsPrevious gives a name a CNAME to a
// .invalid, whose lookup fails, answers with a CNAME and no address. // target under .invalid, whose lookup fails. The addresses the previous
// The addresses the previous check saved from following its CNAME are // check saved from following its CNAME are kept.
// kept.
func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) { func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) {
t.Parallel() t.Parallel()
const name = "www.example.invalid"
w := watcher.NewForTest( w := watcher.NewForTest(
nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil, nil, nil, resolver.NewFromLogger(slog.Default()), nil, nil, nil,
) )
@@ -73,7 +70,7 @@ func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) {
// The result is the same whether or not live DNS answers, so the // The result is the same whether or not live DNS answers, so the
// lookup is not retried. // lookup is not retried.
_ = livednstest.Run(func(ctx context.Context) error { _ = livednstest.Run(func(ctx context.Context) error {
w.ResolveCNAMEAddresses(ctx, name, current, prev) w.ResolveCNAMEAddresses(ctx, host, current, prev)
return nil return nil
}) })
@@ -86,6 +83,93 @@ func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) {
} }
} }
// 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 // TestCNAMEWhoseNameserversAllFailedKeepsPrevious checks a name none of
// whose nameservers answered. The addresses the previous check saved // whose nameservers answered. The addresses the previous check saved
// from following its CNAME are kept, and nothing is looked up: the // from following its CNAME are kept, and nothing is looked up: the
+29 -26
View File
@@ -394,11 +394,13 @@ func (w *Watcher) checkHostname(
// resolveCNAMEAddresses saves in current the addresses at the end of // resolveCNAMEAddresses saves in current the addresses at the end of
// hostname's CNAME chain, when the nameservers' answers in current hold // hostname's CNAME chain, when the nameservers' answers in current hold
// a CNAME and no address, and an empty list otherwise. // a CNAME and no address, and an empty list otherwise. Every CNAME
// ResolveIPAddresses looks the name up again and follows the chain. The // target the nameservers gave is followed with ResolveIPAddresses and
// addresses saved in prev, which may be nil, are kept when none of the // the addresses found for all of them are saved, so nameservers that
// name's nameservers answered, and when the chain cannot be followed, as // disagree on the target do not change the result from check to check.
// when no nameserver of a zone in it answers. // 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( func (w *Watcher) resolveCNAMEAddresses(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -413,7 +415,7 @@ func (w *Watcher) resolveCNAMEAddresses(
current.CNAMEAddresses = []string{} current.CNAMEAddresses = []string{}
answered := false answered := false
hasCNAME := false targets := make(map[string]bool)
for _, nsState := range current.RecordsByNameserver { for _, nsState := range current.RecordsByNameserver {
if nsState.Status != statusOK { if nsState.Status != statusOK {
@@ -426,8 +428,8 @@ func (w *Watcher) resolveCNAMEAddresses(
return return
} }
if len(nsState.Records["CNAME"]) > 0 { for _, target := range nsState.Records["CNAME"] {
hasCNAME = true targets[target] = true
} }
} }
@@ -437,26 +439,27 @@ func (w *Watcher) resolveCNAMEAddresses(
return return
} }
if !hasCNAME { for target := range targets {
return 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...)
} }
ips, err := w.resolver.ResolveIPAddresses(ctx, hostname) // Still the empty list when every chain ends in no address.
if err != nil { slices.Sort(current.CNAMEAddresses)
w.log.Error( current.CNAMEAddresses = slices.Compact(current.CNAMEAddresses)
"failed to follow CNAME",
"hostname", hostname,
"error", err,
)
current.CNAMEAddresses = prevAddresses
return
}
// Appended to the empty list, so a chain that ends in no address is
// saved as empty, not nil.
current.CNAMEAddresses = append(current.CNAMEAddresses, ips...)
} }
// buildHostnameState saves each nameserver's response. A nameserver // buildHostnameState saves each nameserver's response. A nameserver