Files
tailscale/tsnet/dns_test.go
James Tucker 96467f7d16 tsnet,net/tsdial: resolve outgoing names using tailnet DNS policy
Resolve Server.Dial and Server.HTTPClient names using the tailnet's DNS
configuration, including MagicDNS, search domains, split DNS routes, and
global resolvers. Preserve the dialer's local MagicDNS fast path.

Add Server.LookupIP for direct lookups returning netip.Addr values. Keep
the resolver implementation private to tsnet. Search expansion uses
ndots=1, includes the MagicDNS suffix when enabled, and respects trailing
dots. Each lookup uses the current DNS configuration.

Use the internal resolver before considering host fallback. Configured
upstream failures and authoritative local denials must not fall back to
the host resolver; the host's hosts file is consulted only during fallback.
Distinguish configured routes from ambient cloud-host fallback resolvers.

Cover search expansion, split and global DNS, MagicDNS-disabled operation,
configuration updates, hosts-file precedence, and dialing in tests.

Fixes #5840
Updates #7984
Updates #15260
Updates #4677

Change-Id: I832bc4c68658d2d25c5dc29b79acf611fc11e59e
Signed-off-by: James Tucker <james@tailscale.com>
2026-10-29 15:29:43 -07:00

511 lines
15 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tsnet
import (
"errors"
"fmt"
"io"
"net"
"net/http/httptest"
"net/netip"
"slices"
"strings"
"sync"
"testing"
"time"
dnsmessage "golang.org/x/net/dns/dnsmessage"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/netns"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/tstest/integration"
"tailscale.com/tstest/integration/testcontrol"
"tailscale.com/types/dnstype"
"tailscale.com/types/logger"
)
// fakeUpstreamDNS serves UDP queries for split DNS and global resolver tests.
// It answers A and AAAA queries from addrs. Other queries return NXDOMAIN.
type fakeUpstreamDNS struct {
t *testing.T
pc net.PacketConn
mu sync.Mutex
addrs map[string][]netip.Addr // lowercase names with trailing dots
}
func startFakeUpstreamDNS(t *testing.T, addrs map[string][]netip.Addr) *fakeUpstreamDNS {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listening for fake upstream DNS: %v", err)
}
f := &fakeUpstreamDNS{t: t, pc: pc, addrs: addrs}
t.Cleanup(func() { pc.Close() })
go f.serve()
return f
}
// setAnswers replaces DNS answers while the server is running.
func (f *fakeUpstreamDNS) setAnswers(addrs map[string][]netip.Addr) {
f.mu.Lock()
defer f.mu.Unlock()
f.addrs = addrs
}
func (f *fakeUpstreamDNS) addr() string {
return f.pc.LocalAddr().String()
}
func (f *fakeUpstreamDNS) serve() {
for {
buf := make([]byte, 1500)
n, src, err := f.pc.ReadFrom(buf)
if err != nil {
return
}
go f.respond(buf[:n], src)
}
}
func (f *fakeUpstreamDNS) respond(query []byte, src net.Addr) {
var p dnsmessage.Parser
hdr, err := p.Start(query)
if err != nil {
return
}
q, err := p.Question()
if err != nil {
return
}
f.mu.Lock()
var ips []netip.Addr
for _, ip := range f.addrs[strings.ToLower(q.Name.String())] {
if (q.Type == dnsmessage.TypeA && ip.Is4()) || (q.Type == dnsmessage.TypeAAAA && ip.Is6()) {
ips = append(ips, ip)
}
}
f.mu.Unlock()
rcode := dnsmessage.RCodeNameError
if len(ips) > 0 {
rcode = dnsmessage.RCodeSuccess
}
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{
ID: hdr.ID,
Response: true,
RecursionDesired: hdr.RecursionDesired,
RCode: rcode,
})
if err := b.StartQuestions(); err != nil {
return
}
if err := b.Question(q); err != nil {
return
}
if err := b.StartAnswers(); err != nil {
return
}
for _, ip := range ips {
rh := dnsmessage.ResourceHeader{
Name: q.Name,
Type: q.Type,
Class: dnsmessage.ClassINET,
TTL: 60,
}
if ip.Is4() {
var a dnsmessage.AResource
a.A = ip.As4()
if err := b.AResource(rh, a); err != nil {
return
}
} else {
var aaaa dnsmessage.AAAAResource
aaaa.AAAA = ip.As16()
if err := b.AAAAResource(rh, aaaa); err != nil {
return
}
}
}
resp, err := b.Finish()
if err != nil {
return
}
f.pc.WriteTo(resp, src)
}
func startDNSResControl(t *testing.T, dnsCfg *tailcfg.DNSConfig) (*testcontrol.Server, string) {
t.Helper()
tstest.ResourceCheck(t)
// Loopback test servers require netns to be disabled.
netns.SetEnabled(false)
t.Cleanup(func() { netns.SetEnabled(true) })
derpMap := integration.RunDERPAndSTUN(t, logger.Discard, "127.0.0.1")
control := &testcontrol.Server{
DERPMap: derpMap,
DNSConfig: dnsCfg,
MagicDNSDomain: "tail-scale.ts.net",
Logf: t.Logf,
}
control.HTTPTestServer = httptest.NewUnstartedServer(control)
control.HTTPTestServer.Start()
t.Cleanup(control.HTTPTestServer.Close)
return control, control.HTTPTestServer.URL
}
func startDNSResServer(t *testing.T, controlURL, hostname string) *Server {
t.Helper()
s := &Server{
Dir: t.TempDir(),
ControlURL: controlURL,
Hostname: hostname,
Store: new(mem.Store),
Ephemeral: true,
}
t.Cleanup(func() { s.Close() })
if _, err := s.Up(t.Context()); err != nil {
t.Fatalf("bringing up server %s: %v", hostname, err)
}
return s
}
func startDNSResTestServer(t *testing.T, dnsCfg *tailcfg.DNSConfig) *Server {
t.Helper()
_, controlURL := startDNSResControl(t, dnsCfg)
return startDNSResServer(t, controlURL, "s1")
}
// TestLookupIPSearchSplitMagicDNS tests client-side search expansion,
// split DNS routing, local MagicDNS answers, and host fallback without
// global resolvers.
func TestLookupIPSearchSplitMagicDNS(t *testing.T) {
upstream := startFakeUpstreamDNS(t, map[string][]netip.Addr{
"db.corp.example.com.": {
netip.MustParseAddr("192.0.2.10"),
netip.MustParseAddr("2001:db8::10"),
},
})
s := startDNSResTestServer(t, &tailcfg.DNSConfig{
Proxied: true,
Domains: []string{"corp.example.com"},
Routes: map[string][]*dnstype.Resolver{
"corp.example.com.": {{Addr: upstream.addr()}},
},
})
selfIP, _ := s.TailscaleIPs()
// Short peer names use the MagicDNS search suffix.
ips, err := s.LookupIP(t.Context(), "ip4", "s1")
if err != nil {
t.Fatalf("LookupIP(s1): %v", err)
}
if len(ips) != 1 || ips[0] != selfIP {
t.Errorf("LookupIP(s1) = %v; want %v", ips, selfIP)
}
// A short name uses the custom search domain's split DNS route.
got, err := s.LookupIP(t.Context(), "ip", "db")
if err != nil {
t.Fatalf("LookupIP(db): %v", err)
}
want := []netip.Addr{netip.MustParseAddr("192.0.2.10"), netip.MustParseAddr("2001:db8::10")}
if !equalAddrs(got, want) {
t.Errorf("LookupIP(db) = %v; want %v", got, want)
}
// The "ip" network returns both address families, IPv4 first.
ips, err = s.LookupIP(t.Context(), "ip", "db")
if err != nil {
t.Fatalf("LookupIP(ip, db): %v", err)
}
if want := []netip.Addr{netip.MustParseAddr("192.0.2.10"), netip.MustParseAddr("2001:db8::10")}; !equalAddrs(ips, want) {
t.Errorf("LookupIP(ip, db) = %v; want %v", ips, want)
}
// Multi-label names are tried as absolute names first.
got, err = s.LookupIP(t.Context(), "ip", "db.corp.example.com")
if err != nil {
t.Fatalf("LookupIP(db.corp.example.com): %v", err)
}
if want := []netip.Addr{netip.MustParseAddr("192.0.2.10"), netip.MustParseAddr("2001:db8::10")}; !equalAddrs(got, want) {
t.Errorf("LookupIP(db.corp.example.com) = %v; want %v", got, want)
}
// An authoritative denial from the split upstream is a not-found error.
_, err = s.LookupIP(t.Context(), "ip4", "gone.corp.example.com")
if !isNotFound(err) {
t.Errorf("LookupIP(gone.corp.example.com) error = %v; want not-found", err)
}
// Names outside tailnet policy fall back to the host resolver.
got, err = s.LookupIP(t.Context(), "ip", "localhost")
if err != nil {
t.Fatalf("LookupIP(localhost): %v", err)
}
if !slices.Contains(got, netip.MustParseAddr("127.0.0.1")) {
t.Errorf("LookupIP(localhost) = %v; want it to contain 127.0.0.1", got)
}
// UserDialPlan resolves names without connecting to the result.
dialer := s.Sys().Dialer.Get()
ipp, _, err := dialer.UserDialPlan(t.Context(), "tcp", "db:5432")
if err != nil {
t.Fatalf("UserDialPlan(db:5432): %v", err)
}
if want := netip.MustParseAddrPort("192.0.2.10:5432"); ipp != want {
t.Errorf("UserDialPlan(db:5432) = %v; want %v", ipp, want)
}
ipp, _, err = dialer.UserDialPlan(t.Context(), "tcp", "s1:80")
if err != nil {
t.Fatalf("UserDialPlan(s1:80): %v", err)
}
if want := netip.AddrPortFrom(selfIP, 80); ipp != want {
t.Errorf("UserDialPlan(s1:80) = %v; want %v", ipp, want)
}
// A trailing dot disables MagicDNS search expansion.
_, err = s.LookupIP(t.Context(), "ip4", "s1.")
if !isNotFound(err) {
t.Errorf("LookupIP(s1.) error = %v; want not-found", err)
}
}
// TestLookupIPMagicDNSDisabled verifies search expansion without a MagicDNS
// suffix and local peer-name resolution through the dialer.
func TestLookupIPMagicDNSDisabled(t *testing.T) {
upstream := startFakeUpstreamDNS(t, nil)
s := startDNSResTestServer(t, &tailcfg.DNSConfig{
Proxied: false, // MagicDNS off
Domains: []string{"corp.example.com"},
Routes: map[string][]*dnstype.Resolver{
"corp.example.com.": {{Addr: upstream.addr()}},
},
})
upstream.setAnswers(map[string][]netip.Addr{
"db.corp.example.com.": {netip.MustParseAddr("192.0.2.10")},
})
selfIP, _ := s.TailscaleIPs()
// Custom search domains expand and route through split DNS.
got, err := s.LookupIP(t.Context(), "ip", "db")
if err != nil {
t.Fatalf("LookupIP(db): %v", err)
}
if want := []netip.Addr{netip.MustParseAddr("192.0.2.10")}; !equalAddrs(got, want) {
t.Errorf("LookupIP(db) = %v; want %v", got, want)
}
// Without a MagicDNS search suffix, LookupIP does not expand peer names.
ips, err := s.LookupIP(t.Context(), "ip4", "s1")
if err == nil {
for _, ip := range ips {
if ip == selfIP {
t.Errorf("LookupIP(s1) = %v; the resolver must not answer the short node name when MagicDNS is off", ips)
}
}
}
// Dials resolve short node names locally even with MagicDNS disabled.
dialer := s.Sys().Dialer.Get()
ipp, _, err := dialer.UserDialPlan(t.Context(), "tcp", "s1:80")
if err != nil {
t.Fatalf("UserDialPlan(s1:80): %v", err)
}
if want := netip.AddrPortFrom(selfIP, 80); ipp != want {
t.Errorf("UserDialPlan(s1:80) = %v; want %v", ipp, want)
}
}
// TestLookupIPDialSplitDNS tests dialing a short name over the tailnet.
// A custom search domain and split DNS upstream resolve it to a peer address.
func TestLookupIPDialSplitDNS(t *testing.T) {
upstream := startFakeUpstreamDNS(t, nil)
_, controlURL := startDNSResControl(t, &tailcfg.DNSConfig{
Proxied: true,
Domains: []string{"corp.example.com"},
Routes: map[string][]*dnstype.Resolver{
"corp.example.com.": {{Addr: upstream.addr()}},
},
})
s1 := startDNSResServer(t, controlURL, "s1")
s2 := startDNSResServer(t, controlURL, "s2")
s2ip, _ := s2.TailscaleIPs()
// The split DNS upstream maps the service name to a tailnet peer.
upstream.setAnswers(map[string][]netip.Addr{
"db.corp.example.com.": {s2ip},
})
ln, err := s2.Listen("tcp", ":8080")
if err != nil {
t.Fatalf("listening on s2: %v", err)
}
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
io.Copy(c, c)
}()
}
}()
conn, err := s1.Dial(t.Context(), "tcp", "db:8080")
if err != nil {
t.Fatalf("Dial(db:8080): %v", err)
}
defer conn.Close()
want := "hello over split dns"
if _, err := conn.Write([]byte(want)); err != nil {
t.Fatalf("writing: %v", err)
}
buf := make([]byte, len(want))
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
if _, err := io.ReadFull(conn, buf); err != nil {
t.Fatalf("reading echo: %v", err)
}
if string(buf) != want {
t.Errorf("echo = %q; want %q", buf, want)
}
}
// TestLookupIPConfigChange tests DNS policy updates with a cached DNS client.
// Each lookup uses the current search domains, split routes, and MagicDNS state.
func TestLookupIPConfigChange(t *testing.T) {
upstream := startFakeUpstreamDNS(t, map[string][]netip.Addr{
"db.corp1.example.com.": {netip.MustParseAddr("192.0.2.10")},
"db.corp2.example.com.": {netip.MustParseAddr("192.0.2.20")},
})
control, controlURL := startDNSResControl(t, &tailcfg.DNSConfig{
Proxied: true,
Domains: []string{"corp1.example.com"},
Routes: map[string][]*dnstype.Resolver{
"corp1.example.com.": {{Addr: upstream.addr()}},
},
})
s := startDNSResServer(t, controlURL, "s1")
got, err := s.LookupIP(t.Context(), "ip", "db")
if err != nil {
t.Fatalf("LookupIP(db): %v", err)
}
if !slices.Contains(got, netip.MustParseAddr("192.0.2.10")) {
t.Fatalf("LookupIP(db) = %v; want 192.0.2.10", got)
}
// Subsequent lookups use the updated search domain and split route.
control.SetDNSConfig(&tailcfg.DNSConfig{
Proxied: true,
Domains: []string{"corp2.example.com"},
Routes: map[string][]*dnstype.Resolver{
"corp2.example.com.": {{Addr: upstream.addr()}},
},
})
if err := tstest.WaitFor(30*time.Second, func() error {
got, err := s.LookupIP(t.Context(), "ip", "db")
if err != nil {
return fmt.Errorf("LookupIP(db): %w", err)
}
if !slices.Contains(got, netip.MustParseAddr("192.0.2.20")) {
return fmt.Errorf("LookupIP(db) = %v; still using the old config", got)
}
return nil
}); err != nil {
t.Fatal(err)
}
}
// TestLookupIPOverrideGlobal tests that "override local DNS" sends names
// without local answers to the global upstream.
func TestLookupIPOverrideGlobal(t *testing.T) {
upstream := startFakeUpstreamDNS(t, map[string][]netip.Addr{
"example.com.": {netip.MustParseAddr("192.0.2.20")},
"localhost.": {netip.MustParseAddr("192.0.2.21")},
})
s := startDNSResTestServer(t, &tailcfg.DNSConfig{
Proxied: true,
Resolvers: []*dnstype.Resolver{{Addr: upstream.addr()}},
})
got, err := s.LookupIP(t.Context(), "ip", "example.com")
if err != nil {
t.Fatalf("LookupIP(example.com): %v", err)
}
if want := []netip.Addr{netip.MustParseAddr("192.0.2.20")}; !equalAddrs(got, want) {
t.Errorf("LookupIP(example.com) = %v; want %v", got, want)
}
// Configured DNS answers override local host entries.
got, err = s.LookupIP(t.Context(), "ip4", "localhost")
if want := []netip.Addr{netip.MustParseAddr("192.0.2.21")}; err != nil || !equalAddrs(got, want) {
t.Fatalf("LookupIP(localhost) = %v, %v; want %v from tailnet DNS", got, err, want)
}
// MagicDNS answers take precedence over global upstreams.
ips, err := s.LookupIP(t.Context(), "ip4", "s1")
if err != nil {
t.Fatalf("LookupIP(s1): %v", err)
}
selfIP, _ := s.TailscaleIPs()
if len(ips) != 1 || ips[0] != selfIP {
t.Errorf("LookupIP(s1) = %v; want %v", ips, selfIP)
}
// Configured DNS denials disable host fallback.
upstream.setAnswers(nil)
_, err = s.LookupIP(t.Context(), "ip4", "localhost")
if !isNotFound(err) {
t.Errorf("LookupIP(localhost) error = %v; want not-found", err)
}
}
// TestLookupIPLiteral tests that IP literals bypass DNS.
func TestLookupIPLiteral(t *testing.T) {
s := startDNSResTestServer(t, &tailcfg.DNSConfig{Proxied: true})
got, err := s.LookupIP(t.Context(), "ip", "192.0.2.1")
if err != nil {
t.Fatalf("LookupIP(192.0.2.1): %v", err)
}
if want := []netip.Addr{netip.MustParseAddr("192.0.2.1")}; !equalAddrs(got, want) {
t.Errorf("LookupIP(192.0.2.1) = %v; want %v", got, want)
}
ips, err := s.LookupIP(t.Context(), "ip6", "2001:db8::1")
if err != nil {
t.Fatalf("LookupIP(ip6, 2001:db8::1): %v", err)
}
if want := []netip.Addr{netip.MustParseAddr("2001:db8::1")}; !equalAddrs(ips, want) {
t.Errorf("LookupIP(ip6, 2001:db8::1) = %v; want %v", ips, want)
}
// Wrong address family is an error.
if _, err := s.LookupIP(t.Context(), "ip4", "2001:db8::1"); !isNotFound(err) {
t.Errorf("LookupIP(ip4, 2001:db8::1) error = %v; want not-found", err)
}
}
func isNotFound(err error) bool {
var dnsErr *net.DNSError
return errors.As(err, &dnsErr) && dnsErr.IsNotFound
}
func equalAddrs(a, b []netip.Addr) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}