watcher: follow a watched name's CNAME for port and TLS checks (closes #203)
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:
2026-10-02 04:51:29 +00:00
parent 3182fc99a6
commit f41601dd80
8 changed files with 587 additions and 31 deletions
+114 -22
View File
@@ -252,28 +252,9 @@ func (w *Watcher) checkDomain(
LastChecked: now,
})
// Also look up A/AAAA records for the apex domain so that
// port and TLS checks (which read HostnameState) can find
// the domain's IP addresses.
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)
// The apex domain's records are also checked as a hostname's, so
// that the port and TLS checks find its addresses.
w.checkHostname(ctx, domain)
}
func (w *Watcher) detectNSChanges(
@@ -401,6 +382,9 @@ func (w *Watcher) checkHostname(
newState := buildHostnameState(results, time.Now().UTC())
prev, hasPrev := w.state.GetHostnameState(hostname)
w.resolveCNAMEAddresses(ctx, hostname, newState, prev)
if hasPrev && !w.firstRun {
w.detectHostnameChanges(ctx, hostname, prev, newState)
}
@@ -408,6 +392,76 @@ func (w *Watcher) checkHostname(
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
// 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
@@ -451,6 +505,37 @@ func (w *Watcher) detectHostnameChanges(
w.detectNSDisappearances(ctx, hostname, prev, current)
w.detectNSFailures(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
@@ -747,6 +832,9 @@ func (w *Watcher) noNameserverAnswered(name string) bool {
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 {
hs, ok := w.state.GetHostnameState(hostname)
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))
for ip := range ipSet {
result = append(result, ip)