1
0
mirror of https://github.com/juanfont/headscale.git synced 2025-08-14 13:51:01 +02:00

feat: Add magicdns_peer_aaaa config option

This change introduces a new boolean config option,
`magicdns_peer_aaaa`.

When enabled, it adds the `magicdns-aaaa` node attribute to devices with
a valid overlay IPv6 address.
This attribute allows MagicDNS to resolve AAAA (IPv6) DNS queries for
other nodes in the tailnet.

This is a new opt-in feature for Tailscale 1.84+ and is implemented as a
global config option, similar to `RandomizeClientPort`, since support
for configuring node attributes via policies is not yet available.
This commit is contained in:
mindolo 2025-08-09 11:29:28 +01:00
parent a058bf3cd3
commit 6ea0656b87
6 changed files with 214 additions and 1 deletions

View File

@ -50,6 +50,7 @@ jobs:
- TestDERPVerifyEndpoint
- TestResolveMagicDNS
- TestResolveMagicDNSExtraRecordsPath
- TestMagicDNSPeerAAAA
- TestDERPServerScenario
- TestDERPServerWebsocketScenario
- TestPingAllByIP

View File

@ -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

View File

@ -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.

View File

@ -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")
}

View File

@ -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
}

View File

@ -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())
}
}
}