Files
tailscale/net/dns/manager_test.go
T
Brendan Creane 2d4379386a net/dns: don't take over DNS when the OS has no upstream resolvers (#20794)
On backends that can't do OS-level split DNS, Tailscale forwards the default
route to the system's own resolvers, which it reads out of the OS config. At
boot that config may not be populated yet, because NetworkManager or
systemd-resolved haven't run, and Tailscale took over regardless: it pointed
the OS at 100.100.100.100 but compiled an empty "." route, so every
non-Tailscale name failed to resolve for the rest of the process's life.

Fail compileConfig instead, which leaves the OS resolvers in place, and
retry the last config with a bounded backoff until resolvers appear. A
health warning explains why MagicDNS is inactive in the meantime.

Sandboxed macOS and iOS are exempt. There the network extension reapplies
the config itself when the OS nameservers change, and quad-100 as the
primary resolver is what keeps tailnet names resolving while the base config
is still empty. Dropping the exemption is tracked in tailscale/corp#48962.

This also changes the outcome on an openresolv host with no snippets
registered. Since the fix for #20825, openresolv reports an empty base config
there and tailscaled took over with no upstream, so every public name got
SERVFAIL. Such a host now keeps its resolv.conf and shows the health warning
instead. TestOpenresolvDNS checks that outcome.

TestSplitDNSEmptyBaseConfig covers the boot race end to end in natlab: it
empties resolv.conf on a guest using the "direct" backend, checks that
tailscaled leaves it alone, then adds a resolver and checks that both tailnet
and public names resolve.

Fixes #20341

Signed-off-by: Brendan Creane <bcreane@gmail.com>
2026-09-27 10:20:42 -07:00

