From 7e683f79b7a64e179a28f4333a879f173cdf68ce Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Tue, 28 Apr 2026 08:18:58 +0200 Subject: [PATCH] Propagate IPv6 capability changes to other peers --- management/server/peer.go | 11 +- management/server/peer_test.go | 47 ++++++ management/server/types/account.go | 7 +- management/server/types/ipv6_endtoend_test.go | 156 ++++++++++++++++++ 4 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 management/server/types/ipv6_endtoend_test.go diff --git a/management/server/peer.go b/management/server/peer.go index 4e79ddace..d4c2196d8 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -931,7 +931,7 @@ func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) { // SyncPeer checks whether peer is eligible for receiving NetworkMap (authenticated) and returns its NetworkMap if eligible func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { var peer *nbpeer.Peer - var updated, versionChanged bool + var updated, versionChanged, ipv6CapabilityChanged bool var err error var postureChecks []*posture.Checks var peerGroupIDs []string @@ -967,7 +967,9 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return err } + oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) updated, versionChanged = peer.UpdateMetaIfNew(sync.Meta) + ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) if updated { am.metrics.AccountManagerMetrics().CountPeerMetUpdate() log.WithContext(ctx).Tracef("peer %s metadata updated", peer.ID) @@ -991,7 +993,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } - if isStatusChanged || sync.UpdateAccountPeers || (updated && (len(postureChecks) > 0 || versionChanged)) { + if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(postureChecks) > 0 || versionChanged)) { err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}) if err != nil { return nil, nil, nil, 0, fmt.Errorf("notify network map controller of peer update: %w", err) @@ -1041,6 +1043,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer var peer *nbpeer.Peer var updateRemotePeers bool var isPeerUpdated bool + var ipv6CapabilityChanged bool var postureChecks []*posture.Checks var peerGroupIDs []string @@ -1080,7 +1083,9 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return err } + oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) isPeerUpdated, _ = peer.UpdateMetaIfNew(login.Meta) + ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) if isPeerUpdated { am.metrics.AccountManagerMetrics().CountPeerMetUpdate() shouldStorePeer = true @@ -1118,7 +1123,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return nil, nil, nil, err } - if updateRemotePeers || isStatusChanged || (isPeerUpdated && len(postureChecks) > 0) { + if updateRemotePeers || isStatusChanged || ipv6CapabilityChanged || (isPeerUpdated && len(postureChecks) > 0) { err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}) if err != nil { return nil, nil, nil, fmt.Errorf("notify network map controller of peer update: %w", err) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index b70944b83..37a831e8f 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -2790,6 +2790,53 @@ func TestPeerWillHaveIPv6(t *testing.T) { assert.False(t, peerWillHaveIPv6(emptySettings, []string{"group-a"}, "all-group-id"), "no IPv6 groups means no IPv6") } +// TestSyncPeer_IPv6CapabilityChangePropagates ensures that when a peer reports +// a new IPv6 overlay capability via SyncPeer (e.g. after a client upgrade or +// flipping --disable-ipv6) without bumping its WtVersion, other account peers +// receive a fresh network map so their AAAA records for it become unstale. +func TestSyncPeer_IPv6CapabilityChangePropagates(t *testing.T) { + manager, updateManager, _, peer1, peer2, _ := setupNetworkMapTest(t) + + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + // Drain any initial updates from setup. + drain := func() { + for { + select { + case <-updMsg: + case <-time.After(200 * time.Millisecond): + return + } + } + } + drain() + + t.Run("no propagation when capabilities are unchanged", func(t *testing.T) { + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: peer2.Meta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldNotReceiveUpdate(t, updMsg) + }) + + t.Run("propagation when IPv6 capability is added", func(t *testing.T) { + newMeta := peer2.Meta + newMeta.Capabilities = append([]int32{}, peer2.Meta.Capabilities...) + newMeta.Capabilities = append(newMeta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) + + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: newMeta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldReceiveUpdate(t, updMsg) + }) +} + func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") diff --git a/management/server/types/account.go b/management/server/types/account.go index 01b054d8d..49600163a 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -293,10 +293,9 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn // Only advertise AAAA for peers that have a valid IPv6, whose client supports it, // and that belong to an IPv6-enabled group. Old clients don't configure v6 on their // WireGuard interface, so resolving their AAAA causes connections to hang. - // Edge case: toggling --disable-ipv6 on a peer without a version change does not - // propagate to other peers, so AAAA records can be stale until the next full sync. - // This is accepted because v4 connectivity is unaffected. Can be fixed by adding - // capability-change detection to the SyncPeer propagation condition. + // Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate + // to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA + // records refresh when a peer first reports the IPv6 overlay capability. _, peerAllowed := ipv6AllowedPeers[peer.ID] hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed if hasIPv6 { diff --git a/management/server/types/ipv6_endtoend_test.go b/management/server/types/ipv6_endtoend_test.go new file mode 100644 index 000000000..ddd1f649f --- /dev/null +++ b/management/server/types/ipv6_endtoend_test.go @@ -0,0 +1,156 @@ +package types_test + +import ( + "net/netip" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) { + account := createComponentTestAccount() + + // Make all peers IPv6-capable and assign IPv6 addrs. + v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay, nbpeer.PeerCapabilitySourcePrefixes} + account.Peers["peer-src-1"].Meta.Capabilities = v6Caps + account.Peers["peer-src-1"].IPv6 = netip.MustParseAddr("fd00::1") + account.Peers["peer-src-2"].Meta.Capabilities = v6Caps + account.Peers["peer-src-2"].IPv6 = netip.MustParseAddr("fd00::2") + account.Peers["peer-dst-1"].Meta.Capabilities = v6Caps + account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3") + + // Mark group-src and group-dst as IPv6-enabled. + account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"} + + validated := allPeersValidated(account) + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + require.NotNil(t, nm) + + t.Run("v6 AAAA records emitted", func(t *testing.T) { + require.NotEmpty(t, nm.DNSConfig.CustomZones, "expected at least one custom zone") + var hasAAAA bool + var hasA bool + for _, z := range nm.DNSConfig.CustomZones { + for _, r := range z.Records { + if r.Type == int(dns.TypeAAAA) { + hasAAAA = true + } + if r.Type == int(dns.TypeA) { + hasA = true + } + } + } + assert.True(t, hasA, "expected A records") + assert.True(t, hasAAAA, "expected AAAA records for IPv6-enabled peers") + }) + + t.Run("v6 AllowedIPs would be advertised", func(t *testing.T) { + // nm.Peers contains *nbpeer.Peer; IPv6 should be set on those peers + var foundV6 bool + for _, p := range nm.Peers { + if p.IPv6.IsValid() { + foundV6 = true + } + } + assert.True(t, foundV6, "remote peers should have IPv6 set so AllowedIPs gets v6") + }) + + t.Run("v6 firewall rules emitted", func(t *testing.T) { + require.NotEmpty(t, nm.FirewallRules, "expected firewall rules") + var hasV4 bool + var hasV6 bool + for _, r := range nm.FirewallRules { + addr, err := netip.ParseAddr(r.PeerIP) + if err != nil { + continue + } + if addr.Is4() { + hasV4 = true + } + if addr.Is6() { + hasV6 = true + } + } + assert.True(t, hasV4, "expected at least one v4 firewall rule (peer IP)") + assert.True(t, hasV6, "expected at least one v6 firewall rule (peer IPv6)") + }) +} + +// TestNetworkMapComponents_RemotePeerWithoutCapability checks the asymmetric +// case where the target peer is IPv6-capable but a remote peer has an IPv6 +// address assigned in the DB without yet reporting the capability flag. +// In that case the remote peer's v6 still appears in AllowedIPs (gated on +// the target peer's capability) but its AAAA record does not (gated on the +// remote peer's own capability). +func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) { + account := createComponentTestAccount() + + v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay, nbpeer.PeerCapabilitySourcePrefixes} + // Target is fully capable. + account.Peers["peer-src-1"].Meta.Capabilities = v6Caps + account.Peers["peer-src-1"].IPv6 = netip.MustParseAddr("fd00::1") + // Remote peer has v6 assigned but no capability flag yet (e.g. old client). + account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3") + + account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"} + + validated := allPeersValidated(account) + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + require.NotNil(t, nm) + + t.Run("AllowedIPs include remote v6", func(t *testing.T) { + var dst *nbpeer.Peer + for _, p := range nm.Peers { + if p.ID == "peer-dst-1" { + dst = p + } + } + require.NotNil(t, dst) + assert.True(t, dst.IPv6.IsValid(), "remote peer's v6 should still be present so AllowedIPs gets v6/128 (gated on target peer cap)") + }) + + t.Run("no AAAA for non-capable remote peer", func(t *testing.T) { + for _, z := range nm.DNSConfig.CustomZones { + for _, r := range z.Records { + if r.Type == int(dns.TypeAAAA) && r.RData == "fd00::3" { + t.Errorf("AAAA record for non-capable remote peer should NOT be emitted, got %+v", r) + } + } + } + }) +} + +// TestNetworkMapComponents_IPv6Disabled_NoV6Output asserts that a peer that +// does not support IPv6 (e.g. older client without the capability flag) gets +// no v6 firewall rules and no AAAA records, even if other peers have IPv6. +func TestNetworkMapComponents_IPv6Disabled_NoV6Output(t *testing.T) { + account := createComponentTestAccount() + + v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay} + account.Peers["peer-src-2"].Meta.Capabilities = v6Caps + account.Peers["peer-src-2"].IPv6 = netip.MustParseAddr("fd00::2") + account.Peers["peer-dst-1"].Meta.Capabilities = v6Caps + account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3") + // peer-src-1 (target) intentionally has no capability and no IPv6. + + account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"} + + validated := allPeersValidated(account) + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + require.NotNil(t, nm) + + t.Run("no v6 firewall rules", func(t *testing.T) { + for _, r := range nm.FirewallRules { + addr, err := netip.ParseAddr(r.PeerIP) + if err != nil { + continue + } + assert.False(t, addr.Is6(), "v6 firewall rules should not be emitted for non-IPv6 peer (got %s)", r.PeerIP) + } + }) +}