diff --git a/README.md b/README.md index 49e220a..10f64cc 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 stops the lookup at once with the error "this network intercepts DNS +queries", and no other root server is asked. A lookup that every root server +refuses 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..3ddaea3 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 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..bb03a22 100644 --- a/internal/resolver/errors.go +++ b/internal/resolver/errors.go @@ -53,9 +53,12 @@ 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, 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..977e1f8 100644 --- a/internal/resolver/iterative.go +++ b/internal/resolver/iterative.go @@ -287,7 +287,11 @@ func shuffled( // 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. +// servers it is ErrIntercepted. When they are the root servers, the +// first reply marked recursion available also 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. func (r *Resolver) queryServers( ctx context.Context, servers []string, @@ -305,6 +309,14 @@ func (r *Resolver) queryServers( } resp, err := r.queryDNS(ctx, ip, name, qtype) + if err == 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..f02be46 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -850,6 +850,63 @@ func TestQueryServers_NotEveryRootServerRefused(t *testing.T) { require.NotContains(t, err.Error(), "refused a query for") } +// TestQueryServers_RecursiveResolversAsRootServers passes public +// recursive resolvers of four operators 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, 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. A resolver that refuses or 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) + resolvers := []string{ + "1.1.1.1", "8.8.8.8", "9.9.9.9", "208.67.222.222", + } + + for _, name := range []string{"com.", "desec.io."} { + var err error + + livednstest.Retry( + t, + "QueryServers(public recursive 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, each refused or gave no reply 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()