diff --git a/config-example.yaml b/config-example.yaml index dbb08202..6354777b 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -295,14 +295,28 @@ 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. - split: {} + # 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: # - 1.1.1.1 # darp.headscale.net: # - 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: [] diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 4068d72e..e7d601a3 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -114,8 +114,9 @@ type DNSConfig struct { } type Nameservers struct { - Global []string - Split map[string][]string + Global []string + Split map[string][]string + SplitFallback []string } type SqliteConfig struct { @@ -493,12 +494,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( @@ -720,6 +746,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") @@ -814,6 +841,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{} @@ -823,14 +881,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} } diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go index 6b9fc2ef..b55ae6b5 100644 --- a/hscontrol/types/config_test.go +++ b/hscontrol/types/config_test.go @@ -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 { diff --git a/hscontrol/types/testdata/dns_global_without_override.yaml b/hscontrol/types/testdata/dns_global_without_override.yaml new file mode 100644 index 00000000..46e4ce38 --- /dev/null +++ b/hscontrol/types/testdata/dns_global_without_override.yaml @@ -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 diff --git a/hscontrol/types/testdata/dns_override_false_with_split.yaml b/hscontrol/types/testdata/dns_override_false_with_split.yaml new file mode 100644 index 00000000..5f90bc86 --- /dev/null +++ b/hscontrol/types/testdata/dns_override_false_with_split.yaml @@ -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 diff --git a/hscontrol/types/testdata/dns_split_no_fallback_error.yaml b/hscontrol/types/testdata/dns_split_no_fallback_error.yaml new file mode 100644 index 00000000..5fcfed99 --- /dev/null +++ b/hscontrol/types/testdata/dns_split_no_fallback_error.yaml @@ -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 diff --git a/hscontrol/types/testdata/dns_split_with_fallback.yaml b/hscontrol/types/testdata/dns_split_with_fallback.yaml new file mode 100644 index 00000000..f5155ed4 --- /dev/null +++ b/hscontrol/types/testdata/dns_split_with_fallback.yaml @@ -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 diff --git a/hscontrol/types/testdata/dns_split_without_fallback.yaml b/hscontrol/types/testdata/dns_split_without_fallback.yaml new file mode 100644 index 00000000..433790da --- /dev/null +++ b/hscontrol/types/testdata/dns_split_without_fallback.yaml @@ -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 diff --git a/integration/dns_test.go b/integration/dns_test.go index e937a421..32e90159 100644 --- a/integration/dns_test.go +++ b/integration/dns_test.go @@ -224,3 +224,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()) + } +}