Squashed Commit for Fix of issue 2899

This commit is contained in:
Rogan Lynch 2025-12-17 14:11:14 -08:00
parent eb788cd007
commit 62a306ed50
No known key found for this signature in database
9 changed files with 765 additions and 8 deletions

View file

@ -294,7 +294,10 @@ dns:
# - https://dns.nextdns.io/abc123
# Split DNS (see https://tailscale.com/kb/1054/dns/),
# a map of domains and which DNS server to use for each.
# It uses TS's "MagicDNS" Quad 100 functionality internally.
# Provides a map of domains and which DNS server to use for each.
# Queries for domains listed here will be forwarded to the
# specified DNS servers instead of the global nameservers.
split:
{}
# foo.bar.com:
@ -303,6 +306,16 @@ dns:
# - 1.1.1.1
# - 8.8.8.8
# Split DNS - Fallback resolvers.
# These resolvers are used when split DNS is configured and a query doesn't
# match any of the split DNS domains. As a fall through.
# If not specified, the global nameservers will be used.
# Different clients may treat these subtly differently
# variably including them in their internal resolver list.
split_fallback: []
# - 1.1.1.1
# - 8.8.8.8
# Set custom DNS search domains. With MagicDNS enabled,
# your tailnet base_domain is always the first search domain.
search_domains: []

View file

