resolver: never resend a refused query asking for recursion (closes #206) #212
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-02: a query a server refuses is not resent asking for recursion, and
|
||||||
|
every root server refusing is reported as DNS interception (closes #206).
|
||||||
- 2026-10-02: a push to a branch cancels that branch's older CI run, and the
|
- 2026-10-02: a push to a branch cancels that branch's older CI run, and the
|
||||||
checkout leaves no token in `.git/config` (closes #216).
|
checkout leaves no token in `.git/config` (closes #216).
|
||||||
- 2026-10-02: watcher tests send far fewer queries and a live attempt may take
|
- 2026-10-02: watcher tests send far fewer queries and a live attempt may take
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ var (
|
|||||||
"reply is an error or a referral that leads no closer",
|
"reply is an error or a referral that leads no closer",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrIntercepted is returned when every root server refused a
|
||||||
|
// query. Root servers refuse no query, so the refusals came from
|
||||||
|
// something on the network answering in their place.
|
||||||
|
ErrIntercepted = errors.New("this network intercepts DNS queries")
|
||||||
|
|
||||||
// ErrCNAMEDepthExceeded is returned when a CNAME chain
|
// ErrCNAMEDepthExceeded is returned when a CNAME chain
|
||||||
// exceeds MaxCNAMEDepth.
|
// exceeds MaxCNAMEDepth.
|
||||||
ErrCNAMEDepthExceeded = errors.New(
|
ErrCNAMEDepthExceeded = errors.New(
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ func CollectIPs(
|
|||||||
return collectIPs(results)
|
return collectIPs(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryServers exports queryServers for testing.
|
||||||
|
func (r *Resolver) QueryServers(
|
||||||
|
ctx context.Context,
|
||||||
|
servers []string,
|
||||||
|
zone string,
|
||||||
|
name string,
|
||||||
|
qtype uint16,
|
||||||
|
) (*dns.Msg, error) {
|
||||||
|
return r.queryServers(ctx, servers, zone, name, qtype)
|
||||||
|
}
|
||||||
|
|
||||||
// QueryEachNS exports queryEachNS for testing.
|
// QueryEachNS exports queryEachNS for testing.
|
||||||
func (r *Resolver) QueryEachNS(
|
func (r *Resolver) QueryEachNS(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -107,9 +107,8 @@ func (r *Resolver) retryTCP(
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryDNS sends a DNS query to a specific server IP.
|
// queryDNS sends a DNS query to a specific server IP, never asking it
|
||||||
// Tries non-recursive first, falls back to recursive on
|
// for recursion. A reply of REFUSED is returned as ErrRefused.
|
||||||
// REFUSED (handles DNS interception environments).
|
|
||||||
func (r *Resolver) queryDNS(
|
func (r *Resolver) queryDNS(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
serverIP string,
|
serverIP string,
|
||||||
@@ -133,25 +132,12 @@ func (r *Resolver) queryDNS(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resp.Rcode == dns.RcodeRefused {
|
if resp.Rcode == dns.RcodeRefused {
|
||||||
msg.RecursionDesired = true
|
return nil, fmt.Errorf(
|
||||||
|
"query %s @%s: %w", name, serverIP, ErrRefused,
|
||||||
resp, err = r.tryExchange(ctx, msg, addr)
|
)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"query %s @%s: %w", name, serverIP, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Rcode == dns.RcodeRefused {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"query %s @%s: %w", name, serverIP, ErrRefused,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = r.retryTCP(ctx, msg, addr, resp)
|
return r.retryTCP(ctx, msg, addr, resp), nil
|
||||||
|
|
||||||
return resp, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractNSSet(rrs []dns.RR) []string {
|
func extractNSSet(rrs []dns.RR) []string {
|
||||||
@@ -279,7 +265,9 @@ func shuffled(
|
|||||||
|
|
||||||
// queryServers asks servers, the servers of zone, about name in a random
|
// queryServers asks servers, the servers of zone, about name in a random
|
||||||
// order until one gives a usable reply. A server that times out, refuses
|
// order until one gives a usable reply. A server that times out, refuses
|
||||||
// or gives a reply that is not usable is passed over for the next.
|
// or gives a reply that is not usable is passed over for the next. When
|
||||||
|
// every server refused, the error says so, and when they are the root
|
||||||
|
// servers it is ErrIntercepted.
|
||||||
func (r *Resolver) queryServers(
|
func (r *Resolver) queryServers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
servers []string,
|
servers []string,
|
||||||
@@ -289,6 +277,8 @@ func (r *Resolver) queryServers(
|
|||||||
) (*dns.Msg, error) {
|
) (*dns.Msg, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
|
refused := 0
|
||||||
|
|
||||||
for _, ip := range shuffled(servers, rand.Shuffle) {
|
for _, ip := range shuffled(servers, rand.Shuffle) {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
return nil, ErrContextCanceled
|
return nil, ErrContextCanceled
|
||||||
@@ -305,9 +295,27 @@ func (r *Resolver) queryServers(
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, ErrRefused) {
|
||||||
|
refused++
|
||||||
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if refused == len(servers) && zone == "." {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"every root server refused a query for %s: %w",
|
||||||
|
name, ErrIntercepted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if refused == len(servers) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"every server of %s refused a query for %s: %w",
|
||||||
|
zone, name, ErrRefused,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("all servers failed: %w", lastErr)
|
return nil, fmt.Errorf("all servers failed: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +536,11 @@ func (r *Resolver) FindAuthoritativeNameservers(
|
|||||||
|
|
||||||
return nsNames, nil
|
return nsNames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The root servers would refuse every parent name too.
|
||||||
|
if errors.Is(err, ErrIntercepted) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ErrNoNameservers
|
return nil, ErrNoNameservers
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -295,6 +296,187 @@ 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
|
||||||
|
// recursive resolver, about google.com at both of its addresses. Quad9
|
||||||
|
// refuses a query that does not ask for recursion and answers one that
|
||||||
|
// does. The resolver never asks for recursion, so it must be reported
|
||||||
|
// as refusing, never as answering.
|
||||||
|
func TestQueryNameserverIP_RecursiveResolverRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
for _, ip := range []string{"9.9.9.9", "149.112.112.112"} {
|
||||||
|
var resp *resolver.NameserverResponse
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"QueryNameserverIP("+ip+", google.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
resp, err = r.QueryNameserverIP(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
// nameservers, the only addresses the resolver asks servers at.
|
||||||
|
func googleNameserverIPv4s(t *testing.T, r *resolver.Resolver) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
names := liveFindAuthoritative(t, r, "google.com")
|
||||||
|
|
||||||
|
return liveResolveNSIPs(t, r, names, len(names))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryServers_EveryServerRefused asks all of google.com's
|
||||||
|
// nameservers about cloudflare.com, a zone they do not serve, which
|
||||||
|
// they all refuse. The error says every server refused; it is not
|
||||||
|
// ErrIntercepted, which only the root servers refusing shows.
|
||||||
|
func TestQueryServers_EveryServerRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
servers := googleNameserverIPv4s(t, r)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"QueryServers(google.com servers, cloudflare.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
_, err = r.QueryServers(
|
||||||
|
ctx, servers, "google.com.", "cloudflare.com.",
|
||||||
|
dns.TypeNS,
|
||||||
|
)
|
||||||
|
|
||||||
|
// When not every server refused, one may have given no
|
||||||
|
// reply at all, so the attempt is tried again.
|
||||||
|
if err != nil &&
|
||||||
|
!strings.HasPrefix(err.Error(), "every server of") {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %w", livednstest.ErrNoAnswer, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, resolver.ErrRefused)
|
||||||
|
require.NotErrorIs(t, err, resolver.ErrIntercepted)
|
||||||
|
require.EqualError(
|
||||||
|
t, err,
|
||||||
|
"every server of google.com. refused a query for "+
|
||||||
|
"cloudflare.com.: dns query refused",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryServers_EveryRootServerRefused passes google.com's
|
||||||
|
// nameservers to QueryServers as the servers of the root zone. They
|
||||||
|
// refuse a query about cloudflare.com, as root servers would if
|
||||||
|
// something on the network answered in their place, so the error is
|
||||||
|
// ErrIntercepted.
|
||||||
|
func TestQueryServers_EveryRootServerRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
servers := googleNameserverIPv4s(t, r)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"QueryServers(google.com servers as root servers, cloudflare.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
_, err = r.QueryServers(
|
||||||
|
ctx, servers, ".", "cloudflare.com.", dns.TypeNS,
|
||||||
|
)
|
||||||
|
|
||||||
|
// When not every server refused, one may have given no
|
||||||
|
// reply at all, so the attempt is tried again. Both errors
|
||||||
|
// for every server refusing say "refused a query for".
|
||||||
|
if err != nil &&
|
||||||
|
!strings.Contains(err.Error(), "refused a query for") {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %w", livednstest.ErrNoAnswer, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, resolver.ErrIntercepted)
|
||||||
|
require.EqualError(
|
||||||
|
t, err,
|
||||||
|
"every root server refused a query for cloudflare.com.: "+
|
||||||
|
"this network intercepts DNS queries",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryServers_NotEveryRootServerRefused passes google.com's
|
||||||
|
// nameservers and 192.0.2.1 to QueryServers as the servers of the root
|
||||||
|
// zone. The google.com nameservers refuse a query about cloudflare.com,
|
||||||
|
// but nothing answers at 192.0.2.1, a documentation address, so not
|
||||||
|
// every server refused, wherever 192.0.2.1 falls in the random order:
|
||||||
|
// the error is not ErrIntercepted and does not say every server refused.
|
||||||
|
func TestQueryServers_NotEveryRootServerRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
servers := googleNameserverIPv4s(t, r)
|
||||||
|
servers = append(servers, "192.0.2.1")
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"QueryServers(google.com servers and 192.0.2.1, cloudflare.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
_, err = r.QueryServers(
|
||||||
|
ctx, servers, ".", "cloudflare.com.", dns.TypeNS,
|
||||||
|
)
|
||||||
|
|
||||||
|
// An attempt that ran out of time may not have asked every
|
||||||
|
// server, so it is tried again.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %w", livednstest.ErrNoAnswer, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotErrorIs(t, err, resolver.ErrIntercepted)
|
||||||
|
// Both errors for every server refusing say "refused a query for".
|
||||||
|
require.NotContains(t, err.Error(), "refused a query for")
|
||||||
|
}
|
||||||
|
|
||||||
func TestQueryNameserver_RecordsSorted(t *testing.T) {
|
func TestQueryNameserver_RecordsSorted(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user