Files
tailscale/tsnet/dns_split_test.go
T
Brendan Creane b62885dd02 net/dns/resolver: don't retry an upstream REFUSED or SERVFAIL over TCP (#20829)
A TCP retry to the server that just answered can only return the same
rcode, and when that server accepted the connection and never answered,
the query ran the full 10s dnsQueryTimeout and the client got nothing.
An upstream rcode now ends the UDP/TCP race when it arrives, and the
client gets it once every resolver has had udpRaceTimeout to answer
instead of waiting for all of them to report. tcpQueryTimeout is also
set on the conn, since a context cannot interrupt a blocked read, and a
new dns_query_fwd_udp_read_ctx_done counter separates query teardown
from upstream read errors.

Fixes #20826

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-10-06 16:19:28 -07:00

332 lines
9.2 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tsnet
import (
"context"
"fmt"
"net"
"net/netip"
"sync"
"testing"
"time"
dns "golang.org/x/net/dns/dnsmessage"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/dnstype"
)
// TestSplitDNSToTailnetResolverUDP resolves a split-DNS name over UDP from a
// node in userspace networking mode, where the upstream resolver is another
// tailnet node. Neither node has a tun device, so the upstream is reachable
// only through netstack. See tailscale/tailscale#20314.
func TestSplitDNSToTailnetResolverUDP(t *testing.T) {
lt := setupTwoClientTest(t, false) // netstack on both sides; no tun.
const domain = "e2e.split.example.com."
const suffix = "split.example.com."
const upstreamPort = 53
wantAddr := netip.MustParseAddr("100.101.102.103")
// This listener lives inside netstack on s1's tailnet address, so s2 can
// only reach it over the tailnet.
pc, err := lt.s1.ListenPacket("udp", net.JoinHostPort(lt.s1ip4.String(), fmt.Sprint(upstreamPort)))
if err != nil {
t.Fatal(err)
}
defer pc.Close()
go serveOneAnswerDNS(pc, wantAddr)
// Point split DNS for suffix at s1's tailnet address.
upstream := netip.AddrPortFrom(lt.s1ip4, upstreamPort)
if !lt.control.AddRawMapResponse(lt.s2.lb.NodeKey(), &tailcfg.MapResponse{
DNSConfig: &tailcfg.DNSConfig{
Proxied: true,
Routes: map[string][]*dnstype.Resolver{
suffix: {{Addr: upstream.String()}},
},
},
}) {
t.Fatal("AddRawMapResponse failed")
}
res := lt.s2.Sys().DNSManager.Get().Resolver()
if res == nil {
t.Fatal("s2 has no resolver")
}
query := mustDNSQuery(t, domain)
from := netip.MustParseAddrPort("127.0.0.1:12345")
// Until the split-DNS route lands the resolver answers immediately with
// an empty answer section, so wait it out before timing anything below.
if err := tstest.WaitFor(60*time.Second, func() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := res.Query(ctx, query, "udp", from)
if err != nil {
return err
}
if _, err := firstAAnswer(resp); err != nil {
return err
}
return nil
}); err != nil {
t.Fatalf("waiting for split-DNS route to %v to work: %v", upstream, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
resp, err := res.Query(ctx, query, "udp", from)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Query: %v", err)
}
got, err := firstAAnswer(resp)
if err != nil {
t.Fatalf("parsing response: %v", err)
}
if got != wantAddr {
t.Errorf("answer = %v, want %v", got, wantAddr)
}
t.Logf("split-DNS query to a tailnet resolver over netstack UDP took %v", elapsed)
// An answer is also correct if UDP blackholed and the TCP fallback
// supplied it, just late: that fallback can't fire before udpRaceTimeout
// (2s, net/dns/resolver), so bound the time as well as the bytes.
if elapsed >= 2*time.Second {
t.Errorf("query took %v (>= 2s): answered by the TCP fallback, not UDP", elapsed)
}
}
// TestRefusingUpstreamAnswersPromptly exercises the whole daemon DNS path,
// from control pushing a resolver through dns.Manager to the forwarder. The
// upstream answers REFUSED over UDP and accepts TCP without ever answering.
// The client must get the REFUSED, not a stall that ends in no answer at all.
//
// See tailscale/tailscale#20826.
func TestRefusingUpstreamAnswersPromptly(t *testing.T) {
ctx := t.Context()
controlURL, control := startControl(t)
s1, _, _ := startServer(t, ctx, controlURL, "s1")
// A host-loopback upstream, so this is the ordinary "system upstream" case
// rather than a tailnet address reached over netstack.
upstream := listenRefusingUpstream(t)
if !control.AddRawMapResponse(s1.lb.NodeKey(), &tailcfg.MapResponse{
DNSConfig: &tailcfg.DNSConfig{
Proxied: true,
Resolvers: []*dnstype.Resolver{{Addr: upstream.String()}},
},
}) {
t.Fatal("AddRawMapResponse failed")
}
mgr := s1.Sys().DNSManager.Get()
query := mustDNSQuery(t, "refused.example.com.")
from := netip.MustParseAddrPort("127.0.0.1:12345")
// Wait for the pushed resolver to take effect. Until it lands there is no
// upstream at all, and the forwarder synthesizes its own SERVFAIL.
if err := tstest.WaitFor(60*time.Second, func() error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resp, err := mgr.Query(ctx, query, "udp", from)
if err != nil {
return err
}
if got := rcodeOf(t, resp); got != dns.RCodeRefused {
return fmt.Errorf("rcode = %v, want %v", got, dns.RCodeRefused)
}
return nil
}); err != nil {
t.Fatalf("waiting for the REFUSED upstream to take effect: %v", err)
}
for i := range 3 {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
start := time.Now()
resp, err := mgr.Query(ctx, query, "udp", from)
elapsed := time.Since(start)
cancel()
if err != nil {
t.Fatalf("query %d: %v", i, err)
}
if got := rcodeOf(t, resp); got != dns.RCodeRefused {
t.Errorf("query %d: rcode = %v, want %v", i, got, dns.RCodeRefused)
}
// Logged only. A stall returns an error, which the check above catches.
t.Logf("query %d: %v", i, elapsed)
}
}
// listenRefusingUpstream starts a DNS upstream on host loopback that answers
// REFUSED over UDP and accepts TCP connections without ever answering them. It
// returns the address, and stops both listeners when the test ends.
func listenRefusingUpstream(t *testing.T) netip.AddrPort {
t.Helper()
const tries = 25
var (
tcpLn net.Listener
pc net.PacketConn
)
// Listen on TCP first, then bind UDP to the same port, retrying on a clash.
for range tries {
var err error
tcpLn, err = net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
pc, err = net.ListenPacket("udp", tcpLn.Addr().String())
if err == nil {
break
}
tcpLn.Close()
tcpLn = nil
}
if tcpLn == nil {
t.Skipf("failed to listen on the same port for TCP and UDP after %d tries", tries)
}
// Hold accepted conns open until the test ends, so the forwarder's TCP
// retry, if it makes one, connects and then waits rather than failing fast.
var (
heldMu sync.Mutex
held []net.Conn
)
t.Cleanup(func() {
tcpLn.Close()
pc.Close()
heldMu.Lock()
defer heldMu.Unlock()
for _, c := range held {
c.Close()
}
})
go serveRefusedDNS(pc)
go func() {
for {
c, err := tcpLn.Accept()
if err != nil {
return
}
heldMu.Lock()
held = append(held, c)
heldMu.Unlock()
}
}()
return netip.MustParseAddrPort(pc.LocalAddr().String())
}
// serveDNS answers every query on pc with whatever respond builds from the
// query's header and question, until pc is closed. Queries that don't parse
// and responses that don't build are dropped.
func serveDNS(pc net.PacketConn, respond func(hdr dns.Header, q dns.Question) ([]byte, error)) {
buf := make([]byte, 1500)
for {
n, from, err := pc.ReadFrom(buf)
if err != nil {
return // listener closed
}
var p dns.Parser
hdr, err := p.Start(buf[:n])
if err != nil {
continue
}
q, err := p.Question()
if err != nil {
continue
}
resp, err := respond(hdr, q)
if err != nil {
continue
}
pc.WriteTo(resp, from)
}
}
// serveRefusedDNS answers every query on pc with REFUSED, until pc is closed.
func serveRefusedDNS(pc net.PacketConn) {
serveDNS(pc, func(hdr dns.Header, q dns.Question) ([]byte, error) {
hdr.Response = true
hdr.RCode = dns.RCodeRefused
b := dns.NewBuilder(nil, hdr)
b.StartQuestions()
b.Question(q)
return b.Finish()
})
}
// rcodeOf returns the response code in resp.
func rcodeOf(tb testing.TB, resp []byte) dns.RCode {
tb.Helper()
var p dns.Parser
h, err := p.Start(resp)
if err != nil {
tb.Fatalf("parsing response: %v", err)
}
return h.RCode
}
// serveOneAnswerDNS answers every A query on pc with addr, until pc is closed.
func serveOneAnswerDNS(pc net.PacketConn, addr netip.Addr) {
serveDNS(pc, func(hdr dns.Header, q dns.Question) ([]byte, error) {
b := dns.NewBuilder(nil, dns.Header{ID: hdr.ID, Response: true})
b.StartQuestions()
b.Question(q)
b.StartAnswers()
b.AResource(dns.ResourceHeader{
Name: q.Name,
Class: dns.ClassINET,
TTL: 300,
}, dns.AResource{A: addr.As4()})
return b.Finish()
})
}
// mustDNSQuery builds an A query for domain.
func mustDNSQuery(tb testing.TB, domain string) []byte {
tb.Helper()
b := dns.NewBuilder(nil, dns.Header{RecursionDesired: true})
b.StartQuestions()
if err := b.Question(dns.Question{
Name: dns.MustNewName(domain),
Type: dns.TypeA,
Class: dns.ClassINET,
}); err != nil {
tb.Fatal(err)
}
query, err := b.Finish()
if err != nil {
tb.Fatal(err)
}
return query
}
// firstAAnswer returns the address in resp's first answer record, or an error
// if resp doesn't parse or has no A answer.
func firstAAnswer(resp []byte) (netip.Addr, error) {
var p dns.Parser
if _, err := p.Start(resp); err != nil {
return netip.Addr{}, err
}
if err := p.SkipAllQuestions(); err != nil {
return netip.Addr{}, err
}
h, err := p.AnswerHeader()
if err != nil {
return netip.Addr{}, fmt.Errorf("no answer section: %w", err)
}
if h.Type != dns.TypeA {
return netip.Addr{}, fmt.Errorf("answer type = %v, want A", h.Type)
}
r, err := p.AResource()
if err != nil {
return netip.Addr{}, err
}
return netip.AddrFrom4(r.A), nil
}