From b23cf43abf21eae30a2696fb021e7fd1353fa7cd Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 16 Oct 2026 12:26:52 +0200 Subject: [PATCH] resolver: a reply marked recursion available from a root server's address stops the lookup (closes #300) dnswatcher never asks for recursion and a root server never offers it, so a reply marked recursion available from a root server's address came from a resolver on the network answering in its place. The first such reply, a refusal included, now stops the lookup at once with "this network intercepts DNS queries", naming the address, whether or not that resolver had the name cached. queryDNS returns a refused reply along with ErrRefused so the mark can be seen; a refusal without the mark is handled as before. On such a network dnswatcher and its tests fail with that error, by design, as the owner ruled on the issue. Model: opus-5-5 --- README.md | 12 ++++++ TODO.md | 4 ++ internal/resolver/errors.go | 10 +++-- internal/resolver/iterative.go | 28 +++++++++--- internal/resolver/resolver_test.go | 68 ++++++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 49e220a..7fd92fa 100644 --- a/README.md +++ b/README.md @@ -561,6 +561,18 @@ This approach ensures: - Ability to detect split-horizon or inconsistent responses across authoritative servers. +A root server never offers recursion, so a reply marked "recursion available" +from a root server's address came from a recursive resolver on the network +answering in its place, as on a network where a router rule, a VPN or a DNS +filter sends all DNS to one resolver. The first such reply from a root server's +address, a refusal included, stops the lookup at once with the error "this +network intercepts DNS queries", naming that address, and no other root server +is asked. A lookup that every root server refuses without that mark fails with +the same error. Every lookup starts at the root servers, so on such a network +every lookup fails with that error, whether or not that resolver has the name +cached, and dnswatcher's tests fail there too. That is by design: nothing asks +again another way or otherwise works around the interception. + A watched name's records are stored as its nameservers return them, CNAME included. When they return a CNAME and no address, the chain of every CNAME target they gave is followed (with a depth limit to prevent loops) to the A and diff --git a/TODO.md b/TODO.md index 4a5fdf8..f2674ea 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,10 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 # Completed Steps +- 2026-10-16: the first reply marked "recursion available" from a root server's + address, a refusal included, stops the lookup with "this network intercepts + DNS queries", so on a network that sends DNS to a resolver dnswatcher and its + tests fail, by design (closes #300). - 2026-10-15: a query its caller's context ends before it is sent records no reason about the nameserver, and a try the context's deadline cuts short is a timeout, so a nameserver whose only failures were timeouts is `timeout`, not a diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go index a9b109d..5cf5b46 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -53,9 +53,13 @@ var ( "reply truncated and its retry over TCP failed", ) - // 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 is returned when a root server's address gave a + // reply marked recursion available, a refusal included, which ends + // the lookup at once, or when every root server refused a query. + // Root servers do neither, so those replies came from something on + // the network answering in their place. On such a network + // dnswatcher fails with it, by design: nothing works around the + // interception. ErrIntercepted = errors.New("this network intercepts DNS queries") // ErrCNAMEDepthExceeded is returned when a CNAME chain diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go index 9156373..cfca76f 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -115,7 +115,8 @@ func (r *Resolver) retryTCP( } // queryDNS sends a DNS query to a specific server IP, never asking it -// for recursion. A reply of REFUSED is returned as ErrRefused. +// for recursion. A reply of REFUSED is returned together with +// ErrRefused, so that the caller can still read the reply's flags. func (r *Resolver) queryDNS( ctx context.Context, serverIP string, @@ -139,7 +140,7 @@ func (r *Resolver) queryDNS( } if resp.Rcode == dns.RcodeRefused { - return nil, fmt.Errorf( + return resp, fmt.Errorf( "query %s @%s: %w", name, serverIP, ErrRefused, ) } @@ -284,10 +285,15 @@ func shuffled( } // 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 -// 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. +// order until one gives a usable reply. When they are the root servers, +// the first reply marked recursion available, a refusal included, ends +// the lookup with ErrIntercepted, naming the address it came from, and +// no other root server is asked: root servers offer no recursion, so +// that reply came from something on the network answering in their +// place. Otherwise a server that times out, refuses 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( ctx context.Context, servers []string, @@ -304,7 +310,17 @@ func (r *Resolver) queryServers( return nil, ErrContextCanceled } + // queryDNS returns a refusal together with its reply, so a + // refusal's mark is checked here too. resp, err := r.queryDNS(ctx, ip, name, qtype) + if resp != nil && zone == "." && resp.RecursionAvailable { + return nil, fmt.Errorf( + "query %s @%s: reply marked recursion available, "+ + "so not from a root server: %w", + name, ip, ErrIntercepted, + ) + } + if err == nil && !usableReply(resp, zone, name) { err = fmt.Errorf( "query %s @%s: %w", name, ip, ErrUnusableReply, diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 9d105b7..f67fb06 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -850,6 +850,74 @@ func TestQueryServers_NotEveryRootServerRefused(t *testing.T) { require.NotContains(t, err.Error(), "refused a query for") } +// TestQueryServers_RecursiveResolversAsRootServers passes public +// recursive resolvers to QueryServers as the servers of the root zone, +// as a network that sends every DNS query to a resolver would. Their +// replies are marked recursion available, which no root server's is: +// SERVFAIL, REFUSED, or an answer from a resolver's cache, as 1.1.1.1 +// gives for com., which would be used if a root server gave it. The +// first such reply ends the lookup with ErrIntercepted, naming the +// resolver that gave it. In the first group the replies differ; every +// resolver in the second refuses a query that does not ask for +// recursion, as 9.9.9.9 does, so there a refusal ends the lookup +// instead of being passed over. Each group has resolvers of four +// operators, and a resolver that gives no reply is passed over for the +// next, so one of them answering is enough. +func TestQueryServers_RecursiveResolversAsRootServers(t *testing.T) { + t.Parallel() + + r := newTestResolver(t) + groups := [][]string{ + {"1.1.1.1", "8.8.8.8", "9.9.9.9", "208.67.222.222"}, + {"64.6.64.6", "185.222.222.222", "4.2.2.1", "94.140.14.14"}, + } + + for _, resolvers := range groups { + for _, name := range []string{"com.", "desec.io."} { + var err error + + livednstest.Retry( + t, + "QueryServers("+strings.Join(resolvers, ", ")+ + " as root servers, "+name+")", + func(ctx context.Context) error { + _, err = r.QueryServers( + ctx, resolvers, ".", name, dns.TypeNS, + ) + + // When no resolver gave a reply marked recursion + // available, none may have replied at all, so the + // attempt is tried again. + if err != nil && !strings.Contains( + err.Error(), "marked recursion available", + ) { + return fmt.Errorf( + "%w: %w", livednstest.ErrNoAnswer, err, + ) + } + + return nil + }, + ) + + require.ErrorIs(t, err, resolver.ErrIntercepted) + + // The error names the resolver whose reply ended the + // lookup. + messages := make([]string, 0, len(resolvers)) + for _, ip := range resolvers { + messages = append(messages, + "query "+name+" @"+ip+": reply marked recursion "+ + "available, so not from a root server: "+ + "this network intercepts DNS queries", + ) + } + + require.Contains(t, messages, err.Error()) + } + } +} + func TestQueryNameserver_RecordsSorted(t *testing.T) { t.Parallel()