diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index a16f0aab..65c704ba 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -50,6 +50,7 @@ jobs: - TestDERPVerifyEndpoint - TestResolveMagicDNS - TestResolveMagicDNSExtraRecordsPath + - TestMagicDNSPeerAAAA - TestDERPServerScenario - TestDERPServerWebsocketScenario - TestPingAllByIP diff --git a/config-example.yaml b/config-example.yaml index 43dbd056..db449713 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -404,3 +404,11 @@ logtail: # default static port 41641. This option is intended as a workaround for some buggy # firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information. randomize_client_port: false + + +# Enables MagicDNS AAAA query resolution for tailnet nodes with IPv6. +# When this option is enabled, devices that have a valid overlay IPv6 address will +# automatically gain the `magicdns-aaaa` Node Attribute. +# This attribute allows them to resolve AAAA (IPv6) DNS queries through MagicDNS for +# other nodes within the tailnet. +magicdns_peer_aaaa: false diff --git a/hscontrol/mapper/tail.go b/hscontrol/mapper/tail.go index 9729301d..cff82574 100644 --- a/hscontrol/mapper/tail.go +++ b/hscontrol/mapper/tail.go @@ -78,14 +78,17 @@ func tailNode( } var tags []string + for _, tag := range node.RequestTagsSlice().All() { if checker.NodeCanHaveTag(node, tag) { tags = append(tags, tag) } } + for _, tag := range node.ForcedTags().All() { tags = append(tags, tag) } + tags = lo.Uniq(tags) routes := primaryRouteFunc(node.ID()) @@ -133,6 +136,12 @@ func tailNode( tNode.CapMap[tailcfg.NodeAttrRandomizeClientPort] = []tailcfg.RawMessage{} } + // Enable NodeAttrMagicDNSPeerAAAA only if requested in + // the config and for nodes with a valid v6 overlay address + if cfg.MagicDNSPeerAAAA && node.IPv6().Valid() { + tNode.CapMap[tailcfg.NodeAttrMagicDNSPeerAAAA] = []tailcfg.RawMessage{} + } + if !node.IsOnline().Valid() || !node.IsOnline().Get() { // LastSeen is only set when node is // not connected to the control server. diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index c699943f..42075bae 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -20,6 +20,7 @@ import ( func TestTailNode(t *testing.T) { mustNK := func(str string) key.NodePublic { var k key.NodePublic + _ = k.UnmarshalText([]byte(str)) return k @@ -27,6 +28,7 @@ func TestTailNode(t *testing.T) { mustDK := func(str string) key.DiscoPublic { var k key.DiscoPublic + _ = k.UnmarshalText([]byte(str)) return k @@ -34,6 +36,7 @@ func TestTailNode(t *testing.T) { mustMK := func(str string) key.MachinePublic { var k key.MachinePublic + _ = k.UnmarshalText([]byte(str)) return k @@ -204,6 +207,7 @@ func TestTailNode(t *testing.T) { t.Run(tt.name, func(t *testing.T) { polMan, err := policy.NewPolicyManager(tt.pol, []types.User{}, types.Nodes{tt.node}.ViewSlice()) require.NoError(t, err) + primary := routes.New() cfg := &types.Config{ BaseDomain: tt.baseDomain, @@ -215,6 +219,7 @@ func TestTailNode(t *testing.T) { // This is a hack to avoid having a second node to test the primary route. // This should be baked into the test case proper if it is extended in the future. _ = primary.SetRoutes(2, netip.MustParsePrefix("192.168.0.0/24")) + got, err := tailNode( tt.node.View(), 0, @@ -224,7 +229,6 @@ func TestTailNode(t *testing.T) { }, cfg, ) - if (err != nil) != tt.wantErr { t.Errorf("tailNode() error = %v, wantErr %v", err, tt.wantErr) @@ -293,7 +297,9 @@ func TestNodeExpiry(t *testing.T) { if err != nil { t.Fatalf("nodeExpiry() error = %v", err) } + var deseri tailcfg.Node + err = json.Unmarshal(seri, &deseri) if err != nil { t.Fatalf("nodeExpiry() error = %v", err) @@ -309,3 +315,113 @@ func TestNodeExpiry(t *testing.T) { }) } } + +func TestMagicDNSPeerAAAAEnabled(t *testing.T) { + // A node with both IPv4 and IPv6 addresses. + node := &types.Node{ + GivenName: "ipv6node", + IPv4: iap("100.64.0.1"), + IPv6: iap("fd7a:115c:a1e0::1"), + Hostinfo: &tailcfg.Hostinfo{ + RoutableIPs: []netip.Prefix{ + tsaddr.AllIPv4(), + tsaddr.AllIPv6(), + }, + }, + } + + // The key configuration setting is enabled. + cfg := &types.Config{ + MagicDNSPeerAAAA: true, + } + + polMan, err := policy.NewPolicyManager(nil, nil, types.Nodes{node}.ViewSlice()) + require.NoError(t, err) + + got, err := tailNode( + node.View(), + 0, + polMan, + func(id types.NodeID) []netip.Prefix { return []netip.Prefix{} }, + cfg, + ) + require.NoError(t, err) + + // Assert that the magicdns-aaaa capability is present. + _, hasAAAA := got.CapMap[tailcfg.NodeAttrMagicDNSPeerAAAA] + require.True(t, hasAAAA, "expected magicdns-aaaa capability to be present") +} + +func TestMagicDNSPeerAAAADisabled(t *testing.T) { + // A node with both IPv4 and IPv6 addresses. + node := &types.Node{ + GivenName: "ipv6node", + IPv4: iap("100.64.0.1"), + IPv6: iap("fd7a:115c:a1e0::1"), + Hostinfo: &tailcfg.Hostinfo{ + RoutableIPs: []netip.Prefix{ + tsaddr.AllIPv4(), + tsaddr.AllIPv6(), + }, + }, + } + + // The key configuration setting is disabled. + cfg := &types.Config{ + MagicDNSPeerAAAA: false, + } + + polMan, err := policy.NewPolicyManager(nil, nil, types.Nodes{node}.ViewSlice()) + require.NoError(t, err) + + got, err := tailNode( + node.View(), + 0, + polMan, + func(id types.NodeID) []netip.Prefix { return []netip.Prefix{} }, + cfg, + ) + require.NoError(t, err) + + // Assert that the magicdns-aaaa capability is NOT present. + _, hasAAAA := got.CapMap[tailcfg.NodeAttrMagicDNSPeerAAAA] + require.False(t, hasAAAA, "expected magicdns-aaaa capability NOT to be present") +} + +func TestMagicDNSPeerAAAAEnabledWithIPv4OnlyNode(t *testing.T) { + // A node with a valid IPv4 address and no IPv6 address + node := &types.Node{ + ID: 0, + IPv4: iap("100.64.0.1"), + GivenName: "ipv4only", + Hostinfo: &tailcfg.Hostinfo{ + RoutableIPs: []netip.Prefix{ + tsaddr.AllIPv4(), + }, + }, + } + + // This is the configuration with the flag enabled + cfg := &types.Config{ + MagicDNSPeerAAAA: true, + } + + polMan, err := policy.NewPolicyManager(nil, nil, types.Nodes{node}.ViewSlice()) + require.NoError(t, err) + + got, err := tailNode( + node.View(), + 0, + polMan, + func(id types.NodeID) []netip.Prefix { + return []netip.Prefix{} + }, + cfg, + ) + require.NoError(t, err) + + // The feature is enabled but the node lacks a V6 address + // the node attribute should not be present + _, hasAAAA := got.CapMap[tailcfg.NodeAttrMagicDNSPeerAAAA] + require.False(t, hasAAAA, "expected magicdns-aaaa capability NOT to be present on IPv4 only node") +} diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 44773a55..d02b7463 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -98,6 +98,12 @@ type Config struct { Policy PolicyConfig Tuning Tuning + + // Controls the automatic addition of the `magicdns-aaaa` node attribute + // If set to true, the attributes will be set for all nodes that have + // a valid Overlay IPv6 Address. + // This is separate from the real DNS + MagicDNSPeerAAAA bool } type DNSConfig struct { @@ -335,6 +341,8 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("prefixes.allocation", string(IPAllocationStrategySequential)) + viper.SetDefault("magicdns_peer_aaaa", false) + if err := viper.ReadInConfig(); err != nil { if errors.Is(err, fs.ErrNotExist) { log.Warn().Msg("No config file found, using defaults") @@ -872,6 +880,7 @@ func LoadServerConfig() (*Config, error) { derpConfig := derpConfig() logTailConfig := logtailConfig() randomizeClientPort := viper.GetBool("randomize_client_port") + magicDNSPeerAAAA := viper.GetBool("magicdns_peer_aaaa") oidcClientSecret := viper.GetString("oidc.client_secret") oidcClientSecretPath := viper.GetString("oidc.client_secret_path") @@ -999,6 +1008,8 @@ func LoadServerConfig() (*Config, error) { return DefaultBatcherWorkers() }(), }, + + MagicDNSPeerAAAA: magicDNSPeerAAAA, }, nil } diff --git a/integration/dns_test.go b/integration/dns_test.go index 7cac4d47..52723a63 100644 --- a/integration/dns_test.go +++ b/integration/dns_test.go @@ -3,12 +3,15 @@ package integration import ( "encoding/json" "fmt" + "net/netip" "strings" "testing" "time" + "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/tsic" + "github.com/samber/lo" "github.com/stretchr/testify/assert" "tailscale.com/tailcfg" ) @@ -225,3 +228,68 @@ func TestResolveMagicDNSExtraRecordsPath(t *testing.T) { assertCommandOutputContains(t, client, []string{"dig", "copy.myvpn.example.com"}, "8.8.8.8") } } + +func TestMagicDNSPeerAAAA(t *testing.T) { + IntegrationSkip(t) + + spec := ScenarioSpec{ + NodesPerUser: len(MustTestVersions), + Users: []string{"user1", "user2"}, + MaxWait: dockertestMaxWait(), + } + + scenario, err := NewScenario(spec) + assertNoErr(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + 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("magicdnspeeraaaa"), + hsic.WithEmbeddedDERPServerOnly(), + hsic.WithTLS(), + hsic.WithConfigEnv(map[string]string{ + // The feature under test + "HEADSCALE_MAGICDNS_PEER_AAAA": "true", + }), + ) + assertNoErrHeadscaleEnv(t, err) + + allClients, err := scenario.ListTailscaleClients() + assertNoErrListClients(t, err) + + err = scenario.WaitForTailscaleSync() + assertNoErrSync(t, err) + + // Loop through all clients to perform the dig command. + for _, client := range allClients { + // Only run the test for Tailscale clients that support this feature (1.84+). + // The `tailscaled` container has no field or method GivenName(). + // We obtain the name by splitting the FQDN and using the first part. + clientName := client.Hostname() + if util.TailscaleVersionNewerOrEqual("1.84", client.Version()) { + t.Logf("Running AAAA check for client: %s (version: %s)", clientName, client.Version()) + + ips, err := client.IPs() + assertNoErr(t, err) + + targetIPv6 := lo.Filter(ips, func(ip netip.Addr, _ int) bool { + return ip.Is6() + }) + assert.NotEmpty(t, targetIPv6, "expected client to have an IPv6 address") + + // Construct the dig command as a string slice and assert the output. + fqdn := clientName + ".headscale.net" + digCmd := []string{"dig", "+short", "-t", "AAAA", fqdn} + + assertCommandOutputContains(t, client, digCmd, targetIPv6[0].String()) + } else { + t.Logf("Skipping AAAA check for client: %s (version: %s), requires at least 1.84", clientName, client.Version()) + } + } +}