3 Revīzijas
Autors SHA1 Ziņojums Datums
clawbot 6822996134 resolver: a nameserver with a failed record type is not nodata (closes #253)
check / check (push) Successful in 1m16s
classifyResponse set nodata when every record type that answered had
no records, even when another type's query got no usable reply. That
type is listed in FailedTypes and its records are unknown, so the
nameserver has not said it has none. It now stays ok, the status
README describes for a nameserver with a failed type, and nodata is
set only when no type failed. The watcher saved nodata as ok already,
so saved state is unchanged; the live test that rejects nodata no
longer fails when one of a nameserver's queries is lost.

Model: opus-5-5
2026-10-02 13:16:42 +02:00
clawbot 67b67b8475 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 12:38:45 +02:00
clawbot 9bd1a71d8f resolver: the refused-query test asks several operators, one query each (closes #251)
check / check (push) Canceled after 0s
The test asked one operator's recursive resolver for eight record types
at each of its two addresses, and passed only when all eight were
refused within one attempt; when that operator stopped answering, next
went red. It is now TestQueryServers_RecursiveResolverRefused: through
the existing QueryServers test export it sends one A query to public
resolvers of four operators in turn, inside livednstest.Retry, moving
on when one gives no reply. Each refuses a query not asking for
recursion and answers one that does, so putting the resend asking for
recursion back still fails the test at once.

Model: opus-5-5
2026-10-02 12:00:48 +02:00
5 mainīti faili ar 121 papildinājumiem un 55 dzēšanām
+4
Parādīt failu
@@ -19,8 +19,12 @@ 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
+5 -2
Parādīt failu
@@ -941,7 +941,9 @@ 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. // the status of those answers. It has no data only when every type
// 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:
@@ -961,7 +963,8 @@ 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"
case !state.hasRecords && !state.gotNXDomain: // An NXDOMAIN reply with no records was taken by the first case.
case !state.hasRecords && len(resp.FailedTypes) == 0:
resp.Status = StatusNoData resp.Status = StatusNoData
} }
} }
@@ -11,60 +11,77 @@ import (
) )
// TestClassifyResponse sets a nameserver's status from the results of // TestClassifyResponse sets a nameserver's status from the results of
// its queries, built here. One that answered some record types, even // its queries and the record types whose query failed, built here. One
// with no records, has not failed when its query for another type got // that answered some record types, even with no records, has not failed
// no usable reply, whatever the reason; one whose every query got none // when its query for another type got no usable reply, whatever the
// has. // reason, and is ok, not nodata: that type may have records. One whose
// 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},
StatusNoData, "", []string{"A"},
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",
}, },
StatusNoData, "", []string{"A"},
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},
StatusNoData, "", []string{"A"},
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},
StatusNoData, "", []string{"A"},
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},
StatusNoData, "", []string{"TXT"},
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},
StatusNoData, "", []string{"A"},
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",
}, },
} }
@@ -73,11 +90,12 @@ 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} resp := &NameserverResponse{Status: StatusOK, FailedTypes: tt.failedTypes}
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)
}) })
} }
} }
+25 -28
Parādīt failu
@@ -490,48 +490,45 @@ func TestQueryNameserver_Refused(t *testing.T) {
assert.Equal(t, "server returned REFUSED", resp.Error) assert.Equal(t, "server returned REFUSED", resp.Error)
} }
// TestQueryNameserverIP_RecursiveResolverRefused asks Quad9, a public // TestQueryServers_RecursiveResolverRefused passes a public recursive
// recursive resolver, about google.com at both of its addresses. Quad9 // resolver to QueryServers as the server of google.com. These resolvers
// refuses a query that does not ask for recursion and answers one that // refuse a query that does not ask for recursion and answer one that
// does. The resolver never asks for recursion, so it must be reported // does. The resolver never asks for recursion, so the query must be
// as refusing, never as answering. // reported as refused, never answered. Each resolver is run by a
func TestQueryNameserverIP_RecursiveResolverRefused(t *testing.T) { // different operator, and they are asked in turn until one replies, so
// 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",
}
for _, ip := range []string{"9.9.9.9", "149.112.112.112"} { var err error
var resp *resolver.NameserverResponse
livednstest.Retry( livednstest.Retry(
t, t,
"QueryNameserverIP("+ip+", google.com)", "QueryServers(public recursive resolvers, google.com)",
func(ctx context.Context) error { func(ctx context.Context) error {
var err error for _, ip := range resolvers {
_, err = r.QueryServers(
resp, err = r.QueryNameserverIP( ctx, []string{ip}, "google.com.", "google.com.",
ctx, ip, ip, "google.com", dns.TypeA,
) )
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)
}, },
) )
assert.Equal(t, resolver.StatusError, resp.Status, ip) require.ErrorIs(t, err, resolver.ErrRefused)
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
Parādīt failu
@@ -551,6 +551,50 @@ 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()