tsnet/dns_lookup_test.go
Rama main · réplica de lectura del repositorio original.
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tsnet
import (
"context"
"errors"
"net"
"net/netip"
"slices"
"testing"
"time"
dnsmessage "golang.org/x/net/dns/dnsmessage"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/util/dnsname"
)
func TestSearchCandidates(t *testing.T) {
search := []dnsname.FQDN{"tail-scale.ts.net.", "corp.example.com."}
tests := []struct {
name string
host string
want []string
fails bool
}{
{
name: "single_label",
host: "foo",
want: []string{
"foo.tail-scale.ts.net.",
"foo.corp.example.com.",
"foo.",
},
},
{
name: "single_label_absolute",
host: "foo.",
want: []string{"foo."},
},
{
name: "multi_label",
host: "db.corp",
want: []string{
"db.corp.",
"db.corp.tail-scale.ts.net.",
"db.corp.corp.example.com.",
},
},
{
name: "fully_qualified",
host: "db.corp.example.com",
want: []string{
"db.corp.example.com.",
"db.corp.example.com.tail-scale.ts.net.",
"db.corp.example.com.corp.example.com.",
},
},
{
name: "no_search_domains",
host: "foo",
want: []string{"foo."},
},
{
name: "empty",
host: "",
fails: true,
},
{
name: "invalid",
host: "bad name",
fails: true,
},
}
for _, tt := range tests {
var sd []dnsname.FQDN
if tt.name != "no_search_domains" {
sd = search
}
t.Run(tt.name, func(t *testing.T) {
got, err := searchCandidates(tt.host, sd)
if tt.fails {
if err == nil {
t.Fatalf("searchCandidates(%q) = %v; want error", tt.host, got)
}
return
}
if err != nil {
t.Fatalf("searchCandidates(%q): %v", tt.host, err)
}
var gotStrs []string
for _, fqdn := range got {
gotStrs = append(gotStrs, string(fqdn))
}
if !slices.Equal(gotStrs, tt.want) {
t.Errorf("searchCandidates(%q) = %v; want %v", tt.host, gotStrs, tt.want)
}
})
}
}
func TestSearchDomainsFromNetmap(t *testing.T) {
node := func(name string) tailcfg.NodeView {
return (&tailcfg.Node{Name: name}).View()
}
tests := []struct {
name string
nm *netmap.NetworkMap
want []string
}{
{
name: "nil",
nm: nil,
want: nil,
},
{
name: "magicdns_on_includes_suffix",
nm: &netmap.NetworkMap{
SelfNode: node("host.tail-scale.ts.net."),
DNS: tailcfg.DNSConfig{
Proxied: true,
Domains: []string{"tail-scale.ts.net", "corp.example.com"},
},
},
want: []string{"tail-scale.ts.net.", "corp.example.com."},
},
{
name: "magicdns_on_omits_suffix",
nm: &netmap.NetworkMap{
SelfNode: node("host.tail-scale.ts.net."),
DNS: tailcfg.DNSConfig{
Proxied: true,
Domains: []string{"corp.example.com"},
},
},
want: []string{"corp.example.com.", "tail-scale.ts.net."},
},
{
name: "magicdns_off_suffix_not_added",
nm: &netmap.NetworkMap{
SelfNode: node("host.tail-scale.ts.net."),
DNS: tailcfg.DNSConfig{
Proxied: false,
Domains: []string{"corp.example.com"},
},
},
want: []string{"corp.example.com."},
},
{
name: "dedupes_and_lowercases",
nm: &netmap.NetworkMap{
SelfNode: node("host.tail-scale.ts.net."),
DNS: tailcfg.DNSConfig{
Proxied: true,
Domains: []string{"Corp.Example.COM", "corp.example.com", "."},
},
},
want: []string{"corp.example.com.", "tail-scale.ts.net."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := searchDomainsFromNetmap(tt.nm)
var gotStrs []string
for _, fqdn := range got {
gotStrs = append(gotStrs, string(fqdn))
}
if !slices.Equal(gotStrs, tt.want) {
t.Errorf("SearchDomainsFromNetmap = %v; want %v", gotStrs, tt.want)
}
})
}
}
func TestParseResponse(t *testing.T) {
name, err := dnsmessage.NewName("foo.example.com.")
if err != nil {
t.Fatal(err)
}
question := dnsmessage.Question{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}
build := func(mutate func(*dnsmessage.Header), answers ...dnsmessage.Resource) []byte {
t.Helper()
hdr := dnsmessage.Header{ID: 42, Response: true, RecursionDesired: true}
mutate(&hdr)
b := dnsmessage.NewBuilder(nil, hdr)
if err := b.StartQuestions(); err != nil {
t.Fatal(err)
}
if err := b.Question(question); err != nil {
t.Fatal(err)
}
if err := b.StartAnswers(); err != nil {
t.Fatal(err)
}
for _, rr := range answers {
switch body := rr.Body.(type) {
case *dnsmessage.AResource:
if err := b.AResource(rr.Header, *body); err != nil {
t.Fatal(err)
}
case *dnsmessage.AAAAResource:
if err := b.AAAAResource(rr.Header, *body); err != nil {
t.Fatal(err)
}
case *dnsmessage.PTRResource:
if err := b.PTRResource(rr.Header, *body); err != nil {
t.Fatal(err)
}
default:
t.Fatalf("unhandled resource body %T", rr.Body)
}
}
out, err := b.Finish()
if err != nil {
t.Fatal(err)
}
return out
}
aHeader := dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 5}
var fqdn dnsname.FQDN = "foo.example.com."
parse := func(msg []byte) queryResult {
return parseResponse(msg, fqdn, dnsmessage.TypeA)
}
t.Run("answers", func(t *testing.T) {
got := parse(build(func(*dnsmessage.Header) {}, dnsmessage.Resource{
Header: aHeader,
Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}},
}))
if got.err != nil || got.notFound || got.servfail {
t.Fatalf("parseResponse = %+v; want success", got)
}
want := []netip.Addr{netip.MustParseAddr("192.0.2.1")}
if !slices.Equal(got.ips, want) {
t.Errorf("ips = %v; want %v", got.ips, want)
}
})
t.Run("no_data", func(t *testing.T) {
if got := parse(build(func(*dnsmessage.Header) {})); !got.notFound {
t.Errorf("parseResponse = %+v; want notFound", got)
}
})
t.Run("nxdomain", func(t *testing.T) {
got := parse(build(func(h *dnsmessage.Header) { h.RCode = dnsmessage.RCodeNameError }))
if !got.notFound {
t.Errorf("parseResponse = %+v; want notFound", got)
}
})
t.Run("servfail", func(t *testing.T) {
got := parse(build(func(h *dnsmessage.Header) { h.RCode = dnsmessage.RCodeServerFailure }))
if !got.servfail {
t.Errorf("parseResponse = %+v; want servfail", got)
}
})
t.Run("refused", func(t *testing.T) {
got := parse(build(func(h *dnsmessage.Header) { h.RCode = dnsmessage.RCodeRefused }))
if !got.servfail {
t.Errorf("parseResponse = %+v; want servfail", got)
}
})
t.Run("truncated", func(t *testing.T) {
got := parse(build(func(h *dnsmessage.Header) { h.Truncated = true },
dnsmessage.Resource{Header: aHeader, Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}}}))
if got.err == nil {
t.Errorf("parseResponse = %+v; want error", got)
}
})
t.Run("garbage", func(t *testing.T) {
if got := parse([]byte("not a dns response")); got.err == nil {
t.Errorf("parseResponse = %+v; want error", got)
}
})
t.Run("ptr_ignored", func(t *testing.T) {
ptrName, err := dnsmessage.NewName("foo.example.com.")
if err != nil {
t.Fatal(err)
}
question := dnsmessage.Question{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}
hdr := dnsmessage.Header{ID: 42, Response: true}
b := dnsmessage.NewBuilder(nil, hdr)
if err := b.StartQuestions(); err != nil {
t.Fatal(err)
}
if err := b.Question(question); err != nil {
t.Fatal(err)
}
if err := b.StartAnswers(); err != nil {
t.Fatal(err)
}
ptrHeader := dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.TypePTR, Class: dnsmessage.ClassINET, TTL: 5}
if err := b.PTRResource(ptrHeader, dnsmessage.PTRResource{PTR: ptrName}); err != nil {
t.Fatal(err)
}
msg, err := b.Finish()
if err != nil {
t.Fatal(err)
}
got := parseResponse(msg, fqdn, dnsmessage.TypeA)
if got.err != nil || !got.notFound || len(got.ips) != 0 {
t.Errorf("parseResponse = %+v; want PTR record ignored", got)
}
})
t.Run("wrong_question_name", func(t *testing.T) {
other, err := dnsmessage.NewName("other.example.com.")
if err != nil {
t.Fatal(err)
}
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: 42, Response: true})
if err := b.StartQuestions(); err != nil {
t.Fatal(err)
}
if err := b.Question(dnsmessage.Question{Name: other, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}); err != nil {
t.Fatal(err)
}
if err := b.StartAnswers(); err != nil {
t.Fatal(err)
}
if err := b.AResource(aHeader, dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}}); err != nil {
t.Fatal(err)
}
msg, err := b.Finish()
if err != nil {
t.Fatal(err)
}
if got := parse(msg); got.err == nil {
t.Errorf("parse = %+v; want question-mismatch error", got)
}
})
t.Run("wrong_record_type_ignored", func(t *testing.T) {
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: 42, Response: true})
if err := b.StartQuestions(); err != nil {
t.Fatal(err)
}
if err := b.Question(dnsmessage.Question{Name: name, Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET}); err != nil {
t.Fatal(err)
}
if err := b.StartAnswers(); err != nil {
t.Fatal(err)
}
if err := b.AResource(aHeader, dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}}); err != nil {
t.Fatal(err)
}
msg, err := b.Finish()
if err != nil {
t.Fatal(err)
}
got := parseResponse(msg, fqdn, dnsmessage.TypeAAAA)
if got.err != nil || len(got.ips) != 0 || !got.notFound {
t.Errorf("parse(AAAA) = %+v; want notFound with no ips", got)
}
})
}
// TestDNSResolverFakeQuery checks search order, host fallback, and DNS errors.
func TestDNSResolverFakeQuery(t *testing.T) {
type answer struct {
rcode dnsmessage.RCode
ips []netip.Addr // A records for NOERROR responses
}
// fake returns answers keyed by names without trailing dots.
// Missing names return authoritative NOERROR with no records.
fake := func(routedSuffix string, answers map[string]answer) func(context.Context, dnsname.FQDN, dnsmessage.Type) ([]byte, bool, error) {
return func(_ context.Context, name dnsname.FQDN, qtype dnsmessage.Type) ([]byte, bool, error) {
routed := routedSuffix != "" && dnsname.FQDN(routedSuffix).Contains(name)
n, err := dnsmessage.NewName(name.WithTrailingDot())
if err != nil {
return nil, routed, err
}
a := answers[name.WithoutTrailingDot()]
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: 7, Response: true, RCode: a.rcode})
if err := b.StartQuestions(); err != nil {
return nil, routed, err
}
if err := b.Question(dnsmessage.Question{Name: n, Type: qtype, Class: dnsmessage.ClassINET}); err != nil {
return nil, routed, err
}
if err := b.StartAnswers(); err != nil {
return nil, routed, err
}
if a.rcode == dnsmessage.RCodeSuccess {
rh := dnsmessage.ResourceHeader{Name: n, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 5}
for _, ip := range a.ips {
if !ip.Is4() {
continue
}
if err := b.AResource(rh, dnsmessage.AResource{A: ip.As4()}); err != nil {
return nil, routed, err
}
}
}
out, err := b.Finish()
return out, routed, err
}
}
search := func() []dnsname.FQDN {
return []dnsname.FQDN{"tail-scale.ts.net.", "corp.example.com."}
}
wantIP := netip.MustParseAddr("192.0.2.1")
t.Run("search_iterates_past_no_data", func(t *testing.T) {
c := &dnsResolver{
queryDNS: fake("corp.example.com.", map[string]answer{
"foo.corp.example.com": {ips: []netip.Addr{wantIP}},
}),
searchDomains: search,
}
ips, err := c.lookupIP(t.Context(), "ip4", "foo")
if err != nil || len(ips) != 1 || ips[0] != wantIP {
t.Errorf("LookupIP(foo) = %v, %v; want %v", ips, err, wantIP)
}
})
t.Run("routed_servfail_stands", func(t *testing.T) {
c := &dnsResolver{
queryDNS: fake("corp.example.com.", map[string]answer{
"db.corp.example.com": {rcode: dnsmessage.RCodeServerFailure},
}),
}
_, err := c.lookupIP(t.Context(), "ip4", "db.corp.example.com")
var dnsErr *net.DNSError
if !errors.As(err, &dnsErr) || dnsErr.IsNotFound || dnsErr.Err != "server misbehaving" {
t.Errorf("LookupIP error = %v; want temporary server misbehaving", err)
}
})
t.Run("not_found", func(t *testing.T) {
c := &dnsResolver{
queryDNS: fake("corp.example.com.", map[string]answer{
"db.corp.example.com": {rcode: dnsmessage.RCodeNameError},
}),
searchDomains: search,
}
_, err := c.lookupIP(t.Context(), "ip4", "db.corp.example.com")
var dnsErr *net.DNSError
if !errors.As(err, &dnsErr) || !dnsErr.IsNotFound {
t.Errorf("LookupIP error = %v; want not-found", err)
}
})
t.Run("literals", func(t *testing.T) {
c := &dnsResolver{queryDNS: fake("", nil)}
ips, err := c.lookupIP(t.Context(), "ip", "192.0.2.1")
if err != nil || len(ips) != 1 || ips[0] != wantIP {
t.Errorf("LookupIP(192.0.2.1) = %v, %v", ips, err)
}
if _, err := c.lookupIP(t.Context(), "ip4", "2001:db8::1"); err == nil {
t.Errorf("LookupIP(ip4, 2001:db8::1) = nil error; want family mismatch")
}
})
}
// buildBenchmarkResponse returns a wire-format NOERROR response with one
// A or AAAA record.
func buildBenchmarkResponse(b *testing.B, qtype dnsmessage.Type, ip netip.Addr) []byte {
b.Helper()
name, err := dnsmessage.NewName("db.corp.example.com.")
if err != nil {
b.Fatal(err)
}
q := dnsmessage.Question{Name: name, Type: qtype, Class: dnsmessage.ClassINET}
hdr := dnsmessage.Header{ID: 1, Response: true, RecursionDesired: true}
bd := dnsmessage.NewBuilder(nil, hdr)
if err := bd.StartQuestions(); err != nil {
b.Fatal(err)
}
if err := bd.Question(q); err != nil {
b.Fatal(err)
}
if err := bd.StartAnswers(); err != nil {
b.Fatal(err)
}
rh := dnsmessage.ResourceHeader{Name: name, Type: qtype, Class: dnsmessage.ClassINET, TTL: 60}
if qtype == dnsmessage.TypeA {
if err := bd.AResource(rh, dnsmessage.AResource{A: ip.As4()}); err != nil {
b.Fatal(err)
}
} else {
if err := bd.AAAAResource(rh, dnsmessage.AAAAResource{AAAA: ip.As16()}); err != nil {
b.Fatal(err)
}
}
out, err := bd.Finish()
if err != nil {
b.Fatal(err)
}
return out
}
// TestDNSResolverRoutedErrorNoHostFallback verifies that routed queries
// return transport errors without host fallback.
func TestDNSResolverRoutedErrorNoHostFallback(t *testing.T) {
sentinel := errors.New("upstream unreachable")
c := &dnsResolver{
queryDNS: func(context.Context, dnsname.FQDN, dnsmessage.Type) ([]byte, bool, error) {
return nil, true, sentinel
},
}
_, err := c.lookupIP(t.Context(), "ip4", "db.corp.example.com.")
if !errors.Is(err, sentinel) {
t.Errorf("LookupIP error = %v; want the query error (no host fallback)", err)
}
}
// TestDNSResolverContextPropagation verifies that the caller's deadline
// bounds queryDNS.
func TestDNSResolverContextPropagation(t *testing.T) {
c := &dnsResolver{
queryDNS: func(ctx context.Context, _ dnsname.FQDN, _ dnsmessage.Type) ([]byte, bool, error) {
<-ctx.Done()
return nil, false, ctx.Err()
},
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
_, err := c.lookupIP(ctx, "ip4", "db.corp.example.com")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("LookupIP error = %v; want the caller deadline", err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Errorf("LookupIP took %v; want it to honor the caller deadline", elapsed)
}
}
// TestDNSResolverErrorClassification checks errors for mixed address-family
// results and cancellation.
func TestDNSResolverErrorClassification(t *testing.T) {
nxdomain := func(name string) ([]byte, bool, error) {
n, err := dnsmessage.NewName(name)
if err != nil {
return nil, false, err
}
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: 1, Response: true, RCode: dnsmessage.RCodeNameError})
if err := b.StartQuestions(); err != nil {
return nil, false, err
}
if err := b.Question(dnsmessage.Question{Name: n, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}); err != nil {
return nil, false, err
}
if err := b.StartAnswers(); err != nil {
return nil, false, err
}
out, err := b.Finish()
return out, true, err
}
t.Run("canceled_not_timeout", func(t *testing.T) {
c := &dnsResolver{
queryDNS: func(ctx context.Context, _ dnsname.FQDN, _ dnsmessage.Type) ([]byte, bool, error) {
return nil, true, context.Canceled
},
}
_, err := c.lookupIP(t.Context(), "ip4", "db.corp.example.com")
if !errors.Is(err, context.Canceled) {
t.Errorf("error = %v; want the wrapped context.Canceled", err)
}
})
t.Run("deadline_is_timeout", func(t *testing.T) {
c := &dnsResolver{
queryDNS: func(ctx context.Context, _ dnsname.FQDN, _ dnsmessage.Type) ([]byte, bool, error) {
return nil, true, context.DeadlineExceeded
},
}
_, err := c.lookupIP(t.Context(), "ip4", "db.corp.example.com")
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("error = %v; want the wrapped deadline", err)
}
})
t.Run("mixed_family_keeps_error", func(t *testing.T) {
// The AAAA query's deadline error takes precedence over the A
// query's authoritative NXDOMAIN.
c := &dnsResolver{
queryDNS: func(_ context.Context, name dnsname.FQDN, qtype dnsmessage.Type) ([]byte, bool, error) {
if qtype == dnsmessage.TypeAAAA {
return nil, true, context.DeadlineExceeded
}
return nxdomain(name.WithTrailingDot())
},
}
_, err := c.lookupIP(t.Context(), "ip", "db.corp.example.com")
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("mixed-family error = %v; want the deadline to win over the NXDOMAIN", err)
}
})
}
// TestHostFallbackName checks that only bare single-label names allow
// host search expansion. All other candidates keep their trailing dots.
func TestHostFallbackName(t *testing.T) {
tests := []struct {
candidate string
host string
want string
}{
{candidate: "db.corp.example.com.", host: "db", want: "db.corp.example.com."},
{candidate: "db.corp.", host: "db.corp", want: "db.corp."},
{candidate: "foo.", host: "foo.", want: "foo."}, // trailing dot prevents expansion
{candidate: "foo.", host: "foo", want: "foo"}, // host search expansion allowed
{candidate: "Foo.", host: "Foo", want: "Foo"},
{candidate: "db.tail-scale.ts.net.", host: "db", want: "db.tail-scale.ts.net."},
}
for _, tt := range tests {
if got := hostFallbackName(dnsname.FQDN(tt.candidate), tt.host); got != tt.want {
t.Errorf("hostFallbackName(%q, %q) = %q; want %q", tt.candidate, tt.host, got, tt.want)
}
}
}
// BenchmarkLookupIP measures search expansion, parallel A/AAAA queries, and
// response parsing with prebuilt query responses.
func BenchmarkLookupIP(b *testing.B) {
aResp := buildBenchmarkResponse(b, dnsmessage.TypeA, netip.MustParseAddr("192.0.2.10"))
aaaaResp := buildBenchmarkResponse(b, dnsmessage.TypeAAAA, netip.MustParseAddr("2001:db8::10"))
search := []dnsname.FQDN{"tail-scale.ts.net.", "corp.example.com."}
c := &dnsResolver{
queryDNS: func(_ context.Context, _ dnsname.FQDN, qtype dnsmessage.Type) ([]byte, bool, error) {
if qtype == dnsmessage.TypeA {
return aResp, true, nil
}
return aaaaResp, true, nil
},
searchDomains: func() []dnsname.FQDN { return search },
}
ctx := context.Background()
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
ips, err := c.lookupIP(ctx, "ip", "db")
if err != nil {
b.Fatal(err)
}
if len(ips) != 2 {
b.Fatalf("ips = %v", ips)
}
}
}