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:
parent
a058bf3cd3
commit
6ea0656b87
1
.github/workflows/test-integration.yaml
vendored
1
.github/workflows/test-integration.yaml
vendored
@ -50,6 +50,7 @@ jobs:
|
||||
- TestDERPVerifyEndpoint
|
||||
- TestResolveMagicDNS
|
||||
- TestResolveMagicDNSExtraRecordsPath
|
||||
- TestMagicDNSPeerAAAA
|
||||
- TestDERPServerScenario
|
||||
- TestDERPServerWebsocketScenario
|
||||
- TestPingAllByIP
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user