Files
tailscale/net/dnscache/dnscache_test.go
T
Brad Fitzpatrick 67f8c81e61 logpolicy, net/dnscache: cache log server DNS lookups across dials
logtail's dialer only used dnscache in its fallback path, and built a
new Resolver per dial there, so every new connection to the log server
did a fresh DNS lookup (twice when the first dial failed). On systems
without a caching resolver, that adds up to a lot of queries for
log.tailscale.com.

Share one dnscache.Resolver across all log dialers, and have the
bootstrap path go straight to dnsfallback rather than repeating the
regular lookup. Since the cache TTL is a fixed guess, make
dnscache.Dialer expire a cached entry and look the host up again when
dialing its cached IPs fails, so a moved host isn't stuck on stale IPs.

Updates #15326

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I6a55d72a73ed878faebc15317421a54d9ae293ca
2026-10-01 19:22:39 -07:00

639 lines
16 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dnscache
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"flag"
"fmt"
"math/big"
"net"
"net/netip"
"reflect"
"slices"
"testing"
"time"
"tailscale.com/tstest"
)
var dialTest = flag.String("dial-test", "", "if non-empty, addr:port to test dial")
func TestDialer(t *testing.T) {
if *dialTest == "" {
t.Skip("skipping; --dial-test is blank")
}
r := &Resolver{Logf: t.Logf}
var std net.Dialer
dialer := Dialer(std.DialContext, r)
t0 := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
c, err := dialer(ctx, "tcp", *dialTest)
if err != nil {
t.Fatal(err)
}
t.Logf("dialed in %v", time.Since(t0))
c.Close()
}
func TestDialCall_DNSWasTrustworthy(t *testing.T) {
type step struct {
ip netip.Addr // IP we pretended to dial
err error // the dial error or nil for success
}
mustIP := netip.MustParseAddr
errFail := errors.New("some connect failure")
tests := []struct {
name string
steps []step
want bool
}{
{
name: "no-info",
want: false,
},
{
name: "previous-dial",
steps: []step{
{mustIP("2003::1"), nil},
{mustIP("2003::1"), errFail},
},
want: true,
},
{
name: "no-previous-dial",
steps: []step{
{mustIP("2003::1"), errFail},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := &dialer{
pastConnect: map[netip.Addr]time.Time{},
}
dc := &dialCall{
d: d,
}
for _, st := range tt.steps {
dc.noteDialResult(st.ip, st.err)
}
got := dc.dnsWasTrustworthy()
if got != tt.want {
t.Errorf("got %v; want %v", got, tt.want)
}
})
}
}
func TestDialCall_uniqueIPs(t *testing.T) {
dc := &dialCall{}
mustIP := netip.MustParseAddr
errFail := errors.New("some connect failure")
dc.noteDialResult(mustIP("2003::1"), errFail)
dc.noteDialResult(mustIP("2003::2"), errFail)
got := dc.uniqueIPs([]netip.Addr{
mustIP("2003::1"),
mustIP("2003::2"),
mustIP("2003::2"),
mustIP("2003::3"),
mustIP("2003::3"),
mustIP("2003::4"),
mustIP("2003::4"),
})
want := []netip.Addr{
mustIP("2003::3"),
mustIP("2003::4"),
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestResolverAllHostStaticResult(t *testing.T) {
r := &Resolver{
Logf: t.Logf,
SingleHost: "foo.bar",
SingleHostStaticResult: []netip.Addr{
netip.MustParseAddr("2001:4860:4860::8888"),
netip.MustParseAddr("2001:4860:4860::8844"),
netip.MustParseAddr("8.8.8.8"),
netip.MustParseAddr("8.8.4.4"),
},
}
ip4, ip6, allIPs, err := r.LookupIP(context.Background(), "foo.bar")
if err != nil {
t.Fatal(err)
}
if got, want := ip4.String(), "8.8.8.8"; got != want {
t.Errorf("ip4 got %q; want %q", got, want)
}
if got, want := ip6.String(), "2001:4860:4860::8888"; got != want {
t.Errorf("ip4 got %q; want %q", got, want)
}
if got, want := fmt.Sprintf("%q", allIPs), `["2001:4860:4860::8888" "2001:4860:4860::8844" "8.8.8.8" "8.8.4.4"]`; got != want {
t.Errorf("allIPs got %q; want %q", got, want)
}
_, _, _, err = r.LookupIP(context.Background(), "bad")
if got, want := fmt.Sprint(err), `dnscache: unexpected hostname "bad" doesn't match expected "foo.bar"`; got != want {
t.Errorf("bad dial error got %q; want %q", got, want)
}
}
func TestShouldTryBootstrap(t *testing.T) {
tstest.Replace(t, &debug, func() bool { return true })
type step struct {
ip netip.Addr // IP we pretended to dial
err error // the dial error or nil for success
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
deadlineExceeded, cancel := context.WithTimeout(context.Background(), 0)
defer cancel()
ctx := context.Background()
errFailed := errors.New("some failure")
cacheWithFallback := &Resolver{
Logf: t.Logf,
LookupIPFallback: func(_ context.Context, _ string) ([]netip.Addr, error) {
panic("unimplemented")
},
}
cacheNoFallback := &Resolver{Logf: t.Logf}
testCases := []struct {
name string
steps []step
ctx context.Context
err error
noFallback bool
want bool
}{
{
name: "no-error",
ctx: ctx,
err: nil,
want: false,
},
{
name: "canceled",
ctx: canceled,
err: errFailed,
want: false,
},
{
name: "deadline-exceeded",
ctx: deadlineExceeded,
err: errFailed,
want: false,
},
{
name: "no-fallback",
ctx: ctx,
err: errFailed,
noFallback: true,
want: false,
},
{
name: "dns-was-trustworthy",
ctx: ctx,
err: errFailed,
steps: []step{
{netip.MustParseAddr("2003::1"), nil},
{netip.MustParseAddr("2003::1"), errFailed},
},
want: false,
},
{
name: "should-bootstrap",
ctx: ctx,
err: errFailed,
want: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
d := &dialer{
pastConnect: map[netip.Addr]time.Time{},
}
if tt.noFallback {
d.dnsCache = cacheNoFallback
} else {
d.dnsCache = cacheWithFallback
}
dc := &dialCall{d: d}
for _, st := range tt.steps {
dc.noteDialResult(st.ip, st.err)
}
got := d.shouldTryBootstrap(tt.ctx, tt.err, dc)
if got != tt.want {
t.Errorf("got %v; want %v", got, tt.want)
}
})
}
}
func TestSingleHostStaticResult(t *testing.T) {
v4 := netip.MustParseAddr("0.0.0.1")
v6 := netip.MustParseAddr("2001::a")
tests := []struct {
name string
static []netip.Addr
wantIP netip.Addr
wantIP6 netip.Addr
wantAll []netip.Addr
}{
{
name: "just-v6",
static: []netip.Addr{v6},
wantIP: v6,
wantIP6: v6,
wantAll: []netip.Addr{v6},
},
{
name: "just-v4",
static: []netip.Addr{v4},
wantIP: v4,
wantIP6: netip.Addr{},
wantAll: []netip.Addr{v4},
},
{
name: "v6-then-v4",
static: []netip.Addr{v6, v4},
wantIP: v4,
wantIP6: v6,
wantAll: []netip.Addr{v6, v4},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Resolver{
SingleHost: "example.com",
SingleHostStaticResult: tt.static,
}
ip, ip6, all, err := r.LookupIP(context.Background(), "example.com")
if err != nil {
t.Fatal(err)
}
if ip != tt.wantIP {
t.Errorf("got ip %v; want %v", ip, tt.wantIP)
}
if ip6 != tt.wantIP6 {
t.Errorf("got ip6 %v; want %v", ip6, tt.wantIP6)
}
if !slices.Equal(all, tt.wantAll) {
t.Errorf("got all %v; want %v", all, tt.wantAll)
}
})
}
}
type persistCall struct {
host, resolver string
ips []netip.Addr
}
func TestDiskCacheHooks(t *testing.T) {
mustIPs := func(ss ...string) (ips []netip.Addr) {
for _, s := range ss {
ips = append(ips, netip.MustParseAddr(s))
}
return ips
}
errFailed := errors.New("some resolution failure")
t.Run("persist-on-success", func(t *testing.T) {
var calls []persistCall
defer HookPersistResolution.SetForTest(func(host, resolver string, ips []netip.Addr) {
calls = append(calls, persistCall{host, resolver, ips})
})()
r := &Resolver{
Logf: t.Logf,
LookupIPForTest: func(ctx context.Context, host string) ([]netip.Addr, error) {
return mustIPs("1.1.1.1", "2600::1"), nil
},
}
if _, _, _, err := r.LookupIP(t.Context(), "ctrl.example.com"); err != nil {
t.Fatal(err)
}
want := []persistCall{{"ctrl.example.com", "forward", mustIPs("1.1.1.1", "2600::1")}}
if !reflect.DeepEqual(calls, want) {
t.Errorf("persist calls = %+v; want %+v", calls, want)
}
})
t.Run("disk-hit-before-derp", func(t *testing.T) {
defer HookLookupDiskCache.SetForTest(func(host string) ([]netip.Addr, bool) {
if host != "ctrl.example.com" {
t.Errorf("disk lookup host = %q; want ctrl.example.com", host)
}
return mustIPs("2.2.2.2"), true
})()
var persisted []persistCall
defer HookPersistResolution.SetForTest(func(host, resolver string, ips []netip.Addr) {
persisted = append(persisted, persistCall{host, resolver, ips})
})()
r := &Resolver{
Logf: t.Logf,
LookupIPForTest: func(ctx context.Context, host string) ([]netip.Addr, error) {
return nil, errFailed
},
LookupIPFallback: func(ctx context.Context, host string) ([]netip.Addr, error) {
t.Error("DERP fallback used despite disk cache hit")
return nil, errFailed
},
}
hits0 := metricDiskFallbackHit.Value()
ip, _, _, err := r.LookupIP(t.Context(), "ctrl.example.com")
if err != nil {
t.Fatal(err)
}
if want := netip.MustParseAddr("2.2.2.2"); ip != want {
t.Errorf("ip = %v; want %v", ip, want)
}
if d := metricDiskFallbackHit.Value() - hits0; d != 1 {
t.Errorf("disk fallback hit metric delta = %d; want 1", d)
}
if len(persisted) != 0 {
t.Errorf("disk-sourced result was re-persisted: %+v", persisted)
}
})
t.Run("disk-miss-uses-derp", func(t *testing.T) {
defer HookLookupDiskCache.SetForTest(func(host string) ([]netip.Addr, bool) {
return nil, false
})()
var persisted []persistCall
defer HookPersistResolution.SetForTest(func(host, resolver string, ips []netip.Addr) {
persisted = append(persisted, persistCall{host, resolver, ips})
})()
r := &Resolver{
Logf: t.Logf,
LookupIPForTest: func(ctx context.Context, host string) ([]netip.Addr, error) {
return nil, errFailed
},
LookupIPFallback: func(ctx context.Context, host string) ([]netip.Addr, error) {
return mustIPs("3.3.3.3"), nil
},
}
miss0 := metricDiskFallbackMiss.Value()
derp0 := metricDERPFallbackOK.Value()
ip, _, _, err := r.LookupIP(t.Context(), "ctrl.example.com")
if err != nil {
t.Fatal(err)
}
if want := netip.MustParseAddr("3.3.3.3"); ip != want {
t.Errorf("ip = %v; want %v", ip, want)
}
if d := metricDiskFallbackMiss.Value() - miss0; d != 1 {
t.Errorf("disk fallback miss metric delta = %d; want 1", d)
}
if d := metricDERPFallbackOK.Value() - derp0; d != 1 {
t.Errorf("DERP fallback ok metric delta = %d; want 1", d)
}
want := []persistCall{{"ctrl.example.com", "fallback", mustIPs("3.3.3.3")}}
if !reflect.DeepEqual(persisted, want) {
t.Errorf("persist calls = %+v; want %+v", persisted, want)
}
})
}
func newTestCert(t *testing.T, dnsName string) (tls.Certificate, *x509.Certificate) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: dnsName},
DNSNames: []string{dnsName},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
parsed, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, parsed
}
// startTLSServer starts a TLS server on 127.0.0.1 serving cert and
// returns its port.
func startTLSServer(t *testing.T, cert tls.Certificate) (port string) {
t.Helper()
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
c.(*tls.Conn).Handshake()
c.Close()
}()
}
}()
_, port, err = net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
return port
}
func TestTLSDialerHostVerifiedHook(t *testing.T) {
lo := netip.MustParseAddr("127.0.0.1")
resolver := &Resolver{
Logf: t.Logf,
LookupIPForTest: func(ctx context.Context, host string) ([]netip.Addr, error) {
return []netip.Addr{lo}, nil
},
}
var std net.Dialer
type verifiedCall struct {
host string
ip netip.Addr
}
var got []verifiedCall
defer HookHostVerified.SetForTest(func(host string, ip netip.Addr) {
got = append(got, verifiedCall{host, ip})
})()
t.Run("verified", func(t *testing.T) {
got = nil
cert, parsed := newTestCert(t, "ctrl.example.com")
port := startTLSServer(t, cert)
pool := x509.NewCertPool()
pool.AddCert(parsed)
td := TLSDialer(std.DialContext, resolver, &tls.Config{RootCAs: pool})
c, err := td(t.Context(), "tcp", "ctrl.example.com:"+port)
if err != nil {
t.Fatal(err)
}
defer c.Close()
want := []verifiedCall{{"ctrl.example.com", lo}}
if !reflect.DeepEqual(got, want) {
t.Errorf("verified calls = %+v; want %+v", got, want)
}
})
t.Run("insecure-no-hook", func(t *testing.T) {
got = nil
cert, _ := newTestCert(t, "other.example.com")
port := startTLSServer(t, cert)
// Handshake succeeds due to InsecureSkipVerify, but without
// certificate verification the hook must not fire.
td := TLSDialer(std.DialContext, resolver, &tls.Config{InsecureSkipVerify: true})
c, err := td(t.Context(), "tcp", "ctrl.example.com:"+port)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if len(got) != 0 {
t.Errorf("hook fired despite InsecureSkipVerify: %+v", got)
}
})
t.Run("intercepted-no-hook", func(t *testing.T) {
got = nil
cert, _ := newTestCert(t, "ctrl.example.com")
port := startTLSServer(t, cert)
// An interception-tolerant config in the style of controlhttp:
// a VerifyConnection hook that swallows all verification
// errors, because Noise doesn't need TLS to be honest. The
// handshake succeeds even though the cert chain is untrusted,
// and the hook must not fire. (Regression test for the review
// concern that VerifyConnection != nil was treated as proof of
// verification.)
td := TLSDialer(std.DialContext, resolver, &tls.Config{
InsecureSkipVerify: true,
VerifyConnection: func(tls.ConnectionState) error { return nil },
})
c, err := td(t.Context(), "tcp", "ctrl.example.com:"+port)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if len(got) != 0 {
t.Errorf("hook fired on intercepted connection: %+v", got)
}
})
t.Run("bad-cert-no-hook", func(t *testing.T) {
got = nil
cert, parsed := newTestCert(t, "other.example.com")
port := startTLSServer(t, cert)
pool := x509.NewCertPool()
pool.AddCert(parsed)
// Cert is trusted but for the wrong name: handshake fails and
// the hook must not fire.
td := TLSDialer(std.DialContext, resolver, &tls.Config{RootCAs: pool})
if c, err := td(t.Context(), "tcp", "ctrl.example.com:"+port); err == nil {
c.Close()
t.Fatal("dial unexpectedly succeeded with wrong-name cert")
}
if len(got) != 0 {
t.Errorf("hook fired despite failed verification: %+v", got)
}
})
}
func TestDialerRefreshesStaleCache(t *testing.T) {
ipA := netip.MustParseAddr("192.0.2.1")
ipB := netip.MustParseAddr("192.0.2.2")
var (
dnsAnswer = ipA
lookups int
reachable = map[netip.Addr]bool{ipA: true}
dialed []netip.Addr
)
r := &Resolver{
UseLastGood: true,
LookupIPForTest: func(ctx context.Context, host string) ([]netip.Addr, error) {
lookups++
return []netip.Addr{dnsAnswer}, nil
},
}
fwd := func(ctx context.Context, network, address string) (net.Conn, error) {
ip := netip.MustParseAddrPort(address).Addr()
dialed = append(dialed, ip)
if !reachable[ip] {
return nil, fmt.Errorf("can't reach %v", ip)
}
c1, c2 := net.Pipe()
c2.Close()
return c1, nil
}
dial := Dialer(fwd, r)
check := func(name string, wantErr bool, wantLookups int, wantDialed ...netip.Addr) {
t.Helper()
dialed = nil
c, err := dial(t.Context(), "tcp", "example.com:443")
if c != nil {
c.Close()
}
if (err != nil) != wantErr {
t.Errorf("%s: err = %v; want error = %v", name, err, wantErr)
}
if lookups != wantLookups {
t.Errorf("%s: lookups = %d; want %d", name, lookups, wantLookups)
}
if !slices.Equal(dialed, wantDialed) {
t.Errorf("%s: dialed %v; want %v", name, dialed, wantDialed)
}
}
check("first", false, 1, ipA)
check("cached", false, 1, ipA)
// The host moves. The cached IP fails, so the dialer should look the
// host up again and dial the new IP.
dnsAnswer = ipB
reachable = map[netip.Addr]bool{ipB: true}
check("moved", false, 2, ipA, ipB)
check("cached-after-move", false, 2, ipB)
// The host is unreachable but its DNS hasn't changed. The dialer
// should look it up once more but not redial the same IP.
reachable = nil
check("unreachable", true, 3, ipB)
}