Files
tailscale/tsnet/dns_split_test.go
Michael Ben-Ami de7245d25b feature/conn25,net/dns{,/resolver}: thread PeerAPI DoH metadata in DNS
response when DNS request goes to PeerAPI DoH

To support feature/conn25 and other extensions or consumers needing to
know which PeerAPI node handled a DNS request, e.g. for app connectors
or exit nodes, and needing extra data from the server that can be
transmitted in the headers.

DNS responses from other upstream resolvers do not get any metadata
populated, even if those servers are DoH.

Add a new Response type in net/dns/resolver that includes the raw
response bytes and the optional metadata, and replace the bare bytes
with it in some places.

Pass this new type to to net/dns.ResponseMapper instead of the bare
bytes to the extensions can see the metadata.

Updates tailscale/corp#46169

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-10-27 15:25:12 -04: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.Bs); 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.Bs)
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
}