Files
tailscale/net/dns/manager_darwin_test.go
T
Maisem AliandBrad Fitzpatrick 10672a63f4 net/dns: support global resolvers in macOS tailscaled
The macOS tailscaled DNS configurator only wrote /etc/resolver files, which can express split DNS but not a primary resolver. Teach it to configure a global resolver through the SystemConfiguration dynamic store using scutil when OSConfig has nameservers and no match domains.

Let non-sandboxed macOS tailscaled follow Linux split DNS behavior when its OS configurator reports split DNS support. This avoids synthesizing an upstream default route from the machine's base DNS when the netmap did not provide one. Keep iOS and sandboxed macOS app builds on the existing Apple base-config path because those use NetworkExtension DNS settings rather than tailscaled's /etc/resolver configurator.

Add tests for switching between split and global DNS, including cleanup of stale Tailscale-managed resolver files, removal of the dynamic-store global DNS key, and preservation of the sandboxed macOS behavior.

RELNOTE: tailscaled on macOS now supports configuring global DNS resolvers.

Updates #1338

Change-Id: I9b2b61f89750a5529fc0add1cd37b1b9a355db12

Signed-off-by: Maisem Ali <maisem@bold.dev>
2026-07-02 12:21:54 -07:00

289 lines
8.2 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package dns
import (
"errors"
"maps"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
)
func newTestConfigurator(t *testing.T) *darwinConfigurator {
t.Helper()
dir := t.TempDir()
resolvConf := filepath.Join(dir, "resolv.conf")
if err := os.WriteFile(resolvConf, []byte("nameserver 8.8.8.8\n"), 0644); err != nil {
t.Fatal(err)
}
resolverDir := filepath.Join(dir, "resolvers")
if err := os.Mkdir(resolverDir, 0755); err != nil {
t.Fatal(err)
}
return &darwinConfigurator{
logf: logger.Discard,
ifName: "utun99",
resolverDir: resolverDir,
resolvConfPath: resolvConf,
runScutil: func(string) (string, error) { return "No such key\n", nil },
}
}
func TestSetDNS(t *testing.T) {
c := newTestConfigurator(t)
tests := []struct {
name string
cfg OSConfig
fileContents map[string]string // path -> expected file contents
}{
{
name: "basic",
cfg: OSConfig{
Nameservers: []netip.Addr{netip.MustParseAddr("100.100.100.100")},
MatchDomains: []dnsname.FQDN{"example.com.", "ts.net."},
},
fileContents: map[string]string{
"example.com": macResolverFileHeader + "nameserver 100.100.100.100\n",
"ts.net": macResolverFileHeader + "nameserver 100.100.100.100\n",
},
},
{
name: "SearchDomains",
cfg: OSConfig{
Nameservers: []netip.Addr{netip.MustParseAddr("100.100.100.100")},
SearchDomains: []dnsname.FQDN{"tail1234.ts.net."},
MatchDomains: []dnsname.FQDN{"ts.net."},
},
fileContents: map[string]string{
"ts.net": macResolverFileHeader + "nameserver 100.100.100.100\n",
"search.tailscale": macResolverFileHeader + "search tail1234.ts.net\n",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := c.SetDNS(tt.cfg); err != nil {
t.Fatalf("SetDNS failed: %v", err)
}
// We want only the expected files in the resolverDir,
// and nothing else.
files, err := os.ReadDir(c.resolverDir)
if err != nil {
t.Fatalf("reading resolver directory: %v", err)
}
var fileNames []string
for _, f := range files {
fileNames = append(fileNames, f.Name())
}
if len(files) != len(tt.fileContents) {
t.Fatalf("expected %d resolver files, got %d\ngot: %v\nwant: %v",
len(tt.fileContents), len(files),
fileNames, slices.Collect(maps.Keys(tt.fileContents)),
)
}
// Check each file's contents.
for domain, expected := range tt.fileContents {
path := filepath.Join(c.resolverDir, domain)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading resolver file %q: %v", domain, err)
}
if string(data) != expected {
t.Errorf("resolver file %q contents mismatch:\ngot: %q\nwant: %q", domain, string(data), expected)
}
}
})
}
}
func TestSetDNSGlobal(t *testing.T) {
c := newTestConfigurator(t)
// Start with split DNS files present, so this test verifies that switching
// to a global resolver cleans up stale /etc/resolver state.
if err := c.SetDNS(OSConfig{
Nameservers: []netip.Addr{netip.MustParseAddr("100.100.100.100")},
SearchDomains: []dnsname.FQDN{"tail1234.ts.net."},
MatchDomains: []dnsname.FQDN{"ts.net."},
}); err != nil {
t.Fatalf("setting initial split DNS config failed: %v", err)
}
unmanaged := filepath.Join(c.resolverDir, "other.conf")
if err := os.WriteFile(unmanaged, []byte("# not ours\nnameserver 8.8.8.8\n"), 0644); err != nil {
t.Fatal(err)
}
var gotScripts []string
c.runScutil = func(script string) (string, error) {
gotScripts = append(gotScripts, script)
return "", nil
}
cfg := OSConfig{
Nameservers: []netip.Addr{
netip.MustParseAddr("100.100.100.100"),
netip.MustParseAddr("fd7a:115c:a1e0::53"),
},
SearchDomains: []dnsname.FQDN{"tail1234.ts.net."},
}
if err := c.SetDNS(cfg); err != nil {
t.Fatalf("SetDNS failed: %v", err)
}
wantScript := strings.Join([]string{
"d.init",
"d.add SearchOrder # 100000",
"d.add ServerAddresses * 100.100.100.100 fd7a:115c:a1e0::53",
`d.add SupplementalMatchDomains * ""`,
"d.add SearchDomains * tail1234.ts.net",
"set " + macOSGlobalDNSKey,
"quit",
"",
}, "\n")
if !slices.Equal(gotScripts, []string{wantScript}) {
t.Errorf("scutil scripts mismatch:\ngot:\n%s\nwant:\n%s", strings.Join(gotScripts, "\n---\n"), wantScript)
}
files, err := os.ReadDir(c.resolverDir)
if err != nil {
t.Fatalf("reading resolver directory: %v", err)
}
var fileNames []string
for _, f := range files {
fileNames = append(fileNames, f.Name())
}
if !slices.Equal(fileNames, []string{"other.conf"}) {
t.Fatalf("expected only unmanaged resolver file after global DNS config, got %v", fileNames)
}
}
func TestSetDNSSplitRemovesGlobal(t *testing.T) {
c := newTestConfigurator(t)
// Start with global DNS configured, so this test verifies the stale global
// dynamic-store key is removed when switching back to split DNS.
c.runScutil = func(script string) (string, error) {
return "", nil
}
if err := c.SetDNS(OSConfig{
Nameservers: []netip.Addr{netip.MustParseAddr("100.100.100.100")},
}); err != nil {
t.Fatalf("setting initial global DNS config failed: %v", err)
}
var gotScripts []string
c.runScutil = func(script string) (string, error) {
gotScripts = append(gotScripts, script)
return "", nil
}
cfg := OSConfig{
Nameservers: []netip.Addr{netip.MustParseAddr("100.100.100.100")},
MatchDomains: []dnsname.FQDN{"ts.net."},
}
if err := c.SetDNS(cfg); err != nil {
t.Fatalf("SetDNS failed: %v", err)
}
wantScript := "remove " + macOSGlobalDNSKey + "\nquit\n"
if !slices.Equal(gotScripts, []string{wantScript}) {
t.Errorf("scutil scripts mismatch:\ngot:\n%s\nwant:\n%s", strings.Join(gotScripts, "\n---\n"), wantScript)
}
const wantFile = macResolverFileHeader + "nameserver 100.100.100.100\n"
gotFile, err := os.ReadFile(filepath.Join(c.resolverDir, "ts.net"))
if err != nil {
t.Fatalf("reading split resolver file: %v", err)
}
if string(gotFile) != wantFile {
t.Errorf("split resolver file contents mismatch:\ngot: %q\nwant: %q", string(gotFile), wantFile)
}
}
func TestSetDNS_PathTraversal(t *testing.T) {
c := newTestConfigurator(t)
// Use a simple path traversal that tries to escape the resolver
// directory. With the previously-vulnerable code (os.WriteFile with string
// concatenation), this writes to the parent directory. With the
// fix (os.Root), this is rejected.
traversals := []dnsname.FQDN{
"../evil.",
"../../evil.",
"sub/../../evil.",
}
for _, traversal := range traversals {
cfg := OSConfig{
Nameservers: []netip.Addr{netip.MustParseAddr("100.100.100.100")},
MatchDomains: []dnsname.FQDN{traversal},
}
if err := c.SetDNS(cfg); err == nil {
t.Errorf("SetDNS with MatchDomain %q should have failed, but succeeded", traversal)
}
}
// Verify no file named "evil" was written in the parent of resolverDir.
parent := filepath.Dir(c.resolverDir)
if fileExists(filepath.Join(parent, "evil")) {
t.Fatal("file 'evil' was written to parent directory via path traversal")
}
}
func TestRemoveResolverFiles(t *testing.T) {
c := newTestConfigurator(t)
// Write a tailscale-managed file.
managed := filepath.Join(c.resolverDir, "ts.net")
if err := os.WriteFile(managed, []byte(macResolverFileHeader+"nameserver 100.100.100.100\n"), 0644); err != nil {
t.Fatal(err)
}
// Write a non-tailscale file that should be left alone.
unmanaged := filepath.Join(c.resolverDir, "other.conf")
if err := os.WriteFile(unmanaged, []byte("# not ours\nnameserver 8.8.8.8\n"), 0644); err != nil {
t.Fatal(err)
}
// Remove all resolver files and verify that only the managed one is removed.
if err := c.removeResolverFiles(func(domain string) bool { return true }); err != nil {
t.Fatal(err)
}
if fileExists(managed) {
t.Error("managed file should have been removed")
}
if !fileExists(unmanaged) {
t.Error("unmanaged file should still exist")
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
return false
} else if err == nil {
return true
}
panic("unexpected error checking file existence: " + err.Error())
}