watcher: notify NS query failure and recovery (closes #104)
check / check (push) Successful in 1m31s
check / check (push) Successful in 1m31s
LookupAllRecords now returns each nameserver's response, so the watcher saves its status: ok when it answered, NXDOMAIN and no records included, and error with the reason when it timed out, answered SERVFAIL or REFUSED, or could not be reached. A nameserver that starts failing sends NS Failure and one that answers again sends NS Recovery. A failing nameserver is left out of the record change and inconsistency comparisons. The resolver used to report REFUSED and network errors as an answer with no records; they are now errors. A lookup cut short by its context now returns an error instead of a failure of the nameserver it was querying. Model: opus-5-5
This commit was merged in pull request #175.
This commit is contained in:
+95
-50
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
"sneak.berlin/go/dnswatcher/internal/resolver"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
"sneak.berlin/go/dnswatcher/internal/tlscheck"
|
||||
)
|
||||
@@ -227,7 +228,7 @@ func (w *Watcher) checkDomain(
|
||||
// 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.
|
||||
records, err := w.resolver.LookupAllRecords(ctx, domain)
|
||||
results, err := w.resolver.LookupAllRecords(ctx, domain)
|
||||
if err != nil {
|
||||
w.log.Error(
|
||||
"failed to lookup records for domain",
|
||||
@@ -238,12 +239,13 @@ func (w *Watcher) checkDomain(
|
||||
return
|
||||
}
|
||||
|
||||
newState := buildHostnameState(results, now)
|
||||
|
||||
prevHS, hasPrevHS := w.state.GetHostnameState(domain)
|
||||
if hasPrevHS && !w.firstRun {
|
||||
w.detectHostnameChanges(ctx, domain, prevHS, records)
|
||||
w.detectHostnameChanges(ctx, domain, prevHS, newState)
|
||||
}
|
||||
|
||||
newState := buildHostnameState(records, now)
|
||||
w.state.SetHostnameState(domain, newState)
|
||||
}
|
||||
|
||||
@@ -292,7 +294,7 @@ func (w *Watcher) checkHostname(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
) {
|
||||
records, err := w.resolver.LookupAllRecords(ctx, hostname)
|
||||
results, err := w.resolver.LookupAllRecords(ctx, hostname)
|
||||
if err != nil {
|
||||
w.log.Error(
|
||||
"failed to lookup records",
|
||||
@@ -303,19 +305,22 @@ func (w *Watcher) checkHostname(
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
prev, hasPrev := w.state.GetHostnameState(hostname)
|
||||
newState := buildHostnameState(results, time.Now().UTC())
|
||||
|
||||
prev, hasPrev := w.state.GetHostnameState(hostname)
|
||||
if hasPrev && !w.firstRun {
|
||||
w.detectHostnameChanges(ctx, hostname, prev, records)
|
||||
w.detectHostnameChanges(ctx, hostname, prev, newState)
|
||||
}
|
||||
|
||||
newState := buildHostnameState(records, now)
|
||||
w.state.SetHostnameState(hostname, newState)
|
||||
}
|
||||
|
||||
// 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
|
||||
// empty record set is not an answer.
|
||||
func buildHostnameState(
|
||||
records map[string]map[string][]string,
|
||||
results map[string]*resolver.NameserverResponse,
|
||||
now time.Time,
|
||||
) *state.HostnameState {
|
||||
hs := &state.HostnameState{
|
||||
@@ -325,12 +330,20 @@ func buildHostnameState(
|
||||
LastChecked: now,
|
||||
}
|
||||
|
||||
for ns, recs := range records {
|
||||
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
|
||||
Records: recs,
|
||||
for ns, resp := range results {
|
||||
nsState := &state.NameserverRecordState{
|
||||
Records: resp.Records,
|
||||
Status: statusOK,
|
||||
LastChecked: now,
|
||||
}
|
||||
|
||||
if resp.Status == resolver.StatusTimeout ||
|
||||
resp.Status == resolver.StatusError {
|
||||
nsState.Status = statusError
|
||||
nsState.Error = resp.Error
|
||||
}
|
||||
|
||||
hs.RecordsByNameserver[ns] = nsState
|
||||
}
|
||||
|
||||
return hs
|
||||
@@ -339,27 +352,29 @@ func buildHostnameState(
|
||||
func (w *Watcher) detectHostnameChanges(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
prev *state.HostnameState,
|
||||
current map[string]map[string][]string,
|
||||
prev, current *state.HostnameState,
|
||||
) {
|
||||
w.detectRecordChanges(ctx, hostname, prev, current)
|
||||
w.detectNSDisappearances(ctx, hostname, prev, current)
|
||||
w.detectNSFailures(ctx, hostname, prev, current)
|
||||
w.detectInconsistencies(ctx, hostname, prev, current)
|
||||
}
|
||||
|
||||
// detectRecordChanges compares each nameserver's records with those of
|
||||
// the previous check. Only answers are compared: a nameserver that
|
||||
// failed on either check has no records to compare.
|
||||
func (w *Watcher) detectRecordChanges(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
prev *state.HostnameState,
|
||||
current map[string]map[string][]string,
|
||||
prev, current *state.HostnameState,
|
||||
) {
|
||||
for ns, recs := range current {
|
||||
for ns, cur := range current.RecordsByNameserver {
|
||||
prevNS, ok := prev.RecordsByNameserver[ns]
|
||||
if !ok {
|
||||
if !ok || prevNS.Status != statusOK || cur.Status != statusOK {
|
||||
continue
|
||||
}
|
||||
|
||||
if recordsEqual(prevNS.Records, recs) {
|
||||
if recordsEqual(prevNS.Records, cur.Records) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -367,7 +382,7 @@ func (w *Watcher) detectRecordChanges(
|
||||
"Hostname: %s\nNameserver: %s\n"+
|
||||
"Old: %v\nNew: %v",
|
||||
hostname, ns,
|
||||
prevNS.Records, recs,
|
||||
prevNS.Records, cur.Records,
|
||||
)
|
||||
|
||||
w.notify.SendNotification(
|
||||
@@ -382,11 +397,10 @@ func (w *Watcher) detectRecordChanges(
|
||||
func (w *Watcher) detectNSDisappearances(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
prev *state.HostnameState,
|
||||
current map[string]map[string][]string,
|
||||
prev, current *state.HostnameState,
|
||||
) {
|
||||
for ns, prevNS := range prev.RecordsByNameserver {
|
||||
if _, ok := current[ns]; ok || prevNS.Status != statusOK {
|
||||
if _, ok := current.RecordsByNameserver[ns]; ok || prevNS.Status != statusOK {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -402,32 +416,55 @@ func (w *Watcher) detectNSDisappearances(
|
||||
"error",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for ns := range current {
|
||||
// detectNSFailures notifies when a nameserver that answered on the
|
||||
// previous check fails, and when one that failed answers again. A
|
||||
// nameserver missing from the previous check is not compared.
|
||||
func (w *Watcher) detectNSFailures(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
prev, current *state.HostnameState,
|
||||
) {
|
||||
for ns, cur := range current.RecordsByNameserver {
|
||||
prevNS, ok := prev.RecordsByNameserver[ns]
|
||||
if !ok || prevNS.Status != statusError {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\nNameserver: %s recovered",
|
||||
hostname, ns,
|
||||
)
|
||||
switch {
|
||||
case prevNS.Status == statusOK && cur.Status == statusError:
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\nNameserver: %s\nError: %s",
|
||||
hostname, ns, cur.Error,
|
||||
)
|
||||
|
||||
w.notify.SendNotification(
|
||||
ctx,
|
||||
"NS Recovery: "+hostname,
|
||||
msg,
|
||||
"success",
|
||||
)
|
||||
w.notify.SendNotification(
|
||||
ctx,
|
||||
"NS Failure: "+hostname,
|
||||
msg,
|
||||
"error",
|
||||
)
|
||||
case prevNS.Status == statusError && cur.Status == statusOK:
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\nNameserver: %s recovered",
|
||||
hostname, ns,
|
||||
)
|
||||
|
||||
w.notify.SendNotification(
|
||||
ctx,
|
||||
"NS Recovery: "+hostname,
|
||||
msg,
|
||||
"success",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) detectInconsistencies(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
prev *state.HostnameState,
|
||||
current map[string]map[string][]string,
|
||||
prev, current *state.HostnameState,
|
||||
) {
|
||||
for _, pair := range newlyDisagreeingPairs(prev, current) {
|
||||
ns1, ns2 := pair[0], pair[1]
|
||||
@@ -435,8 +472,8 @@ func (w *Watcher) detectInconsistencies(
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\n%s: %v\n%s: %v",
|
||||
hostname,
|
||||
ns1, current[ns1],
|
||||
ns2, current[ns2],
|
||||
ns1, current.RecordsByNameserver[ns1].Records,
|
||||
ns2, current.RecordsByNameserver[ns2].Records,
|
||||
)
|
||||
|
||||
w.notify.SendNotification(
|
||||
@@ -448,17 +485,20 @@ func (w *Watcher) detectInconsistencies(
|
||||
}
|
||||
}
|
||||
|
||||
// newlyDisagreeingPairs returns every pair of nameservers whose records
|
||||
// differ in current, in sorted order of name, except pairs where both
|
||||
// nameservers were in prev and already differed there. A nameserver
|
||||
// missing from prev is paired with every nameserver it differs from.
|
||||
// newlyDisagreeingPairs returns every pair of nameservers that answered
|
||||
// in current and whose records differ there, in sorted order of name,
|
||||
// except pairs where both nameservers answered in prev and already
|
||||
// differed there. A nameserver missing from prev, or that failed there,
|
||||
// is paired with every nameserver it differs from. A nameserver that
|
||||
// failed in current has no records to compare and is in no pair.
|
||||
func newlyDisagreeingPairs(
|
||||
prev *state.HostnameState,
|
||||
current map[string]map[string][]string,
|
||||
prev, current *state.HostnameState,
|
||||
) [][2]string {
|
||||
nameservers := make([]string, 0, len(current))
|
||||
for ns := range current {
|
||||
nameservers = append(nameservers, ns)
|
||||
nameservers := make([]string, 0, len(current.RecordsByNameserver))
|
||||
for ns, cur := range current.RecordsByNameserver {
|
||||
if cur.Status == statusOK {
|
||||
nameservers = append(nameservers, ns)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(nameservers)
|
||||
@@ -467,14 +507,19 @@ func newlyDisagreeingPairs(
|
||||
|
||||
for i, ns1 := range nameservers {
|
||||
for _, ns2 := range nameservers[i+1:] {
|
||||
if recordsEqual(current[ns1], current[ns2]) {
|
||||
if recordsEqual(
|
||||
current.RecordsByNameserver[ns1].Records,
|
||||
current.RecordsByNameserver[ns2].Records,
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
prev1, ok1 := prev.RecordsByNameserver[ns1]
|
||||
prev2, ok2 := prev.RecordsByNameserver[ns2]
|
||||
|
||||
if ok1 && ok2 && !recordsEqual(prev1.Records, prev2.Records) {
|
||||
if ok1 && ok2 &&
|
||||
prev1.Status == statusOK && prev2.Status == statusOK &&
|
||||
!recordsEqual(prev1.Records, prev2.Records) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user