1 Commits
Author SHA1 Message Date
sneak 01879aaa2d resolver, watcher: a domain's nameservers are only its own delegation (closes #222)
check / check (push) Canceled after 0s
When a domain's parent zone's servers answer NXDOMAIN, LookupNS returns
ErrNXDomain. The watcher then saves the domain with no nameservers and
nxdomain set, shown on the dashboard and in /api/v1/status, asks for none
of its records and removes those saved, so its old nameservers go in one
NS Change. A domain with no delegation of its own gets an empty set and
its records are still asked at the zone it is in.
FindAuthoritativeNameservers moves to a parent name only on one of those
two answers; when the servers do not answer, it returns the error. After
an upgrade, a domain without its own delegation that was saved with its
parent zone's nameservers gets one NS Change; the README says so.

Model: opus-5-5
2026-10-02 09:54:29 +00:00
5 changed files with 50 additions and 116 deletions
-4
View File
@@ -19,12 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-02: a nameserver whose query for one record type failed while the
others answered with no records is `ok`, not `nodata` (closes #253).
- 2026-10-02: a domain that does not exist is shown so, with no nameservers; no - 2026-10-02: a domain that does not exist is shown so, with no nameservers; no
name gets a parent's nameservers when its own did not answer (closes #222). name gets a parent's nameservers when its own did not answer (closes #222).
- 2026-10-02: the refused-query test sends one query to four operators' public
resolvers in turn until one replies, not eight to one operator (closes #251).
- 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so - 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so
the dashboard and API, at startup, before the first check (closes #223). the dashboard and API, at startup, before the first check (closes #223).
- 2026-10-02: a record type whose query to a nameserver fails keeps its previous - 2026-10-02: a record type whose query to a nameserver fails keeps its previous
+2 -5
View File
@@ -941,9 +941,7 @@ func isTimeout(err error) bool {
// classifyResponse sets the nameserver's status. One that answered no // classifyResponse sets the nameserver's status. One that answered no
// record type has failed, and Error says why; one that answered some has // record type has failed, and Error says why; one that answered some has
// the status of those answers. It has no data only when every type // the status of those answers.
// answered with no records: a type in FailedTypes may have records, so a
// nameserver with one stays ok.
func classifyResponse(resp *NameserverResponse, state queryState) { func classifyResponse(resp *NameserverResponse, state queryState) {
switch { switch {
case state.gotNXDomain && !state.hasRecords: case state.gotNXDomain && !state.hasRecords:
@@ -963,8 +961,7 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotReferral && !state.answered: case state.gotReferral && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned a referral" resp.Error = "server returned a referral"
// An NXDOMAIN reply with no records was taken by the first case. case !state.hasRecords && !state.gotNXDomain:
case !state.hasRecords && len(resp.FailedTypes) == 0:
resp.Status = StatusNoData resp.Status = StatusNoData
} }
} }
+11 -29
View File
@@ -11,77 +11,60 @@ import (
) )
// TestClassifyResponse sets a nameserver's status from the results of // TestClassifyResponse sets a nameserver's status from the results of
// its queries and the record types whose query failed, built here. One // its queries, built here. One that answered some record types, even
// that answered some record types, even with no records, has not failed // with no records, has not failed when its query for another type got
// when its query for another type got no usable reply, whatever the // no usable reply, whatever the reason; one whose every query got none
// reason, and is ok, not nodata: that type may have records. One whose // has.
// every query got none has failed. Only one whose every type answered
// with no records is nodata.
func TestClassifyResponse(t *testing.T) { func TestClassifyResponse(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
results queryState results queryState
failedTypes []string
wantStatus string wantStatus string
wantError string wantError string
}{ }{
{
"every type answered with no records",
queryState{answered: true},
nil,
StatusNoData, "",
},
{ {
"some types answered with no records, another timed out", "some types answered with no records, another timed out",
queryState{answered: true, gotTimeout: true}, queryState{answered: true, gotTimeout: true},
[]string{"A"}, StatusNoData, "",
StatusOK, "",
}, },
{ {
"some types answered with no records, another got SERVFAIL", "some types answered with no records, another got SERVFAIL",
queryState{ queryState{
answered: true, gotErrorReply: true, errorReply: "SERVFAIL", answered: true, gotErrorReply: true, errorReply: "SERVFAIL",
}, },
[]string{"A"}, StatusNoData, "",
StatusOK, "",
}, },
{ {
"some types answered with no records, another was refused", "some types answered with no records, another was refused",
queryState{answered: true, gotRefused: true}, queryState{answered: true, gotRefused: true},
[]string{"A"}, StatusNoData, "",
StatusOK, "",
}, },
{ {
"some types answered with no records, another got a network error", "some types answered with no records, another got a network error",
queryState{answered: true, netErr: syscall.ECONNREFUSED}, queryState{answered: true, netErr: syscall.ECONNREFUSED},
[]string{"A"}, StatusNoData, "",
StatusOK, "",
}, },
{ {
"some types answered with no records, another's reply was " + "some types answered with no records, another's reply was " +
"truncated and its retry over TCP failed", "truncated and its retry over TCP failed",
queryState{answered: true, netErr: ErrTruncated}, queryState{answered: true, netErr: ErrTruncated},
[]string{"TXT"}, StatusNoData, "",
StatusOK, "",
}, },
{ {
"some types answered with no records, another got a referral", "some types answered with no records, another got a referral",
queryState{answered: true, gotReferral: true}, queryState{answered: true, gotReferral: true},
[]string{"A"}, StatusNoData, "",
StatusOK, "",
}, },
{ {
"every query timed out", "every query timed out",
queryState{gotTimeout: true}, queryState{gotTimeout: true},
[]string{"A", "AAAA", "CNAME"},
StatusTimeout, "all queries timed out", StatusTimeout, "all queries timed out",
}, },
{ {
"every query got NOTIMP", "every query got NOTIMP",
queryState{gotErrorReply: true, errorReply: "NOTIMP"}, queryState{gotErrorReply: true, errorReply: "NOTIMP"},
[]string{"A", "AAAA", "CNAME"},
StatusError, "server returned NOTIMP", StatusError, "server returned NOTIMP",
}, },
} }
@@ -90,12 +73,11 @@ func TestClassifyResponse(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
resp := &NameserverResponse{Status: StatusOK, FailedTypes: tt.failedTypes} resp := &NameserverResponse{Status: StatusOK}
classifyResponse(resp, tt.results) classifyResponse(resp, tt.results)
assert.Equal(t, tt.wantStatus, resp.Status) assert.Equal(t, tt.wantStatus, resp.Status)
assert.Equal(t, tt.wantError, resp.Error) assert.Equal(t, tt.wantError, resp.Error)
assert.Equal(t, tt.failedTypes, resp.FailedTypes)
}) })
} }
} }
+28 -25
View File
@@ -490,45 +490,48 @@ func TestQueryNameserver_Refused(t *testing.T) {
assert.Equal(t, "server returned REFUSED", resp.Error) assert.Equal(t, "server returned REFUSED", resp.Error)
} }
// TestQueryServers_RecursiveResolverRefused passes a public recursive // TestQueryNameserverIP_RecursiveResolverRefused asks Quad9, a public
// resolver to QueryServers as the server of google.com. These resolvers // recursive resolver, about google.com at both of its addresses. Quad9
// refuse a query that does not ask for recursion and answer one that // refuses a query that does not ask for recursion and answers one that
// does. The resolver never asks for recursion, so the query must be // does. The resolver never asks for recursion, so it must be reported
// reported as refused, never answered. Each resolver is run by a // as refusing, never as answering.
// different operator, and they are asked in turn until one replies, so func TestQueryNameserverIP_RecursiveResolverRefused(t *testing.T) {
// one operator not answering does not fail the test.
func TestQueryServers_RecursiveResolverRefused(t *testing.T) {
t.Parallel() t.Parallel()
r := newTestResolver(t) r := newTestResolver(t)
resolvers := []string{
"64.6.64.6", "185.222.222.222", "4.2.2.1", "9.9.9.9",
}
var err error for _, ip := range []string{"9.9.9.9", "149.112.112.112"} {
var resp *resolver.NameserverResponse
livednstest.Retry( livednstest.Retry(
t, t,
"QueryServers(public recursive resolvers, google.com)", "QueryNameserverIP("+ip+", google.com)",
func(ctx context.Context) error { func(ctx context.Context) error {
for _, ip := range resolvers { var err error
_, err = r.QueryServers(
ctx, []string{ip}, "google.com.", "google.com.", resp, err = r.QueryNameserverIP(
dns.TypeA, ctx, ip, ip, "google.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, ip, resp.Error,
)
}
// A refusal or an answer is a reply; anything else may
// be no reply at all, so the next resolver is asked.
if err == nil || errors.Is(err, resolver.ErrRefused) {
return nil return nil
}
}
return fmt.Errorf("%w: %w", livednstest.ErrNoAnswer, err)
}, },
) )
require.ErrorIs(t, err, resolver.ErrRefused) assert.Equal(t, resolver.StatusError, resp.Status, ip)
assert.Equal(t, "server returned REFUSED", resp.Error, ip)
}
} }
// googleNameserverIPv4s returns the IPv4 addresses of google.com's // googleNameserverIPv4s returns the IPv4 addresses of google.com's
-44
View File
@@ -551,50 +551,6 @@ func TestDomainThatDoesNotExist(t *testing.T) {
} }
} }
// TestDomainWithNoDelegationOfItsOwn checks a domain with no delegation
// of its own: codeberg.page is on the public suffix list, so
// docs.codeberg.page is a domain, but the .page servers delegate only
// codeberg.page, whose servers answer for it. It is saved with no
// nameservers and without nxdomain, and its records, asked at the
// codeberg.page servers, are saved. Those are testSmallDomain's two
// nameservers; github.io, the zone of the README's example, has eight.
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
t.Parallel()
const domain = "docs.codeberg.page"
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
var deps *testDeps
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg)
err := checkOnce(ctx, w, deps)
// A domain saved as not existing has no records to wait for;
// the checks below fail on it.
if ds, ok := deps.state.GetDomainState(domain); ok && ds.NXDomain {
return nil
}
return err
})
ds, _ := deps.state.GetDomainState(domain)
if ds.NXDomain || len(ds.Nameservers) != 0 {
t.Errorf("saved nxdomain %v and nameservers %v, want false and none",
ds.NXDomain, ds.Nameservers)
}
if _, ok := deps.state.GetHostnameState(domain); !ok {
t.Errorf("no records saved for %s", domain)
}
}
func TestNSAddressChangeDetection(t *testing.T) { func TestNSAddressChangeDetection(t *testing.T) {
t.Parallel() t.Parallel()