watcher: follow a watched name's CNAME for port and TLS checks (closes #203)
check / check (push) Failing after 2m4s
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
This commit is contained in:
+111
-22
@@ -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,73 @@ 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.
|
||||
// ResolveIPAddresses looks the name up again and follows the chain. The
|
||||
// addresses saved in prev, which may be nil, are kept when none of the
|
||||
// name's nameservers answered, and when the chain cannot be followed, as
|
||||
// when no nameserver of a zone in it 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
|
||||
hasCNAME := false
|
||||
|
||||
for _, nsState := range current.RecordsByNameserver {
|
||||
if nsState.Status != statusOK {
|
||||
continue
|
||||
}
|
||||
|
||||
answered = true
|
||||
|
||||
if len(nsState.Records["A"]) > 0 || len(nsState.Records["AAAA"]) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if len(nsState.Records["CNAME"]) > 0 {
|
||||
hasCNAME = true
|
||||
}
|
||||
}
|
||||
|
||||
if !answered {
|
||||
current.CNAMEAddresses = prevAddresses
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !hasCNAME {
|
||||
return
|
||||
}
|
||||
|
||||
ips, err := w.resolver.ResolveIPAddresses(ctx, hostname)
|
||||
if err != nil {
|
||||
w.log.Error(
|
||||
"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
|
||||
// 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 +502,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 +829,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 +850,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)
|
||||
|
||||
Reference in New Issue
Block a user