resolver: never resend a refused query asking for recursion (closes #206)
check / check (push) Failing after 2m13s
check / check (push) Failing after 2m13s
queryDNS resent a query that a server refused, this time asking for recursion, so on a network that intercepts DNS the answers could come from a recursive resolver without anyone knowing. A refusal is now only a refusal, and the server is passed over for the next. When every server of a zone refuses, the error says so. When every root server refuses, the error is ErrIntercepted: root servers refuse no query, so something on the network is answering in their place. FindAuthoritativeNameservers stops at that error instead of trying each parent name, so the watcher's log line says it. Model: opus-5-5
This commit is contained in:
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-01: 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-01: a certificate within the expiry warning period is warned about on
|
- 2026-10-01: a certificate within the expiry warning period is warned about on
|
||||||
every TLS check, where some checks used to skip it at random (closes #204).
|
every TLS check, where some checks used to skip it at random (closes #204).
|
||||||
- 2026-10-01: a domain's NS set is its delegation from the parent zone's
|
- 2026-10-01: a domain's NS set is its delegation from the parent zone's
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -105,9 +105,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,
|
||||||
@@ -131,25 +130,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 {
|
||||||
@@ -261,7 +247,9 @@ func (r *Resolver) followDelegation(
|
|||||||
|
|
||||||
// queryServers asks servers, the servers of zone, about name until one
|
// queryServers asks servers, the servers of zone, about name until one
|
||||||
// gives a usable reply. A server that times out, refuses or gives a
|
// gives a usable reply. A server that times out, refuses or gives a
|
||||||
// reply that is not usable is passed over for the next.
|
// 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,
|
||||||
@@ -271,6 +259,8 @@ func (r *Resolver) queryServers(
|
|||||||
) (*dns.Msg, error) {
|
) (*dns.Msg, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
|
refused := 0
|
||||||
|
|
||||||
for _, ip := range servers {
|
for _, ip := range servers {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
return nil, ErrContextCanceled
|
return nil, ErrContextCanceled
|
||||||
@@ -287,9 +277,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +518,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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package resolver_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -11,6 +12,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"
|
||||||
|
|
||||||
@@ -273,6 +275,57 @@ func TestQueryNameserver_Refused(t *testing.T) {
|
|||||||
assert.Equal(t, "server returned REFUSED", resp.Error)
|
assert.Equal(t, "server returned REFUSED", resp.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// The resolver asks servers only at their IPv4 addresses.
|
||||||
|
var servers []string
|
||||||
|
|
||||||
|
for _, ns := range liveFindAuthoritative(t, r, "google.com") {
|
||||||
|
for _, ip := range liveResolveIPs(t, r, ns) {
|
||||||
|
if net.ParseIP(ip).To4() != nil {
|
||||||
|
servers = append(servers, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
// A server that did not reply at all did not refuse.
|
||||||
|
if err != nil && !errors.Is(err, resolver.ErrRefused) {
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func TestQueryNameserver_RecordsSorted(t *testing.T) {
|
func TestQueryNameserver_RecordsSorted(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user