@ -113,8 +113,9 @@ type DNSConfig struct {
}
type Nameservers struct {
Global []string
Split map[string][]string
Global []string
Split map[string][]string
SplitFallback []string
}
type SqliteConfig struct {
@ -485,12 +486,37 @@ func validateServerConfig() error {
)
}
// Validate override_local_dns configuration
if viper.GetBool("dns.override_local_dns") {
if global := viper.GetStringSlice("dns.nameservers.global"); len(global) == 0 {
errorText += "Fatal config error: dns.nameservers.global must be set when dns.override_local_dns is true\n"
}
}
// Validate split DNS configuration
splitDNS := viper.GetStringMapStringSlice("dns.nameservers.split")
if len(splitDNS) > 0 {
// Check if fallback resolvers are available
fallbackResolvers := viper.GetStringSlice("dns.split_dns_fallback_resolvers")
globalResolvers := viper.GetStringSlice("dns.nameservers.global")
if len(fallbackResolvers) == 0 && len(globalResolvers) == 0 {
errorText += "Fatal config error: when dns.nameservers.split is configured, either dns.split_dns_fallback_resolvers or dns.nameservers.global must be set\n"
}
// Log info if fallback resolvers will be derived from global resolvers
if len(fallbackResolvers) == 0 && len(globalResolvers) > 0 {
log.Info().
Msg("dns.split_dns_fallback_resolvers not configured - using dns.nameservers.global as fallback resolvers for split DNS")
}
}
// Validate MagicDNS configuration
if viper.GetBool("dns.magic_dns") {
if global := viper.GetStringSlice("dns.nameservers.global"); len(global) == 0 {
errorText += "Fatal config error: dns.nameservers.global must be set when dns.magic_dns is true\n"
}
}
// Validate tuning parameters
if size := viper.GetInt("tuning.node_store_batch_size"); size <= 0 {
errorText += fmt.Sprintf(
@ -712,6 +738,7 @@ func dns() (DNSConfig, error) {
dns.OverrideLocalDNS = viper.GetBool("dns.override_local_dns")
dns.Nameservers.Global = viper.GetStringSlice("dns.nameservers.global")
dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split")
dns.Nameservers.SplitFallback = viper.GetStringSlice("dns.nameservers.split_fallback")
dns.SearchDomains = viper.GetStringSlice("dns.search_domains")
dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path")
@ -806,6 +833,37 @@ func (d *DNSConfig) splitResolvers() map[string][]*dnstype.Resolver {
return routes
}
// splitDNSFallbackResolvers returns the fallback DNS resolvers for split DNS
// defined in the config file.
// If a nameserver is a valid IP, it will be used as a Fallback Regular resolver.
// If a nameserver is a valid URL, it will be used as a Fallback DoH resolver.
// If a nameserver is neither a valid URL nor a valid IP, it will be ignored.
func (d *DNSConfig) splitDNSFallbackResolvers() []*dnstype.Resolver {
var resolvers []*dnstype.Resolver
for _, nsStr := range d.Nameservers.SplitFallback {
if _, err := netip.ParseAddr(nsStr); err == nil {
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
})
continue
}
if _, err := url.Parse(nsStr); err == nil {
resolvers = append(resolvers, &dnstype.Resolver{
Addr: nsStr,
})
continue
}
log.Warn().Msgf("Invalid split DNS fallback resolver %q - must be a valid IP address or URL, ignoring", nsStr)
}
return resolvers
}
func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig {
cfg := tailcfg.DNSConfig{}
@ -815,14 +873,37 @@ func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig {
cfg.Proxied = dns.MagicDNS
cfg.ExtraRecords = dns.ExtraRecords
if dns.OverrideLocalDNS {
cfg.Resolvers = dns.globalResolvers()
} else {
cfg.FallbackResolvers = dns.globalResolvers()
globalResolvers := dns.globalResolvers()
// Only populate main Resolvers field if:
// 1. MagicDNS is enabled (MagicDNS supersedes override_local_dns), OR
// 2. override_local_dns is explicitly enabled
//
// This prevents leaking DNS configuration to clients when neither
// MagicDNS nor override_local_dns are enabled.
// See: https://github.com/juanfont/headscale/issues/2899
if dns.MagicDNS || dns.OverrideLocalDNS {
cfg.Resolvers = globalResolvers
}
routes := dns.splitResolvers()
cfg.Routes = routes
// Populate FallbackResolvers when split DNS is configured.
// FallbackResolvers are used when a split DNS query doesn't match any route.
// They are needed even when override_local_dns=false because the Magic DNS Forwarder
// requires fallback resolvers to function properly.
if len(routes) > 0 {
fallbackResolvers := dns.splitDNSFallbackResolvers()
if len(fallbackResolvers) > 0 {
cfg.FallbackResolvers = fallbackResolvers
} else if len(globalResolvers) > 0 {
// Backwards compatibility measure
cfg.FallbackResolvers = globalResolvers
}
}
if dns.BaseDomain != "" {
cfg.Domains = []string{dns.BaseDomain}
}

View file

@ -125,7 +125,7 @@ func TestReadConfig(t *testing.T) {
},
},
{
name: "dns-to-tailcfg.DNSConfig",
name: "dns-to-tailcfg.DNSConfig-no-magic-no-override",
configPath: "testdata/dns_full_no_magic.yaml",
setup: func(t *testing.T) (any, error) {
dns, err := dns()
@ -237,6 +237,133 @@ func TestReadConfig(t *testing.T) {
"policy.path": "/etc/policy.hujson",
},
},
{
name: "dns-override-false-with-split-dns",
configPath: "testdata/dns_override_false_with_split.yaml",
setup: func(t *testing.T) (any, error) {
_, err := LoadServerConfig()
if err != nil {
return nil, err
}
dns, err := dns()
if err != nil {
return nil, err
}
return dnsToTailcfgDNS(dns), nil
},
want: &tailcfg.DNSConfig{
Proxied: false,
Domains: []string{"example.com", "test.com"},
// No Resolvers - override_local_dns is false
Routes: map[string][]*dnstype.Resolver{
"foo.bar.com": {{Addr: "1.1.1.1"}},
"darp.headscale.net": {{Addr: "8.8.8.8"}},
},
FallbackResolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"},
{Addr: "1.0.0.1"},
},
},
},
{
name: "dns-split-with-explicit-fallback-resolvers",
configPath: "testdata/dns_split_with_fallback.yaml",
setup: func(t *testing.T) (any, error) {
_, err := LoadServerConfig()
if err != nil {
return nil, err
}
dns, err := dns()
if err != nil {
return nil, err
}
return dnsToTailcfgDNS(dns), nil
},
want: &tailcfg.DNSConfig{
Proxied: false,
Domains: []string{"example.com"},
Resolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"},
{Addr: "1.0.0.1"},
},
Routes: map[string][]*dnstype.Resolver{
"foo.bar.com": {{Addr: "1.1.1.1"}},
"darp.headscale.net": {{Addr: "8.8.8.8"}},
},
FallbackResolvers: []*dnstype.Resolver{
{Addr: "9.9.9.9"},
{Addr: "8.8.4.4"},
},
},
},
{
name: "dns-split-without-explicit-fallback-uses-global",
configPath: "testdata/dns_split_without_fallback.yaml",
setup: func(t *testing.T) (any, error) {
_, err := LoadServerConfig()
if err != nil {
return nil, err
}
dns, err := dns()
if err != nil {
return nil, err
}
return dnsToTailcfgDNS(dns), nil
},
want: &tailcfg.DNSConfig{
Proxied: false,
Domains: []string{"example.com"},
Resolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"},
{Addr: "1.0.0.1"},
},
Routes: map[string][]*dnstype.Resolver{
"foo.bar.com": {{Addr: "1.1.1.1"}},
"darp.headscale.net": {{Addr: "8.8.8.8"}},
},
FallbackResolvers: []*dnstype.Resolver{
{Addr: "1.1.1.1"},
{Addr: "1.0.0.1"},
},
},
},
{
name: "dns-split-no-fallback-source-error",
configPath: "testdata/dns_split_no_fallback_error.yaml",
setup: func(t *testing.T) (any, error) {
return LoadServerConfig()
},
wantErr: "Fatal config error: when dns.nameservers.split is configured, either dns.nameservers.split_fallback or dns.nameservers.global must be set",
},
{
name: "dns-global-without-override-warning",
configPath: "testdata/dns_global_without_override.yaml",
setup: func(t *testing.T) (any, error) {
_, err := LoadServerConfig()
if err != nil {
return nil, err
}
dns, err := dns()
if err != nil {
return nil, err
}
return dnsToTailcfgDNS(dns), nil
},
want: &tailcfg.DNSConfig{
Proxied: false,
Domains: []string{"example.com"},
// No Resolvers - override_local_dns is false
Routes: map[string][]*dnstype.Resolver{},
},
},
}
for _, tt := range tests {

View file

@ -0,0 +1,20 @@
# minimum to not fatal
noise:
private_key_path: "private_key.pem"
server_url: "https://derp.no"
prefixes:
v4: "100.64.0.0/10"
database:
type: sqlite3
dns:
magic_dns: false
base_domain: example.com
override_local_dns: false
nameservers:
global:
- 1.1.1.1
- 1.0.0.1

View file

@ -0,0 +1,29 @@
# minimum to not fatal
noise:
private_key_path: "private_key.pem"
server_url: "https://derp.no"
prefixes:
v4: "100.64.0.0/10"
database:
type: sqlite3
dns:
magic_dns: false
base_domain: example.com
override_local_dns: false
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
split:
foo.bar.com:
- 1.1.1.1
darp.headscale.net:
- 8.8.8.8
search_domains:
- test.com

View file

@ -0,0 +1,20 @@
# minimum to not fatal
noise:
private_key_path: "private_key.pem"
server_url: "https://derp.no"
prefixes:
v4: "100.64.0.0/10"
database:
type: sqlite3
dns:
magic_dns: false
base_domain: example.com
override_local_dns: false
nameservers:
split:
foo.bar.com:
- 1.1.1.1

View file

@ -0,0 +1,30 @@
# minimum to not fatal
noise:
private_key_path: "private_key.pem"
server_url: "https://derp.no"
prefixes:
v4: "100.64.0.0/10"
database:
type: sqlite3
dns:
magic_dns: false
base_domain: example.com
override_local_dns: true
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
split:
foo.bar.com:
- 1.1.1.1
darp.headscale.net:
- 8.8.8.8
split_dns_fallback_resolvers:
- 9.9.9.9
- 8.8.4.4

View file

@ -0,0 +1,26 @@
# minimum to not fatal
noise:
private_key_path: "private_key.pem"
server_url: "https://derp.no"
prefixes:
v4: "100.64.0.0/10"
database:
type: sqlite3
dns:
magic_dns: false
base_domain: example.com
override_local_dns: true
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
split:
foo.bar.com:
- 1.1.1.1
darp.headscale.net:
- 8.8.8.8

View file

@ -226,3 +226,414 @@ func TestResolveMagicDNSExtraRecordsPath(t *testing.T) {
assertCommandOutputContains(t, client, []string{"dig", "copy.myvpn.example.com"}, "8.8.8.8")
}
}
// TestDNSOverrideLocalBehavior tests issue #2899
// https://github.com/juanfont/headscale/issues/2899
//
// Correct behavior:
// - MagicDNS supersedes override_local_dns
// - When MagicDNS=true: Always send Resolvers (regardless of override_local_dns)
// - When MagicDNS=false:
// - override_local_dns=true: Send Resolvers
// - override_local_dns=false/unset: No Resolvers
func TestDNSOverrideLocalBehavior(t *testing.T) {
IntegrationSkip(t)
// Test 1: MagicDNS=true, override_local_dns=true
// Expected: DNS resolvers configured (MagicDNS supersedes)
t.Run("magic_dns_true_override_true", func(t *testing.T) {
spec := ScenarioSpec{
NodesPerUser: 1,
Users: []string{"user1"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithTestName("dns-override-true"),
hsic.WithConfigEnv(map[string]string{
"HEADSCALE_DNS_MAGIC_DNS": "true",
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "true",
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
}),
)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
for _, client := range allClients {
assertDNSResolversConfigured(t, client, []string{"8.8.8.8", "1.1.1.1"})
}
})
// Test 2: override_local_dns = false
// Expected: DNS resolvers should be configured
t.Run("override_local_dns_false", func(t *testing.T) {
spec := ScenarioSpec{
NodesPerUser: 1,
Users: []string{"user1"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithTestName("dns-override-false"),
hsic.WithConfigEnv(map[string]string{
"HEADSCALE_DNS_MAGIC_DNS": "true",
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "false",
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
}),
)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
for _, client := range allClients {
assertDNSResolversConfigured(t, client, []string{"8.8.8.8", "1.1.1.1"})
}
})
// Test 3: override_local_dns not set (using default from DefaultConfigEnv)
// Expected: DNS resolvers should be configured consistently with explicit false
t.Run("override_local_dns_default", func(t *testing.T) {
spec := ScenarioSpec{
NodesPerUser: 1,
Users: []string{"user1"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
// Don't set HEADSCALE_DNS_OVERRIDE_LOCAL_DNS, use the default
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithTestName("dns-override-default"),
hsic.WithConfigEnv(map[string]string{
"HEADSCALE_DNS_MAGIC_DNS": "true",
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
// HEADSCALE_DNS_OVERRIDE_LOCAL_DNS intentionally not set
}),
)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
for _, client := range allClients {
assertDNSResolversConfigured(t, client, []string{"8.8.8.8", "1.1.1.1"})
}
})
}
// assertDNSResolversConfigured checks that the Tailscale client has the expected DNS resolvers configured.
// It uses EventuallyWithT to handle eventual consistency as DNS configuration may take time to propagate.
func assertDNSResolversConfigured(t *testing.T, client TailscaleClient, expectedResolvers []string) {
t.Helper()
assert.EventuallyWithT(t, func(c *assert.CollectT) {
// Query DNS status from the client
// The netmap contains the DNS configuration sent by headscale
netmap, err := client.Netmap()
assert.NoError(c, err, "Failed to get netmap from client %s", client.Hostname())
if netmap == nil {
assert.Fail(c, "Netmap is nil for client %s", client.Hostname())
return
}
if netmap.DNS.Resolvers == nil {
assert.Fail(c, "DNS Resolvers is nil for client %s", client.Hostname())
return
}
// Extract resolver IPs from the netmap
// Resolver.Addr is a string (can be IP or DoH URL)
var actualResolvers []string
for _, resolver := range netmap.DNS.Resolvers {
actualResolvers = append(actualResolvers, resolver.Addr)
}
assert.NotEmpty(c, actualResolvers,
"Client %s should have DNS resolvers configured, but none were found",
client.Hostname())
// Check that all expected resolvers are present
for _, expected := range expectedResolvers {
assert.Contains(c, actualResolvers, expected,
"Client %s should have resolver %s configured. Actual resolvers: %v",
client.Hostname(), expected, actualResolvers)
}
}, 30*time.Second, 2*time.Second,
"DNS resolvers should be configured on client %s with resolvers %v",
client.Hostname(), expectedResolvers)
}
// TestDNSOverrideLocalWithMagicDNSDisabled tests that when MagicDNS is disabled,
// DNS resolvers should not be pushed to clients regardless of override_local_dns setting.
func TestDNSOverrideLocalWithMagicDNSDisabled(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
NodesPerUser: 1,
Users: []string{"user1"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{},
hsic.WithTestName("dns-magicdns-disabled"),
hsic.WithConfigEnv(map[string]string{
"HEADSCALE_DNS_MAGIC_DNS": "false",
"HEADSCALE_DNS_BASE_DOMAIN": "example.com",
"HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "false",
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": "8.8.8.8 1.1.1.1",
}),
)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
for _, client := range allClients {
// When MagicDNS is disabled, no resolvers should be configured
assert.EventuallyWithT(t, func(c *assert.CollectT) {
netmap, err := client.Netmap()
assert.NoError(c, err, "Failed to get netmap from client %s", client.Hostname())
if netmap == nil {
assert.Fail(c, "Netmap is nil for client %s", client.Hostname())
return
}
// When MagicDNS is off, DNS configuration might be nil or empty
if netmap.DNS.Resolvers != nil {
assert.Empty(c, netmap.DNS.Resolvers,
"Client %s should NOT have DNS resolvers when MagicDNS is disabled",
client.Hostname())
}
}, 30*time.Second, 2*time.Second,
"DNS resolvers should NOT be configured when MagicDNS is disabled on client %s",
client.Hostname())
}
}
// This is a Canary test - to be sure that TS client doesn't leak DNS config when it shouldn't.
// Even though both Global Resolver, split DNS & Fallback resolvers are configured, the client should not receive any DNS resolvers
// when override_local_dns is false. (otherwise split DNS is overriding the override_local_dns setting at the client).
// TestDNSSplitWithoutOverride tests that when split DNS is enabled but override_local_dns is false,
// the client should NOT receive global DNS resolvers, but SHOULD receive split DNS routes and fallback resolvers,
// but they should not make it to the hosts' actual DNS configuration.
// This test uses distinct DNS server addresses to identify which configuration field any leaked values came from.
func TestDNSSplitWithoutOverride(t *testing.T) {
IntegrationSkip(t)
spec := ScenarioSpec{
NodesPerUser: 1,
Users: []string{"user1"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
// Use distinct DNS server addresses to identify source of any leaked configuration:
// - Global nameservers: 1.1.1.1, 1.0.0.1 (should NOT appear in client Resolvers)
// - Fallback resolvers: 9.9.9.9, 149.112.112.112 (SHOULD appear in FallbackResolvers)
// - Split DNS resolvers: 8.8.8.8, 8.8.4.4 (SHOULD appear in Routes)
// Create a custom config file with split DNS configuration
configYAML := []byte(`
noise:
private_key_path: /tmp/noise_private.key
server_url: http://headscale:8080
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
allocation: sequential
database:
type: sqlite3
sqlite:
path: /tmp/integration_test_db.sqlite3
dns:
magic_dns: false
base_domain: example.com
override_local_dns: false
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
split:
corp.example.com:
- 8.8.8.8
internal.example.com:
- 8.8.4.4
split_fallback:
- 9.9.9.9
- 149.112.112.112
`)
err = scenario.CreateHeadscaleEnv(
[]tsic.Option{
tsic.WithDockerEntrypoint([]string{
"/bin/sh",
"-c",
"/bin/sleep 3 ; apk add python3 curl bind-tools ; update-ca-certificates ; tailscaled --tun=tsdev",
}),
},
hsic.WithTestName("dns-split-no-override"),
hsic.WithFileInContainer("/etc/headscale/config.yaml", configYAML),
)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
for _, client := range allClients {
assert.EventuallyWithT(t, func(c *assert.CollectT) {
netmap, err := client.Netmap()
assert.NoError(c, err, "Failed to get netmap from client %s", client.Hostname())
if netmap == nil {
assert.Fail(c, "Netmap is nil for client %s", client.Hostname())
return
}
// Critical assertion: Resolvers should be nil or empty when override_local_dns is false
if len(netmap.DNS.Resolvers) > 0 {
var leakedResolvers []string
for _, resolver := range netmap.DNS.Resolvers {
leakedResolvers = append(leakedResolvers, resolver.Addr)
// Identify which configuration field this resolver came from
switch resolver.Addr {
case "1.1.1.1", "1.0.0.1":
assert.Fail(c, "Client %s received global nameserver %s in Resolvers field - this should NOT happen when override_local_dns=false. "+
"This value came from dns.nameservers.global configuration.",
client.Hostname(), resolver.Addr)
case "9.9.9.9", "149.112.112.112":
assert.Fail(c, "Client %s received fallback resolver %s in Resolvers field - this should be in FallbackResolvers, not Resolvers. "+
"This value came from dns.split_dns_fallback_resolvers configuration.",
client.Hostname(), resolver.Addr)
case "8.8.8.8", "8.8.4.4":
assert.Fail(c, "Client %s received split DNS resolver %s in Resolvers field - this should be in Routes, not Resolvers. "+
"This value came from dns.nameservers.split configuration.",
client.Hostname(), resolver.Addr)
default:
assert.Fail(c, "Client %s received unexpected resolver %s in Resolvers field",
client.Hostname(), resolver.Addr)
}
}
assert.Fail(c, "Client %s should NOT have Resolvers configured when override_local_dns=false, but found: %v",
client.Hostname(), leakedResolvers)
return
}
// FallbackResolvers should be configured with the explicit fallback resolvers
assert.NotNil(c, netmap.DNS.FallbackResolvers,
"Client %s should have FallbackResolvers configured for split DNS",
client.Hostname())
var actualFallback []string
for _, resolver := range netmap.DNS.FallbackResolvers {
actualFallback = append(actualFallback, resolver.Addr)
}
expectedFallback := []string{"9.9.9.9", "149.112.112.112"}
assert.ElementsMatch(c, expectedFallback, actualFallback,
"Client %s FallbackResolvers mismatch. Expected: %v (from dns.nameservers.split_fallback), Got: %v",
client.Hostname(), expectedFallback, actualFallback)
// Routes should be configured with split DNS
assert.NotNil(c, netmap.DNS.Routes,
"Client %s should have DNS Routes configured for split DNS",
client.Hostname())
// Verify specific split DNS routes
corpResolvers, hasCorp := netmap.DNS.Routes["corp.example.com"]
assert.True(c, hasCorp, "Client %s should have route for corp.example.com", client.Hostname())
if hasCorp {
assert.Len(c, corpResolvers, 1, "corp.example.com should have 1 resolver")
assert.Equal(c, "8.8.8.8", corpResolvers[0].Addr,
"corp.example.com resolver should be 8.8.8.8 (from dns.nameservers.split)")
}
internalResolvers, hasInternal := netmap.DNS.Routes["internal.example.com"]
assert.True(c, hasInternal, "Client %s should have route for internal.example.com", client.Hostname())
if hasInternal {
assert.Len(c, internalResolvers, 1, "internal.example.com should have 1 resolver")
assert.Equal(c, "8.8.4.4", internalResolvers[0].Addr,
"internal.example.com resolver should be 8.8.4.4 (from dns.nameservers.split)")
}
}, 30*time.Second, 2*time.Second,
"DNS configuration validation failed for client %s",
client.Hostname())
// Verify the actual DNS configuration on the client doesn't contain ANY of the configured nameservers
// Use 'dig' without arguments to query the client's actual DNS server
assert.EventuallyWithT(t, func(c *assert.CollectT) {
result, _, err := client.Execute([]string{"dig"})
assert.NoError(c, err, "Failed to execute dig command on client %s", client.Hostname())
// NONE of the configured nameservers should appear next to ";; SERVER:" in the dig output
// Global nameservers (should NOT leak)
assert.NotContains(c, result, ";; SERVER: 1.1.1.1",
"Client %s should NOT be using global nameserver 1.1.1.1 when override_local_dns=false",
client.Hostname())
assert.NotContains(c, result, ";; SERVER: 1.0.0.1",
"Client %s should NOT be using global nameserver 1.0.0.1 when override_local_dns=false",
client.Hostname())
// Fallback resolvers (should NOT leak to actual DNS configuration)
assert.NotContains(c, result, ";; SERVER: 9.9.9.9",
"Client %s should NOT be using fallback resolver 9.9.9.9 as actual DNS server when override_local_dns=false",
client.Hostname())
assert.NotContains(c, result, ";; SERVER: 149.112.112.112",
"Client %s should NOT be using fallback resolver 149.112.112.112 as actual DNS server when override_local_dns=false",
client.Hostname())
// Split DNS resolvers (should NOT leak to actual DNS configuration)
assert.NotContains(c, result, ";; SERVER: 8.8.8.8",
"Client %s should NOT be using split DNS resolver 8.8.8.8 as actual DNS server when override_local_dns=false",
client.Hostname())
assert.NotContains(c, result, ";; SERVER: 8.8.4.4",
"Client %s should NOT be using split DNS resolver 8.8.4.4 as actual DNS server when override_local_dns=false",
client.Hostname())
}, 30*time.Second, 2*time.Second,
"Client %s should not be using any of headscale's configured DNS servers",
client.Hostname())
}
}