Files
tailscale/net/dns/manager_test.go
Nick Khyl eaf3fc8daf net/dns: don't make quad-100 the primary resolver on Windows when hosts are unrouted
In #20775 (and the follow-up in #21471), we started installing quad-100
as the OS's primary resolver whenever cfg has hosts not covered by a split
DNS route, so that sandboxed macOS clients could still answer them.

However, that check was added to the generic split DNS gate rather than
to the Apple-specific one, so it also applies on Windows, where dcfg.Hosts
always carries every node's records and MagicDNSHostsUnrouted is set
whenever MagicDNS is disabled. As a result, a Windows node with MagicDNS
off and at least one split DNS route (or with MagicDNS on and a peer shared
in from another tailnet) gets a catch-all NRPT rule pointing at quad-100,
and all DNS traffic on the device is proxied through it instead of only
the configured suffixes.

The check is redundant for Apple platforms: iOS never takes the scoped
path there, and sandboxed macOS only does so via scopeApple, which already
requires !cfg.requiresPrimaryResolver(). This removes it from the generic
gate, restoring the pre-1.104 behavior on Windows, and adds tests.

Updates tailscale/corp#45534
Fixes #21863

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2026-10-20 09:21:11 -04:00

1905 lines
56 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"),
},
},
{
// A custom split resolver requires Mode B even with macOS scoping
// enabled: otherwise the split suffix becomes a global search suffix.
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", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(
".", "8.8.8.8",
"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 can remain scoped when both its forward and
// synthesized PTR records have authoritative routes.
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.", "", "64.100.in-addr.arpa.", ""),
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("64.100.in-addr.arpa", "corp.ts.net", "extra.example.com"),
},
rs: resolver.Config{
Hosts: hosts("extra.example.com.", "100.64.0.9"),
LocalDomains: fqdns("64.100.in-addr.arpa.", "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,
},
{
// Windows with MagicDNS off: control sends a split route but no
// global resolvers. Hosts carries every node (Windows always
// does) and MagicDNSHostsUnrouted is set, but neither may force
// quad-100 to become the OS's primary (catch-all) resolver; the
// OS should only get an NRPT rule for the split suffix.
name: "windows-magicdns-off-split-route-stays-scoped",
in: Config{
Routes: upstreams("corp.example.com.", "10.0.0.53"),
SearchDomains: fqdns("corp.ts.net"),
Hosts: hosts(
"self.corp.ts.net.", "100.100.0.1",
"peer.corp.ts.net.", "100.100.0.2"),
MagicDNSHostsUnrouted: true,
},
split: true,
bs: OSConfig{Nameservers: mustIPs("192.168.1.1")},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("corp.ts.net"),
MatchDomains: fqdns("corp.example.com"),
},
rs: resolver.Config{
Routes: upstreams("corp.example.com.", "10.0.0.53"),
Hosts: hosts(
"self.corp.ts.net.", "100.100.0.1",
"peer.corp.ts.net.", "100.100.0.2"),
},
goos: "windows",
},
{
// Windows with MagicDNS on and a shared-in peer from another
// tailnet. Its name is outside our MagicDNS root, so Hosts has a
// name no route covers; that still must not make quad-100 the
// primary resolver.
name: "windows-magicdns-on-shared-peer-stays-scoped",
in: Config{
Routes: upstreams(
"corp.ts.net.", "",
"100.100.in-addr.arpa.", ""),
SearchDomains: fqdns("corp.ts.net"),
Hosts: hosts(
"self.corp.ts.net.", "100.100.0.1",
"shared.tail1234.ts.net.", "100.100.0.9"),
},
split: true,
bs: OSConfig{Nameservers: mustIPs("192.168.1.1")},
os: OSConfig{
Hosts: []*HostEntry{
{Addr: netip.MustParseAddr("100.100.0.1"), Hosts: []string{"self.corp.ts.net.", "self"}},
},
Nameservers: serviceAddr46,
SearchDomains: fqdns("corp.ts.net"),
MatchDomains: fqdns("100.100.in-addr.arpa", "corp.ts.net"),
},
rs: resolver.Config{
Hosts: hosts(
"self.corp.ts.net.", "100.100.0.1",
"shared.tail1234.ts.net.", "100.100.0.9"),
LocalDomains: fqdns("100.100.in-addr.arpa.", "corp.ts.net."),
},
goos: "windows",
},
{
// 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: quad-100 stays
// the primary resolver (the scoping optimization is skipped when split
// routes carry custom resolvers), so MatchDomains is empty. Split-DNS
// suffixes are match-only and never enter the search list.
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,
bs: OSConfig{
Nameservers: mustIPs("192.168.1.1"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(
".", "192.168.1.1",
"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 forward records are covered, but their public-IP PTR names
// are not. Keep quad-100 primary so it can answer those PTR queries.
name: "magic-split-uncovered-ptr-keeps-primary-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: true,
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
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."),
},
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 iOS: quad-100 stays
// the primary resolver (the scoping optimization is skipped when split
// routes carry custom resolvers), so MatchDomains is empty. Split-DNS
// suffixes are match-only and never enter the search list.
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,
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(
".", "8.8.8.8",
"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("optimistic-display.ts.net.", "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,
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
},
os: OSConfig{
Nameservers: serviceAddr46,
SearchDomains: fqdns("optimistic-display.ts.net."),
},
rs: resolver.Config{
Routes: upstreams(
".", "8.8.8.8",
"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 also withhold
// primary-mode takeover, but rely on the network extension to recompile when
// the OS nameservers change rather than starting the retry goroutine. A
// non-Apple split-DNS-capable backend doesn't need to read the base config.
func TestEmptyBaseConfigPlatforms(t *testing.T) {
tests := []struct {
name string
goos string
sandboxedMacOS bool
split bool // OSConfigurator.SupportsSplitDNS
wantWithhold bool
wantRetry bool
customRoute bool
}{
{name: "linux-direct", goos: "linux", wantWithhold: true, wantRetry: true},
{name: "windows", goos: "windows", wantWithhold: true, wantRetry: true},
{name: "freebsd", goos: "freebsd", wantWithhold: true, wantRetry: true},
{name: "darwin-tailscaled", goos: "darwin", wantWithhold: true, wantRetry: true},
// The special ts.net route can stay scoped on iOS, but custom
// split resolvers require primary mode. Sandboxed macOS defaults
// to primary mode even for the ts.net-only case.
{name: "ios-scoped", goos: "ios", split: true},
{name: "ios-primary", goos: "ios", split: true, customRoute: true, wantWithhold: true},
{name: "darwin-sandboxed", goos: "darwin", sandboxedMacOS: true, split: true, wantWithhold: 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() })
cfg := splitDNSOnlyConfig()
if test.customRoute {
cfg.Routes["example.com."] = mustRes("192.0.2.53")
}
err := m.Set(cfg)
if got := err != nil; got != test.wantWithhold {
t.Fatalf("withheld takeover = %v (err %v), want %v", got, err, test.wantWithhold)
}
if got := errors.Is(err, errEmptyBaseConfig); got != test.wantWithhold {
t.Errorf("empty-base error = %v (err %v), want %v", got, err, test.wantWithhold)
}
if got := m.health.IsUnhealthy(EmptyBaseConfigWarnable); got != test.wantWithhold {
t.Errorf("empty-base warning = %v, want %v", got, test.wantWithhold)
}
m.mu.Lock()
waiting := m.waitingForBaseCfg
m.mu.Unlock()
if waiting != test.wantRetry {
t.Errorf("waiting for base config = %v, want %v", waiting, test.wantRetry)
}
// Where takeover is withheld, the OS config must be untouched.
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`)
}
})
}
// TestEmptyBaseConfigRetryWithResolvers checks that full-fidelity upstreams
// without a Nameservers entry satisfy the retry's empty-base guard.
func TestEmptyBaseConfigRetryWithResolvers(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)
}
want := []*dnstype.Resolver{{
Addr: "https://dns.example/dns-query",
BootstrapResolution: mustIPs("192.0.2.53"),
}}
f.setBaseConfig(OSConfig{Resolvers: want})
time.Sleep(baseConfigRetryTotal())
synctest.Wait()
if len(f.OSConfig.Nameservers) == 0 {
t.Error("OSConfig.Nameservers is empty, want takeover after resolvers appeared")
}
if got := f.ResolverConfig.Routes["."]; !reflect.DeepEqual(got, want) {
t.Errorf("default route = %v, want %v", got, want)
}
if _, ok := ht.CurrentState().Warnings[EmptyBaseConfigWarnable.Code]; ok {
t.Error("empty-base warning still set after full-fidelity resolvers appeared")
}
})
}
// 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
}