watcher: notify NS query failure and recovery (closes #104)
check / check (push) Successful in 1m5s

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 is contained in:
2026-10-01 18:13:08 +00:00
parent 651429137f
commit 2d208773e0
10 changed files with 422 additions and 87 deletions
+22 -14
View File
@@ -504,7 +504,9 @@ func (r *Resolver) queryAllTypes(
type queryState struct {
gotNXDomain bool
gotSERVFAIL bool
gotRefused bool
gotTimeout bool
netErr error
hasRecords bool
}
@@ -542,8 +544,13 @@ func (r *Resolver) querySingleType(
) {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil {
if isTimeout(err) {
switch {
case isTimeout(err):
state.gotTimeout = true
case errors.Is(err, ErrRefused):
state.gotRefused = true
default:
state.netErr = err
}
return
@@ -603,6 +610,12 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotSERVFAIL && !state.hasRecords:
resp.Status = StatusError
resp.Error = "server returned SERVFAIL"
case state.gotRefused && !state.hasRecords:
resp.Status = StatusError
resp.Error = "server returned REFUSED"
case state.netErr != nil && !state.hasRecords:
resp.Status = StatusError
resp.Error = "network error: " + state.netErr.Error()
case !state.hasRecords && !state.gotNXDomain:
resp.Status = StatusNoData
}
@@ -682,11 +695,14 @@ func (r *Resolver) queryEachNS(
results := make(map[string]*NameserverResponse)
for _, ns := range nameservers {
resp, err := r.QueryNameserver(ctx, ns, hostname)
// A query the context cut short says nothing about the
// nameserver, so it must not be returned as its failure.
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
resp, err := r.QueryNameserver(ctx, ns, hostname)
if err != nil {
results[ns] = &NameserverResponse{
Nameserver: ns,
@@ -714,21 +730,13 @@ func (r *Resolver) LookupNS(
// LookupAllRecords performs iterative resolution to find all DNS
// records for the given hostname, keyed by authoritative nameserver.
// Each nameserver's response carries its status and error with its
// records.
func (r *Resolver) LookupAllRecords(
ctx context.Context,
hostname string,
) (map[string]map[string][]string, error) {
results, err := r.QueryAllNameservers(ctx, hostname)
if err != nil {
return nil, err
}
out := make(map[string]map[string][]string, len(results))
for ns, resp := range results {
out[ns] = resp.Records
}
return out, nil
) (map[string]*NameserverResponse, error) {
return r.QueryAllNameservers(ctx, hostname)
}
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
+42 -1
View File
@@ -2,6 +2,7 @@ package resolver_test
import (
"context"
"fmt"
"log/slog"
"net"
"os"
@@ -13,6 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/livednstest"
"sneak.berlin/go/dnswatcher/internal/resolver"
)
@@ -231,6 +233,45 @@ func TestQueryNameserver_NXDomain(t *testing.T) {
assert.Equal(t, resolver.StatusNXDomain, resp.Status)
}
// TestQueryNameserver_Refused asks a google.com nameserver about
// cloudflare.com, a zone it does not serve, which it refuses. Refusing
// is a failure to answer, not an answer with no records.
func TestQueryNameserver_Refused(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "google.com")
var resp *resolver.NameserverResponse
livednstest.Retry(
t,
"QueryNameserver("+ns+", cloudflare.com)",
func(ctx context.Context) error {
var err error
resp, err = r.QueryNameserver(ctx, ns, "cloudflare.com")
if err != nil {
return err
}
// A timeout or a network error is no reply at all.
if resp.Status == resolver.StatusTimeout ||
strings.HasPrefix(resp.Error, "network error") {
return fmt.Errorf(
"%w: %s: %s",
livednstest.ErrNoAnswer, ns, resp.Error,
)
}
return nil
},
)
assert.Equal(t, resolver.StatusError, resp.Status)
assert.Equal(t, "server returned REFUSED", resp.Error)
}
func TestQueryNameserver_RecordsSorted(t *testing.T) {
t.Parallel()
@@ -530,7 +571,7 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
// Nothing answers at 192.0.2.1, a documentation address. The
// resolver tries each query twice, and the first try gives up
// after two seconds. A deadline that ends during the first try
// makes the status vary from run to run between nodata and
// makes the status vary from run to run between error and
// timeout, so the deadline must outlast the first try.
ctx, cancel := context.WithTimeout(
context.Background(), 3*time.Second,