Ctrl K
ComunidadGuías…

tsnet/dns_lookup_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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)
		}
	}
}