1786 lines
52 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"reflect"
"runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
dns "golang.org/x/net/dns/dnsmessage"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/net/dns/publicdns"
"tailscale.com/net/dns/resolver"
"tailscale.com/net/netmon"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tsdial"
"tailscale.com/tstest"
"tailscale.com/types/dnstype"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus/eventbustest"
"tailscale.com/util/httpm"
)
type fakeOSConfigurator struct {
SplitDNS bool
mu sync.Mutex // guards BaseConfig/baseConfigErrOnce
BaseConfig OSConfig
baseConfigErrOnce error // if non-nil, returned by the next GetBaseConfig then cleared
OSConfig OSConfig
ResolverConfig resolver.Config
GetBaseConfigErr *error
// onGetBaseConfig, if non-nil, runs at the start of GetBaseConfig, letting
// tests count or interleave with config reads.
onGetBaseConfig func()
}
func (c *fakeOSConfigurator) SetDNS(cfg OSConfig) error {
if !c.SplitDNS && len(cfg.MatchDomains) > 0 {
panic("split DNS config passed to non-split OSConfigurator")
}
c.OSConfig = cfg
return nil
}
func (c *fakeOSConfigurator) SetResolver(cfg resolver.Config) {
c.ResolverConfig = cfg
}
func (c *fakeOSConfigurator) SupportsSplitDNS() bool {
return c.SplitDNS
}
// setBaseConfig updates BaseConfig, which a retry goroutine may read
// concurrently via GetBaseConfig.
func (c *fakeOSConfigurator) setBaseConfig(cfg OSConfig) {
c.mu.Lock()
defer c.mu.Unlock()
c.BaseConfig = cfg
}
// setBaseConfigErrOnce arms GetBaseConfig to return err exactly once, then
// resume returning BaseConfig.
func (c *fakeOSConfigurator) setBaseConfigErrOnce(err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.baseConfigErrOnce = err
}
func (c *fakeOSConfigurator) GetBaseConfig() (OSConfig, error) {
if c.onGetBaseConfig != nil {
c.onGetBaseConfig()
}
if c.GetBaseConfigErr != nil {
return OSConfig{}, *c.GetBaseConfigErr
}
c.mu.Lock()
defer c.mu.Unlock()
if err := c.baseConfigErrOnce; err != nil {
c.baseConfigErrOnce = nil
return OSConfig{}, err
}
return c.BaseConfig, nil
}
func (c *fakeOSConfigurator) Close() error { return nil }
func TestCompileHostEntries(t *testing.T) {
tests := []struct {
name string
cfg Config
want []*HostEntry
}{
{
name: "empty",
},
{
name: "no-search-domains",
cfg: Config{
Hosts: map[dnsname.FQDN][]netip.Addr{
"a.b.c.": {netip.MustParseAddr("1.1.1.1")},
},
},
},
{
name: "search-domains",
cfg: Config{
Hosts: map[dnsname.FQDN][]netip.Addr{
"a.foo.ts.net.": {netip.MustParseAddr("1.1.1.1")},
"b.foo.ts.net.": {netip.MustParseAddr("1.1.1.2")},
"c.foo.ts.net.": {netip.MustParseAddr("1.1.1.3")},
"d.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.4")},
"d.foo.ts.net.": {netip.MustParseAddr("1.1.1.4")},
"e.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.5")},
"random.example.com.": {netip.MustParseAddr("1.1.1.1")},
"other.example.com.": {netip.MustParseAddr("1.1.1.2")},
"othertoo.example.com.": {netip.MustParseAddr("1.1.5.2")},
},
SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."},
},
want: []*HostEntry{
{Addr: netip.MustParseAddr("1.1.1.1"), Hosts: []string{"a.foo.ts.net.", "a"}},
{Addr: netip.MustParseAddr("1.1.1.2"), Hosts: []string{"b.foo.ts.net.", "b"}},
{Addr: netip.MustParseAddr("1.1.1.3"), Hosts: []string{"c.foo.ts.net.", "c"}},
{Addr: netip.MustParseAddr("1.1.1.4"), Hosts: []string{"d.foo.ts.net.", "d", "d.foo.beta.tailscale.net."}},
{Addr: netip.MustParseAddr("1.1.1.5"), Hosts: []string{"e.foo.beta.tailscale.net.", "e"}},
},
},
{
name: "only-exact-subdomain-match",
cfg: Config{
Hosts: map[dnsname.FQDN][]netip.Addr{
"e.foo.ts.net.": {netip.MustParseAddr("1.1.1.5")},
"e.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.5")},
"e.ignored.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.6")},
},
SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."},
},
want: []*HostEntry{
{Addr: netip.MustParseAddr("1.1.1.5"), Hosts: []string{"e.foo.ts.net.", "e", "e.foo.beta.tailscale.net."}},
},
},
{
name: "unmatched-domains",
cfg: Config{
Hosts: map[dnsname.FQDN][]netip.Addr{
"d.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.4")},
"d.foo.ts.net.": {netip.MustParseAddr("1.1.1.4")},
"random.example.com.": {netip.MustParseAddr("1.1.1.1")},
"other.example.com.": {netip.MustParseAddr("1.1.1.2")},
"othertoo.example.com.": {netip.MustParseAddr("1.1.5.2")},
},
SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."},
},
want: []*HostEntry{
{Addr: netip.MustParseAddr("1.1.1.4"), Hosts: []string{"d.foo.ts.net.", "d", "d.foo.beta.tailscale.net."}},
},
},
{
name: "overlaps",
cfg: Config{
Hosts: map[dnsname.FQDN][]netip.Addr{
"h1.foo.ts.net.": {netip.MustParseAddr("1.1.1.3")},
"h1.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.2")},
"h2.foo.ts.net.": {netip.MustParseAddr("1.1.1.1")},
"h2.foo.beta.tailscale.net.": {netip.MustParseAddr("1.1.1.1")},
"example.com": {netip.MustParseAddr("1.1.1.1")},
},
SearchDomains: []dnsname.FQDN{"foo.ts.net.", "foo.beta.tailscale.net."},
},
want: []*HostEntry{
{Addr: netip.MustParseAddr("1.1.1.2"), Hosts: []string{"h1.foo.beta.tailscale.net."}},
{Addr: netip.MustParseAddr("1.1.1.3"), Hosts: []string{"h1.foo.ts.net.", "h1"}},
{Addr: netip.MustParseAddr("1.1.1.1"), Hosts: []string{"h2.foo.ts.net.", "h2", "h2.foo.beta.tailscale.net."}},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := compileHostEntries(tc.cfg)
if diff := cmp.Diff(tc.want, got, cmp.Comparer(func(a, b netip.Addr) bool {
return a == b
})); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}
}
var serviceAddr46 = []netip.Addr{tsaddr.TailscaleServiceIP(), tsaddr.TailscaleServiceIPv6()}
// scopeQuad100Knobs returns Knobs with ScopeQuad100OnMacOS set, i.e. the
// NodeAttrScopeQuad100OnMacOS opt-in that lets sandboxed macOS scope quad-100
// to its match domains instead of installing it as the primary resolver.
func scopeQuad100Knobs() *controlknobs.Knobs {
k := new(controlknobs.Knobs)
k.ScopeQuad100OnMacOS.Store(true)
return k
}
func TestManager(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skipf("test's assumptions break because of https://github.com/tailscale/corp/issues/1662")
}
// Note: these tests assume that it's safe to switch the
// OSConfigurator's split-dns support on and off between Set
// calls. Empirically this is currently true, because we reprobe
// the support every time we generate configs. It would be
// reasonable to make this unsupported as well, in which case
// these tests will need tweaking.
tests := []struct {
name string
in Config
split bool
bs OSConfig
bsErr error // if set, GetBaseConfig returns this
os OSConfig
knobs *controlknobs.Knobs
rs resolver.Config
goos string // empty means "linux"
sandboxedMacOS bool
}{
{
name: "empty",
},
{
name: "search-only",
in: Config{
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
os: OSConfig{
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
},
{
// Regression test for https://github.com/tailscale/tailscale/issues/1886
name: "hosts-only",
in: Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
},
rs: resolver.Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
},
},
{
// If Hosts are specified (i.e. ExtraRecords) that aren't a split
// DNS route and a global resolver is specified, then make
// everything go via 100.100.100.100.
name: "hosts-with-global-dns-uses-quad100",
split: true,
in: Config{
DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"),
Hosts: hosts(
"foo.tld.", "1.2.3.4",
"bar.tld.", "2.3.4.5"),
},
os: OSConfig{
Nameservers: serviceAddr46,
},
rs: resolver.Config{
Hosts: hosts(
"foo.tld.", "1.2.3.4",
"bar.tld.", "2.3.4.5"),
Routes: upstreams(".", "1.1.1.1", "9.9.9.9"),
},
},
{
// This is the above hosts-with-global-dns-uses-quad100 test but
// verifying that if global DNS servers aren't set (the 1.1.1.1 and
// 9.9.9.9 above), then we don't configure 100.100.100.100 as the
// resolver.
name: "hosts-without-global-dns-not-use-quad100",
split: true,
in: Config{
Hosts: hosts(
"foo.tld.", "1.2.3.4",
"bar.tld.", "2.3.4.5"),
},
os: OSConfig{},
rs: resolver.Config{
Hosts: hosts(
"foo.tld.", "1.2.3.4",
"bar.tld.", "2.3.4.5"),
},
},
{
// This tests that ExtraRecords (foo.tld and bar.tld here) don't trigger forcing
// traffic through 100.100.100.100 if there's Split DNS support and the extra
// records are part of a split DNS route.
name: "hosts-with-extrarecord-hosts-with-routes-no-quad100",
split: true,
in: Config{
Routes: upstreams(
"tld.", "4.4.4.4",
),
Hosts: hosts(
"foo.tld.", "1.2.3.4",
"bar.tld.", "2.3.4.5"),
},
os: OSConfig{
Nameservers: mustIPs("4.4.4.4"),
MatchDomains: fqdns("tld."),
},
rs: resolver.Config{
Hosts: hosts(
"foo.tld.", "1.2.3.4",
"bar.tld.", "2.3.4.5"),
},
},
{
name: "corp",
in: Config{
DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
os: OSConfig{
Nameservers: mustIPs("1.1.1.1", "9.9.9.9"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
},
{
name: "corp-split",
in: Config{
DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: mustIPs("1.1.1.1", "9.9.9.9"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
},
{
name: "corp-magic",
in: Config{
DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
Routes: upstreams("ts.com", ""),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(".", "1.1.1.1", "9.9.9.9"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
{
name: "corp-magic-split",
in: Config{
DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
Routes: upstreams("ts.com", ""),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(".", "1.1.1.1", "9.9.9.9"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
{
name: "corp-routes",
in: Config{
DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"),
Routes: upstreams("corp.com", "2.2.2.2"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(
".", "1.1.1.1", "9.9.9.9",
"corp.com.", "2.2.2.2"),
},
},
{
name: "corp-routes-split",
in: Config{
DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"),
Routes: upstreams("corp.com", "2.2.2.2"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(
".", "1.1.1.1", "9.9.9.9",
"corp.com.", "2.2.2.2"),
},
},
{
name: "controlknob-disable-v6-registration",
in: Config{
DefaultResolvers: mustRes("1.1.1.1", "9.9.9.9"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
Routes: upstreams("ts.com", ""),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
},
knobs: (func() *controlknobs.Knobs {
k := new(controlknobs.Knobs)
k.ForceRegisterMagicDNSIPv4Only.Store(true)
return k
})(),
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"), // without IPv6
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(".", "1.1.1.1", "9.9.9.9"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
{
name: "routes",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(
".", "8.8.8.8",
"corp.com.", "2.2.2.2"),
},
},
{
name: "routes-split",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: mustIPs("2.2.2.2"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("corp.com"),
},
},
{
// Sandboxed macOS: split traffic stays pointed at quad-100 (upstreams
// may only be reachable via the tunnel). With NodeAttrScopeQuad100OnMacOS
// set, quad-100 is scoped to the match domains so public names fall
// through to the OS resolver (e.g. a DoH profile) rather than being
// shadowed. See the -no-knob variant for the default. tailscale/corp#45534.
name: "routes-split-sandboxed-darwin",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
knobs: scopeQuad100Knobs(),
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("corp.com"),
},
rs: resolver.Config{
Routes: upstreams(
"corp.com.", "2.2.2.2"),
},
goos: "darwin",
sandboxedMacOS: true,
},
{
// As above but without NodeAttrScopeQuad100OnMacOS (the default,
// matching iOS): quad-100 is the OS primary resolver (a "." route to
// the base config's 8.8.8.8), shadowing any DoH profile.
name: "routes-split-sandboxed-darwin-no-knob",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(
".", "8.8.8.8",
"corp.com.", "2.2.2.2"),
},
goos: "darwin",
sandboxedMacOS: true,
},
{
// An ExtraRecord paired with an authoritative (resolver-less) route
// is scoped like any other split domain, on every platform. The
// darwin and linux variants must agree.
name: "extra-record-routed-scopes-quad100",
in: Config{
Hosts: hosts("extra.example.com.", "100.64.0.9"),
Routes: upstreams("corp.ts.net.", "", "extra.example.com.", ""),
SearchDomains: fqdns("corp.ts.net"),
},
split: true,
knobs: scopeQuad100Knobs(),
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("corp.ts.net"),
MatchDomains: fqdns("corp.ts.net", "extra.example.com"),
},
rs: resolver.Config{
Hosts: hosts("extra.example.com.", "100.64.0.9"),
LocalDomains: fqdns("corp.ts.net.", "extra.example.com."),
},
goos: "darwin",
sandboxedMacOS: true,
},
{
name: "extra-record-routed-scopes-quad100-linux",
in: Config{
Hosts: hosts("extra.example.com.", "100.64.0.9"),
Routes: upstreams("corp.ts.net.", "", "extra.example.com.", ""),
SearchDomains: fqdns("corp.ts.net"),
},
split: true,
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("corp.ts.net"),
MatchDomains: fqdns("corp.ts.net", "extra.example.com"),
},
rs: resolver.Config{
Hosts: hosts("extra.example.com.", "100.64.0.9"),
LocalDomains: fqdns("corp.ts.net.", "extra.example.com."),
},
},
{
// MagicDNS names present but domain routing off: no route suffix
// covers them, so quad-100 must stay primary or they stop resolving.
// requiresPrimaryResolver overrides NodeAttrScopeQuad100OnMacOS, at
// the cost of shadowing the DoH profile.
name: "unrouted-magicdns-hosts-keep-quad100-primary",
in: Config{
Routes: upstreams("corp.ts.net.", "1.2.3.4"),
SearchDomains: fqdns("corp.ts.net"),
MagicDNSHostsUnrouted: true,
},
split: true,
knobs: scopeQuad100Knobs(),
bs: OSConfig{
Nameservers: mustIPs("192.168.1.1"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("corp.ts.net"),
},
rs: resolver.Config{
Routes: upstreams(
".", "192.168.1.1",
"corp.ts.net.", "1.2.3.4"),
},
goos: "darwin",
sandboxedMacOS: true,
},
{
// MagicDNSHostsUnrouted on a split-DNS manager with no base config
// (e.g. systemd-resolved): fall back to a scoped config instead of
// erroring. Two differing resolver sets keep us off the
// single-resolver fast path so we reach GetBaseConfig. Regression
// test for tailscale/corp#45534.
name: "unrouted-magicdns-hosts-no-base-config-linux",
in: Config{
Routes: upstreams(
"corp.ts.net.", "1.2.3.4",
"bigco.net.", "3.3.3.3"),
SearchDomains: fqdns("corp.ts.net"),
MagicDNSHostsUnrouted: true,
},
split: true,
bsErr: ErrGetBaseConfigNotSupported,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("corp.ts.net"),
MatchDomains: fqdns("bigco.net", "corp.ts.net"),
},
rs: resolver.Config{
Routes: upstreams(
"corp.ts.net.", "1.2.3.4",
"bigco.net.", "3.3.3.3"),
},
goos: "linux",
},
{
name: "routes-multi",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2",
"bigco.net", "3.3.3.3"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(
".", "8.8.8.8",
"corp.com.", "2.2.2.2",
"bigco.net.", "3.3.3.3"),
},
},
{
name: "routes-multi-split-linux",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2",
"bigco.net", "3.3.3.3"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("bigco.net", "corp.com"),
},
rs: resolver.Config{
Routes: upstreams(
"corp.com.", "2.2.2.2",
"bigco.net.", "3.3.3.3"),
},
goos: "linux",
},
{
// The `routes-multi-split-linux` test case above should match on
// macOS, where tailscaled configures split DNS via /etc/resolver.
name: "routes-multi-split-darwin",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2",
"bigco.net", "3.3.3.3"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("bigco.net", "corp.com"),
},
rs: resolver.Config{
Routes: upstreams(
"corp.com.", "2.2.2.2",
"bigco.net.", "3.3.3.3"),
},
goos: "darwin",
},
{
// The `routes-multi-split-linux` test case above on iOS should NOT result in a split
// DNS configuration.
// Check that MatchDomains is empty. Due to Apple limitations, we cannot set MatchDomains
// without those domains also being SearchDomains.
name: "routes-multi-does-not-split-on-ios",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2",
"bigco.net", "3.3.3.3"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: false,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(
".", "",
"corp.com.", "2.2.2.2",
"bigco.net.", "3.3.3.3"),
},
goos: "ios",
},
{
name: "magic",
in: Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
Routes: upstreams("ts.com", ""),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(".", "8.8.8.8"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
{
name: "magic-split",
in: Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
Routes: upstreams("ts.com", ""),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("ts.com"),
},
rs: resolver.Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
goos: "linux",
},
{
// The `magic-split` test case above should match on macOS, where
// tailscaled configures split DNS via /etc/resolver.
name: "magic-split-darwin",
in: Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
Routes: upstreams("ts.com", ""),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("ts.com"),
},
rs: resolver.Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
goos: "darwin",
},
{
// The `magic-split` test case above on iOS should NOT result in a split DNS configuration.
// Check that MatchDomains is empty. Due to Apple limitations, we cannot set MatchDomains
// without those domains also being SearchDomains.
name: "magic-split-does-not-split-on-ios",
in: Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
Routes: upstreams("ts.com", ""),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: false,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(".", ""),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
goos: "ios",
},
{
name: "routes-magic",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2", "ts.com", ""),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(
"corp.com.", "2.2.2.2",
".", "8.8.8.8"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
{
name: "routes-magic-split-linux",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2",
"ts.com", ""),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("corp.com", "ts.com"),
},
rs: resolver.Config{
Routes: upstreams("corp.com.", "2.2.2.2"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
goos: "linux",
},
{
// The `routes-magic-split-linux` test case above should match on
// macOS, where tailscaled configures split DNS via /etc/resolver.
name: "routes-magic-split-darwin",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2",
"ts.com", ""),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("corp.com", "ts.com"),
},
rs: resolver.Config{
Routes: upstreams("corp.com.", "2.2.2.2"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
goos: "darwin",
},
{
// The `routes-magic-split-linux` test case above on Darwin should NOT result in a
// split DNS configuration.
// Check that MatchDomains is empty. Due to Apple limitations, we cannot set MatchDomains
// without those domains also being SearchDomains.
name: "routes-magic-does-not-split-on-ios",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2",
"ts.com", ""),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(
".", "",
"corp.com.", "2.2.2.2",
),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
goos: "ios",
},
{
name: "exit-node-forward",
in: Config{
DefaultResolvers: mustRes("http://[fd7a:115c:a1e0:ab12:4843:cd96:6245:7a66]:2982/doh"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(".", "http://[fd7a:115c:a1e0:ab12:4843:cd96:6245:7a66]:2982/doh"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
},
},
{
name: "corp-v6",
in: Config{
DefaultResolvers: mustRes("1::1"),
},
os: OSConfig{
Nameservers: mustIPs("1::1"),
},
},
{
// This one's structurally the same as the previous one (corp-v6), but
// instead of 1::1 as the IPv6 address, it uses a NextDNS IPv6 address which
// is specially recognized.
name: "corp-v6-nextdns",
in: Config{
DefaultResolvers: mustRes("2a07:a8c0::c3:a884"),
},
os: OSConfig{
Nameservers: serviceAddr46,
},
rs: resolver.Config{
Routes: upstreams(".", "2a07:a8c0::c3:a884"),
},
},
{
name: "nextdns-doh",
in: Config{
DefaultResolvers: mustRes("https://dns.nextdns.io/c3a884"),
},
os: OSConfig{
Nameservers: serviceAddr46,
},
rs: resolver.Config{
Routes: upstreams(".", "https://dns.nextdns.io/c3a884"),
},
},
{
// on iOS exclusively, tests the split DNS behavior for battery life optimization added in
// https://github.com/tailscale/tailscale/pull/10576
name: "ios-use-split-dns-when-no-custom-resolvers",
in: Config{
Routes: upstreams("ts.net", "199.247.155.52", "optimistic-display.ts.net", ""),
SearchDomains: fqdns("optimistic-display.ts.net"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("optimistic-display.ts.net"),
MatchDomains: fqdns("ts.net"),
},
rs: resolver.Config{
Routes: upstreams(
".", "",
"ts.net", "199.247.155.52",
),
LocalDomains: fqdns("optimistic-display.ts.net."),
},
goos: "ios",
},
{
// if using app connectors, the battery life optimization above should not be applied
name: "ios-dont-use-split-dns-when-app-connector-resolver-needed",
in: Config{
Routes: upstreams(
"ts.net", "199.247.155.52",
"optimistic-display.ts.net", "",
"github.com", "https://dnsresolver.bigcorp.com/2f143"),
SearchDomains: fqdns("optimistic-display.ts.net"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("optimistic-display.ts.net"),
},
rs: resolver.Config{
Routes: upstreams(
".", "",
"github.com", "https://dnsresolver.bigcorp.com/2f143",
"ts.net", "199.247.155.52",
),
LocalDomains: fqdns("optimistic-display.ts.net."),
},
goos: "ios",
},
{
// macOS should match Linux here. iOS remains special-cased above
// for battery-life behavior.
name: "darwin-use-split-dns-when-no-custom-resolvers",
in: Config{
Routes: upstreams("ts.net", "199.247.155.52", "optimistic-display.ts.net", ""),
SearchDomains: fqdns("optimistic-display.ts.net"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("optimistic-display.ts.net"),
MatchDomains: fqdns("optimistic-display.ts.net", "ts.net"),
},
rs: resolver.Config{
Routes: upstreams("ts.net", "199.247.155.52"),
LocalDomains: fqdns("optimistic-display.ts.net."),
},
goos: "darwin",
},
{
name: "populate-hosts-magicdns",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2",
"ts.com", ""),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
SearchDomains: fqdns("ts.com", "universe.tf"),
},
split: true,
os: OSConfig{
Hosts: []*HostEntry{
{
Addr: netip.MustParseAddr("2.3.4.5"),
Hosts: []string{
"bradfitz.ts.com.",
"bradfitz",
},
},
{
Addr: netip.MustParseAddr("1.2.3.4"),
Hosts: []string{
"dave.ts.com.",
"dave",
},
},
},
Nameservers: serviceAddr46,
SearchDomains: fqdns("ts.com", "universe.tf"),
MatchDomains: fqdns("corp.com", "ts.com"),
},
rs: resolver.Config{
Routes: upstreams("corp.com.", "2.2.2.2"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
goos: "windows",
},
{
// Regression test for https://github.com/tailscale/tailscale/issues/14428
name: "nopopulate-hosts-nomagicdns",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2",
"ts.com", "1.1.1.1"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
SearchDomains: fqdns("ts.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("ts.com", "universe.tf"),
MatchDomains: fqdns("corp.com", "ts.com"),
},
rs: resolver.Config{
Routes: upstreams(
"corp.com.", "2.2.2.2",
"ts.com", "1.1.1.1"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
},
goos: "windows",
},
{
// Regression test for #19834
name: "single-doh-splitdns-no-magicdns",
in: Config{
Routes: upstreams(
"example.com", "http://100.101.102.103:1234/dns-query"),
},
split: true,
os: OSConfig{
Nameservers: serviceAddr46,
MatchDomains: fqdns("example.com"),
},
rs: resolver.Config{
Routes: upstreams("example.com.", "http://100.101.102.103:1234/dns-query"),
},
goos: "linux",
},
}
trIP := cmp.Transformer("ipStr", func(ip netip.Addr) string { return ip.String() })
trIPPort := cmp.Transformer("ippStr", func(ipp netip.AddrPort) string {
if ipp.Port() == 53 {
return ipp.Addr().String()
}
return ipp.String()
})
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
tstest.Replace(t, &isSandboxedMacOS, func() bool { return test.sandboxedMacOS })
f := fakeOSConfigurator{
SplitDNS: test.split,
BaseConfig: test.bs,
}
if test.bsErr != nil {
f.GetBaseConfigErr = &test.bsErr
}
goos := test.goos
if goos == "" {
goos = "linux"
}
knobs := test.knobs
if knobs == nil {
knobs = &controlknobs.Knobs{}
}
bus := eventbustest.NewBus(t)
dialer := tsdial.NewDialer(netmon.NewStatic())
dialer.SetBus(bus)
m := NewManager(t.Logf, &f, health.NewTracker(bus), dialer, nil, knobs, goos, bus)
m.resolver.TestOnlySetHook(f.SetResolver)
if err := m.Set(test.in); err != nil {
t.Fatalf("m.Set: %v", err)
}
if diff := cmp.Diff(f.OSConfig, test.os, trIP, trIPPort, cmpopts.EquateEmpty()); diff != "" {
t.Errorf("wrong OSConfig (-got+want)\n%s", diff)
}
if diff := cmp.Diff(f.ResolverConfig, test.rs, trIP, trIPPort, cmpopts.EquateEmpty()); diff != "" {
t.Errorf("wrong resolver.Config (-got+want)\n%s", diff)
}
})
}
}
func mustIPs(strs ...string) (ret []netip.Addr) {
for _, s := range strs {
ret = append(ret, netip.MustParseAddr(s))
}
return ret
}
func mustRes(strs ...string) (ret []*dnstype.Resolver) {
for _, s := range strs {
ret = append(ret, &dnstype.Resolver{Addr: s})
}
return ret
}
func fqdns(strs ...string) (ret []dnsname.FQDN) {
for _, s := range strs {
fqdn, err := dnsname.ToFQDN(s)
if err != nil {
panic(err)
}
ret = append(ret, fqdn)
}
return ret
}
func hosts(strs ...string) (ret map[dnsname.FQDN][]netip.Addr) {
var key dnsname.FQDN
ret = map[dnsname.FQDN][]netip.Addr{}
for _, s := range strs {
if ip, err := netip.ParseAddr(s); err == nil {
if key == "" {
panic("IP provided before name")
}
ret[key] = append(ret[key], ip)
} else {
fqdn, err := dnsname.ToFQDN(s)
if err != nil {
panic(err)
}
key = fqdn
}
}
return ret
}
func upstreams(strs ...string) (ret map[dnsname.FQDN][]*dnstype.Resolver) {
var key dnsname.FQDN
ret = map[dnsname.FQDN][]*dnstype.Resolver{}
for _, s := range strs {
if s == "" {
if key == "" {
panic("IPPort provided before suffix")
}
ret[key] = nil
} else if ipp, err := netip.ParseAddrPort(s); err == nil {
if key == "" {
panic("IPPort provided before suffix")
}
ret[key] = append(ret[key], &dnstype.Resolver{Addr: ipp.String()})
} else if _, err := netip.ParseAddr(s); err == nil {
if key == "" {
panic("IPPort provided before suffix")
}
ret[key] = append(ret[key], &dnstype.Resolver{Addr: s})
} else if strings.HasPrefix(s, "http") {
ret[key] = append(ret[key], &dnstype.Resolver{Addr: s})
} else {
fqdn, err := dnsname.ToFQDN(s)
if err != nil {
panic(err)
}
key = fqdn
}
}
return ret
}
func TestConfigRecompilation(t *testing.T) {
fakeErr := errors.New("fake os configurator error")
f := &fakeOSConfigurator{}
f.GetBaseConfigErr = &fakeErr
f.BaseConfig = OSConfig{
Nameservers: mustIPs("1.1.1.1"),
}
config := Config{
Routes: upstreams("ts.net", "69.4.2.0", "foo.ts.net", ""),
SearchDomains: fqdns("foo.ts.net"),
}
bus := eventbustest.NewBus(t)
dialer := tsdial.NewDialer(netmon.NewStatic())
dialer.SetBus(bus)
m := NewManager(t.Logf, f, health.NewTracker(bus), dialer, nil, nil, "darwin", bus)
var managerConfig *resolver.Config
m.resolver.TestOnlySetHook(func(cfg resolver.Config) {
managerConfig = &cfg
})
// Initial set should error out and store the config
if err := m.Set(config); err == nil {
t.Fatalf("Want non-nil error. Got nil")
}
if m.config == nil {
t.Fatalf("Want persisted config. Got nil.")
}
if managerConfig != nil {
t.Fatalf("Want nil managerConfig. Got %v", managerConfig)
}
// Clear the error. We should take the happy path now and
// set m.manager's Config.
f.GetBaseConfigErr = nil
// Recompilation without an error should succeed and set m.config and m.manager's [resolver.Config]
if err := m.RecompileDNSConfig(); err != nil {
t.Fatalf("Want nil error. Got err %v", err)
}
if m.config == nil {
t.Fatalf("Want non-nil config. Got nil")
}
if managerConfig == nil {
t.Fatalf("Want non nil managerConfig. Got nil")
}
}
// newEmptyBaseConfigManager returns a Manager whose OS backend can't split DNS
// and reports an empty base config, so compileConfig takes the blend-in path.
func newEmptyBaseConfigManager(t *testing.T) (*Manager, *fakeOSConfigurator, *health.Tracker) {
t.Helper()
f := &fakeOSConfigurator{SplitDNS: false}
bus := eventbustest.NewBus(t)
ht := health.NewTracker(bus)
ht.SetAnyInterfaceUp(true) // else NetworkStatusWarnable suppresses the warning
dialer := tsdial.NewDialer(netmon.NewStatic())
dialer.SetBus(bus)
m := NewManager(t.Logf, f, ht, dialer, nil, nil, "linux", bus)
m.resolver.TestOnlySetHook(f.SetResolver)
t.Cleanup(func() { m.Down() }) // stop the retry goroutine before the test ends
return m, f, ht
}
// baseConfigRetryTotal returns how long a full retry sequence takes, mirroring
// the backoff in [Manager.retryEmptyBaseConfig].
func baseConfigRetryTotal() time.Duration {
var total time.Duration
d := baseConfigRetryInterval
for range baseConfigRetryAttempts {
total += d
d *= 2
}
return total
}
// splitDNSOnlyConfig returns a config with a route and MagicDNS but no
// DefaultResolvers, so the "." route can only come from the OS base config.
func splitDNSOnlyConfig() Config {
return Config{
Routes: upstreams("ts.net", "199.247.155.53"),
SearchDomains: fqdns("foo.ts.net"),
}
}
// TestEmptyBaseConfigNoTakeover checks that Set fails, and leaves the OS config
// alone, when the base config has no upstream resolvers.
func TestEmptyBaseConfigNoTakeover(t *testing.T) {
m, f, _ := newEmptyBaseConfigManager(t)
if err := m.Set(splitDNSOnlyConfig()); !errors.Is(err, errEmptyBaseConfig) {
t.Fatalf("Set = %v, want %v", err, errEmptyBaseConfig)
}
if len(f.OSConfig.Nameservers) != 0 {
t.Errorf("OSConfig.Nameservers = %v, want none", f.OSConfig.Nameservers)
}
}
// TestEmptyBaseConfigPlatforms checks which platforms withhold takeover when
// the OS base config has no resolvers. Sandboxed macOS and iOS are exempt:
// the network extension reapplies the config itself, via RecompileDNSConfig,
// when the OS nameservers change. A split-DNS-capable backend never reads the
// base config.
func TestEmptyBaseConfigPlatforms(t *testing.T) {
tests := []struct {
name string
goos string
sandboxedMacOS bool
split bool // OSConfigurator.SupportsSplitDNS
wantWithhold bool
}{
{name: "linux-direct", goos: "linux", wantWithhold: true},
{name: "windows", goos: "windows", wantWithhold: true},
{name: "freebsd", goos: "freebsd", wantWithhold: true},
{name: "darwin-tailscaled", goos: "darwin", wantWithhold: true},
// Apple's sandboxed builds do support split DNS, hence split: true.
{name: "ios", goos: "ios", split: true},
{name: "darwin-sandboxed", goos: "darwin", sandboxedMacOS: true, split: true},
// A split-DNS-capable backend (e.g. systemd-resolved) doesn't reach
// the base config at all; it scopes to its match domains instead.
{name: "linux-split", goos: "linux", split: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
tstest.Replace(t, &isSandboxedMacOS, func() bool { return test.sandboxedMacOS })
f := &fakeOSConfigurator{SplitDNS: test.split}
bus := eventbustest.NewBus(t)
dialer := tsdial.NewDialer(netmon.NewStatic())
dialer.SetBus(bus)
m := NewManager(t.Logf, f, health.NewTracker(bus), dialer, nil, nil, test.goos, bus)
m.resolver.TestOnlySetHook(f.SetResolver)
t.Cleanup(func() { m.Down() })
err := m.Set(splitDNSOnlyConfig())
if got := errors.Is(err, errEmptyBaseConfig); got != test.wantWithhold {
t.Fatalf("withheld takeover = %v (err %v), want %v", got, err, test.wantWithhold)
}
// Where takeover is withheld, the OS config must be untouched.
// The exempt platforms still install quad-100 with an empty "."
// route, longstanding behavior this change leaves alone.
if test.wantWithhold && len(f.OSConfig.Nameservers) != 0 {
t.Errorf("OSConfig.Nameservers = %v, want none", f.OSConfig.Nameservers)
}
})
}
}
// TestEmptyBaseConfigWarnable checks that the health warning is set while
// waiting and cleared once takeover succeeds.
func TestEmptyBaseConfigWarnable(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
m, f, ht := newEmptyBaseConfigManager(t)
if err := m.Set(splitDNSOnlyConfig()); !errors.Is(err, errEmptyBaseConfig) {
t.Fatalf("Set = %v, want %v", err, errEmptyBaseConfig)
}
if _, ok := ht.CurrentState().Warnings[EmptyBaseConfigWarnable.Code]; !ok {
t.Errorf("%s warning not set, want it while withholding takeover", EmptyBaseConfigWarnable.Code)
}
f.setBaseConfig(OSConfig{Nameservers: mustIPs("8.8.8.8")})
time.Sleep(baseConfigRetryTotal())
synctest.Wait()
if _, ok := ht.CurrentState().Warnings[EmptyBaseConfigWarnable.Code]; ok {
t.Errorf("%s warning still set after takeover, want cleared", EmptyBaseConfigWarnable.Code)
}
})
}
// TestEmptyBaseConfigRetryTakesOver checks that the retry installs the config
// once the OS publishes resolvers.
func TestEmptyBaseConfigRetryTakesOver(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
m, f, _ := newEmptyBaseConfigManager(t)
if err := m.Set(splitDNSOnlyConfig()); !errors.Is(err, errEmptyBaseConfig) {
t.Fatalf("Set = %v, want %v", err, errEmptyBaseConfig)
}
f.setBaseConfig(OSConfig{Nameservers: mustIPs("8.8.8.8")})
time.Sleep(baseConfigRetryTotal())
synctest.Wait()
if got := f.OSConfig.Nameservers; len(got) == 0 {
t.Error("OSConfig.Nameservers is empty, want takeover after resolvers appeared")
}
if rs := f.ResolverConfig.Routes["."]; len(rs) == 0 {
t.Error(`resolver "." route is empty, want the OS upstream resolver`)
}
})
}
// TestEmptyBaseConfigRetryGivesUp checks that the retry stops if resolvers
// never appear, after exactly baseConfigRetryAttempts tries. The count also
// pins that repeated failures share one goroutine rather than each starting
// another.
func TestEmptyBaseConfigRetryGivesUp(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
m, f, _ := newEmptyBaseConfigManager(t)
var reads atomic.Int64
f.onGetBaseConfig = func() { reads.Add(1) }
if err := m.Set(splitDNSOnlyConfig()); !errors.Is(err, errEmptyBaseConfig) {
t.Fatalf("Set = %v, want %v", err, errEmptyBaseConfig)
}
time.Sleep(2 * baseConfigRetryTotal())
synctest.Wait()
// One read for the initial Set, then one per retry attempt.
if got, want := reads.Load(), int64(1+baseConfigRetryAttempts); got != want {
t.Errorf("GetBaseConfig calls = %d, want %d", got, want)
}
m.mu.Lock()
waiting := m.waitingForBaseCfg
m.mu.Unlock()
if waiting {
t.Error("still waiting for base config, want the retry to have given up")
}
})
}
// TestEmptyBaseConfigRetrySurvivesError checks that a transient GetBaseConfig
// error doesn't end the retry early.
func TestEmptyBaseConfigRetrySurvivesError(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
m, f, _ := newEmptyBaseConfigManager(t)
if err := m.Set(splitDNSOnlyConfig()); !errors.Is(err, errEmptyBaseConfig) {
t.Fatalf("Set = %v, want %v", err, errEmptyBaseConfig)
}
f.setBaseConfigErrOnce(errors.New("transient resolvconf failure"))
f.setBaseConfig(OSConfig{Nameservers: mustIPs("8.8.8.8")})
time.Sleep(baseConfigRetryTotal())
synctest.Wait()
if got := f.OSConfig.Nameservers; len(got) == 0 {
t.Error("OSConfig.Nameservers is empty, want takeover despite the transient error")
}
})
}
// TestEmptyBaseConfigNoTakeoverAfterDown checks that a retry pending when Down
// runs abandons its apply, rather than reconfiguring DNS during shutdown.
func TestEmptyBaseConfigNoTakeoverAfterDown(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
m, f, _ := newEmptyBaseConfigManager(t)
if err := m.Set(splitDNSOnlyConfig()); !errors.Is(err, errEmptyBaseConfig) {
t.Fatalf("Set = %v, want %v", err, errEmptyBaseConfig)
}
// Resolvers appear, so the next attempt would otherwise take over.
f.setBaseConfig(OSConfig{Nameservers: mustIPs("8.8.8.8")})
if err := m.Down(); err != nil {
t.Fatalf("Down: %v", err)
}
time.Sleep(baseConfigRetryTotal())
synctest.Wait()
if got := f.OSConfig.Nameservers; len(got) != 0 {
t.Errorf("OSConfig.Nameservers = %v after Down, want none", got)
}
})
}
func TestTrampleRetrample(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f := &fakeOSConfigurator{}
f.BaseConfig = OSConfig{
Nameservers: mustIPs("1.1.1.1")}
config := Config{
Routes: upstreams("ts.net", "69.4.2.0", "foo.ts.net", ""),
SearchDomains: fqdns("foo.ts.net"),
}
bus := eventbustest.NewBus(t)
dialer := tsdial.NewDialer(netmon.NewStatic())
dialer.SetBus(bus)
m := NewManager(t.Logf, f, health.NewTracker(bus), dialer, nil, nil, "linux", bus)
// Initial set should error out and store the config
if err := m.Set(config); err != nil {
t.Fatalf("Want nil error. Got non-nil")
}
// Set no config
f.OSConfig = OSConfig{}
inj := eventbustest.NewInjector(t, bus)
eventbustest.Inject(inj, TrampleDNS{})
synctest.Wait()
t.Logf("OSConfig: %+v", f.OSConfig)
if reflect.DeepEqual(f.OSConfig, OSConfig{}) {
t.Errorf("Expected config to be set, got empty config")
}
})
}
// TestSystemDNSDoHUpgrade tests that if the user doesn't configure DNS servers
// in their tailnet, and the system DNS happens to be a known DoH provider,
// queries will use DNS-over-HTTPS.
func TestSystemDNSDoHUpgrade(t *testing.T) {
var (
// This is a non-routable TEST-NET-2 IP (RFC 5737).
testDoHResolverIP = netip.MustParseAddr("198.51.100.1")
// This is a non-routable TEST-NET-1 IP (RFC 5737).
testResponseIP = netip.MustParseAddr("192.0.2.1")
)
const testDomain = "test.example.com."
var (
mu sync.Mutex
dohRequestSeen bool
receivedQuery []byte
)
dohServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("[DoH Server] received request: %v %v", r.Method, r.URL)
if r.Method != httpm.POST {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Header.Get("Content-Type") != "application/dns-message" {
http.Error(w, "bad content type", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusInternalServerError)
return
}
mu.Lock()
defer mu.Unlock()
dohRequestSeen = true
receivedQuery = body
// Build a DNS response
response := buildTestDNSResponse(t, testDomain, testResponseIP)
w.Header().Set("Content-Type", "application/dns-message")
w.Write(response)
}))
t.Cleanup(dohServer.Close)
// Register the test IP to route to our mock DoH server
cleanup := publicdns.RegisterTestDoHEndpoint(testDoHResolverIP, dohServer.URL)
t.Cleanup(cleanup)
// This simulates a system with the single DoH-capable DNS server
// configured.
f := &fakeOSConfigurator{
SplitDNS: false, // non-split DNS required to use the forwarder
BaseConfig: OSConfig{
Nameservers: []netip.Addr{testDoHResolverIP},
},
}
logf := tstest.WhileTestRunningLogger(t)
bus := eventbustest.NewBus(t)
dialer := tsdial.NewDialer(netmon.NewStatic())
dialer.SetBus(bus)
m := NewManager(logf, f, health.NewTracker(bus), dialer, nil, &controlknobs.Knobs{}, "linux", bus)
t.Cleanup(func() { m.Down() })
// Set up hook to capture the resolver config
m.resolver.TestOnlySetHook(f.SetResolver)
// Configure the manager with routes but no default resolvers, which
// reads BaseConfig from the OS configurator.
config := Config{
Routes: upstreams("tailscale.com.", "10.0.0.1"),
SearchDomains: fqdns("tailscale.com."),
}
if err := m.Set(config); err != nil {
t.Fatal(err)
}
// Verify the resolver config has our test IP in Routes["."]
if f.ResolverConfig.Routes == nil {
t.Fatal("ResolverConfig.Routes is nil (SetResolver hook not called)")
}
const defaultRouteKey = "."
defaultRoute, ok := f.ResolverConfig.Routes[defaultRouteKey]
if !ok {
t.Fatalf("ResolverConfig.Routes[%q] not found", defaultRouteKey)
}
if !slices.ContainsFunc(defaultRoute, func(r *dnstype.Resolver) bool {
return r.Addr == testDoHResolverIP.String()
}) {
t.Errorf("test IP %v not found in Routes[%q], got: %v", testDoHResolverIP, defaultRouteKey, defaultRoute)
}
// Build a DNS query to something not handled by our split DNS route
// (tailscale.com) above.
query := buildTestDNSQuery(t, testDomain)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
resp, err := m.Query(ctx, query, "udp", netip.MustParseAddrPort("127.0.0.1:12345"))
if err != nil {
t.Fatal(err)
}
if len(resp) == 0 {
t.Fatal("empty response")
}
// Parse the response to verify we get our test IP back.
var parser dns.Parser
if _, err := parser.Start(resp); err != nil {
t.Fatalf("parsing response header: %v", err)
}
if err := parser.SkipAllQuestions(); err != nil {
t.Fatalf("skipping questions: %v", err)
}
answers, err := parser.AllAnswers()
if err != nil {
t.Fatalf("parsing answers: %v", err)
}
if len(answers) == 0 {
t.Fatal("no answers in response")
}
aRecord, ok := answers[0].Body.(*dns.AResource)
if !ok {
t.Fatalf("first answer is not A record: %T", answers[0].Body)
}
gotIP := netip.AddrFrom4(aRecord.A)
if gotIP != testResponseIP {
t.Errorf("wrong A record IP: got %v, want %v", gotIP, testResponseIP)
}
// Also verify that our DoH server received the query.
mu.Lock()
defer mu.Unlock()
if !dohRequestSeen {
t.Error("DoH server never received request")
}
if !bytes.Equal(receivedQuery, query) {
t.Errorf("DoH server received wrong query:\ngot: %x\nwant: %x", receivedQuery, query)
}
}
// buildTestDNSQuery builds a simple DNS A query for the given domain.
func buildTestDNSQuery(t *testing.T, domain string) []byte {
t.Helper()
builder := dns.NewBuilder(nil, dns.Header{})
builder.StartQuestions()
builder.Question(dns.Question{
Name: dns.MustNewName(domain),
Type: dns.TypeA,
Class: dns.ClassINET,
})
msg, err := builder.Finish()
if err != nil {
t.Fatal(err)
}
return msg
}
// buildTestDNSResponse builds a DNS response for the given query with the specified IP.
func buildTestDNSResponse(t *testing.T, domain string, ip netip.Addr) []byte {
t.Helper()
builder := dns.NewBuilder(nil, dns.Header{Response: true})
builder.StartQuestions()
builder.Question(dns.Question{
Name: dns.MustNewName(domain),
Type: dns.TypeA,
Class: dns.ClassINET,
})
builder.StartAnswers()
builder.AResource(dns.ResourceHeader{
Name: dns.MustNewName(domain),
Class: dns.ClassINET,
TTL: 300,
}, dns.AResource{A: ip.As4()})
msg, err := builder.Finish()
if err != nil {
t.Fatal(err)
}
return msg
}