From 967e2d68645ae8d8b8ae9e4f178faf8d70f509ee Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:43:22 +0200 Subject: [PATCH] [management] network map for affected peers (#6105) --- client/internal/rosenpass/manager_test.go | 18 +- client/internal/rosenpass/seed_test.go | 1 - .../network_map/controller/controller.go | 311 ++- .../controllers/network_map/interface.go | 8 +- .../controllers/network_map/interface_mock.go | 52 +- management/server/account.go | 10 +- management/server/account/manager.go | 6 +- management/server/account/manager_mock.go | 21 +- management/server/account_test.go | 29 +- .../server/affected_peers_coverage_test.go | 117 ++ .../server/affected_peers_oldstate_test.go | 143 ++ .../server/affected_peers_property_test.go | 255 +++ .../server/affected_peers_querycount_test.go | 164 ++ .../affected_peers_router_paths_test.go | 333 +++ .../server/affected_peers_router_test.go | 771 +++++++ management/server/affected_peers_test.go | 1802 +++++++++++++++++ management/server/affectedpeers/resolver.go | 825 ++++++++ .../server/affectedpeers/resolver_test.go | 140 ++ management/server/dns.go | 32 +- management/server/group.go | 344 ++-- management/server/mock_server/account_mock.go | 14 +- management/server/nameserver.go | 57 +- management/server/networks/manager.go | 35 +- .../server/networks/resources/manager.go | 153 +- management/server/networks/routers/manager.go | 118 +- management/server/peer.go | 291 ++- management/server/peer_test.go | 39 +- management/server/policy.go | 92 +- management/server/policy_test.go | 12 +- management/server/posture_checks.go | 46 +- management/server/posture_checks_test.go | 41 +- management/server/route.go | 73 +- management/server/route_test.go | 10 +- management/server/setupkey_test.go | 4 + management/server/store/sql_store.go | 61 +- management/server/store/store.go | 3 + management/server/store/store_mock.go | 45 + management/server/user.go | 21 +- management/server/user_test.go | 11 +- 39 files changed, 5841 insertions(+), 667 deletions(-) create mode 100644 management/server/affected_peers_coverage_test.go create mode 100644 management/server/affected_peers_oldstate_test.go create mode 100644 management/server/affected_peers_property_test.go create mode 100644 management/server/affected_peers_querycount_test.go create mode 100644 management/server/affected_peers_router_paths_test.go create mode 100644 management/server/affected_peers_router_test.go create mode 100644 management/server/affected_peers_test.go create mode 100644 management/server/affectedpeers/resolver.go create mode 100644 management/server/affectedpeers/resolver_test.go diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go index ace6f88da..d74960d0d 100644 --- a/client/internal/rosenpass/manager_test.go +++ b/client/internal/rosenpass/manager_test.go @@ -22,14 +22,14 @@ type removePeerCall struct { } type mockServer struct { - mu sync.Mutex - addCalls []addPeerCall - removed []removePeerCall - nextID rp.PeerID - addErr error - removeErr error - closed bool - ran bool + mu sync.Mutex + addCalls []addPeerCall + removed []removePeerCall + nextID rp.PeerID + addErr error + removeErr error + closed bool + ran bool } func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) { @@ -51,7 +51,7 @@ func (m *mockServer) RemovePeer(id rp.PeerID) error { return m.removeErr } -func (m *mockServer) Run() error { m.ran = true; return nil } +func (m *mockServer) Run() error { m.ran = true; return nil } func (m *mockServer) Close() error { m.closed = true; return nil } type setPSKCall struct { diff --git a/client/internal/rosenpass/seed_test.go b/client/internal/rosenpass/seed_test.go index 0dfa478c7..b6a9a5991 100644 --- a/client/internal/rosenpass/seed_test.go +++ b/client/internal/rosenpass/seed_test.go @@ -41,4 +41,3 @@ func TestDeterministicSeedKey_TooShortKey_ReturnsError(t *testing.T) { _, err = DeterministicSeedKey(long, short) require.Error(t, err) } - diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 2b81cd6e5..9adf594cd 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -45,7 +45,7 @@ type Controller struct { EphemeralPeersManager ephemeral.Manager accountUpdateLocks sync.Map - sendAccountUpdateLocks sync.Map + affectedPeerUpdateLocks sync.Map updateAccountPeersBufferInterval atomic.Int64 // dnsDomain is used for peer resolution. This is appended to the peer's name dnsDomain string @@ -64,6 +64,13 @@ type bufferUpdate struct { update atomic.Bool } +type bufferAffectedUpdate struct { + sendMu sync.Mutex + dataMu sync.Mutex + next *time.Timer + peerIDs map[string]struct{} +} + var _ network_map.Controller = (*Controller)(nil) func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config) *Controller { @@ -201,7 +208,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] + proxyNetworkMap, ok := proxyNetworkMaps[p.ID] if ok { remotePeerNetworkMap.Merge(proxyNetworkMap) } @@ -226,44 +233,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin return nil } -func (c *Controller) bufferSendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { - log.WithContext(ctx).Tracef("buffer sending update peers for account %s from %s", accountID, util.GetCallerName()) - - if c.accountManagerMetrics != nil { - c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation)) - } - - bufUpd, _ := c.sendAccountUpdateLocks.LoadOrStore(accountID, &bufferUpdate{}) - b := bufUpd.(*bufferUpdate) - - if !b.mu.TryLock() { - b.update.Store(true) - return nil - } - - if b.next != nil { - b.next.Stop() - } - - go func() { - defer b.mu.Unlock() - _ = c.sendUpdateAccountPeers(ctx, accountID, reason) - if !b.update.Load() { - return - } - b.update.Store(false) - if b.next == nil { - b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() { - _ = c.sendUpdateAccountPeers(ctx, accountID, reason) - }) - return - } - b.next.Reset(time.Duration(c.updateAccountPeersBufferInterval.Load())) - }() - - return nil -} - // UpdatePeers updates all peers that belong to an account. // Should be called when changes have to be synced to peers. func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { @@ -273,6 +242,143 @@ func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, r return c.sendUpdateAccountPeers(ctx, accountID, reason) } +// UpdateAffectedPeers updates only the specified peers that belong to an account. +func (c *Controller) UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error { + if len(peerIDs) == 0 { + return nil + } + return c.sendUpdateForAffectedPeers(ctx, accountID, peerIDs) +} + +func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error { + log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: account %s, %d affected peers: %v (caller: %s)", accountID, len(peerIDs), peerIDs, util.GetCallerName()) + + if !c.hasConnectedPeers(peerIDs) { + log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: no connected peers among %v, skipping", peerIDs) + return nil + } + + account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to get account: %v", err) + } + + globalStart := time.Now() + + peersToUpdate := c.filterConnectedAffectedPeers(account, peerIDs) + if len(peersToUpdate) == 0 { + log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: no peers to update (affected peers not found in account or no channels)") + return nil + } + + log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate)) + + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + if err != nil { + return fmt.Errorf("failed to get validate peers: %v", err) + } + + var wg sync.WaitGroup + semaphore := make(chan struct{}, 10) + + account.InjectProxyPolicies(ctx) + dnsCache := &cache.DNSConfigCache{} + dnsDomain := c.GetDNSDomain(account.Settings) + peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers) + if err != nil { + log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) + return fmt.Errorf("failed to get proxy network maps: %v", err) + } + + extraSetting, err := c.settingsManager.GetExtraSettings(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to get flow enabled status: %v", err) + } + + dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) + + accountZones, err := c.repo.GetAccountZones(ctx, account.Id) + if err != nil { + log.WithContext(ctx).Errorf("failed to get account zones: %v", err) + return fmt.Errorf("failed to get account zones: %v", err) + } + + for _, peer := range peersToUpdate { + wg.Add(1) + semaphore <- struct{}{} + go func(p *nbpeer.Peer) { + defer wg.Done() + defer func() { <-semaphore }() + + start := time.Now() + + postureChecks, err := c.getPeerPostureChecks(account, p.ID) + if err != nil { + log.WithContext(ctx).Debugf("failed to get posture checks for peer %s: %v", p.ID, err) + return + } + + c.metrics.CountCalcPostureChecksDuration(time.Since(start)) + start = time.Now() + + remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + + c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) + + proxyNetworkMap, ok := proxyNetworkMaps[p.ID] + if ok { + remotePeerNetworkMap.Merge(proxyNetworkMap) + } + + peerGroups := account.GetPeerGroups(p.ID) + start = time.Now() + update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + c.metrics.CountToSyncResponseDuration(time.Since(start)) + + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + }(peer) + } + + wg.Wait() + if c.accountManagerMetrics != nil { + c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart)) + } + + return nil +} + +func (c *Controller) hasConnectedPeers(peerIDs []string) bool { + for _, id := range peerIDs { + if c.peersUpdateManager.HasChannel(id) { + return true + } + } + return false +} + +func (c *Controller) filterConnectedAffectedPeers(account *types.Account, peerIDs []string) []*nbpeer.Peer { + affected := make(map[string]struct{}, len(peerIDs)) + for _, id := range peerIDs { + affected[id] = struct{}{} + } + + var result []*nbpeer.Peer + for _, peer := range account.Peers { + if _, ok := affected[peer.ID]; ok && c.peersUpdateManager.HasChannel(peer.ID) { + result = append(result, peer) + } + } + return result +} + func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error { if !c.peersUpdateManager.HasChannel(peerId) { return fmt.Errorf("peer %s doesn't have a channel, skipping network map update", peerId) @@ -381,6 +487,104 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str return nil } +// BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval. +func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error { + if len(peerIDs) == 0 { + return nil + } + + if c.accountManagerMetrics != nil { + c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation)) + } + + log.WithContext(ctx).Tracef("buffer updating %d affected peers for account %s from %s", len(peerIDs), accountID, util.GetCallerName()) + + bufUpd, _ := c.affectedPeerUpdateLocks.LoadOrStore(accountID, &bufferAffectedUpdate{ + peerIDs: make(map[string]struct{}), + }) + b := bufUpd.(*bufferAffectedUpdate) + + b.addPeerIDs(peerIDs) + + if !b.sendMu.TryLock() { + // Another goroutine is already sending; it will pick up our IDs on its next drain. + return nil + } + + b.stopTimer() + + // The send and the debounced timer outlive the calling request, so detach from + // its context to avoid sending with a cancelled context once the handler returns. + bgCtx := context.WithoutCancel(ctx) + + collected := b.drainPeerIDs() + go func() { + defer b.sendMu.Unlock() + _ = c.sendUpdateForAffectedPeers(bgCtx, accountID, collected) + + // Check if more peer IDs accumulated while we were sending. + if !b.hasPending() { + return + } + + // Schedule a debounced flush for the newly accumulated IDs. + b.setTimer(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() { + ids := b.drainPeerIDs() + if len(ids) > 0 { + _ = c.sendUpdateForAffectedPeers(bgCtx, accountID, ids) + } + }) + }() + + return nil +} + +func (b *bufferAffectedUpdate) addPeerIDs(ids []string) { + b.dataMu.Lock() + for _, id := range ids { + b.peerIDs[id] = struct{}{} + } + b.dataMu.Unlock() +} + +func (b *bufferAffectedUpdate) drainPeerIDs() []string { + b.dataMu.Lock() + defer b.dataMu.Unlock() + if len(b.peerIDs) == 0 { + return nil + } + ids := make([]string, 0, len(b.peerIDs)) + for id := range b.peerIDs { + ids = append(ids, id) + } + b.peerIDs = make(map[string]struct{}) + return ids +} + +func (b *bufferAffectedUpdate) hasPending() bool { + b.dataMu.Lock() + defer b.dataMu.Unlock() + return len(b.peerIDs) > 0 +} + +func (b *bufferAffectedUpdate) stopTimer() { + b.dataMu.Lock() + defer b.dataMu.Unlock() + if b.next != nil { + b.next.Stop() + } +} + +func (b *bufferAffectedUpdate) setTimer(d time.Duration, f func()) { + b.dataMu.Lock() + defer b.dataMu.Unlock() + if b.next == nil { + b.next = time.AfterFunc(d, f) + return + } + b.next.Reset(d) +} + func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { if isRequiresApproval { network, err := c.repo.GetAccountNetwork(ctx, accountID) @@ -578,21 +782,24 @@ func isPeerInPolicySourceGroups(account *types.Account, peerID string, policy *t return false, nil } -func (c *Controller) OnPeersUpdated(ctx context.Context, accountID string, peerIDs []string) error { - err := c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate}) - if err != nil { - log.WithContext(ctx).Errorf("failed to buffer update account peers for peer update in account %s: %v", accountID, err) +func (c *Controller) OnPeersUpdated(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { + if len(affectedPeerIDs) == 0 { + log.WithContext(ctx).Tracef("no affected peers for peer update in account %s, skipping", accountID) + return nil } - - return nil + return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate}) } -func (c *Controller) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error { +func (c *Controller) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { log.WithContext(ctx).Debugf("OnPeersAdded call to add peers: %v", peerIDs) - return c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationCreate}) + if len(affectedPeerIDs) == 0 { + log.WithContext(ctx).Tracef("no affected peers for peer add in account %s, skipping", accountID) + return nil + } + return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationCreate}) } -func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error { +func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { network, err := c.repo.GetAccountNetwork(ctx, accountID) if err != nil { return err @@ -625,7 +832,11 @@ func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerI c.peersUpdateManager.CloseChannel(ctx, peerID) } - return c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete}) + if len(affectedPeerIDs) == 0 { + log.WithContext(ctx).Tracef("no affected peers for peer delete in account %s, skipping", accountID) + return nil + } + return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete}) } // GetNetworkMap returns Network map for a given peer (omits original peer from the Peers result) diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index 44d8f7d72..dbdd87708 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -19,6 +19,8 @@ const ( type Controller interface { UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error + UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error + BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) @@ -27,9 +29,9 @@ type Controller interface { GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) CountStreams() int - OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string) error - OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error - OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error + OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string, affectedPeerIDs []string) error + OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error + OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error DisconnectPeers(ctx context.Context, accountId string, peerIDs []string) OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *UpdateMessage, error) OnPeerDisconnected(ctx context.Context, accountID string, peerID string) diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go index 073a75d3b..a67156719 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -57,6 +57,20 @@ func (mr *MockControllerMockRecorder) BufferUpdateAccountPeers(ctx, accountID, r return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAccountPeers), ctx, accountID, reason) } +// BufferUpdateAffectedPeers mocks base method. +func (m *MockController) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BufferUpdateAffectedPeers", ctx, accountID, peerIDs, reason) + ret0, _ := ret[0].(error) + return ret0 +} + +// BufferUpdateAffectedPeers indicates an expected call of BufferUpdateAffectedPeers. +func (mr *MockControllerMockRecorder) BufferUpdateAffectedPeers(ctx, accountID, peerIDs, reason any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAffectedPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAffectedPeers), ctx, accountID, peerIDs, reason) +} + // CountStreams mocks base method. func (m *MockController) CountStreams() int { m.ctrl.T.Helper() @@ -158,45 +172,45 @@ func (mr *MockControllerMockRecorder) OnPeerDisconnected(ctx, accountID, peerID } // OnPeersAdded mocks base method. -func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error { +func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs) + ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) return ret0 } // OnPeersAdded indicates an expected call of OnPeersAdded. -func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs any) *gomock.Call { +func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs, affectedPeerIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersAdded", reflect.TypeOf((*MockController)(nil).OnPeersAdded), ctx, accountID, peerIDs) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersAdded", reflect.TypeOf((*MockController)(nil).OnPeersAdded), ctx, accountID, peerIDs, affectedPeerIDs) } // OnPeersDeleted mocks base method. -func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error { +func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs) + ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) return ret0 } // OnPeersDeleted indicates an expected call of OnPeersDeleted. -func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs any) *gomock.Call { +func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs, affectedPeerIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersDeleted", reflect.TypeOf((*MockController)(nil).OnPeersDeleted), ctx, accountID, peerIDs) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersDeleted", reflect.TypeOf((*MockController)(nil).OnPeersDeleted), ctx, accountID, peerIDs, affectedPeerIDs) } // OnPeersUpdated mocks base method. -func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string) error { +func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string, affectedPeerIDs []string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs) + ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) return ret0 } // OnPeersUpdated indicates an expected call of OnPeersUpdated. -func (mr *MockControllerMockRecorder) OnPeersUpdated(ctx, accountId, peerIDs any) *gomock.Call { +func (mr *MockControllerMockRecorder) OnPeersUpdated(ctx, accountId, peerIDs, affectedPeerIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersUpdated", reflect.TypeOf((*MockController)(nil).OnPeersUpdated), ctx, accountId, peerIDs) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersUpdated", reflect.TypeOf((*MockController)(nil).OnPeersUpdated), ctx, accountId, peerIDs, affectedPeerIDs) } // StartWarmup mocks base method. @@ -250,3 +264,17 @@ func (mr *MockControllerMockRecorder) UpdateAccountPeers(ctx, accountID, reason mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockController)(nil).UpdateAccountPeers), ctx, accountID, reason) } + +// UpdateAffectedPeers mocks base method. +func (m *MockController) UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAffectedPeers", ctx, accountID, peerIDs) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAffectedPeers indicates an expected call of UpdateAffectedPeers. +func (mr *MockControllerMockRecorder) UpdateAffectedPeers(ctx, accountID, peerIDs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAffectedPeers", reflect.TypeOf((*MockController)(nil).UpdateAffectedPeers), ctx, accountID, peerIDs) +} diff --git a/management/server/account.go b/management/server/account.go index e7fcad9d1..f58c797b7 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -1894,7 +1894,7 @@ func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err) } - if err := am.MarkPeerConnected(ctx, peerPubKey, realIP, accountID, syncTime.UnixNano()); err != nil { + if err := am.MarkPeerConnected(ctx, peerPubKey, realIP, accountID, syncTime.UnixNano(), netMap); err != nil { log.WithContext(ctx).Warnf("failed marking peer as connected %s %v", peerPubKey, err) } @@ -2577,7 +2577,9 @@ func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, us if err != nil { return err } - err = am.networkMapController.OnPeersUpdated(ctx, peer.AccountID, []string{peerID}) + changedPeerIDs := []string{peerID} + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) + err = am.networkMapController.OnPeersUpdated(ctx, peer.AccountID, changedPeerIDs, affectedPeerIDs) if err != nil { return fmt.Errorf("notify network map controller of peer update: %w", err) } @@ -2668,7 +2670,9 @@ func (am *DefaultAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, } if updateNetworkMap { - if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peerID}); err != nil { + changedPeerIDs := []string{peerID} + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) + if err := am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { return fmt.Errorf("notify network map controller: %w", err) } } diff --git a/management/server/account/manager.go b/management/server/account/manager.go index b7b159915..2fdfdba5a 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -13,6 +13,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/idp" nbpeer "github.com/netbirdio/netbird/management/server/peer" @@ -61,7 +62,7 @@ type Manager interface { GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) - MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error + MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error DeletePeer(ctx context.Context, accountID, peerID, userID string) error UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error) @@ -109,7 +110,7 @@ type Manager interface { UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) // used by peer gRPC API - ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession + ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API GetExternalCacheManager() ExternalCacheManager GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error) @@ -128,6 +129,7 @@ type Manager interface { GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) + ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) BuildUserInfosForAccount(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index 81127a6b4..0e06ebf91 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -15,6 +15,7 @@ import ( dns "github.com/netbirdio/netbird/dns" service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" activity "github.com/netbirdio/netbird/management/server/activity" + affectedpeers "github.com/netbirdio/netbird/management/server/affectedpeers" idp "github.com/netbirdio/netbird/management/server/idp" peer "github.com/netbirdio/netbird/management/server/peer" posture "github.com/netbirdio/netbird/management/server/posture" @@ -1320,17 +1321,17 @@ func (mr *MockManagerMockRecorder) ExtendPeerSession(ctx, peerPubKey, userID int } // MarkPeerConnected mocks base method. -func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error { +func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, realIP, accountID, sessionStartedAt) + ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, realIP, accountID, sessionStartedAt, nmap) ret0, _ := ret[0].(error) return ret0 } // MarkPeerConnected indicates an expected call of MarkPeerConnected. -func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, realIP, accountID, sessionStartedAt interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, realIP, accountID, sessionStartedAt, nmap interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, realIP, accountID, sessionStartedAt) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, realIP, accountID, sessionStartedAt, nmap) } // MarkPeerDisconnected mocks base method. @@ -1637,6 +1638,18 @@ func (mr *MockManagerMockRecorder) UpdateAccountPeers(ctx, accountID, reason int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).UpdateAccountPeers), ctx, accountID, reason) } +// ExpandAndUpdateAffected mocks base method. +func (m *MockManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ExpandAndUpdateAffected", ctx, accountID, snap, change) +} + +// ExpandAndUpdateAffected indicates an expected call of ExpandAndUpdateAffected. +func (mr *MockManagerMockRecorder) ExpandAndUpdateAffected(ctx, accountID, snap, change interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExpandAndUpdateAffected", reflect.TypeOf((*MockManager)(nil).ExpandAndUpdateAffected), ctx, accountID, snap, change) +} + // UpdateAccountSettings mocks base method. func (m *MockManager) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) { m.ctrl.T.Helper() diff --git a/management/server/account_test.go b/management/server/account_test.go index bb4779d85..51f079a57 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -1836,7 +1836,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) { accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to get the account") - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano()) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ @@ -1907,7 +1907,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. require.NoError(t, err, "unable to get the account") // when we mark peer as connected, the peer login expiration routine should trigger - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano()) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") failed := waitTimeout(wg, time.Second) @@ -1935,7 +1935,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { t.Run("disconnect peer when session token matches", func(t *testing.T) { streamStartTime := time.Now().UTC() - err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano()) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) @@ -1956,7 +1956,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { t.Run("skip disconnect when stored session is newer (zombie stream protection)", func(t *testing.T) { // Newer stream wins on connect (sets SessionStartedAt = now ns). streamStartTime := time.Now().UTC() - err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano()) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) @@ -1980,7 +1980,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { t.Run("skip stale connect when stored session is newer (blocked goroutine protection)", func(t *testing.T) { node2SyncTime := time.Now().UTC() - err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node2SyncTime.UnixNano()) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node2SyncTime.UnixNano(), nil) require.NoError(t, err, "node 2 should connect peer") peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) @@ -1990,7 +1990,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { "SessionStartedAt should equal node2SyncTime token") node1StaleSyncTime := node2SyncTime.Add(-1 * time.Minute) - err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node1StaleSyncTime.UnixNano()) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node1StaleSyncTime.UnixNano(), nil) require.NoError(t, err, "stale connect should not return error") peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) @@ -2052,7 +2052,7 @@ func TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace(t *testing.T) { defer done.Done() ready.Done() start.Wait() - errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, token) + errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, token, nil) }() } @@ -2093,7 +2093,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test account, err := manager.Store.GetAccount(context.Background(), accountID) require.NoError(t, err, "unable to get the account") - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano()) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") wg := &sync.WaitGroup{} @@ -3305,6 +3305,19 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel. // when the channel delivers. const peerUpdateTimeout = 5 * time.Second +func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) { + for { + select { + case _, ok := <-ch: + if !ok { + return + } + case <-time.After(200 * time.Millisecond): + return + } + } +} + func peerShouldNotReceiveUpdate(t *testing.T, updateMessage <-chan *network_map.UpdateMessage) { t.Helper() select { diff --git a/management/server/affected_peers_coverage_test.go b/management/server/affected_peers_coverage_test.go new file mode 100644 index 000000000..56917905f --- /dev/null +++ b/management/server/affected_peers_coverage_test.go @@ -0,0 +1,117 @@ +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/affectedpeers" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/types" +) + +// TestAffectedPeers_DependencyCoverageMatrix enumerates each network-map +// dependency crossed with the change-type that can alter it, asserting the +// resolver folds in exactly the peers whose map changes. A new dependency that +// the resolver fails to walk should fail one of these rows; a new change-type +// without a row is a coverage gap to add here. +func TestAffectedPeers_DependencyCoverageMatrix(t *testing.T) { + type row struct { + name string + build func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) + } + + rows := []row{ + { + name: "policy-groups/source-group-change refreshes source+routing, excludes unrelated", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + return affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "resource-routing-bridge/router-peer-change refreshes policy sources", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + return affectedpeers.Change{ChangedPeerIDs: []string{s.routerPeerID}}, + []string{s.sourcePeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "policy-change/explicit-policy refreshes source+routing", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + return affectedpeers.Change{Policies: []*types.Policy{policy}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "policy-destinationresource/explicit-policy bridges to routing peer", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID) + return affectedpeers.Change{Policies: []*types.Policy{policy}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "resource-change refreshes source+routing on its network", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + return affectedpeers.Change{Resources: []*resourceTypes.NetworkResource{ + {ID: s.resourceID, NetworkID: s.networkID, GroupIDs: []string{s.resourceGroupID}}, + }}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "network-change refreshes source+routing on that network", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + return affectedpeers.Change{Networks: []*networkTypes.Network{{ID: s.networkID}}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "posture-check-change refreshes source+routing of gated policy", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ + Name: "cov-min-version", + Checks: posture.ChecksDefinition{NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.30.0"}}, + }, true) + require.NoError(t, err) + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + policy.SourcePostureChecks = []string{check.ID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true) + require.NoError(t, err) + return affectedpeers.Change{PostureCheckIDs: []string{check.ID}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + } + + for _, r := range rows { + t.Run(r.name, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + change, mustContain, mustExclude := r.build(t, s, ctx) + affected := resolveAffected(t, s.manager.Store, s.accountID, change) + + for _, id := range mustContain { + assert.Contains(t, affected, id, "expected peer to be affected") + } + for _, id := range mustExclude { + assert.NotContains(t, affected, id, "peer must not be affected") + } + }) + } +} diff --git a/management/server/affected_peers_oldstate_test.go b/management/server/affected_peers_oldstate_test.go new file mode 100644 index 000000000..bcb78a660 --- /dev/null +++ b/management/server/affected_peers_oldstate_test.go @@ -0,0 +1,143 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// An update spans an old and a new state. The affected set must be the UNION of +// peers reachable before and after the change; resolving only against the final +// state drops peers that were reachable but no longer are. These tests pin the +// two paths where the old state is reachable only by the changed object's +// previous references: detaching a resource group, and re-pointing a router peer. + +// TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources: +// a resource is reachable by a source group via two destination resource groups; +// detaching one of them must still refresh that group's policy source peers, even +// though the post-update resource no longer maps to it. +func TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + // A second resource group + a second source group/peer that reaches the + // resource only through that second group. + const detachGroupID = "rs-detach-grp" + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"})) + + const secondSourceGroupID = "rs-source-grp-2" + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, + })) + + resourcesManager, _, _ := s.managers() + + // Attach the resource to the detach group as well: now in [resourceGroup, detachGroup]. + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID, detachGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + // Policy granting the second source group access via the detach group. + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true) + require.NoError(t, err) + + secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) }) + settleAffectedUpdates(secondSrcCh) + + done := make(chan struct{}) + go func() { + // Detaching the resource from detachGroup removes the second source's + // access; that source peer must be refreshed even though the post-update + // resource no longer maps to detachGroup. + peerShouldReceiveUpdate(t, secondSrcCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID}, // detached detachGroup + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer") + } +} + +// TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer: +// changing router.Peer within the same network must still refresh the OLD routing +// peer, which loses its routing role. +func TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + _, routersManager, _ := s.managers() + + routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID) + require.NoError(t, err) + require.Len(t, routers, 1) + router := routers[0] + oldRoutingPeer := router.Peer + require.NotEmpty(t, oldRoutingPeer) + + // A new peer to become the routing peer in place of the old one. + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + + oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) }) + settleAffectedUpdates(oldCh) + + done := make(chan struct{}) + go func() { + // The old routing peer stops serving the resource and must be refreshed. + peerShouldReceiveUpdate(t, oldCh) + close(done) + }() + + _, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{ + ID: router.ID, + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: newRoutingPeer.ID, // repoint within the same network + Masquerade: true, + Metric: 9999, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: re-pointing the router peer did not refresh the old routing peer") + } +} diff --git a/management/server/affected_peers_property_test.go b/management/server/affected_peers_property_test.go new file mode 100644 index 000000000..f393465bc --- /dev/null +++ b/management/server/affected_peers_property_test.go @@ -0,0 +1,255 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "sort" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/server/affectedpeers" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// allPeerMaps computes the serialized per-peer network map for every peer in the +// account, mirroring the controller's compute path so the property test compares +// against real output. +func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string) map[string]string { + t.Helper() + ctx := context.Background() + + account, err := manager.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + + account.InjectProxyPolicies(ctx) + + validated := make(map[string]struct{}, len(account.Peers)) + for id := range account.Peers { + validated[id] = struct{}{} + } + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + out := make(map[string]string, len(account.Peers)) + for peerID := range account.Peers { + nm := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validated, resourcePolicies, routers, nil, groupIDToUserIDs) + // Network.Serial is an account-global counter bumped on every change; it + // is not a per-peer dependency, so normalize it out of the comparison. + if nm.Network != nil { + nm.Network.Serial = 0 + } + out[peerID] = canonicalJSON(t, nm) + } + return out +} + +// canonicalJSON marshals v and returns an order-insensitive string form: every +// JSON array is sorted by the canonical form of its elements. The network map's +// Peers/Routes/FirewallRules/SourceRanges slices have nondeterministic order, so +// a raw JSON compare would report spurious changes. +func canonicalJSON(t *testing.T, v interface{}) string { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + var parsed interface{} + require.NoError(t, json.Unmarshal(b, &parsed)) + canonicalized, err := json.Marshal(sortAny(parsed)) + require.NoError(t, err) + return string(canonicalized) +} + +func sortAny(v interface{}) interface{} { + switch val := v.(type) { + case []interface{}: + for i := range val { + val[i] = sortAny(val[i]) + } + sort.Slice(val, func(i, j int) bool { + bi, _ := json.Marshal(val[i]) + bj, _ := json.Marshal(val[j]) + return string(bi) < string(bj) + }) + return val + case map[string]interface{}: + for k := range val { + val[k] = sortAny(val[k]) + } + return val + default: + return v + } +} + +// changedPeers returns the peer IDs whose serialized map differs between before +// and after. +func changedPeers(before, after map[string]string) []string { + var changed []string + for id, b := range before { + a, ok := after[id] + if !ok || a != b { + changed = append(changed, id) + } + } + for id := range after { + if _, ok := before[id]; !ok { + changed = append(changed, id) + } + } + return changed +} + +// TestAffectedPeers_Property_ResolverSupersetsRealChanges builds a topology, +// applies random changes, and asserts that the resolver's affected set is a +// superset of the peers whose real network map actually changed. If the resolver +// ever misses a dependency, a change will alter a peer's map without that peer +// appearing in the affected set, failing here. +func TestAffectedPeers_Property_ResolverSupersetsRealChanges(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + // A pre-existing peer->resource policy so the resource/router bridge is live. + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + // Extra peers and groups to give mutations room to move membership around. + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "prop-key", types.SetupKeyReusable, 0, nil, 999, userID, false, false) + require.NoError(t, err) + extraPeers := make([]string, 0, 4) + for i := 0; i < 4; i++ { + p := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + extraPeers = append(extraPeers, p.ID) + } + extraGroups := []string{"prop-grp-0", "prop-grp-1"} + for _, g := range extraGroups { + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: g, Name: g})) + } + + rng := rand.New(rand.NewSource(1)) + allGroups := append([]string{s.sourceGroupID, s.resourceGroupID, s.routerPeerGroupID}, extraGroups...) + allPeers := append([]string{s.sourcePeerID, s.routerPeerID, s.routerGroupPeerID, s.unrelatedPeerID}, extraPeers...) + + for iter := 0; iter < 60; iter++ { + change, apply := s.randomMutation(t, rng, allGroups, allPeers) + if apply == nil { + continue + } + + before := allPeerMaps(t, s.manager, s.accountID) + + resolvedSet := make(map[string]struct{}) + resolve := func() { + require.NoError(t, s.manager.Store.ExecuteInTransaction(ctx, func(tx store.Store) error { + snap, err := affectedpeers.Load(ctx, tx, s.accountID, change) + if err != nil { + return err + } + for _, id := range snap.Expand(ctx, s.accountID, change) { + resolvedSet[id] = struct{}{} + } + return nil + })) + } + + // Resolve on both sides of the mutation and union: removals are visible + // only pre-apply (the leaving peer is still a member), additions only + // post-apply (the joining peer is now a member). Production captures both + // via per-path handling (e.g. UpdateGroup passes peersToRemove); the union + // models that without coupling the test to each path's ordering. + resolve() + changedIDs := change.ChangedPeerIDs + apply() + resolve() + + after := allPeerMaps(t, s.manager, s.accountID) + + // The explicitly-changed peer's own map refresh is the caller's + // responsibility (the resolver returns the peers to propagate to), so it + // is allowed to be absent from the resolved set. + changedExplicitly := make(map[string]struct{}, len(changedIDs)) + for _, id := range changedIDs { + changedExplicitly[id] = struct{}{} + } + + for _, id := range changedPeers(before, after) { + if _, stillExists := after[id]; !stillExists { + continue + } + if _, isExplicit := changedExplicitly[id]; isExplicit { + continue + } + _, ok := resolvedSet[id] + require.Truef(t, ok, + "iter %d: peer %s network map changed but was not in the resolver's affected set %v (change=%+v)", + iter, id, maps.Keys(resolvedSet), change) + } + } +} + +// randomMutation picks a random change, returns the Change to resolve and a +// function that applies the underlying store mutation. apply is nil when the +// drawn mutation is a no-op for the current state. +func (s *routerScenario) randomMutation(t *testing.T, rng *rand.Rand, allGroups, allPeers []string) (affectedpeers.Change, func()) { + t.Helper() + ctx := context.Background() + + switch rng.Intn(3) { + case 0: + groupID := allGroups[rng.Intn(len(allGroups))] + peerID := allPeers[rng.Intn(len(allPeers))] + grp, err := s.manager.Store.GetGroupByID(ctx, store.LockingStrengthNone, s.accountID, groupID) + require.NoError(t, err) + if slicesContains(grp.Peers, peerID) { + return affectedpeers.Change{}, nil + } + return affectedpeers.Change{ChangedGroupIDs: []string{groupID}, ChangedPeerIDs: []string{peerID}}, + func() { + require.NoError(t, s.manager.GroupAddPeer(ctx, s.accountID, groupID, peerID)) + } + case 1: + groupID := allGroups[rng.Intn(len(allGroups))] + grp, err := s.manager.Store.GetGroupByID(ctx, store.LockingStrengthNone, s.accountID, groupID) + require.NoError(t, err) + if len(grp.Peers) == 0 { + return affectedpeers.Change{}, nil + } + peerID := grp.Peers[rng.Intn(len(grp.Peers))] + return affectedpeers.Change{ChangedGroupIDs: []string{groupID}, ChangedPeerIDs: []string{peerID}}, + func() { + require.NoError(t, s.manager.GroupDeletePeer(ctx, s.accountID, groupID, peerID)) + } + default: + src := allGroups[rng.Intn(len(allGroups))] + dst := allGroups[rng.Intn(len(allGroups))] + policy := &types.Policy{ + Enabled: true, + Name: fmt.Sprintf("prop-policy-%d", rng.Int()), + Rules: []*types.PolicyRule{{ + Enabled: true, + Sources: []string{src}, + Destinations: []string{dst}, + Action: types.PolicyTrafficActionAccept, + }}, + } + return affectedpeers.Change{Policies: []*types.Policy{policy}}, + func() { + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, policy, true) + require.NoError(t, err) + } + } +} + +func slicesContains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/management/server/affected_peers_querycount_test.go b/management/server/affected_peers_querycount_test.go new file mode 100644 index 000000000..d451a0a29 --- /dev/null +++ b/management/server/affected_peers_querycount_test.go @@ -0,0 +1,164 @@ +package server + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/affectedpeers" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" +) + +// countingStore wraps a real store and counts the per-account collection loads +// the resolver performs, so a test can assert each is read at most once and that +// irrelevant collections are skipped entirely. +type countingStore struct { + store.Store + mu sync.Mutex + counts map[string]int +} + +func newCountingStore(s store.Store) *countingStore { + return &countingStore{Store: s, counts: map[string]int{}} +} + +func (c *countingStore) bump(name string) { + c.mu.Lock() + c.counts[name]++ + c.mu.Unlock() +} + +func (c *countingStore) count(name string) int { + c.mu.Lock() + defer c.mu.Unlock() + return c.counts[name] +} + +func (c *countingStore) total() int { + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for _, v := range c.counts { + n += v + } + return n +} + +func (c *countingStore) GetAccountPolicies(ctx context.Context, ls store.LockingStrength, accountID string) ([]*types.Policy, error) { + c.bump("policies") + return c.Store.GetAccountPolicies(ctx, ls, accountID) +} + +func (c *countingStore) GetAccountRoutes(ctx context.Context, ls store.LockingStrength, accountID string) ([]*route.Route, error) { + c.bump("routes") + return c.Store.GetAccountRoutes(ctx, ls, accountID) +} + +func (c *countingStore) GetAccountNameServerGroups(ctx context.Context, ls store.LockingStrength, accountID string) ([]*nbdns.NameServerGroup, error) { + c.bump("nameservers") + return c.Store.GetAccountNameServerGroups(ctx, ls, accountID) +} + +func (c *countingStore) GetAccountDNSSettings(ctx context.Context, ls store.LockingStrength, accountID string) (*types.DNSSettings, error) { + c.bump("dnssettings") + return c.Store.GetAccountDNSSettings(ctx, ls, accountID) +} + +func (c *countingStore) GetNetworkRoutersByAccountID(ctx context.Context, ls store.LockingStrength, accountID string) ([]*routerTypes.NetworkRouter, error) { + c.bump("routers") + return c.Store.GetNetworkRoutersByAccountID(ctx, ls, accountID) +} + +func (c *countingStore) GetNetworkResourcesByAccountID(ctx context.Context, ls store.LockingStrength, accountID string) ([]*resourceTypes.NetworkResource, error) { + c.bump("resources") + return c.Store.GetNetworkResourcesByAccountID(ctx, ls, accountID) +} + +func (c *countingStore) GetAccountServices(ctx context.Context, ls store.LockingStrength, accountID string) ([]*rpservice.Service, error) { + c.bump("services") + return c.Store.GetAccountServices(ctx, ls, accountID) +} + +// TestAffectedPeers_QueryCount_NoRedundantFullTableLoads asserts the resolver +// loads each per-account collection at most once per Resolve (memoization) even +// on a change that drives every bridge, and skips the services table when the +// account has no embedded proxy peers. +func TestAffectedPeers_QueryCount_NoRedundantFullTableLoads(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + cs := newCountingStore(s.manager.Store) + + // A group change that exercises policies, routers, resources and the bridge. + change := affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}} + snap, err := affectedpeers.Load(ctx, cs, s.accountID, change) + require.NoError(t, err) + affected := snap.Expand(ctx, s.accountID, change) + assert.Contains(t, affected, s.routerPeerID, "bridge must still resolve the routing peer") + + for _, name := range []string{"policies", "routes", "nameservers", "dnssettings", "routers", "resources"} { + assert.LessOrEqualf(t, cs.count(name), 1, + "%s must be loaded at most once per Resolve, got %d", name, cs.count(name)) + } + assert.Equal(t, 0, cs.count("services"), + "services must not be loaded when the account has no embedded proxy peers") +} + +// TestAffectedPeers_QueryCount_NarrowChangeSkipsLoads asserts that a change with +// no group/peer signal touches no per-account collections beyond what its inputs +// require. +func TestAffectedPeers_QueryCount_NarrowChangeSkipsLoads(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + cs := newCountingStore(s.manager.Store) + + // A bare network change drives only the router->source bridge: routers and + // resources are needed, but routes/nameservers/dnssettings/services are not. + _, err := affectedpeers.Load(ctx, cs, s.accountID, affectedpeers.Change{Networks: []*networkTypes.Network{{ID: s.networkID}}}) + require.NoError(t, err) + + assert.Equal(t, 0, cs.count("routes"), "routes must not be loaded for a network-only change") + assert.Equal(t, 0, cs.count("nameservers"), "nameservers must not be loaded for a network-only change") + assert.Equal(t, 0, cs.count("dnssettings"), "dnssettings must not be loaded for a network-only change") + assert.Equal(t, 0, cs.count("services"), "services must not be loaded for a network-only change") +} + +// TestAffectedPeers_QueryCount_ExpandReadsNothing is the core invariant of the +// Load/Expand split: Load (run inside the transaction) does all store reads; +// Expand (run after commit) must touch the store ZERO times, so it never holds +// the write lock and never reads post-commit state. +func TestAffectedPeers_QueryCount_ExpandReadsNothing(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + change := affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}} + + cs := newCountingStore(s.manager.Store) + snap, err := affectedpeers.Load(ctx, cs, s.accountID, change) + require.NoError(t, err) + require.Greater(t, cs.total(), 0, "Load must read the store") + + // Any store access during Expand would increment the same counter. Expand + // operates purely on the snapshot, so the count must not move. + readsAfterLoad := cs.total() + affected := snap.Expand(ctx, s.accountID, change) + assert.Contains(t, affected, s.routerPeerID, "Expand must still produce the affected peers from the snapshot") + assert.Equal(t, readsAfterLoad, cs.total(), "Expand must perform zero store reads — it operates purely on the loaded snapshot") +} diff --git a/management/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go new file mode 100644 index 000000000..11313c387 --- /dev/null +++ b/management/server/affected_peers_router_paths_test.go @@ -0,0 +1,333 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/affectedpeers" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/types" +) + +func (s *routerScenario) resolveGroupChangeAffected(ctx context.Context, changedGroupIDs []string) []string { + change := affectedpeers.Change{ChangedGroupIDs: changedGroupIDs} + snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change) + if err != nil { + return nil + } + return snap.Expand(ctx, s.accountID, change) +} + +func (s *routerScenario) resolvePeerChangeAffected(ctx context.Context, changedPeerIDs []string) []string { + change := affectedpeers.Change{ChangedPeerIDs: changedPeerIDs} + snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change) + if err != nil { + return nil + } + return snap.Expand(ctx, s.accountID, change) +} + +func TestAffectedPeers_GroupChange_SourceGroupMembership_RefreshesRoutingPeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID}) + + assert.Contains(t, affected, s.sourcePeerID, "source group member must be affected") + assert.Contains(t, affected, s.routerPeerID, + "changing the source group of a peer->resource policy must refresh the resource's routing peer") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_GroupChange_SourceGroupMembership_RefreshesRoutingPeer_RouterPeerGroups(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID}) + + assert.Contains(t, affected, s.routerGroupPeerID, + "changing the source group must refresh the routing peer defined via router.PeerGroups") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_GroupChange_RouterPeerGroupMembership_RefreshesPolicySources(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolveGroupChangeAffected(ctx, []string{s.routerPeerGroupID}) + + assert.Contains(t, affected, s.routerGroupPeerID, "the routing peer itself must be affected") + assert.Contains(t, affected, s.sourcePeerID, + "changing the router's PeerGroups must refresh the source peers of policies serving the resource") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_PeerChange_SourcePeer_RefreshesRoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID}) + + assert.Contains(t, affected, s.routerPeerID, + "a status change on a source peer must refresh the resource's routing peer that serves it") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_PeerChange_SourcePeer_ByDestinationResource_RefreshesRoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID}) + + assert.Contains(t, affected, s.routerPeerID, + "DestinationResource-targeted policy must still bridge a source-peer change to the routing peer") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_E2E_DeleteGroup_ResolvesAffectedPeers(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + const memberOnlyGroupID = "rs-memberonly-grp" + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: memberOnlyGroupID, Name: "rs-memberonly", Peers: []string{s.sourcePeerID}, + })) + + affected := s.resolveGroupChangeAffected(ctx, []string{memberOnlyGroupID}) + assert.Empty(t, affected, "an unlinked group has no network-map impact, so no peer is affected") + + require.NoError(t, s.manager.DeleteGroup(ctx, s.accountID, userID, memberOnlyGroupID)) +} + +func TestAffectedPeers_GroupAddResource_RefreshesRoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + const extraResourceGroupID = "rs-resource-grp-extra" + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: extraResourceGroupID, Name: "rs-resource-extra", + })) + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, extraResourceGroupID), true) + require.NoError(t, err) + + require.NoError(t, s.manager.GroupAddResource(ctx, s.accountID, extraResourceGroupID, types.Resource{ + ID: s.resourceID, + Type: types.ResourceTypeHost, + })) + + affected := s.resolveGroupChangeAffected(ctx, []string{extraResourceGroupID}) + + assert.Contains(t, affected, s.routerPeerID, + "attaching a resource to a policy destination group must refresh the resource's routing peer") + assert.Contains(t, affected, s.sourcePeerID, "policy source peers must refresh") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context.Context) string { + t.Helper() + + check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ + Name: "rs-min-version", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.30.0"}, + }, + }, true) + require.NoError(t, err) + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + policy.SourcePostureChecks = []string{check.ID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true) + require.NoError(t, err) + + return check.ID +} + +func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + checkID := s.createPostureCheckGatedPolicy(t, ctx) + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ + ID: checkID, + Name: "rs-min-version", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"}, + }, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: editing a posture check did not refresh source + routing peers") + } +} + +func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSourcePeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + resourcesManager, _, _ := s.managers() + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/25", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer") + } +} + +func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + resourcesManager, routersManager, _ := s.managers() + + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: disabledRouterPeer.ID, + Masquerade: true, + Metric: 9000, + Enabled: false, + }) + require.NoError(t, err) + + disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) }) + + settleAffectedUpdates(disabledCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, disabledCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/25", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: resource update did not refresh the disabled sibling router's peer") + } +} + +func TestAffectedPeers_GroupChange_RouterInOtherNetworkNotAffected(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "groupiso") + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID}) + + assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected") + assert.NotContains(t, affected, second.routerPeerID, + "a router in an unrelated network must not be affected by a source-group change for another resource") +} + +func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "peeriso") + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID}) + + assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected") + assert.NotContains(t, affected, second.routerPeerID, + "a router in an unrelated network must not be affected by a source-peer change for another resource") +} diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go new file mode 100644 index 000000000..dc064e787 --- /dev/null +++ b/management/server/affected_peers_router_test.go @@ -0,0 +1,771 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/server/affectedpeers" + "github.com/netbirdio/netbird/management/server/groups" + "github.com/netbirdio/netbird/management/server/networks" + "github.com/netbirdio/netbird/management/server/networks/resources" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + "github.com/netbirdio/netbird/management/server/networks/routers" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// routerScenario captures the topology from the bug report: +// +// network ── router (routing peer) ── resource (in resourceGroup) +// independent peer ──(policy: source -> resource)──> resource +// +// The routing peer must be refreshed when a policy grants a source peer access +// to the resource, because the network map connects the source peer to the +// routing peer at compute time (Account.GetPoliciesForNetworkResource + +// addNetworksRoutingPeers). The routing peer is NOT a member of the resource +// group, so static group/peer resolution alone cannot find it. +type routerScenario struct { + manager *DefaultAccountManager + updateManager *update_channel.PeersUpdateManager + accountID string + networkID string + + sourcePeerID string // independent peer that the policy grants access from + sourceGroupID string // group containing the source peer + + routerPeerID string // peer acting as the routing peer (direct router.Peer) + routerGroupPeerID string // peer that is a member of routerPeerGroup + routerPeerGroupID string // group used for router.PeerGroups + + resourceID string // network resource + resourceGroupID string // group whose member is the resource (no peers) + + unrelatedPeerID string // peer in no relevant entity +} + +// setupRouterScenario builds the topology above with the default policy removed +// and channels NOT yet created, so callers control exactly when updates can flow. +func setupRouterScenario(t *testing.T, directRouterPeer bool) *routerScenario { + t.Helper() + + manager, updateManager, err := createManager(t) + require.NoError(t, err) + + ctx := context.Background() + + account, err := createAccount(manager, "router_scenario", userID, "") + require.NoError(t, err) + accountID := account.Id + + // Remove the default policy so AddPeer/CreateGroup don't schedule unrelated updates. + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } + + setupKey, err := manager.CreateSetupKey(ctx, accountID, "rs-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + sourcePeer := addPeerToAccount(t, manager, accountID, setupKey.Key) + routerPeer := addPeerToAccount(t, manager, accountID, setupKey.Key) + routerGroupPeer := addPeerToAccount(t, manager, accountID, setupKey.Key) + unrelatedPeer := addPeerToAccount(t, manager, accountID, setupKey.Key) + + const ( + sourceGroupID = "rs-source-grp" + routerPeerGroupID = "rs-router-grp" + resourceGroupID = "rs-resource-grp" + ) + + for _, g := range []*types.Group{ + {ID: sourceGroupID, Name: "rs-source", Peers: []string{sourcePeer.ID}}, + {ID: routerPeerGroupID, Name: "rs-router", Peers: []string{routerGroupPeer.ID}}, + {ID: resourceGroupID, Name: "rs-resource"}, // intentionally peerless; the resource is its only member + } { + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, g)) + } + + permissionsManager := permissions.NewManager(manager.Store) + groupsManager := groups.NewManager(manager.Store, permissionsManager, manager) + resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager) + routersManager := routers.NewManager(manager.Store, permissionsManager, manager) + networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager) + + network, err := networksManager.CreateNetwork(ctx, userID, &networkTypes.Network{ + ID: "rs-network", + AccountID: accountID, + Name: "rs-network", + }) + require.NoError(t, err) + + resource, err := resourcesManager.CreateResource(ctx, userID, &resourceTypes.NetworkResource{ + AccountID: accountID, + NetworkID: network.ID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + router := &routerTypes.NetworkRouter{ + ID: "rs-router", + NetworkID: network.ID, + AccountID: accountID, + Masquerade: true, + Metric: 9999, + Enabled: true, + } + if directRouterPeer { + router.Peer = routerPeer.ID + } else { + router.PeerGroups = []string{routerPeerGroupID} + } + _, err = routersManager.CreateRouter(ctx, userID, router) + require.NoError(t, err) + + return &routerScenario{ + manager: manager, + updateManager: updateManager, + accountID: accountID, + networkID: network.ID, + sourcePeerID: sourcePeer.ID, + sourceGroupID: sourceGroupID, + routerPeerID: routerPeer.ID, + routerGroupPeerID: routerGroupPeer.ID, + routerPeerGroupID: routerPeerGroupID, + resourceID: resource.ID, + resourceGroupID: resourceGroupID, + unrelatedPeerID: unrelatedPeer.ID, + } +} + +// peerToResourcePolicy builds a policy granting the source group access to the +// resource, referencing the resource by its group in the rule destination. +func peerToResourcePolicyByGroup(sourceGroupID, resourceGroupID string) *types.Policy { + return &types.Policy{ + Enabled: true, + Name: "peer-to-resource-by-group", + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{sourceGroupID}, + Destinations: []string{resourceGroupID}, + Action: types.PolicyTrafficActionAccept, + }, + }, + } +} + +// peerToResourcePolicyByResource builds a policy referencing the resource +// directly via DestinationResource rather than its group. +func peerToResourcePolicyByResource(sourceGroupID, resourceID string) *types.Policy { + return &types.Policy{ + Enabled: true, + Name: "peer-to-resource-by-resource", + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{sourceGroupID}, + DestinationResource: types.Resource{ID: resourceID, Type: types.ResourceTypeHost}, + Action: types.PolicyTrafficActionAccept, + }, + }, + } +} + +// resolvePolicyAffected mirrors SavePolicy's resolution: resolve the affected +// peers for the given policy. +func (s *routerScenario) resolvePolicyAffected(ctx context.Context, policy *types.Policy) []string { + change := affectedpeers.Change{Policies: []*types.Policy{policy}} + snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change) + if err != nil { + return nil + } + return snap.Expand(ctx, s.accountID, change) +} + +func TestAffectedPeers_SourcePeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + affected := s.resolvePolicyAffected(ctx, policy) + + assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected") +} + +func TestAffectedPeers_RoutingPeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + affected := s.resolvePolicyAffected(ctx, policy) + + // BUG: the direct routing peer serves the resource's subnet to the source + // peer, so it must be refreshed when the policy is created. The policy path + // only resolves the literal rule groups (source group + resource group); + // the resource group has no peer members and the router peer is reachable + // only through the network, so it is dropped. + assert.Contains(t, affected, s.routerPeerID, + "routing peer (router.Peer) serving the resource must be affected by a policy granting access to it") +} + +func TestAffectedPeers_RoutingPeer_RouterPeerGroups(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + affected := s.resolvePolicyAffected(ctx, policy) + + // Router defined via PeerGroups instead of a direct peer. + assert.Contains(t, affected, s.routerGroupPeerID, + "routing peer (router.PeerGroups member) serving the resource must be affected") +} + +func TestAffectedPeers_DestResource_RoutingPeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID) + affected := s.resolvePolicyAffected(ctx, policy) + + // When the resource is referenced via DestinationResource, RuleGroups() + // returns only the source group and the resource ID is not a peer, so + // collectPolicyAffectedGroupsAndPeers yields nothing for the destination at + // all. The routing peer is dropped here too. + assert.Contains(t, affected, s.routerPeerID, + "routing peer must be affected when the resource is referenced via DestinationResource") +} + +func TestAffectedPeers_DestResource_RoutingPeer_RouterPeerGroups(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID) + affected := s.resolvePolicyAffected(ctx, policy) + + assert.Contains(t, affected, s.routerGroupPeerID, + "routing peer (PeerGroups) must be affected when the resource is referenced via DestinationResource") +} + +func TestAffectedPeers_SourceResourcePeer_RoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + // Source expressed as a direct peer (SourceResource), destination as resource group. + policy := &types.Policy{ + Enabled: true, + Name: "sourceResource-peer-to-resource", + Rules: []*types.PolicyRule{ + { + Enabled: true, + SourceResource: types.Resource{ID: s.sourcePeerID, Type: types.ResourceTypePeer}, + Destinations: []string{s.resourceGroupID}, + Action: types.PolicyTrafficActionAccept, + }, + }, + } + affected := s.resolvePolicyAffected(ctx, policy) + + // The direct source peer IS picked up (collectPolicyAffectedGroupsAndPeers + // handles SourceResource peers), but the routing peer is still missing. + assert.Contains(t, affected, s.sourcePeerID, "direct source peer must be affected") + assert.Contains(t, affected, s.routerPeerID, "routing peer must be affected") +} + +func TestAffectedPeers_PolicyToResource_UnrelatedPeerNotAffected(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + affected := s.resolvePolicyAffected(ctx, policy) + + // Guard against an over-broad fix: a peer in no relevant entity must never + // be pulled in. + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_ResourceSideBridgesToRoutingPeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + // A pre-existing policy grants the source group access to the resource. + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + // Drive an update through the resource manager and assert the routing peer + // is among the affected set by observing the channel. This path walks + // policies whose destinations reference the resource's groups, folds in the + // source groups, and loads the network's routers, so it reaches both the + // source peer and the routing peer. + permissionsManager := permissions.NewManager(s.manager.Store) + groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager) + rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager) + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: resource update did not refresh source peer + routing peer") + } +} + +// settleAffectedUpdates waits for in-flight async updates to arrive, then drains +// every given channel so subsequent assertions start from a clean slate. +// +// Setup (CreateNetwork/CreateResource/CreateRouter) fires async UpdateAffectedPeers +// goroutines; draining first means the assertion only observes updates from the +// action under test, not setup stragglers. +func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) { + time.Sleep(300 * time.Millisecond) + for _, ch := range chans { + drainPeerUpdates(ch) + } +} + +func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: creating peer->resource policy did not refresh the routing peer") + } +} + +func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_RouterPeerGroups(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create") + } +} + +func TestAffectedPeers_E2E_DestResource_RoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: routing peer not refreshed when policy targets DestinationResource") + } +} + +func TestAffectedPeers_E2E_DeletePolicy_RoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID)) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: deleting peer->resource policy did not refresh the routing peer") + } +} + +func (s *routerScenario) managers() (resources.Manager, routers.Manager, networks.Manager) { + permissionsManager := permissions.NewManager(s.manager.Store) + groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager) + resourcesManager := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager) + routersManager := routers.NewManager(s.manager.Store, permissionsManager, s.manager) + networksManager := networks.NewManager(s.manager.Store, permissionsManager, resourcesManager, routersManager, s.manager) + return resourcesManager, routersManager, networksManager +} + +type secondTopology struct { + networkID string + resourceID string + resourceGroupID string + routerPeerID string +} + +func (s *routerScenario) addSecondTopology(t *testing.T, suffix string) secondTopology { + t.Helper() + ctx := context.Background() + resourcesManager, routersManager, networksManager := s.managers() + + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-"+suffix, types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + routerPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + + resourceGroupID := "rs-resource-grp-" + suffix + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: resourceGroupID, Name: "rs-resource-" + suffix, + })) + + network, err := networksManager.CreateNetwork(ctx, userID, &networkTypes.Network{ + ID: "rs-network-" + suffix, + AccountID: s.accountID, + Name: "rs-network-" + suffix, + }) + require.NoError(t, err) + + resource, err := resourcesManager.CreateResource(ctx, userID, &resourceTypes.NetworkResource{ + AccountID: s.accountID, + NetworkID: network.ID, + Name: "rs-resource-host-" + suffix, + Address: "10.40.50.0/24", + GroupIDs: []string{resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ + NetworkID: network.ID, + AccountID: s.accountID, + Peer: routerPeer.ID, + Masquerade: true, + Metric: 9999, + Enabled: true, + }) + require.NoError(t, err) + + return secondTopology{ + networkID: network.ID, + resourceID: resource.ID, + resourceGroupID: resourceGroupID, + routerPeerID: routerPeer.ID, + } +} + +func TestAffectedPeers_E2E_UpdatePolicy_BothRoutingPeers(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "b") + ctx := context.Background() + + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, second.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerACh, routerBCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerACh) + peerShouldReceiveUpdate(t, routerBCh) + close(done) + }() + + policy.Rules[0].Destinations = []string{second.resourceGroupID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: re-pointing the policy destination did not refresh both routing peers") + } +} + +func TestAffectedPeers_E2E_UpdatePolicy_AddSource(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + const secondSourceGroupID = "rs-source-grp-2" + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, + })) + + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(newSrcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, newSrcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: adding a source group did not refresh the new source peer + routing peer") + } +} + +func TestAffectedPeers_E2E_DestResource_RouterPeerGroups(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer") + } +} + +func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, routersManager, _ := s.managers() + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-r2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: secondRouterPeer.ID, + Masquerade: true, + Metric: 9998, + Enabled: true, + }) + require.NoError(t, err) + + affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + + assert.Contains(t, affected, s.routerPeerID, "first routing peer must be affected") + assert.Contains(t, affected, secondRouterPeer.ID, "second routing peer on the same network must also be affected") +} + +func TestAffectedPeers_DisabledRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID) + require.NoError(t, err) + require.Len(t, routers, 1) + routers[0].Enabled = false + require.NoError(t, s.manager.Store.UpdateNetworkRouter(ctx, routers[0])) + + affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + + assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected") + assert.Contains(t, affected, s.routerPeerID, + "disabled router's peer must still be affected: Enabled must not gate affected-peers") +} + +func TestAffectedPeers_DisabledResource(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + res, err := s.manager.Store.GetNetworkResourceByID(ctx, store.LockingStrengthNone, s.accountID, s.resourceID) + require.NoError(t, err) + res.Enabled = false + require.NoError(t, s.manager.Store.SaveNetworkResource(ctx, res)) + + affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + + assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected") + assert.Contains(t, affected, s.routerPeerID, + "disabled resource must still resolve the routing peer: Enabled must not gate affected-peers") +} + +func TestAffectedPeers_DisabledRule(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + policy.Rules[0].Enabled = false + + affected := s.resolvePolicyAffected(ctx, policy) + + assert.Contains(t, affected, s.routerPeerID, + "disabled rule must still resolve the routing peer: Enabled must not gate affected-peers") +} + +func TestAffectedPeers_MultiRule(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "c") + ctx := context.Background() + + policy := &types.Policy{ + Enabled: true, + Name: "multi-rule-two-resources", + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{s.sourceGroupID}, + Destinations: []string{s.resourceGroupID}, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{s.sourceGroupID}, + Destinations: []string{second.resourceGroupID}, + Action: types.PolicyTrafficActionAccept, + }, + }, + } + + affected := s.resolvePolicyAffected(ctx, policy) + + assert.Contains(t, affected, s.routerPeerID, "routing peer for resource A must be affected") + assert.Contains(t, affected, second.routerPeerID, "routing peer for resource B must be affected") +} + +func TestAffectedPeers_RouterOtherNetwork(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "d") + ctx := context.Background() + + affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + + assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected") + assert.NotContains(t, affected, second.routerPeerID, + "a router in an unrelated network must not be affected by a policy that does not target its resource") +} diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go new file mode 100644 index 000000000..b66eeb3b5 --- /dev/null +++ b/management/server/affected_peers_test.go @@ -0,0 +1,1802 @@ +package server + +import ( + "context" + "fmt" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + nbdns "github.com/netbirdio/netbird/dns" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/affectedpeers" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" +) + +// resolveAffected is a test helper for the resolver's Load+Expand, used where a +// test asserts on the fully expanded affected peer set. +func resolveAffected(t *testing.T, s store.Store, accountID string, change affectedpeers.Change) []string { + t.Helper() + ctx := context.Background() + snap, err := affectedpeers.Load(ctx, s, accountID, change) + require.NoError(t, err) + return snap.Expand(ctx, accountID, change) +} + +// Thin test adapters over affectedpeers.Collect, preserving the (groups, peers) +// shape these tests assert on after the resolver was unified. +func collectGroupChangeAffectedGroups(ctx context.Context, s store.Store, accountID string, changedGroupIDs []string) ([]string, []string) { + return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{ChangedGroupIDs: changedGroupIDs}) +} + +func collectPeerChangeAffectedGroups(ctx context.Context, s store.Store, accountID string, changedGroupIDs, changedPeerIDs []string) ([]string, []string) { + return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{ChangedGroupIDs: changedGroupIDs, ChangedPeerIDs: changedPeerIDs}) +} + +func collectPostureCheckAffectedGroupsAndPeers(ctx context.Context, s store.Store, accountID, postureCheckID string) ([]string, []string) { + return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{PostureCheckIDs: []string{postureCheckID}}) +} + +// setupAffectedPeersTest creates a manager with a clean account (default policy deleted) +// and 5 peers, each in its own group: peer0->group0, peer1->group1, ..., peer4->group4. +func setupAffectedPeersTest(t *testing.T) (*DefaultAccountManager, store.Store, string, []string, []string) { + t.Helper() + + manager, _, err := createManager(t) + require.NoError(t, err) + + account, err := createAccount(manager, "affected_test", userID, "") + require.NoError(t, err) + + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + peerIDs := make([]string, 5) + for i := 0; i < 5; i++ { + peer := addPeerToAccount(t, manager, accountID, setupKey.Key) + peerIDs[i] = peer.ID + } + + groupIDs := make([]string, 5) + for i := 0; i < 5; i++ { + g := &types.Group{ + ID: affectedGroupID(i), + Name: affectedGroupName(i), + Peers: []string{peerIDs[i]}, + } + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + groupIDs[i] = g.ID + } + + return manager, manager.Store, accountID, peerIDs, groupIDs +} + +func affectedGroupID(i int) string { return fmt.Sprintf("affected-grp-%d", i) } +func affectedGroupName(i int) string { return fmt.Sprintf("AffectedGroup%d", i) } + +func TestCollectGroupChange_PolicyLinked(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + assert.Empty(t, groups) +} + +func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypePeer}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + assert.Contains(t, directPeers, peerIDs[3]) +} + +func TestCollectGroupChange_PolicyWithNonPeerResource_NoDirectPeers(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: "some-domain", Type: types.ResourceTypeDomain}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + assert.Empty(t, directPeers, "non-peer resources should not produce direct peer IDs") +} + +func TestCollectGroupChange_RouteLinked(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.0.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[0]}, + "test route", + "testnet", + false, + 9999, + []string{groupIDs[1]}, + []string{groupIDs[2]}, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + assert.Contains(t, groups, groupIDs[2]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + assert.Contains(t, groups, groupIDs[2]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]}) + assert.Empty(t, groups) +} + +func TestCollectGroupChange_RouteWithDirectPeer(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.1.0.0/24"), + route.IPv4Network, + nil, + peerIDs[4], + nil, + "test route peer", + "testnet2", + false, + 9999, + []string{groupIDs[1]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Contains(t, groups, groupIDs[1]) + assert.Contains(t, directPeers, peerIDs[4]) +} + +func TestCollectGroupChange_NameServerGroupLinked(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns1", "NS Group 1", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{groupIDs[0]}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Empty(t, groups) +} + +func TestCollectGroupChange_DNSSettingsLinked(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{groupIDs[2]}, + }) + require.NoError(t, err) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + assert.Contains(t, groups, groupIDs[2]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Empty(t, groups) +} + +func TestCollectGroupChange_NetworkRouterLinked(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + net1 := &networkTypes.Network{ + ID: "net-test-1", + AccountID: accountID, + Name: "test-network", + } + err := manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "router1", + NetworkID: net1.ID, + AccountID: accountID, + PeerGroups: []string{groupIDs[0]}, + Peer: peerIDs[3], + }) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, directPeers, peerIDs[3]) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Empty(t, groups) + assert.Empty(t, directPeers) +} + +func TestCollectGroupChange_NetworkRouterPeerOnlyNoGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + net1 := &networkTypes.Network{ + ID: "net-peer-only", + AccountID: accountID, + Name: "peer-only-network", + } + err := manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + // Router with only a direct peer, no PeerGroups + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "router-peer-only", + NetworkID: net1.ID, + AccountID: accountID, + Peer: peerIDs[4], + }) + require.NoError(t, err) + + // None of the groups should match since router has no PeerGroups + for i := 0; i < 5; i++ { + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[i]}) + assert.Empty(t, groups, "group%d should not match router with only direct peer", i) + assert.Empty(t, directPeers, "group%d should not produce direct peers", i) + } +} + +func TestCollectGroupChange_MultipleEntities(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.2.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[2]}, + "multi route", + "multinet", + false, + 9999, + []string{groupIDs[3]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + assert.NotContains(t, groups, groupIDs[2]) + assert.NotContains(t, groups, groupIDs[3]) + assert.Empty(t, directPeers) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]}) + assert.Contains(t, groups, groupIDs[2]) + assert.Contains(t, groups, groupIDs[3]) + assert.NotContains(t, groups, groupIDs[0]) + assert.NotContains(t, groups, groupIDs[1]) + assert.Empty(t, directPeers) +} + +func TestCollectGroupChange_MultipleNameServerGroups_OnlyLinkedAffected(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + // Create two nameserver groups using different groups + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-a", "NS-A", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{groupIDs[0]}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + _, err = manager.CreateNameServerGroup(ctx, accountID, "ns-b", "NS-B", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{groupIDs[2]}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + // Changing group0 should only find group0 (from ns-a), not group2 (from ns-b) + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + assert.NotContains(t, groups, groupIDs[2]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + assert.Contains(t, groups, groupIDs[2]) + assert.NotContains(t, groups, groupIDs[0]) + + // Unrelated group + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[4]}) + assert.Empty(t, groups) +} + +func TestResolveAffectedPeers_PolicyBetweenTwoGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) + assert.Empty(t, result) +} + +func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0], groupIDs[1]}, + Destinations: []string{groupIDs[2]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result) +} + +func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.3.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[0]}, + "test route", + "routenet", + false, + 9999, + []string{groupIDs[1]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) + assert.Empty(t, result) +} + +func TestResolveAffectedPeers_RouteWithDirectPeer(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.4.0.0/24"), + route.IPv4Network, + nil, + peerIDs[4], + nil, + "route with peer", + "routenet2", + false, + 9999, + []string{groupIDs[1]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]}) + assert.ElementsMatch(t, []string{peerIDs[1], peerIDs[4]}, result) +} + +func TestResolveAffectedPeers_RouteWithAccessControlGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.7.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[0]}, + "acl route", + "aclnet", + false, + 9999, + []string{groupIDs[1]}, + []string{groupIDs[2]}, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + // peer2 is only in AccessControlGroups, still should be affected + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result) + + // peer3 is unrelated + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[3]}) + assert.Empty(t, result) +} + +func TestResolveAffectedPeers_NetworkRouter(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + net1 := &networkTypes.Network{ + ID: "net-test-2", + AccountID: accountID, + Name: "test-net", + } + err := manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "router-test", + NetworkID: net1.ID, + AccountID: accountID, + PeerGroups: []string{groupIDs[0]}, + Peer: peerIDs[3], + }) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[3]}, result) +} + +func TestResolveAffectedPeers_NameServerGroup(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-test", "NS Test", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{groupIDs[0]}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.Contains(t, result, peerIDs[0]) +} + +func TestResolveAffectedPeers_DNSSettings(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{groupIDs[0]}, + }) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.Contains(t, result, peerIDs[0]) +} + +func TestResolveAffectedPeers_PeerInMultipleGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + err := manager.GroupAddPeer(ctx, accountID, groupIDs[1], peerIDs[0]) + require.NoError(t, err) + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[2]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[1]}, + Destinations: []string{groupIDs[3]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + // peer0 is in group0 AND group1, so both policies apply + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2], peerIDs[3]}, result) +} + +func TestResolveAffectedPeers_MultipleChangedPeers(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[2]}, + Destinations: []string{groupIDs[3]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0], peerIDs[2]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2], peerIDs[3]}, result) +} + +func TestResolveAffectedPeers_SharedGroupAcrossPolicyAndRoute(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.5.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[2]}, + "shared group route", + "sharednet", + false, + 9999, + []string{groupIDs[0]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + // group0 is shared: policy gives peer0+peer1, route gives peer0+peer2 + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result) +} + +func TestResolveAffectedPeers_NoDuplicates(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + err := manager.GroupAddPeer(ctx, accountID, groupIDs[1], peerIDs[0]) + require.NoError(t, err) + err = manager.GroupAddPeer(ctx, accountID, groupIDs[2], peerIDs[0]) + require.NoError(t, err) + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0], groupIDs[1]}, + Destinations: []string{groupIDs[2]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + count := 0 + for _, id := range result { + if id == peerIDs[0] { + count++ + } + } + assert.Equal(t, 1, count, "peer0 should appear exactly once") +} + +func TestCollectPostureCheckAffected_LinkedToPolicy(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + // Create the posture check in the store so the policy validation keeps the reference. + err := s.SavePostureChecks(ctx, &posture.Checks{ + ID: "pc-1", + Name: "test-posture-check", + AccountID: accountID, + }) + require.NoError(t, err) + + policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + SourcePostureChecks: []string{"pc-1"}, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + _ = policy + + groups, directPeers := collectPostureCheckAffectedGroupsAndPeers(ctx, s, accountID, "pc-1") + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + assert.Empty(t, directPeers) + + // Different posture check ID should not match + groups, directPeers = collectPostureCheckAffectedGroupsAndPeers(ctx, s, accountID, "pc-other") + assert.Empty(t, groups) + assert.Empty(t, directPeers) +} + +func TestAffectedPeers_IsolatedPolicies(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[2]}, + Destinations: []string{groupIDs[3]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + assert.NotContains(t, result, peerIDs[2]) + assert.NotContains(t, result, peerIDs[3]) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) + assert.ElementsMatch(t, []string{peerIDs[2], peerIDs[3]}, result) + assert.NotContains(t, result, peerIDs[0]) + assert.NotContains(t, result, peerIDs[1]) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[4]}) + assert.Empty(t, result) +} + +func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.6.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[2]}, + "isolated route", + "isonet", + false, + 9999, + []string{groupIDs[3]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + assert.NotContains(t, result, peerIDs[2]) + assert.NotContains(t, result, peerIDs[3]) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) + assert.ElementsMatch(t, []string{peerIDs[2], peerIDs[3]}, result) + assert.NotContains(t, result, peerIDs[0]) + assert.NotContains(t, result, peerIDs[1]) +} + +func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "ap-grpA", Name: "AP-A", Peers: []string{peer1.ID}}, + {ID: "ap-grpB", Name: "AP-B", Peers: []string{peer2.ID}}, + {ID: "ap-grpC", Name: "AP-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"ap-grpA"}, + Destinations: []string{"ap-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, manager.Store, accountID, []string{peer1.ID}) + assert.ElementsMatch(t, []string{peer1.ID, peer2.ID}, result) + + t.Run("group change updates all peers in policy groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "ap-grpA", + Name: "AP-A", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +func TestAffectedPeers_UnlinkedGroupChange_NoUpdates(t *testing.T) { + manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) + ctx := context.Background() + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.Empty(t, result) +} + +// TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate verifies that creating/deleting a +// policy only sends updates to peers in the policy's groups, not to unrelated peers. +func TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "pol-grpA", Name: "Pol-A", Peers: []string{peer1.ID}}, + {ID: "pol-grpB", Name: "Pol-B", Peers: []string{peer2.ID}}, + {ID: "pol-grpC", Name: "Pol-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("create policy only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"pol-grpA"}, + Destinations: []string{"pol-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate verifies that creating a route +// only sends updates to peers in the route's groups, not to unrelated peers. +func TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "rt-grpA", Name: "Rt-A", Peers: []string{peer1.ID}}, + {ID: "rt-grpB", Name: "Rt-B", Peers: []string{peer2.ID}}, + {ID: "rt-grpC", Name: "Rt-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("create route only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.10.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"rt-grpA"}, + "test route", + "routenoaffect", + false, + 9999, + []string{"rt-grpB"}, + nil, + true, + userID, + false, + false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate verifies that creating a +// nameserver group only sends updates to peers in its groups, not to unrelated peers. +func TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "ns-grpA", Name: "NS-A", Peers: []string{peer1.ID}}, + {ID: "ns-grpB", Name: "NS-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("create nameserver group only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-unrelated", "NS Unrelated", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"ns-grpA"}, + true, nil, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate verifies that changing DNS +// settings only sends updates to peers in the affected groups, not to unrelated peers. +func TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "dns-grpA", Name: "DNS-A", Peers: []string{peer1.ID}}, + {ID: "dns-grpB", Name: "DNS-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("dns settings change only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"dns-grpA"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration tests the full integration: +// updating a group that is NOT referenced by any policy/route/ns/dns should not send +// updates to any peer. +func TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ + ID: "unlinked-grp", + Name: "Unlinked", + Peers: []string{peer1.ID}, + }) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("updating unlinked group sends no peer updates", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "unlinked-grp", + Name: "Unlinked", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate: a network router with peer +// groups updates only those groups' peers (and resource policy sources), not others. +func TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate(t *testing.T) { + // Delete the default policy before adding peers so AddPeer schedules no async + // update that races with the test. + manager, updateManager, err := createManager(t) + require.NoError(t, err) + + ctx := context.Background() + + account, err := createAccount(manager, "nr_test_account", userID, "") + require.NoError(t, err) + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + peer1 := addPeerToAccount(t, manager, accountID, setupKey.Key) + peer2 := addPeerToAccount(t, manager, accountID, setupKey.Key) + peer3 := addPeerToAccount(t, manager, accountID, setupKey.Key) + + for _, g := range []*types.Group{ + {ID: "nr-grpA", Name: "NR-A", Peers: []string{peer1.ID}}, + {ID: "nr-grpB", Name: "NR-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + net1 := &networkTypes.Network{ + ID: "nr-net-test", + AccountID: accountID, + Name: "nr-test-network", + } + err = manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "nr-router-test", + NetworkID: net1.ID, + AccountID: accountID, + PeerGroups: []string{"nr-grpA"}, + }) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("network router group change only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldReceiveUpdate(t, updMsg3) + close(done) + }() + + err = manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "nr-grpA", + Name: "NR-A", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers: with a policy (peer1<->peer2) +// and a separate route (peer3), changing one entity's groups affects only its peers. +func TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "iso-grpA", Name: "ISO-A", Peers: []string{peer1.ID}}, + {ID: "iso-grpB", Name: "ISO-B", Peers: []string{peer2.ID}}, + {ID: "iso-grpC", Name: "ISO-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"iso-grpA"}, + Destinations: []string{"iso-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.20.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"iso-grpC"}, + "isolated route", + "isonet2", + false, + 9999, + []string{"iso-grpC"}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + // The setup policy/route above dispatch affected-peer updates asynchronously; + // drain any in-flight ones so the assertions only observe the UpdateGroup below. + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + t.Run("policy group change does not affect route-only peer", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "iso-grpA", + Name: "ISO-A-updated", + Peers: []string{peer1.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate verifies that deleting a route +// only sends updates to peers in the route's groups. +func TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "del-rt-grpA", Name: "Del-Rt-A", Peers: []string{peer1.ID}}, + {ID: "del-rt-grpB", Name: "Del-Rt-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + newRoute, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.30.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"del-rt-grpA"}, + "deletable route", + "delnet", + false, + 9999, + []string{"del-rt-grpB"}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("delete route only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeleteRoute(ctx, accountID, newRoute.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate verifies that deleting a policy +// only sends updates to peers in the policy's groups. +func TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "del-pol-grpA", Name: "Del-Pol-A", Peers: []string{peer1.ID}}, + {ID: "del-pol-grpB", Name: "Del-Pol-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"del-pol-grpA"}, + Destinations: []string{"del-pol-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("delete policy only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeletePolicy(ctx, accountID, policy.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate verifies that deleting a +// nameserver group only sends updates to peers in its groups. +func TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ + ID: "del-ns-grpA", + Name: "Del-NS-A", + Peers: []string{peer1.ID}, + }) + require.NoError(t, err) + + nsGroup, err := manager.CreateNameServerGroup(ctx, accountID, "del-ns", "Del NS", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"del-ns-grpA"}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("delete nameserver group only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeleteNameServerGroup(ctx, accountID, nsGroup.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +func addPeerToAccount(t *testing.T, manager *DefaultAccountManager, _, setupKeyKey string) *nbpeer.Peer { + t.Helper() + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + peer, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: key.PublicKey().String()}, + }, false) + require.NoError(t, err) + return peer +} + +// markPeerAsProxy flips an existing peer's ProxyMeta to mark it as an embedded +// proxy peer in the given cluster. +func markPeerAsProxy(t *testing.T, s store.Store, accountID, peerID, cluster string) { + t.Helper() + ctx := context.Background() + peer, err := s.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + require.NoError(t, err) + peer.ProxyMeta = nbpeer.ProxyMeta{Embedded: true, Cluster: cluster} + require.NoError(t, s.SavePeer(ctx, accountID, peer)) +} + +// createServiceWithTargets persists a service with the given cluster and targets +// directly in the store, bypassing the proxy-service manager (which would also +// run cluster derivation and trigger UpdateAccountPeers). +func createServiceWithTargets(t *testing.T, s store.Store, accountID, cluster string, targets []*rpservice.Target) *rpservice.Service { + t.Helper() + svc := &rpservice.Service{ + AccountID: accountID, + Name: fmt.Sprintf("svc-%s", cluster), + Domain: fmt.Sprintf("%s.example.com", cluster), + ProxyCluster: cluster, + Enabled: true, + Mode: "tcp", + Targets: targets, + } + svc.InitNewRecord() + for _, target := range targets { + target.AccountID = accountID + target.ServiceID = svc.ID + } + require.NoError(t, s.CreateService(context.Background(), svc)) + return svc +} + +func TestCollectAffectedFromProxyServices_TargetPeerChanged(t *testing.T) { + manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) + ctx := context.Background() + + cluster := "cluster-a" + markPeerAsProxy(t, s, accountID, peerIDs[0], cluster) + + createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{ + {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"}, + }) + + _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]}) + assert.Contains(t, directPeers, peerIDs[0], "proxy peer must be refreshed when its target peer changes") + assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed") +} + +func TestCollectAffectedFromProxyServices_ProxyPeerChanged(t *testing.T) { + manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) + ctx := context.Background() + + cluster := "cluster-a" + markPeerAsProxy(t, s, accountID, peerIDs[0], cluster) + + createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{ + {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"}, + {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[2], Enabled: true, Port: 80, Protocol: "tcp"}, + }) + + _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[0]}) + assert.Contains(t, directPeers, peerIDs[0], "changed proxy peer is itself refreshed") + assert.Contains(t, directPeers, peerIDs[1], "target peer 1 must be refreshed when proxy peer changes") + assert.Contains(t, directPeers, peerIDs[2], "target peer 2 must be refreshed when proxy peer changes") +} + +func TestCollectAffectedFromProxyServices_GroupContainingTargetPeerChanged(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + cluster := "cluster-a" + markPeerAsProxy(t, s, accountID, peerIDs[0], cluster) + + createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{ + {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"}, + }) + + _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, []string{groupIDs[1]}, nil) + assert.Contains(t, directPeers, peerIDs[0], "proxy peer must be refreshed when a group containing its target peer changes") + assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed") +} + +func TestCollectAffectedFromProxyServices_DisabledServiceStillMatches(t *testing.T) { + manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) + ctx := context.Background() + + cluster := "cluster-a" + markPeerAsProxy(t, s, accountID, peerIDs[0], cluster) + + svc := &rpservice.Service{ + AccountID: accountID, + Name: "disabled-svc", + Domain: "disabled.example.com", + ProxyCluster: cluster, + Enabled: false, + Mode: "tcp", + Targets: []*rpservice.Target{ + {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: false, Port: 80, Protocol: "tcp"}, + }, + } + svc.InitNewRecord() + for _, target := range svc.Targets { + target.AccountID = accountID + target.ServiceID = svc.ID + } + require.NoError(t, s.CreateService(ctx, svc)) + + _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]}) + assert.Contains(t, directPeers, peerIDs[0], "disabled service should still trigger a refresh so peers are ready when re-enabled") + assert.Contains(t, directPeers, peerIDs[1], "disabled target should still trigger a refresh") +} + +func TestCollectAffectedFromProxyServices_NonPeerTargetType(t *testing.T) { + manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) + ctx := context.Background() + + cluster := "cluster-a" + markPeerAsProxy(t, s, accountID, peerIDs[0], cluster) + + createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{ + {TargetType: rpservice.TargetTypeHost, TargetId: "10.0.0.1", Host: "10.0.0.1", Enabled: true, Port: 80, Protocol: "tcp"}, + }) + + _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[0]}) + assert.Contains(t, directPeers, peerIDs[0], "host target service still refreshes its proxy peer when the proxy peer changes") + assert.NotContains(t, directPeers, "10.0.0.1", "non-peer target ids must not appear as affected peer IDs") +} diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go new file mode 100644 index 000000000..4ef986345 --- /dev/null +++ b/management/server/affectedpeers/resolver.go @@ -0,0 +1,825 @@ +// Package affectedpeers computes which peers' network maps a change touches, so +// only those peers are refreshed instead of the whole account. +// +// Two phases keep the dependency walk off the write transaction: +// - Load: reads the needed collections. Call INSIDE the mutating tx (consistent, +// and before a delete/removal severs the old state). +// - Snapshot.Expand: in-memory walk, no store access. Run AFTER the tx commits. +// +// Enabled is never consulted: toggling it is itself an observable change. +package affectedpeers + +import ( + "context" + + log "github.com/sirupsen/logrus" + + nbdns "github.com/netbirdio/netbird/dns" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" +) + +// Snapshot is an in-memory view of the collections needed to expand a Change. +// Loaded in-tx, walked by Expand after commit. Only the collections the Change +// can touch are loaded; the rest stay nil (see Load). +type Snapshot struct { + policies []*types.Policy + routes []*route.Route + nsGroups []*nbdns.NameServerGroup + dnsSettings *types.DNSSettings + routers []*routerTypes.NetworkRouter + resources []*resourceTypes.NetworkResource + services []*rpservice.Service + proxyByCluster map[string][]string + groups map[string]*types.Group + groupPeers map[string]map[string]struct{} // groupID -> member peer IDs +} + +// Load reads the collections a Change requires, inside the caller's tx. It mirrors +// Expand's walker preconditions, loading only what the change can touch. +func Load(ctx context.Context, s store.Store, accountID string, c Change) (*Snapshot, error) { + snap := &Snapshot{} + if c.isEmpty() { + return snap, nil + } + + if err := snap.loadCollections(ctx, s, accountID, c); err != nil { + return nil, err + } + if err := snap.loadGroupIndex(ctx, s, accountID); err != nil { + return nil, err + } + + return snap, nil +} + +// loadCollections reads the policy/route/nameserver/dns/router/resource/proxy +// collections a Change can touch, gated to what the walk needs. +func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accountID string, c Change) error { + hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.Resources) > 0 + hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0 + // the resource<->router bridge can fire for any of these + needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject + + if needsRoutersResources { + if err := snap.loadPolicyRoutersResources(ctx, s, accountID); err != nil { + return err + } + } + if hasGroupOrPeerChange { + if err := snap.loadRoutesAndProxy(ctx, s, accountID); err != nil { + return err + } + } + if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 { + if err := snap.loadDNS(ctx, s, accountID); err != nil { + return err + } + } + return nil +} + +// loadPolicyRoutersResources loads the policies plus the routers and resources +// the resource<->router bridge walks. +func (snap *Snapshot) loadPolicyRoutersResources(ctx context.Context, s store.Store, accountID string) error { + var err error + if snap.policies, err = s.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } + if snap.routers, err = s.GetNetworkRoutersByAccountID(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } + snap.resources, err = s.GetNetworkResourcesByAccountID(ctx, store.LockingStrengthNone, accountID) + return err +} + +// loadRoutesAndProxy loads the routes and the embedded-proxy services index. +func (snap *Snapshot) loadRoutesAndProxy(ctx context.Context, s store.Store, accountID string) error { + var err error + if snap.routes, err = s.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } + return snap.loadProxyServices(ctx, s, accountID) +} + +// loadDNS loads the nameserver groups and account DNS settings. +func (snap *Snapshot) loadDNS(ctx context.Context, s store.Store, accountID string) error { + var err error + if snap.nsGroups, err = s.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } + snap.dnsSettings, err = s.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID) + return err +} + +// loadProxyServices loads the embedded-proxy cluster index, and the services only +// when the account actually has embedded proxy peers. +func (snap *Snapshot) loadProxyServices(ctx context.Context, s store.Store, accountID string) error { + var err error + if snap.proxyByCluster, err = s.GetEmbeddedProxyPeerIDsByCluster(ctx, accountID); err != nil { + return err + } + if len(snap.proxyByCluster) == 0 { + return nil + } + snap.services, err = s.GetAccountServices(ctx, store.LockingStrengthNone, accountID) + return err +} + +// loadGroupIndex loads all groups (for group.Resources) and builds the +// group->member-peers index. Always needed: the bridge resolves group.Resources +// and Expand maps groups to member peers. +func (snap *Snapshot) loadGroupIndex(ctx context.Context, s store.Store, accountID string) error { + groups, err := s.GetAccountGroups(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + snap.groups = make(map[string]*types.Group, len(groups)) + snap.groupPeers = make(map[string]map[string]struct{}, len(groups)) + for _, g := range groups { + snap.groups[g.ID] = g + members := make(map[string]struct{}, len(g.Peers)) + for _, pID := range g.Peers { + members[pID] = struct{}{} + } + snap.groupPeers[g.ID] = members + } + return nil +} + +// Change describes what changed in an account. +type Change struct { + ChangedGroupIDs []string + ChangedPeerIDs []string + Policies []*types.Policy + Routes []*route.Route + Routers []*routerTypes.NetworkRouter + Resources []*resourceTypes.NetworkResource + Networks []*networkTypes.Network + PostureCheckIDs []string + + // DistributionGroupIDs are groups whose members are directly affected, with no + // dependency walk — the change distributes config to the groups' member peers + // only (nameserver groups, DNS DisabledManagementGroups), not through the + // policy/route reachability graph. Pass old∪new so both states refresh. + DistributionGroupIDs []string + + // RemovedPeersByGroup: peers that left a group, keyed by that group. They are no + // longer in the group's member index but still lose its reachability, so they are + // folded in — but only when the group is linked (an unlinked group has no map + // impact), matching how current members are handled. + RemovedPeersByGroup map[string][]string +} + +func (c Change) isEmpty() bool { + return len(c.ChangedGroupIDs) == 0 && + len(c.ChangedPeerIDs) == 0 && + len(c.Policies) == 0 && + len(c.Routes) == 0 && + len(c.Routers) == 0 && + len(c.Resources) == 0 && + len(c.Networks) == 0 && + len(c.PostureCheckIDs) == 0 && + len(c.DistributionGroupIDs) == 0 && + len(c.RemovedPeersByGroup) == 0 +} + +// Expand returns the deduplicated affected peer IDs from the preloaded Snapshot, +// no store access. Run after the producing tx commits. Logs the full walk at +// trace level for diagnosing a miscalculation. +func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) []string { + if c.isEmpty() { + return nil + } + r := newResolver(ctx, snap, accountID, c) + log.WithContext(ctx).Tracef("affectedpeers expand start: account=%s changedGroups=%v changedPeers=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v", + accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, len(c.Policies), len(c.Routes), len(c.Routers), len(c.Resources), len(c.Networks), c.PostureCheckIDs, c.DistributionGroupIDs) + r.walk() + return r.expand() +} + +// Collect returns the affected group and direct-peer IDs without expanding groups +// to members. Test-only introspection; use Resolve otherwise. +func Collect(ctx context.Context, s store.Store, accountID string, c Change) (groupIDs []string, directPeerIDs []string) { + if c.isEmpty() { + return nil, nil + } + snap, err := Load(ctx, s, accountID, c) + if err != nil { + log.WithContext(ctx).Errorf("failed to load snapshot for affected peers collect: %v", err) + return nil, nil + } + r := newResolver(ctx, snap, accountID, c) + r.walk() + return setToSlice(r.groupSet), setToSlice(r.peerSet) +} + +func newResolver(ctx context.Context, snap *Snapshot, accountID string, c Change) *resolver { + r := &resolver{ + ctx: ctx, + snap: snap, + accountID: accountID, + change: c, + changedGroupSet: toSet(c.ChangedGroupIDs), + changedPeerSet: toSet(c.ChangedPeerIDs), + groupSet: make(map[string]struct{}), + peerSet: make(map[string]struct{}), + networkIDs: make(map[string]struct{}), + } + // Resolve each changed peer to its groups here so callers pass only ChangedPeerIDs. + r.seedChangedGroupsFromPeers() + r.matchedPolicies = append(r.matchedPolicies, c.Policies...) + return r +} + +// seedChangedGroupsFromPeers adds each changed peer's groups to changedGroupSet so +// the group-driven walkers fire for memberships, not just direct peer references. +func (r *resolver) seedChangedGroupsFromPeers() { + if len(r.changedPeerSet) == 0 { + return + } + for groupID, members := range r.snap.groupPeers { + for pID := range r.changedPeerSet { + if _, ok := members[pID]; ok { + r.changedGroupSet[groupID] = struct{}{} + break + } + } + } +} + +func (r *resolver) walk() { + r.collectFromExplicitPolicies() + r.collectFromExplicitRoutes(r.change.Routes) + r.collectFromExplicitRouters(r.change.Routers) + r.collectFromExplicitResources(r.change.Resources) + r.collectFromExplicitNetworks(r.change.Networks) + r.collectFromPostureChecks(r.change.PostureCheckIDs) + + // Distribution groups (nameserver/DNS) affect only their member peers: fold them + // straight into groupSet so expand() maps them to members, without the policy/ + // route walk that changedGroupSet would trigger. + addAll(r.groupSet, r.change.DistributionGroupIDs) + + if len(r.changedGroupSet) > 0 || len(r.changedPeerSet) > 0 { + r.collectFromPolicies() + r.collectFromRoutes() + r.collectFromNameServers() + r.collectFromDNSSettings() + r.collectFromNetworkRouters() + r.collectFromProxyServices() + } + + r.collectResourceRouterBridge() +} + +type resolver struct { + ctx context.Context + snap *Snapshot + accountID string + change Change + + changedGroupSet map[string]struct{} + changedPeerSet map[string]struct{} + + groupSet map[string]struct{} + peerSet map[string]struct{} + + matchedPolicies []*types.Policy + networkIDs map[string]struct{} +} + +func (r *resolver) policies() []*types.Policy { return r.snap.policies } + +func (r *resolver) networkResources() []*resourceTypes.NetworkResource { return r.snap.resources } + +func (r *resolver) networkRouters() []*routerTypes.NetworkRouter { return r.snap.routers } + +// peerIDsForGroups maps a group set to its member peer IDs via the preloaded index. +func (r *resolver) peerIDsForGroups(groupSet map[string]struct{}) []string { + seen := make(map[string]struct{}) + var ids []string + for gID := range groupSet { + for pID := range r.snap.groupPeers[gID] { + if _, ok := seen[pID]; ok { + continue + } + seen[pID] = struct{}{} + ids = append(ids, pID) + } + } + return ids +} + +func (r *resolver) expand() []string { + peerIDs := r.peerIDsForGroups(r.groupSet) + + log.WithContext(r.ctx).Tracef("affectedpeers expand: account=%s affectedGroups=%v -> %d group-member peers; direct peers=%v", + r.accountID, setToSlice(r.groupSet), len(peerIDs), setToSlice(r.peerSet)) + + seen := make(map[string]struct{}, len(peerIDs)) + for _, id := range peerIDs { + seen[id] = struct{}{} + } + for id := range r.peerSet { + if _, ok := seen[id]; !ok { + peerIDs = append(peerIDs, id) + seen[id] = struct{}{} + } + } + + // Fold in removed peers only when their group is linked (in groupSet). + for groupID, removed := range r.change.RemovedPeersByGroup { + if _, linked := r.groupSet[groupID]; !linked { + continue + } + for _, id := range removed { + if _, ok := seen[id]; !ok { + peerIDs = append(peerIDs, id) + seen[id] = struct{}{} + log.WithContext(r.ctx).Tracef("affectedpeers expand: removed peer %s from linked group %s -> affected", id, groupID) + } + } + } + + log.WithContext(r.ctx).Tracef("affectedpeers expand done: account=%s -> %d affected peers: %v", r.accountID, len(peerIDs), peerIDs) + return peerIDs +} + +func (r *resolver) collectFromExplicitPolicies() { + for _, policy := range r.matchedPolicies { + if policy == nil { + continue + } + log.WithContext(r.ctx).Tracef("collectFromExplicitPolicies: changed policy %s (%s) -> folding rule groups %v + direct peers", + policy.ID, policy.Name, policy.RuleGroups()) + addAll(r.groupSet, policy.RuleGroups()) + collectPolicyDirectPeers(policy, r.peerSet) + } +} + +func (r *resolver) collectFromExplicitRoutes(routes []*route.Route) { + for _, rt := range routes { + if rt == nil { + continue + } + log.WithContext(r.ctx).Tracef("collectFromExplicitRoutes: changed route %s -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q", + rt.ID, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer) + addAll(r.groupSet, rt.Groups, rt.PeerGroups, rt.AccessControlGroups) + if rt.Peer != "" { + r.peerSet[rt.Peer] = struct{}{} + } + } +} + +// collectFromExplicitRouters folds changed routers' peers and marks their networks +// for the bridge. Passing the old router keeps a repointed router's previous peers +// affected without a post-commit read. +func (r *resolver) collectFromExplicitRouters(routers []*routerTypes.NetworkRouter) { + for _, router := range routers { + if router == nil { + continue + } + log.WithContext(r.ctx).Tracef("collectFromExplicitRouters: changed router %s on network %s -> folding peerGroups=%v peer=%q and marking network for source bridge", + router.ID, router.NetworkID, router.PeerGroups, router.Peer) + addAll(r.groupSet, router.PeerGroups) + if router.Peer != "" { + r.peerSet[router.Peer] = struct{}{} + } + if router.NetworkID != "" { + r.networkIDs[router.NetworkID] = struct{}{} + } + } +} + +// collectFromExplicitResources marks changed resources' networks for the bridge and +// treats their group IDs as changed, so policies targeting the resource via a +// now-detached (old) group still refresh. +func (r *resolver) collectFromExplicitResources(resources []*resourceTypes.NetworkResource) { + for _, resource := range resources { + if resource == nil { + continue + } + log.WithContext(r.ctx).Tracef("collectFromExplicitResources: changed resource %s on network %s -> marking network for bridge and treating groups %v as changed", + resource.ID, resource.NetworkID, resource.GroupIDs) + addAll(r.changedGroupSet, resource.GroupIDs) + if resource.NetworkID != "" { + r.networkIDs[resource.NetworkID] = struct{}{} + } + } +} + +// collectFromExplicitNetworks marks changed networks for the bridge. A network has +// no groups/peers of its own. +func (r *resolver) collectFromExplicitNetworks(networks []*networkTypes.Network) { + for _, network := range networks { + if network == nil { + continue + } + log.WithContext(r.ctx).Tracef("collectFromExplicitNetworks: changed network %s -> marking for bridge", network.ID) + if network.ID != "" { + r.networkIDs[network.ID] = struct{}{} + } + } +} + +func (r *resolver) collectFromPostureChecks(postureCheckIDs []string) { + if len(postureCheckIDs) == 0 { + return + } + ids := toSet(postureCheckIDs) + for _, policy := range r.policies() { + if !policyReferencesPostureChecks(policy, ids) { + continue + } + log.WithContext(r.ctx).Tracef("collectFromPostureChecks: policy %s (%s) references changed posture checks %v -> folding rule groups %v + direct peers", + policy.ID, policy.Name, postureCheckIDs, policy.RuleGroups()) + addAll(r.groupSet, policy.RuleGroups()) + collectPolicyDirectPeers(policy, r.peerSet) + r.matchedPolicies = append(r.matchedPolicies, policy) + } +} + +func (r *resolver) collectFromPolicies() { + for _, policy := range r.policies() { + matchedByGroup := policyReferencesGroups(policy, r.changedGroupSet) + matchedByPeer := len(r.changedPeerSet) > 0 && policyReferencesDirectPeers(policy, r.changedPeerSet) + if !matchedByGroup && !matchedByPeer { + continue + } + log.WithContext(r.ctx).Tracef("collectFromPolicies: policy %s (%s) matched (byGroup=%t byPeer=%t) -> folding rule groups %v + direct peers", + policy.ID, policy.Name, matchedByGroup, matchedByPeer, policy.RuleGroups()) + addAll(r.groupSet, policy.RuleGroups()) + collectPolicyDirectPeers(policy, r.peerSet) + r.matchedPolicies = append(r.matchedPolicies, policy) + } +} + +func (r *resolver) collectFromRoutes() { + for _, rt := range r.snap.routes { + matchedByGroup := anyInSet(rt.Groups, r.changedGroupSet) || anyInSet(rt.PeerGroups, r.changedGroupSet) || anyInSet(rt.AccessControlGroups, r.changedGroupSet) + matchedByPeer := rt.Peer != "" && len(r.changedPeerSet) > 0 && isInSet(rt.Peer, r.changedPeerSet) + if !matchedByGroup && !matchedByPeer { + continue + } + log.WithContext(r.ctx).Tracef("collectFromRoutes: route %s matched (byGroup=%t byPeer=%t) -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q", + rt.ID, matchedByGroup, matchedByPeer, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer) + addAll(r.groupSet, rt.Groups, rt.PeerGroups, rt.AccessControlGroups) + if rt.Peer != "" { + r.peerSet[rt.Peer] = struct{}{} + } + } +} + +func (r *resolver) collectFromNameServers() { + if len(r.changedGroupSet) == 0 { + return + } + for _, ns := range r.snap.nsGroups { + if anyInSet(ns.Groups, r.changedGroupSet) { + log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a changed group -> folding its groups %v", ns.ID, ns.Groups) + addAll(r.groupSet, ns.Groups) + } + } +} + +func (r *resolver) collectFromDNSSettings() { + if len(r.changedGroupSet) == 0 || r.snap.dnsSettings == nil { + return + } + for _, gID := range r.snap.dnsSettings.DisabledManagementGroups { + if _, ok := r.changedGroupSet[gID]; ok { + log.WithContext(r.ctx).Tracef("collectFromDNSSettings: changed group %s is in DisabledManagementGroups -> folding it", gID) + r.groupSet[gID] = struct{}{} + } + } +} + +func (r *resolver) collectFromNetworkRouters() { + for _, router := range r.networkRouters() { + matchedByGroup := anyInSet(router.PeerGroups, r.changedGroupSet) + matchedByPeer := router.Peer != "" && len(r.changedPeerSet) > 0 && isInSet(router.Peer, r.changedPeerSet) + if !matchedByGroup && !matchedByPeer { + continue + } + log.WithContext(r.ctx).Tracef("collectFromNetworkRouters: router %s on network %s matched (byGroup=%t byPeer=%t) -> folding peerGroups=%v peer=%q and marking network for source bridge", + router.ID, router.NetworkID, matchedByGroup, matchedByPeer, router.PeerGroups, router.Peer) + addAll(r.groupSet, router.PeerGroups) + if router.Peer != "" { + r.peerSet[router.Peer] = struct{}{} + } + r.networkIDs[router.NetworkID] = struct{}{} + } +} + +func (r *resolver) collectFromProxyServices() { + if len(r.snap.proxyByCluster) == 0 || len(r.snap.services) == 0 { + return + } + services, proxyByCluster := r.snap.services, r.snap.proxyByCluster + + expanded := r.expandChangedPeersWithGroups() + + for _, svc := range services { + if svc == nil { + continue + } + proxyPeers := proxyByCluster[svc.ProxyCluster] + if len(proxyPeers) == 0 { + continue + } + matchedByPeer := serviceMatchesChangedPeers(svc, proxyPeers, expanded) + matchedByAccessGroup := anyInSet(svc.AccessGroups, r.changedGroupSet) + if !matchedByPeer && !matchedByAccessGroup { + continue + } + log.WithContext(r.ctx).Tracef("collectFromProxyServices: service %s (cluster=%s) matched (byProxyOrTargetPeer=%t byAccessGroup=%t) -> folding %d proxy peers, peer targets and access groups %v", + svc.ID, svc.ProxyCluster, matchedByPeer, matchedByAccessGroup, len(proxyPeers), svc.AccessGroups) + for _, pid := range proxyPeers { + r.peerSet[pid] = struct{}{} + } + for _, target := range svc.Targets { + if target.TargetType == rpservice.TargetTypePeer && target.TargetId != "" { + r.peerSet[target.TargetId] = struct{}{} + } + } + addAll(r.groupSet, svc.AccessGroups) + } +} + +func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} { + if len(r.changedGroupSet) == 0 { + return r.changedPeerSet + } + ids := r.peerIDsForGroups(r.changedGroupSet) + if len(ids) == 0 { + return r.changedPeerSet + } + merged := make(map[string]struct{}, len(r.changedPeerSet)+len(ids)) + for id := range r.changedPeerSet { + merged[id] = struct{}{} + } + for _, id := range ids { + merged[id] = struct{}{} + } + return merged +} + +// collectResourceRouterBridge crosses between source peers and routing peers, which +// are reachable only via resource -> network -> router, not through the policy's own +// groups: source -> router (targeted resources' networks), then router -> source. +func (r *resolver) collectResourceRouterBridge() { + r.bridgeSourceToRouters() + r.bridgeRoutersToSources() +} + +func (r *resolver) bridgeSourceToRouters() { + resourceIDs := r.policyDestinationResourceIDs(r.matchedPolicies...) + if len(resourceIDs) == 0 { + return + } + + networkIDs := r.resourceNetworkIDs(resourceIDs) + log.WithContext(r.ctx).Tracef("bridgeSourceToRouters: targeted resources %v -> networks %v (their routers become affected via the router->source pass)", + setToSlice(resourceIDs), setToSlice(networkIDs)) + for id := range networkIDs { + r.networkIDs[id] = struct{}{} + } +} + +func (r *resolver) bridgeRoutersToSources() { + if len(r.networkIDs) == 0 { + return + } + + log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: affected networks %v -> folding their routing peers and the source peers of policies targeting their resources", + setToSlice(r.networkIDs)) + + r.foldRoutersOnNetworks(r.networkIDs) + + resourceIDs := make(map[string]struct{}) + for _, resource := range r.networkResources() { + if _, ok := r.networkIDs[resource.NetworkID]; ok { + resourceIDs[resource.ID] = struct{}{} + } + } + if len(resourceIDs) == 0 { + return + } + + for _, policy := range r.policies() { + if r.policyTargetsResources(policy, resourceIDs) { + log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: policy %s (%s) targets an affected-network resource -> folding its source groups/peers", policy.ID, policy.Name) + collectPolicySources(policy, r.groupSet, r.peerSet) + } + } +} + +func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) { + for _, router := range r.networkRouters() { + if _, ok := networkIDs[router.NetworkID]; !ok { + continue + } + log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: router %s serves affected network %s -> folding peerGroups=%v peer=%q", + router.ID, router.NetworkID, router.PeerGroups, router.Peer) + addAll(r.groupSet, router.PeerGroups) + if router.Peer != "" { + r.peerSet[router.Peer] = struct{}{} + } + } +} + +func (r *resolver) resourceNetworkIDs(resourceIDs map[string]struct{}) map[string]struct{} { + networkIDs := make(map[string]struct{}) + for _, resource := range r.networkResources() { + if _, ok := resourceIDs[resource.ID]; ok { + networkIDs[resource.NetworkID] = struct{}{} + } + } + return networkIDs +} + +func (r *resolver) policyTargetsResources(policy *types.Policy, resourceIDs map[string]struct{}) bool { + if policy == nil { + return false + } + destGroupSet := make(map[string]struct{}) + for _, rule := range policy.Rules { + if rule.DestinationResource.Type != types.ResourceTypePeer && isInSet(rule.DestinationResource.ID, resourceIDs) { + return true + } + for _, gID := range rule.Destinations { + destGroupSet[gID] = struct{}{} + } + } + if len(destGroupSet) == 0 { + return false + } + for gID := range destGroupSet { + group := r.snap.groups[gID] + if group == nil { + continue + } + for _, res := range group.Resources { + if isInSet(res.ID, resourceIDs) { + return true + } + } + } + return false +} + +func (r *resolver) policyDestinationResourceIDs(policies ...*types.Policy) map[string]struct{} { + resourceIDs := make(map[string]struct{}) + destGroupSet := collectPolicyDestinations(resourceIDs, policies...) + r.addGroupResourceIDs(destGroupSet, resourceIDs) + return resourceIDs +} + +// collectPolicyDestinations adds direct destination resource IDs to resourceIDs and +// returns the referenced destination group IDs. +func collectPolicyDestinations(resourceIDs map[string]struct{}, policies ...*types.Policy) map[string]struct{} { + destGroupSet := make(map[string]struct{}) + for _, policy := range policies { + if policy == nil { + continue + } + for _, rule := range policy.Rules { + addAll(destGroupSet, rule.Destinations) + if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" { + resourceIDs[rule.DestinationResource.ID] = struct{}{} + } + } + } + return destGroupSet +} + +// addGroupResourceIDs folds the resource IDs of the given groups into resourceIDs. +func (r *resolver) addGroupResourceIDs(groupIDs map[string]struct{}, resourceIDs map[string]struct{}) { + for gID := range groupIDs { + group := r.snap.groups[gID] + if group == nil { + continue + } + for _, res := range group.Resources { + if res.ID != "" { + resourceIDs[res.ID] = struct{}{} + } + } + } +} + +func collectPolicyDirectPeers(policy *types.Policy, peerSet map[string]struct{}) { + for _, rule := range policy.Rules { + if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" { + peerSet[rule.SourceResource.ID] = struct{}{} + } + if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" { + peerSet[rule.DestinationResource.ID] = struct{}{} + } + } +} + +func collectPolicySources(policy *types.Policy, groupSet, peerSet map[string]struct{}) { + for _, rule := range policy.Rules { + addAll(groupSet, rule.Sources) + if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" { + peerSet[rule.SourceResource.ID] = struct{}{} + } + } +} + +func policyReferencesGroups(policy *types.Policy, groupSet map[string]struct{}) bool { + for _, rule := range policy.Rules { + if anyInSet(rule.Sources, groupSet) || anyInSet(rule.Destinations, groupSet) { + return true + } + } + return false +} + +func policyReferencesDirectPeers(policy *types.Policy, changedSet map[string]struct{}) bool { + for _, rule := range policy.Rules { + if isDirectPeerInSet(rule.SourceResource, changedSet) || isDirectPeerInSet(rule.DestinationResource, changedSet) { + return true + } + } + return false +} + +func policyReferencesPostureChecks(policy *types.Policy, ids map[string]struct{}) bool { + for _, id := range policy.SourcePostureChecks { + if _, ok := ids[id]; ok { + return true + } + } + return false +} + +func isDirectPeerInSet(res types.Resource, set map[string]struct{}) bool { + if res.Type != types.ResourceTypePeer || res.ID == "" { + return false + } + _, ok := set[res.ID] + return ok +} + +func serviceMatchesChangedPeers(svc *rpservice.Service, proxyPeers []string, changedPeers map[string]struct{}) bool { + for _, pid := range proxyPeers { + if _, ok := changedPeers[pid]; ok { + return true + } + } + for _, target := range svc.Targets { + if target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" { + continue + } + if _, ok := changedPeers[target.TargetId]; ok { + return true + } + } + return false +} + +func anyInSet(ids []string, set map[string]struct{}) bool { + for _, id := range ids { + if _, ok := set[id]; ok { + return true + } + } + return false +} + +func isInSet(id string, set map[string]struct{}) bool { + _, ok := set[id] + return ok +} + +func addAll(set map[string]struct{}, slices ...[]string) { + for _, s := range slices { + for _, id := range s { + set[id] = struct{}{} + } + } +} + +func toSet(ids []string) map[string]struct{} { + set := make(map[string]struct{}, len(ids)) + for _, id := range ids { + set[id] = struct{}{} + } + return set +} + +func setToSlice(set map[string]struct{}) []string { + s := make([]string, 0, len(set)) + for id := range set { + s = append(s, id) + } + return s +} diff --git a/management/server/affectedpeers/resolver_test.go b/management/server/affectedpeers/resolver_test.go new file mode 100644 index 000000000..dcd304a56 --- /dev/null +++ b/management/server/affectedpeers/resolver_test.go @@ -0,0 +1,140 @@ +package affectedpeers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/management/server/types" +) + +// policyGroupsAndPeers mirrors the explicit-policy extraction (RuleGroups + +// direct peers) the resolver folds in, for asserting the pure logic. +func policyGroupsAndPeers(policies ...*types.Policy) (groups []string, peers []string) { + peerSet := map[string]struct{}{} + for _, p := range policies { + if p == nil { + continue + } + groups = append(groups, p.RuleGroups()...) + collectPolicyDirectPeers(p, peerSet) + } + for id := range peerSet { + peers = append(peers, id) + } + return groups, peers +} + +func TestPolicyGroupsAndPeers_Basic(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1", "g2"}, Destinations: []string{"g3"}}}} + groups, peers := policyGroupsAndPeers(policy) + assert.ElementsMatch(t, []string{"g1", "g2", "g3"}, groups) + assert.Empty(t, peers) +} + +func TestPolicyGroupsAndPeers_WithPeerResources(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{ + Sources: []string{"g1"}, + SourceResource: types.Resource{ID: "p1", Type: types.ResourceTypePeer}, + Destinations: []string{"g2"}, + DestinationResource: types.Resource{ID: "p2", Type: types.ResourceTypePeer}, + }}} + groups, peers := policyGroupsAndPeers(policy) + assert.ElementsMatch(t, []string{"g1", "g2"}, groups) + assert.ElementsMatch(t, []string{"p1", "p2"}, peers) +} + +func TestPolicyGroupsAndPeers_NilPolicy(t *testing.T) { + groups, peers := policyGroupsAndPeers(nil) + assert.Nil(t, groups) + assert.Nil(t, peers) +} + +func TestPolicyGroupsAndPeers_MultiplePolicies(t *testing.T) { + old := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1"}, Destinations: []string{"g2"}}}} + updated := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g3"}, Destinations: []string{"g4"}}}} + groups, _ := policyGroupsAndPeers(updated, old) + assert.ElementsMatch(t, []string{"g1", "g2", "g3", "g4"}, groups) +} + +func TestPolicyGroupsAndPeers_NonPeerResource(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{ + Sources: []string{"g1"}, + SourceResource: types.Resource{ID: "domain-1", Type: types.ResourceTypeDomain}, + Destinations: []string{"g2"}, + }}} + groups, peers := policyGroupsAndPeers(policy) + assert.ElementsMatch(t, []string{"g1", "g2"}, groups) + assert.Empty(t, peers, "domain resource type should not produce direct peer IDs") +} + +func TestChangeIsEmpty(t *testing.T) { + assert.True(t, Change{}.isEmpty()) + assert.False(t, Change{ChangedGroupIDs: []string{"g"}}.isEmpty()) + assert.False(t, Change{ChangedPeerIDs: []string{"p"}}.isEmpty()) + assert.False(t, Change{Policies: []*types.Policy{{}}}.isEmpty()) + assert.False(t, Change{Resources: []*resourceTypes.NetworkResource{{ID: "r"}}}.isEmpty()) + assert.False(t, Change{Networks: []*networkTypes.Network{{ID: "n"}}}.isEmpty()) + assert.False(t, Change{PostureCheckIDs: []string{"pc"}}.isEmpty()) +} + +func TestPolicyReferencesGroups(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1", "g2"}, Destinations: []string{"g3"}}}} + + assert.True(t, policyReferencesGroups(policy, map[string]struct{}{"g1": {}})) + assert.True(t, policyReferencesGroups(policy, map[string]struct{}{"g3": {}})) + assert.False(t, policyReferencesGroups(policy, map[string]struct{}{"g4": {}})) + assert.False(t, policyReferencesGroups(policy, map[string]struct{}{})) +} + +func TestPolicyReferencesDirectPeers(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{ + SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"}, + DestinationResource: types.Resource{Type: types.ResourceTypeHost, ID: "r1"}, + }}} + + assert.True(t, policyReferencesDirectPeers(policy, map[string]struct{}{"p1": {}})) + assert.False(t, policyReferencesDirectPeers(policy, map[string]struct{}{"r1": {}})) + assert.False(t, policyReferencesDirectPeers(policy, map[string]struct{}{"p2": {}})) +} + +func TestPolicyReferencesPostureChecks(t *testing.T) { + policy := &types.Policy{SourcePostureChecks: []string{"pc1", "pc2"}} + + assert.True(t, policyReferencesPostureChecks(policy, map[string]struct{}{"pc1": {}})) + assert.False(t, policyReferencesPostureChecks(policy, map[string]struct{}{"pc3": {}})) +} + +func TestCollectPolicyDirectPeers(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{ + SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"}, + DestinationResource: types.Resource{Type: types.ResourceTypePeer, ID: "p2"}, + }, { + DestinationResource: types.Resource{Type: types.ResourceTypeHost, ID: "r1"}, + }}} + + peerSet := map[string]struct{}{} + collectPolicyDirectPeers(policy, peerSet) + + assert.Contains(t, peerSet, "p1") + assert.Contains(t, peerSet, "p2") + assert.NotContains(t, peerSet, "r1") +} + +func TestCollectPolicySources(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{ + Sources: []string{"g1"}, + SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"}, + Destinations: []string{"g2"}, + }}} + + groupSet := map[string]struct{}{} + peerSet := map[string]struct{}{} + collectPolicySources(policy, groupSet, peerSet) + + assert.Contains(t, groupSet, "g1") + assert.NotContains(t, groupSet, "g2", "destination groups must not be collected as sources") + assert.Contains(t, peerSet, "p1") +} diff --git a/management/server/dns.go b/management/server/dns.go index dcc3f21c7..612c8ecba 100644 --- a/management/server/dns.go +++ b/management/server/dns.go @@ -8,6 +8,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" @@ -47,8 +48,9 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID return status.NewPermissionDeniedError() } - var updateAccountPeers bool var eventsToStore []func() + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateDNSSettings(ctx, transaction, accountID, dnsSettingsToSave); err != nil { @@ -63,11 +65,6 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID addedGroups := util.Difference(dnsSettingsToSave.DisabledManagementGroups, oldSettings.DisabledManagementGroups) removedGroups := util.Difference(oldSettings.DisabledManagementGroups, dnsSettingsToSave.DisabledManagementGroups) - updateAccountPeers, err = areDNSSettingChangesAffectPeers(ctx, transaction, accountID, addedGroups, removedGroups) - if err != nil { - return err - } - events := am.prepareDNSSettingsEvents(ctx, transaction, accountID, userID, addedGroups, removedGroups) eventsToStore = append(eventsToStore, events...) @@ -75,6 +72,11 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID return err } + change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(addedGroups, removedGroups)} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -85,9 +87,7 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID storeEvent() } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceDNSSettings, Operation: types.UpdateOperationUpdate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -133,20 +133,6 @@ func (am *DefaultAccountManager) prepareDNSSettingsEvents(ctx context.Context, t return eventsToStore } -// areDNSSettingChangesAffectPeers checks if the DNS settings changes affect any peers. -func areDNSSettingChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, addedGroups, removedGroups []string) (bool, error) { - hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, accountID, addedGroups) - if err != nil { - return false, err - } - - if hasPeers { - return true, nil - } - - return anyGroupHasPeersOrResources(ctx, transaction, accountID, removedGroups) -} - // validateDNSSettings validates the DNS settings. func validateDNSSettings(ctx context.Context, transaction store.Store, accountID string, settings *types.DNSSettings) error { if len(settings.DisabledManagementGroups) == 0 { diff --git a/management/server/group.go b/management/server/group.go index 7e02af245..070344c61 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -11,6 +11,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -79,7 +80,8 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use } var eventsToStore []func() - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { @@ -91,11 +93,6 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) eventsToStore = append(eventsToStore, events...) - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{newGroup.ID}) - if err != nil { - return err - } - if err := transaction.CreateGroup(ctx, newGroup); err != nil { return status.Errorf(status.Internal, "failed to create group: %v", err) } @@ -106,6 +103,11 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use } } + snap, err = affectedpeers.Load(ctx, transaction, accountID, change) + if err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -116,9 +118,7 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use storeEvent() } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationCreate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -134,7 +134,8 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use } var eventsToStore []func() - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { @@ -153,20 +154,7 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use peersToAdd := util.Difference(newGroup.Peers, oldGroup.Peers) peersToRemove := util.Difference(oldGroup.Peers, newGroup.Peers) - - for _, peerID := range peersToAdd { - if err := transaction.AddPeerToGroup(ctx, accountID, peerID, newGroup.ID); err != nil { - return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, newGroup.ID, err) - } - } - for _, peerID := range peersToRemove { - if err := transaction.RemovePeerFromGroup(ctx, peerID, newGroup.ID); err != nil { - return status.Errorf(status.Internal, "failed to remove peer %s from group %s: %v", peerID, newGroup.ID, err) - } - } - - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{newGroup.ID}) - if err != nil { + if err = syncGroupMembership(ctx, transaction, accountID, newGroup.ID, peersToAdd, peersToRemove); err != nil { return err } @@ -178,6 +166,17 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use return err } + // A membership change does not alter which entities reference the group, so + // the dependency walk runs once against the post-change snapshot. The new + // members are already in the snapshot's index; the removed members are + // carried separately and folded in only when the group is linked. + if len(peersToRemove) > 0 { + change.RemovedPeersByGroup = map[string][]string{newGroup.ID: peersToRemove} + } + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -188,13 +187,26 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use storeEvent() } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } +// syncGroupMembership applies the peer membership delta for a group within a transaction. +func syncGroupMembership(ctx context.Context, transaction store.Store, accountID, groupID string, peersToAdd, peersToRemove []string) error { + for _, peerID := range peersToAdd { + if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { + return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, groupID, err) + } + } + for _, peerID := range peersToRemove { + if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil { + return status.Errorf(status.Internal, "failed to remove peer %s from group %s: %v", peerID, groupID, err) + } + } + return nil +} + // CreateGroups adds new groups to the account. // Note: This function does not acquire the global lock. // It is the caller's responsibility to ensure proper locking is in place before invoking this method. @@ -209,11 +221,14 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us } var eventsToStore []func() - var updateAccountPeers bool + var snaps []*affectedpeers.Snapshot + var changes []affectedpeers.Change var globalErr error - groupIDs := make([]string, 0, len(groups)) + createdCount := 0 for _, newGroup := range groups { + change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} + var snap *affectedpeers.Snapshot err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { return err @@ -230,35 +245,31 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us return err } - groupIDs = append(groupIDs, newGroup.ID) - events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) eventsToStore = append(eventsToStore, events...) - return nil + snap, err = affectedpeers.Load(ctx, transaction, accountID, change) + return err }) if err != nil { log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err) - if len(groupIDs) == 1 { + if createdCount == 0 { return err } globalErr = errors.Join(globalErr, err) // continue updating other groups + continue } - } - - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, am.Store, accountID, groupIDs) - if err != nil { - return err + createdCount++ + snaps = append(snaps, snap) + changes = append(changes, change) } for _, storeEvent := range eventsToStore { storeEvent() } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationCreate}) - } + go am.dispatchAffected(ctx, accountID, snaps, changes) return globalErr } @@ -277,12 +288,13 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us } var eventsToStore []func() - var updateAccountPeers bool + var snaps []*affectedpeers.Snapshot + var changes []affectedpeers.Change var globalErr error - groupIDs := make([]string, 0, len(groups)) for _, newGroup := range groups { - events, err := am.updateSingleGroup(ctx, accountID, userID, newGroup) + change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} + events, snap, err := am.updateSingleGroup(ctx, accountID, userID, newGroup, change) if err != nil { log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err) if len(groups) == 1 { @@ -292,27 +304,22 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us continue } eventsToStore = append(eventsToStore, events...) - groupIDs = append(groupIDs, newGroup.ID) - } - - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, am.Store, accountID, groupIDs) - if err != nil { - return err + snaps = append(snaps, snap) + changes = append(changes, change) } for _, storeEvent := range eventsToStore { storeEvent() } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) - } + go am.dispatchAffected(ctx, accountID, snaps, changes) return globalErr } -func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) ([]func(), error) { +func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) { var events []func() + var snap *affectedpeers.Snapshot err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err := validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { return err @@ -333,9 +340,12 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI } events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) - return nil + + var err error + snap, err = affectedpeers.Load(ctx, transaction, accountID, change) + return err }) - return events, err + return events, snap, err } // prepareGroupEvents prepares a list of event functions to be stored. @@ -438,6 +448,8 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us var allErrors error var groupIDsToDelete []string var deletedGroups []*types.Group + var snap *affectedpeers.Snapshot + var change affectedpeers.Change extraSettings, err := am.settingsManager.GetExtraSettings(ctx, accountID) if err != nil { @@ -445,26 +457,23 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us } err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - for _, groupID := range groupIDs { - group, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID) - if err != nil { - allErrors = errors.Join(allErrors, err) - continue - } - - if err = validateDeleteGroup(ctx, transaction, group, userID, extraSettings.FlowGroups); err != nil { - allErrors = errors.Join(allErrors, err) - continue - } - - groupIDsToDelete = append(groupIDsToDelete, groupID) - deletedGroups = append(deletedGroups, group) + deletedGroups, allErrors = collectDeletableGroups(ctx, transaction, accountID, userID, groupIDs, extraSettings.FlowGroups) + for _, group := range deletedGroups { + groupIDsToDelete = append(groupIDsToDelete, group.ID) } if len(groupIDsToDelete) == 0 { return allErrors } + // Delete: compute affected peers from the PRE-delete state. The groups, + // their members and the entities referencing them still exist, so a plain + // Load+Expand captures everyone — no removed-peer folding needed. + change = affectedpeers.Change{ChangedGroupIDs: groupIDsToDelete} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + if err = transaction.DeleteGroups(ctx, accountID, groupIDsToDelete); err != nil { return err } @@ -483,25 +492,47 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us am.StoreEvent(ctx, userID, group.ID, accountID, activity.GroupDeleted, group.EventMeta()) } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) + return allErrors } +// collectDeletableGroups loads and validates each group for deletion, returning +// the groups that may be deleted and the joined validation errors for the rest. +func collectDeletableGroups(ctx context.Context, transaction store.Store, accountID, userID string, groupIDs, flowGroups []string) ([]*types.Group, error) { + var deletable []*types.Group + var allErrors error + for _, groupID := range groupIDs { + group, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID) + if err != nil { + allErrors = errors.Join(allErrors, err) + continue + } + if err = validateDeleteGroup(ctx, transaction, group, userID, flowGroups); err != nil { + allErrors = errors.Join(allErrors, err) + continue + } + deletable = append(deletable, group) + } + return deletable, allErrors +} + // GroupAddPeer appends peer to the group func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, groupID, peerID string) error { - var updateAccountPeers bool - var err error + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}} - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID}) - if err != nil { + err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { return err } - if err = transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { + if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { return err } - if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { + var err error + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -511,9 +542,7 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr return err } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -521,8 +550,9 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr // GroupAddResource appends resource to the group func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID, groupID string, resource types.Resource) error { var group *types.Group - var updateAccountPeers bool + var snap *affectedpeers.Snapshot var err error + change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { group, err = transaction.GetGroupByID(context.Background(), store.LockingStrengthUpdate, accountID, groupID) @@ -534,12 +564,11 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID return nil } - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID}) - if err != nil { + if err = transaction.UpdateGroup(ctx, group); err != nil { return err } - if err = transaction.UpdateGroup(ctx, group); err != nil { + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -549,29 +578,32 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID return err } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } // GroupDeletePeer removes peer from the group func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, groupID, peerID string) error { - var updateAccountPeers bool - var err error + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{ + ChangedGroupIDs: []string{groupID}, + RemovedPeersByGroup: map[string][]string{groupID: {peerID}}, + } - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID}) - if err != nil { + err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil { return err } - if err = transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil { + if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { return err } - if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { + // The removed peer is carried in change.RemovedPeersByGroup and folded in + // only when the group is linked, so loading post-removal is correct. + var err error + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -581,9 +613,7 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, return err } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -591,8 +621,9 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, // GroupDeleteResource removes resource from the group func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accountID, groupID string, resource types.Resource) error { var group *types.Group - var updateAccountPeers bool + var snap *affectedpeers.Snapshot var err error + change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { group, err = transaction.GetGroupByID(context.Background(), store.LockingStrengthUpdate, accountID, groupID) @@ -604,8 +635,9 @@ func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accoun return nil } - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID}) - if err != nil { + // Load before persisting the removal, so the snapshot still maps the group + // to the resource and the bridge can reach its routing peers. + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -619,9 +651,7 @@ func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accoun return err } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -832,49 +862,103 @@ func isGroupLinkedToNetworkRouter(ctx context.Context, transaction store.Store, } // areGroupChangesAffectPeers checks if any changes to the specified groups will affect peers. +// It fetches each collection once and checks all groupIDs against them in memory. func areGroupChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) { if len(groupIDs) == 0 { return false, nil } + groupSet := make(map[string]struct{}, len(groupIDs)) + for _, id := range groupIDs { + groupSet[id] = struct{}{} + } + + if affected, err := dnsSettingsReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil { + return affected, err + } + if affected, err := nameServersReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil { + return affected, err + } + if affected, err := policiesReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil { + return affected, err + } + if affected, err := routesReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil { + return affected, err + } + if affected, err := networkRoutersReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil { + return affected, err + } + + return false, nil +} + +func dnsSettingsReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) { dnsSettings, err := transaction.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { return false, err } - - for _, groupID := range groupIDs { - if slices.Contains(dnsSettings.DisabledManagementGroups, groupID) { - return true, nil - } - if linked, _ := isGroupLinkedToDns(ctx, transaction, accountID, groupID); linked { - return true, nil - } - if linked, _ := isGroupLinkedToPolicy(ctx, transaction, accountID, groupID); linked { - return true, nil - } - if linked, _ := isGroupLinkedToRoute(ctx, transaction, accountID, groupID); linked { - return true, nil - } - if linked, _ := isGroupLinkedToNetworkRouter(ctx, transaction, accountID, groupID); linked { - return true, nil - } - } - - return false, nil + return anyInSet(dnsSettings.DisabledManagementGroups, groupSet), nil } -// anyGroupHasPeersOrResources checks if any of the given groups in the account have peers or resources. -func anyGroupHasPeersOrResources(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) { - groups, err := transaction.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, groupIDs) +func nameServersReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) { + nameServerGroups, err := transaction.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID) if err != nil { return false, err } - - for _, group := range groups { - if group.HasPeers() || group.HasResources() { + for _, ns := range nameServerGroups { + if anyInSet(ns.Groups, groupSet) { return true, nil } } - return false, nil } + +func policiesReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) { + policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return false, err + } + for _, policy := range policies { + for _, rule := range policy.Rules { + if anyInSet(rule.Sources, groupSet) || anyInSet(rule.Destinations, groupSet) { + return true, nil + } + } + } + return false, nil +} + +func routesReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) { + routes, err := transaction.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return false, err + } + for _, r := range routes { + if anyInSet(r.Groups, groupSet) || anyInSet(r.PeerGroups, groupSet) || anyInSet(r.AccessControlGroups, groupSet) { + return true, nil + } + } + return false, nil +} + +func networkRoutersReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) { + routers, err := transaction.GetNetworkRoutersByAccountID(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return false, err + } + for _, router := range routers { + if anyInSet(router.PeerGroups, groupSet) { + return true, nil + } + } + return false, nil +} + +func anyInSet(ids []string, set map[string]struct{}) bool { + for _, id := range ids { + if _, ok := set[id]; ok { + return true + } + } + return false +} diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 32549a521..15eb9b190 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/idp" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" @@ -38,7 +39,7 @@ type MockAccountManager struct { GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error) GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) - MarkPeerConnectedFunc func(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error + MarkPeerConnectedFunc func(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error @@ -132,6 +133,7 @@ type MockAccountManager struct { AllowSyncFunc func(string, uint64) bool UpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason) + ExpandAndUpdateAffectedFunc func(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) BufferUpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason) RecalculateNetworkMapCacheFunc func(ctx context.Context, accountId string) error @@ -209,6 +211,12 @@ func (am *MockAccountManager) UpdateAccountPeers(ctx context.Context, accountID } } +func (am *MockAccountManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) { + if am.ExpandAndUpdateAffectedFunc != nil { + am.ExpandAndUpdateAffectedFunc(ctx, accountID, snap, change) + } +} + func (am *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { if am.BufferUpdateAccountPeersFunc != nil { am.BufferUpdateAccountPeersFunc(ctx, accountID, reason) @@ -337,9 +345,9 @@ func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth } // MarkPeerConnected mock implementation of MarkPeerConnected from server.AccountManager interface -func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error { +func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { if am.MarkPeerConnectedFunc != nil { - return am.MarkPeerConnectedFunc(ctx, peerKey, realIP, accountID, sessionStartedAt) + return am.MarkPeerConnectedFunc(ctx, peerKey, realIP, accountID, sessionStartedAt, nmap) } return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented") } diff --git a/management/server/nameserver.go b/management/server/nameserver.go index c836fefeb..b9cebf726 100644 --- a/management/server/nameserver.go +++ b/management/server/nameserver.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "unicode/utf8" @@ -11,6 +12,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" @@ -57,19 +59,19 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco SearchDomainsEnabled: searchDomainEnabled, } - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{DistributionGroupIDs: newNSGroup.Groups} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateNameServerGroup(ctx, transaction, accountID, newNSGroup); err != nil { return err } - updateAccountPeers, err = anyGroupHasPeersOrResources(ctx, transaction, accountID, newNSGroup.Groups) - if err != nil { + if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil { return err } - if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil { + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -81,9 +83,7 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco am.StoreEvent(ctx, userID, newNSGroup.ID, accountID, activity.NameserverGroupCreated, newNSGroup.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationCreate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return newNSGroup.Copy(), nil } @@ -102,7 +102,8 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun return status.NewPermissionDeniedError() } - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { oldNSGroup, err := transaction.GetNameServerGroupByID(ctx, store.LockingStrengthNone, accountID, nsGroupToSave.ID) @@ -115,12 +116,12 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun return err } - updateAccountPeers, err = areNameServerGroupChangesAffectPeers(ctx, transaction, nsGroupToSave, oldNSGroup) - if err != nil { + if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil { return err } - if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil { + change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(nsGroupToSave.Groups, oldNSGroup.Groups)} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -132,9 +133,7 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun am.StoreEvent(ctx, userID, nsGroupToSave.ID, accountID, activity.NameserverGroupUpdated, nsGroupToSave.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationUpdate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -150,7 +149,8 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco } var nsGroup *nbdns.NameServerGroup - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { nsGroup, err = transaction.GetNameServerGroupByID(ctx, store.LockingStrengthUpdate, accountID, nsGroupID) @@ -158,8 +158,9 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco return err } - updateAccountPeers, err = anyGroupHasPeersOrResources(ctx, transaction, accountID, nsGroup.Groups) - if err != nil { + // Load before delete: the post-delete state no longer references the groups. + change = affectedpeers.Change{DistributionGroupIDs: nsGroup.Groups} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -175,9 +176,7 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco am.StoreEvent(ctx, userID, nsGroup.ID, accountID, activity.NameserverGroupDeleted, nsGroup.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationDelete}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -224,24 +223,6 @@ func validateNameServerGroup(ctx context.Context, transaction store.Store, accou return validateGroups(nameserverGroup.Groups, groups) } -// areNameServerGroupChangesAffectPeers checks if the changes in the nameserver group affect the peers. -func areNameServerGroupChangesAffectPeers(ctx context.Context, transaction store.Store, newNSGroup, oldNSGroup *nbdns.NameServerGroup) (bool, error) { - if !newNSGroup.Enabled && !oldNSGroup.Enabled { - return false, nil - } - - hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, newNSGroup.AccountID, newNSGroup.Groups) - if err != nil { - return false, err - } - - if hasPeers { - return true, nil - } - - return anyGroupHasPeersOrResources(ctx, transaction, oldNSGroup.AccountID, oldNSGroup.Groups) -} - func validateDomainInput(primary bool, domains []string, searchDomainsEnabled bool) error { if !primary && len(domains) == 0 { return status.Errorf(status.InvalidArgument, "nameserver group primary status is false and domains are empty,"+ diff --git a/management/server/networks/manager.go b/management/server/networks/manager.go index f825ae015..d572502fd 100644 --- a/management/server/networks/manager.go +++ b/management/server/networks/manager.go @@ -8,6 +8,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/routers" "github.com/netbirdio/netbird/management/server/networks/types" @@ -15,7 +16,6 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" - serverTypes "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -127,30 +127,39 @@ func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, netw } var eventsToStore []func() + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{Networks: []*types.Network{network}} err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { resources, err := transaction.GetNetworkResourcesByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID) if err != nil { return fmt.Errorf("failed to get resources in network: %w", err) } - for _, resource := range resources { - event, err := m.resourcesManager.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resource.ID) - if err != nil { - return fmt.Errorf("failed to delete resource: %w", err) - } - eventsToStore = append(eventsToStore, event...) - } - - routers, err := transaction.GetNetworkRoutersByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID) + netRouters, err := transaction.GetNetworkRoutersByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID) if err != nil { return fmt.Errorf("failed to get routers in network: %w", err) } - for _, router := range routers { - event, err := m.routersManager.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, router.ID) + var lerr error + if snap, lerr = affectedpeers.Load(ctx, transaction, accountID, change); lerr != nil { + return lerr + } + + for _, resource := range resources { + deleted, event, err := m.resourcesManager.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resource.ID) + if err != nil { + return fmt.Errorf("failed to delete resource: %w", err) + } + change.Resources = append(change.Resources, deleted) + eventsToStore = append(eventsToStore, event...) + } + + for _, router := range netRouters { + deleted, event, err := m.routersManager.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, router.ID) if err != nil { return fmt.Errorf("failed to delete router: %w", err) } + change.Routers = append(change.Routers, deleted) eventsToStore = append(eventsToStore, event) } @@ -178,7 +187,7 @@ func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, netw event() } - go m.accountManager.UpdateAccountPeers(ctx, accountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetwork, Operation: serverTypes.UpdateOperationDelete}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } diff --git a/management/server/networks/resources/manager.go b/management/server/networks/resources/manager.go index 51a269163..6c427ce62 100644 --- a/management/server/networks/resources/manager.go +++ b/management/server/networks/resources/manager.go @@ -10,6 +10,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/permissions" @@ -29,7 +30,7 @@ type Manager interface { GetResource(ctx context.Context, accountID, userID, networkID, resourceID string) (*types.NetworkResource, error) UpdateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error) DeleteResource(ctx context.Context, accountID, userID, networkID, resourceID string) error - DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error) + DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error) } type managerImpl struct { @@ -114,45 +115,12 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc } var eventsToStore []func() + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{Resources: []*types.NetworkResource{resource}} err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - _, err = transaction.GetNetworkResourceByName(ctx, store.LockingStrengthNone, resource.AccountID, resource.Name) - if err == nil { - return status.Errorf(status.InvalidArgument, "resource with name %s already exists", resource.Name) - } - - network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID) - if err != nil { - return fmt.Errorf("failed to get network: %w", err) - } - - err = transaction.SaveNetworkResource(ctx, resource) - if err != nil { - return fmt.Errorf("failed to save network resource: %w", err) - } - - event := func() { - m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceCreated, resource.EventMeta(network)) - } - eventsToStore = append(eventsToStore, event) - - res := nbtypes.Resource{ - ID: resource.ID, - Type: nbtypes.ResourceType(resource.Type.String()), - } - for _, groupID := range resource.GroupIDs { - event, err := m.groupsManager.AddResourceToGroupInTransaction(ctx, transaction, resource.AccountID, userID, groupID, &res) - if err != nil { - return fmt.Errorf("failed to add resource to group: %w", err) - } - eventsToStore = append(eventsToStore, event) - } - - err = transaction.IncrementNetworkSerial(ctx, resource.AccountID) - if err != nil { - return fmt.Errorf("failed to increment network serial: %w", err) - } - - return nil + var txErr error + eventsToStore, snap, txErr = m.createResourceInTransaction(ctx, transaction, userID, resource, change) + return txErr }) if err != nil { return nil, fmt.Errorf("failed to create network resource: %w", err) @@ -162,11 +130,55 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc event() } - go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationCreate}) + m.accountManager.ExpandAndUpdateAffected(ctx, resource.AccountID, snap, change) return resource, nil } +func (m *managerImpl) createResourceInTransaction(ctx context.Context, transaction store.Store, userID string, resource *types.NetworkResource, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) { + _, err := transaction.GetNetworkResourceByName(ctx, store.LockingStrengthNone, resource.AccountID, resource.Name) + if err == nil { + return nil, nil, status.Errorf(status.InvalidArgument, "resource with name %s already exists", resource.Name) + } + + network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID) + if err != nil { + return nil, nil, fmt.Errorf("failed to get network: %w", err) + } + + if err = transaction.SaveNetworkResource(ctx, resource); err != nil { + return nil, nil, fmt.Errorf("failed to save network resource: %w", err) + } + + var eventsToStore []func() + eventsToStore = append(eventsToStore, func() { + m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceCreated, resource.EventMeta(network)) + }) + + res := nbtypes.Resource{ + ID: resource.ID, + Type: nbtypes.ResourceType(resource.Type.String()), + } + for _, groupID := range resource.GroupIDs { + event, err := m.groupsManager.AddResourceToGroupInTransaction(ctx, transaction, resource.AccountID, userID, groupID, &res) + if err != nil { + return nil, nil, fmt.Errorf("failed to add resource to group: %w", err) + } + eventsToStore = append(eventsToStore, event) + } + + if err = transaction.IncrementNetworkSerial(ctx, resource.AccountID); err != nil { + return nil, nil, fmt.Errorf("failed to increment network serial: %w", err) + } + + snap, err := affectedpeers.Load(ctx, transaction, resource.AccountID, change) + if err != nil { + return nil, nil, err + } + + return eventsToStore, snap, nil +} + func (m *managerImpl) GetResource(ctx context.Context, accountID, userID, networkID, resourceID string) (*types.NetworkResource, error) { ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) if err != nil { @@ -207,6 +219,8 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc resource.Prefix = prefix var eventsToStore []func() + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID) if err != nil { @@ -232,6 +246,14 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc return fmt.Errorf("failed to get network resource: %w", err) } + oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, resource.AccountID, resource.ID) + if err != nil { + return fmt.Errorf("failed to get old resource groups: %w", err) + } + for _, g := range oldGroups { + oldResource.GroupIDs = append(oldResource.GroupIDs, g.ID) + } + err = transaction.SaveNetworkResource(ctx, resource) if err != nil { return fmt.Errorf("failed to save network resource: %w", err) @@ -247,6 +269,11 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceUpdated, resource.EventMeta(network)) }) + change = affectedpeers.Change{Resources: []*types.NetworkResource{oldResource, resource}} + if snap, err = affectedpeers.Load(ctx, transaction, resource.AccountID, change); err != nil { + return err + } + err = transaction.IncrementNetworkSerial(ctx, resource.AccountID) if err != nil { return fmt.Errorf("failed to increment network serial: %w", err) @@ -270,7 +297,7 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc } }() - go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationUpdate}) + m.accountManager.ExpandAndUpdateAffected(ctx, resource.AccountID, snap, change) return resource, nil } @@ -331,8 +358,26 @@ func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, net } var events []func() + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - events, err = m.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resourceID) + existing, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthUpdate, accountID, resourceID) + if err != nil { + return fmt.Errorf("failed to get network resource: %w", err) + } + oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, accountID, resourceID) + if err != nil { + return fmt.Errorf("failed to get resource groups: %w", err) + } + for _, g := range oldGroups { + existing.GroupIDs = append(existing.GroupIDs, g.ID) + } + change = affectedpeers.Change{Resources: []*types.NetworkResource{existing}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + + _, events, err = m.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resourceID) if err != nil { return fmt.Errorf("failed to delete resource: %w", err) } @@ -352,51 +397,53 @@ func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, net event() } - go m.accountManager.UpdateAccountPeers(ctx, accountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationDelete}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } -func (m *managerImpl) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error) { +func (m *managerImpl) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error) { resource, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthUpdate, accountID, resourceID) if err != nil { - return nil, fmt.Errorf("failed to get network resource: %w", err) + return nil, nil, fmt.Errorf("failed to get network resource: %w", err) } network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, accountID, networkID) if err != nil { - return nil, fmt.Errorf("failed to get network: %w", err) + return nil, nil, fmt.Errorf("failed to get network: %w", err) } if resource.NetworkID != networkID { - return nil, errors.New("resource not part of network") + return nil, nil, errors.New("resource not part of network") } groups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthUpdate, accountID, resourceID) if err != nil { - return nil, fmt.Errorf("failed to get resource groups: %w", err) + return nil, nil, fmt.Errorf("failed to get resource groups: %w", err) } var eventsToStore []func() for _, group := range groups { + resource.GroupIDs = append(resource.GroupIDs, group.ID) + event, err := m.groupsManager.RemoveResourceFromGroupInTransaction(ctx, transaction, accountID, userID, group.ID, resourceID) if err != nil { - return nil, fmt.Errorf("failed to remove resource from group: %w", err) + return nil, nil, fmt.Errorf("failed to remove resource from group: %w", err) } eventsToStore = append(eventsToStore, event) } err = transaction.DeleteNetworkResource(ctx, accountID, resourceID) if err != nil { - return nil, fmt.Errorf("failed to delete network resource: %w", err) + return nil, nil, fmt.Errorf("failed to delete network resource: %w", err) } eventsToStore = append(eventsToStore, func() { m.accountManager.StoreEvent(ctx, userID, resourceID, accountID, activity.NetworkResourceDeleted, resource.EventMeta(network)) }) - return eventsToStore, nil + return resource, eventsToStore, nil } func NewManagerMock() Manager { @@ -431,6 +478,6 @@ func (m *mockManager) DeleteResource(ctx context.Context, accountID, userID, net return nil } -func (m *mockManager) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error) { - return []func(){}, nil +func (m *mockManager) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error) { + return nil, []func(){}, nil } diff --git a/management/server/networks/routers/manager.go b/management/server/networks/routers/manager.go index 9fa2b95f7..cff387a7c 100644 --- a/management/server/networks/routers/manager.go +++ b/management/server/networks/routers/manager.go @@ -9,13 +9,13 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" - serverTypes "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -26,7 +26,7 @@ type Manager interface { GetRouter(ctx context.Context, accountID, userID, networkID, routerID string) (*types.NetworkRouter, error) UpdateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error) DeleteRouter(ctx context.Context, accountID, userID, networkID, routerID string) error - DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error) + DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error) } type managerImpl struct { @@ -90,6 +90,8 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t } var network *networkTypes.Network + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{Routers: []*types.NetworkRouter{router}} err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { network, err = transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID) if err != nil { @@ -112,6 +114,10 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t return fmt.Errorf("failed to increment network serial: %w", err) } + if snap, err = affectedpeers.Load(ctx, transaction, router.AccountID, change); err != nil { + return err + } + return nil }) if err != nil { @@ -120,7 +126,7 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t m.accountManager.StoreEvent(ctx, userID, router.ID, router.AccountID, activity.NetworkRouterCreated, router.EventMeta(network)) - go m.accountManager.UpdateAccountPeers(ctx, router.AccountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationCreate}) + m.accountManager.ExpandAndUpdateAffected(ctx, router.AccountID, snap, change) return router, nil } @@ -156,36 +162,12 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t } var network *networkTypes.Network + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - network, err = transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID) - if err != nil { - return fmt.Errorf("failed to get network: %w", err) - } - - existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, router.AccountID, router.ID) - if err != nil { - return fmt.Errorf("failed to get network router: %w", err) - } - - if existing.AccountID != router.AccountID { - return status.NewNetworkRouterNotFoundError(router.ID) - } - - if existing.NetworkID != router.NetworkID { - return status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID) - } - - err = transaction.UpdateNetworkRouter(ctx, router) - if err != nil { - return fmt.Errorf("failed to update network router: %w", err) - } - - err = transaction.IncrementNetworkSerial(ctx, router.AccountID) - if err != nil { - return fmt.Errorf("failed to increment network serial: %w", err) - } - - return nil + var txErr error + network, snap, change, txErr = m.updateRouterInTransaction(ctx, transaction, router) + return txErr }) if err != nil { return nil, err @@ -193,11 +175,47 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t m.accountManager.StoreEvent(ctx, userID, router.ID, router.AccountID, activity.NetworkRouterUpdated, router.EventMeta(network)) - go m.accountManager.UpdateAccountPeers(ctx, router.AccountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationUpdate}) + m.accountManager.ExpandAndUpdateAffected(ctx, router.AccountID, snap, change) return router, nil } +func (m *managerImpl) updateRouterInTransaction(ctx context.Context, transaction store.Store, router *types.NetworkRouter) (*networkTypes.Network, *affectedpeers.Snapshot, affectedpeers.Change, error) { + network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID) + if err != nil { + return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to get network: %w", err) + } + + existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, router.AccountID, router.ID) + if err != nil { + return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to get network router: %w", err) + } + + if existing.AccountID != router.AccountID { + return nil, nil, affectedpeers.Change{}, status.NewNetworkRouterNotFoundError(router.ID) + } + + if existing.NetworkID != router.NetworkID { + return nil, nil, affectedpeers.Change{}, status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID) + } + + if err = transaction.UpdateNetworkRouter(ctx, router); err != nil { + return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to update network router: %w", err) + } + + if err = transaction.IncrementNetworkSerial(ctx, router.AccountID); err != nil { + return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to increment network serial: %w", err) + } + + change := affectedpeers.Change{Routers: []*types.NetworkRouter{existing, router}} + snap, err := affectedpeers.Load(ctx, transaction, router.AccountID, change) + if err != nil { + return nil, nil, affectedpeers.Change{}, err + } + + return network, snap, change, nil +} + func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, networkID, routerID string) error { ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete) if err != nil { @@ -208,8 +226,19 @@ func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, netwo } var event func() + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - event, err = m.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, routerID) + existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, accountID, routerID) + if err != nil { + return fmt.Errorf("failed to get network router: %w", err) + } + change = affectedpeers.Change{Routers: []*types.NetworkRouter{existing}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + + _, event, err = m.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, routerID) if err != nil { return fmt.Errorf("failed to delete network router: %w", err) } @@ -227,36 +256,36 @@ func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, netwo event() - go m.accountManager.UpdateAccountPeers(ctx, accountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationDelete}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } -func (m *managerImpl) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error) { +func (m *managerImpl) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error) { network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthNone, accountID, networkID) if err != nil { - return nil, fmt.Errorf("failed to get network: %w", err) + return nil, nil, fmt.Errorf("failed to get network: %w", err) } router, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, accountID, routerID) if err != nil { - return nil, fmt.Errorf("failed to get network router: %w", err) + return nil, nil, fmt.Errorf("failed to get network router: %w", err) } if router.NetworkID != networkID { - return nil, status.NewRouterNotPartOfNetworkError(routerID, networkID) + return nil, nil, status.NewRouterNotPartOfNetworkError(routerID, networkID) } err = transaction.DeleteNetworkRouter(ctx, accountID, routerID) if err != nil { - return nil, fmt.Errorf("failed to delete network router: %w", err) + return nil, nil, fmt.Errorf("failed to delete network router: %w", err) } event := func() { m.accountManager.StoreEvent(ctx, userID, routerID, accountID, activity.NetworkRouterDeleted, router.EventMeta(network)) } - return event, nil + return router, event, nil } func NewManagerMock() Manager { @@ -287,6 +316,9 @@ func (m *mockManager) DeleteRouter(ctx context.Context, accountID, userID, netwo return nil } -func (m *mockManager) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error) { - return func() {}, nil +func (m *mockManager) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error) { + return nil, func() { + // no-op mock: returns zero values so tests that don't exercise router deletion + // can satisfy the Manager interface without a real store. + }, nil } diff --git a/management/server/peer.go b/management/server/peer.go index d4e3ebb49..baf62a7eb 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -27,6 +27,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/shared/management/status" @@ -73,7 +74,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID // // Disconnects use MarkPeerDisconnected and require the session to match // exactly; see PeerStatus.SessionStartedAt for the protocol. -func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, realIP net.IP, accountID string, sessionStartedAt int64) error { +func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { start := time.Now() defer func() { am.metrics.AccountManagerMetrics().RecordPeerStatusUpdateDuration(telemetry.PeerStatusConnect, time.Since(start)) @@ -105,35 +106,22 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK am.updatePeerLocationIfChanged(ctx, accountID, peer, realIP) } - expired := peer.Status != nil && peer.Status.LoginExpired - - if peer.AddedWithSSOLogin() { - settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return err - } - if peer.LoginExpirationEnabled && settings.PeerLoginExpirationEnabled { - am.schedulePeerLoginExpiration(ctx, accountID) - } - if peer.InactivityExpirationEnabled && settings.PeerInactivityExpirationEnabled { - am.checkAndSchedulePeerInactivityExpiration(ctx, accountID) - } + if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil { + return err } - if expired { - if err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil { + // A login-expired peer reconnecting, or an embedded proxy peer flipping to + // connected (which triggers SynthesizePrivateServiceZones), must refresh the + // peers reachable from it. The embedded-proxy fan-out tolerates a dispatch error. + if peer.Status != nil && peer.Status.LoginExpired { + affectedPeerIDs := am.markConnectedAffectedPeers(ctx, accountID, peer.ID, nmap) + if err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}, affectedPeerIDs); err != nil { return fmt.Errorf("notify network map controller of peer update: %w", err) } } - - // An embedded proxy peer flipping to connected is the trigger for - // SynthesizePrivateServiceZones to emit DNS A records pointing at its - // tunnel IP. Without an account-wide netmap recompute, user peers keep - // the stale synth (or no synth at all on first connect) until some - // other change pokes the controller. Fire OnPeersUpdated so the - // buffered recompute fans the new state out to every peer. if peer.ProxyMeta.Embedded { - if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil { + affectedPeerIDs := am.markConnectedAffectedPeers(ctx, accountID, peer.ID, nmap) + if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}, affectedPeerIDs); err != nil { log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s connect: %v", peer.ID, err) } } @@ -141,6 +129,25 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK return nil } +// schedulePeerExpirations reschedules the account's login/inactivity expiration +// timers for an SSO peer that just connected. +func (am *DefaultAccountManager) schedulePeerExpirations(ctx context.Context, accountID string, peer *nbpeer.Peer) error { + if !peer.AddedWithSSOLogin() { + return nil + } + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + if peer.LoginExpirationEnabled && settings.PeerLoginExpirationEnabled { + am.schedulePeerLoginExpiration(ctx, accountID) + } + if peer.InactivityExpirationEnabled && settings.PeerInactivityExpirationEnabled { + am.checkAndSchedulePeerInactivityExpiration(ctx, accountID) + } + return nil +} + // MarkPeerDisconnected marks a peer as disconnected, but only when the // stored session token matches the one passed in. A mismatch means a // newer stream has already taken ownership of the peer — disconnects from @@ -175,11 +182,12 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied) // Symmetric with MarkPeerConnected: when an embedded proxy peer goes - // offline, drive an account-wide netmap recompute so the synthesized - // DNS records that pointed at it are pulled. Without this the records - // linger client-side at TTL until something else triggers a refresh. + // offline, refresh the peers that had synthesized records pointing at + // it so they pull the stale entries instead of waiting out TTL. if peer.ProxyMeta.Embedded { - if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil { + changedPeerIDs := []string{peer.ID} + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) + if err := am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s disconnect: %v", peer.ID, err) } } @@ -346,7 +354,10 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user } } - err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}) + changedPeerIDs := []string{peer.ID} + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) + affectedPeerIDs = append(affectedPeerIDs, peer.ID) + err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs) if err != nil { return nil, fmt.Errorf("notify network map controller of peer update: %w", err) } @@ -501,10 +512,6 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer return status.NewPeerNotPartOfAccountError() } - var peer *nbpeer.Peer - var settings *types.Settings - var eventsToStore []func() - serviceID, err := am.serviceManager.GetServiceIDByTargetID(ctx, accountID, peerID) if err != nil { return fmt.Errorf("failed to check if resource is used by service: %w", err) @@ -513,8 +520,38 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer return status.NewPeerInUseError(peerID, serviceID) } - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - peer, err = transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + change := affectedpeers.Change{ChangedPeerIDs: []string{peerID}} + settings, eventsToStore, snap, err := am.deletePeerInTransaction(ctx, accountID, userID, peerID, change) + if err != nil { + return err + } + + for _, storeEvent := range eventsToStore { + storeEvent() + } + + if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, settings.Extra); err != nil { + log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err) + } + + affectedPeerIDs := snap.Expand(ctx, accountID, change) + if err = am.networkMapController.OnPeersDeleted(ctx, accountID, []string{peerID}, affectedPeerIDs); err != nil { + log.WithContext(ctx).Errorf("failed to delete peer %s from network map: %v", peerID, err) + } + + return nil +} + +// deletePeerInTransaction loads the peer + settings, captures the affected-peers +// snapshot (before the delete, while the peer's group memberships still exist), +// then deletes the peer and bumps the network serial — all in one transaction. +func (am *DefaultAccountManager) deletePeerInTransaction(ctx context.Context, accountID, userID, peerID string, change affectedpeers.Change) (*types.Settings, []func(), *affectedpeers.Snapshot, error) { + var settings *types.Settings + var eventsToStore []func() + var snap *affectedpeers.Snapshot + + err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) if err != nil { return err } @@ -528,8 +565,11 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer return err } - eventsToStore, err = deletePeers(ctx, am, transaction, accountID, userID, []*nbpeer.Peer{peer}, settings) - if err != nil { + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + + if eventsToStore, err = deletePeers(ctx, am, transaction, accountID, userID, []*nbpeer.Peer{peer}, settings); err != nil { return fmt.Errorf("failed to delete peer: %w", err) } @@ -539,23 +579,7 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer return nil }) - if err != nil { - return err - } - - for _, storeEvent := range eventsToStore { - storeEvent() - } - - if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, settings.Extra); err != nil { - log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err) - } - - if err = am.networkMapController.OnPeersDeleted(ctx, accountID, []string{peerID}); err != nil { - log.WithContext(ctx).Errorf("failed to delete peer %s from network map: %v", peerID, err) - } - - return nil + return settings, eventsToStore, snap, err } // GetNetworkMap returns Network map for a given peer (omits original peer from the Peers result) @@ -924,12 +948,18 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta) } - if err := am.networkMapController.OnPeersAdded(ctx, accountID, []string{newPeer.ID}); err != nil { + p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, false, accountID, newPeer) + if err != nil { + return p, nmap, pc, err + } + + changedPeerIDs := []string{newPeer.ID} + affectedPeerIDs := affectedPeerIDsFromNetworkMap(nmap, newPeer.ID) + if err := am.networkMapController.OnPeersAdded(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { log.WithContext(ctx).Errorf("failed to update network map cache for peer %s: %v", newPeer.ID, err) } - p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, false, accountID, newPeer) - return p, nmap, pc, err + return p, nmap, pc, nil } func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) { @@ -1011,14 +1041,48 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } + resPeer, nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer) + if err != nil { + return nil, nil, nil, 0, err + } + if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(postureChecks) > 0 || versionChanged)) { - err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}) - if err != nil { + changedPeerIDs := []string{peer.ID} + affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, updated, len(postureChecks) > 0) + if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { return nil, nil, nil, 0, fmt.Errorf("notify network map controller of peer update: %w", err) } } - return am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer) + return resPeer, nmap, resPostureChecks, dnsFwdPort, nil +} + +// syncPeerAffectedPeers resolves the peers affected by a SyncPeer change. The +// peer's own validated network map is bidirectional for policy and routing +// reachability, so when the peer stays valid and no source-posture gate is in +// play it already lists every affected peer — reuse it and skip the full +// dependency walk. Posture checks gate the source side of a policy only, so a +// metadata change that flips a posture result removes this peer from others' +// maps asymmetrically; that case (and an invalid peer, whose map is empty) falls +// back to the resolver. +func (am *DefaultAccountManager) syncPeerAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap, peerNotValid, metaUpdated, hasPostureChecks bool) []string { + if peerNotValid || (metaUpdated && hasPostureChecks) { + return am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, []string{peerID}) + } + return affectedPeerIDsFromNetworkMap(nmap, peerID) +} + +// markConnectedAffectedPeers resolves the peers affected when a peer connects +// (login-expiry reconnect or embedded-proxy connect). The connecting peer's +// network map already lists them bidirectionally — the synthesized +// private-service policy puts proxy access-group members in the proxy peer's own +// map, and these edges carry no source-posture gate. An invalid peer has an +// empty map, so fall back to the resolver in that case. +func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap) []string { + if nmap == nil || len(nmap.Peers)+len(nmap.OfflinePeers) == 0 { + return am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, []string{peerID}) + } + return affectedPeerIDsFromNetworkMap(nmap, peerID) } func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { @@ -1141,15 +1205,20 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return nil, nil, nil, err } + p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer) + if err != nil { + return nil, nil, nil, err + } + if updateRemotePeers || isStatusChanged || ipv6CapabilityChanged || (isPeerUpdated && len(postureChecks) > 0) { - err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}) - if err != nil { + changedPeerIDs := []string{peer.ID} + affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, isRequiresApproval, isPeerUpdated, len(postureChecks) > 0) + if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { return nil, nil, nil, fmt.Errorf("notify network map controller of peer update: %w", err) } } - p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer) - return p, nmap, pc, err + return p, nmap, pc, nil } // ExtendPeerSession refreshes the peer's SSO session deadline by updating @@ -1407,6 +1476,100 @@ func (am *DefaultAccountManager) UpdateAccountPeers(ctx context.Context, account _ = am.networkMapController.UpdateAccountPeers(ctx, accountID, reason) } +// ExpandAndUpdateAffected expands a Snapshot (loaded INSIDE the now-committed +// transaction) into the affected peers and dispatches the network-map refresh. +// Pure in-memory work plus dispatch, so it runs AFTER commit — the fan-out walk +// never holds the write lock, over the consistent in-tx snapshot. Exported so the +// networks sub-package managers (which hold only account.Manager) share it. +func (am *DefaultAccountManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) { + go am.dispatchAffected(ctx, accountID, []*affectedpeers.Snapshot{snap}, []affectedpeers.Change{change}) +} + +// dispatchAffected expands one or more (snapshot, change) pairs — collected across +// one or several transactions — unions their affected peers, and dispatches a +// single network-map refresh. Each snapshot must already be loaded inside its +// transaction; this runs AFTER commit (pure in-memory + dispatch). It is spawned +// in a goroutine that outlives the request, so it detaches from the request +// context's cancellation up front. +func (am *DefaultAccountManager) dispatchAffected(ctx context.Context, accountID string, snaps []*affectedpeers.Snapshot, changes []affectedpeers.Change) { + ctx = context.WithoutCancel(ctx) + + var lists [][]string + for i, snap := range snaps { + if snap == nil { + continue + } + lists = append(lists, snap.Expand(ctx, accountID, changes[i])) + } + + affectedPeerIDs := unionStrings(lists...) + if len(affectedPeerIDs) == 0 { + log.WithContext(ctx).Tracef("no affected peers for account %s", accountID) + return + } + + log.WithContext(ctx).Debugf("updating %d affected peers for account %s: %v", len(affectedPeerIDs), accountID, affectedPeerIDs) + _ = am.networkMapController.UpdateAffectedPeers(ctx, accountID, affectedPeerIDs) +} + +// unionStrings concatenates the given string lists into one deduplicated slice, +// preserving first-occurrence order. +func unionStrings(lists ...[]string) []string { + seen := make(map[string]struct{}) + var out []string + for _, list := range lists { + for _, id := range list { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + } + return out +} + +// affectedPeerIDsFromNetworkMap returns the peer IDs referenced by a peer's +// network map (its connected and offline peers, which include routing and proxy +// peers), excluding the peer itself. For a freshly added peer these are, by ACL +// symmetry, exactly the peers its addition affects. +func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) []string { + if nmap == nil { + return nil + } + seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers)) + ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers)) + add := func(peers []*nbpeer.Peer) { + for _, p := range peers { + if p == nil || p.ID == "" || p.ID == selfPeerID { + continue + } + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + ids = append(ids, p.ID) + } + } + add(nmap.Peers) + add(nmap.OfflinePeers) + return ids +} + +// resolveAffectedPeersForPeerChanges loads a snapshot and expands it for a peer +// change. The graph is unchanged by these paths, so it runs out of the mutating +// transaction (after commit); the resolver derives the peers' group memberships +// during the walk, so the caller passes only the changed peer IDs. +func (am *DefaultAccountManager) resolveAffectedPeersForPeerChanges(ctx context.Context, s store.Store, accountID string, changedPeerIDs []string) []string { + change := affectedpeers.Change{ChangedPeerIDs: changedPeerIDs} + snap, err := affectedpeers.Load(ctx, s, accountID, change) + if err != nil { + log.WithContext(ctx).Errorf("failed to load snapshot for affected peers: %v", err) + return nil + } + return snap.Expand(ctx, accountID, change) +} + func (am *DefaultAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { _ = am.networkMapController.BufferUpdateAccountPeers(ctx, accountID, reason) } diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 9d6856740..ee1b33da2 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1855,7 +1855,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { t.Run("adding peer to unlinked group", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) // + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -1880,7 +1880,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { t.Run("deleting peer with unlinked group", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2018,7 +2018,10 @@ func TestPeerAccountPeersUpdate(t *testing.T) { } }) - // Adding peer to group linked with route should update account peers and send peer update + // drain any buffered updates from previous subtests + drainPeerUpdates(updMsg) + + // Adding peer to group linked with route should update peers in that group, not unrelated peers t.Run("adding peer to group linked with route", func(t *testing.T) { route := nbroute.Route{ ID: "testingRoute1", @@ -2042,7 +2045,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2059,16 +2062,16 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") } }) - // Deleting peer with linked group to route should update account peers and send peer update + // Deleting peer with linked group to route should update peers in that group, not unrelated peers t.Run("deleting peer with linked group to route", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2077,12 +2080,12 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") } }) - // Adding peer to group linked with name server group should update account peers and send peer update + // Adding peer to group linked with name server group should update peers in that group, not unrelated peers t.Run("adding peer to group linked with name server group", func(t *testing.T) { _, err = manager.CreateNameServerGroup( context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ @@ -2097,7 +2100,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2114,16 +2117,16 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") } }) - // Deleting peer with linked group to name server group should update account peers and send peer update + // Deleting peer with linked group to name server group should update peers in that group, not unrelated peers t.Run("deleting peer with linked group to route", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2132,8 +2135,8 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") } }) } diff --git a/management/server/policy.go b/management/server/policy.go index d67b3206e..187c879cb 100644 --- a/management/server/policy.go +++ b/management/server/policy.go @@ -5,7 +5,7 @@ import ( _ "embed" "github.com/rs/xid" - "github.com/sirupsen/logrus" + log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -13,6 +13,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/shared/management/status" ) @@ -45,44 +46,47 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user } var isUpdate = policy.ID != "" - var updateAccountPeers bool + var existingPolicy *types.Policy var action = activity.PolicyAdded var unchanged bool + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - existingPolicy, err := validatePolicy(ctx, transaction, accountID, policy) + existingPolicy, err = validatePolicy(ctx, transaction, accountID, policy) if err != nil { return err } if isUpdate { if policy.Equal(existingPolicy) { - logrus.WithContext(ctx).Tracef("policy update skipped because equal to stored one - policy id %s", policy.ID) + log.WithContext(ctx).Tracef("policy update skipped because equal to stored one - policy id %s", policy.ID) unchanged = true return nil } action = activity.PolicyUpdated - updateAccountPeers, err = arePolicyChangesAffectPeersWithExisting(ctx, transaction, policy, existingPolicy) - if err != nil { - return err - } - if err = transaction.SavePolicy(ctx, policy); err != nil { return err } } else { - updateAccountPeers, err = arePolicyChangesAffectPeers(ctx, transaction, policy) - if err != nil { - return err - } - if err = transaction.CreatePolicy(ctx, policy); err != nil { return err } } + // On update carry both the old and new policy so peers losing access via a + // removed rule still refresh; on create there is no prior policy. + if isUpdate { + change = affectedpeers.Change{Policies: []*types.Policy{existingPolicy, policy}} + } else { + change = affectedpeers.Change{Policies: []*types.Policy{policy}} + } + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -95,13 +99,7 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user am.StoreEvent(ctx, userID, policy.ID, accountID, action, policy.EventMeta()) - if updateAccountPeers { - policyOp := types.UpdateOperationCreate - if isUpdate { - policyOp = types.UpdateOperationUpdate - } - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePolicy, Operation: policyOp}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return policy, nil } @@ -117,7 +115,8 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po } var policy *types.Policy - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { policy, err = transaction.GetPolicyByID(ctx, store.LockingStrengthUpdate, accountID, policyID) @@ -125,8 +124,9 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po return err } - updateAccountPeers, err = arePolicyChangesAffectPeers(ctx, transaction, policy) - if err != nil { + // Load before delete: pre-state still references the policy. + change = affectedpeers.Change{Policies: []*types.Policy{policy}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -142,9 +142,7 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po am.StoreEvent(ctx, userID, policyID, accountID, activity.PolicyRemoved, policy.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePolicy, Operation: types.UpdateOperationDelete}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -162,46 +160,6 @@ func (am *DefaultAccountManager) ListPolicies(ctx context.Context, accountID, us return am.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) } -// arePolicyChangesAffectPeers checks if a policy (being created or deleted) will affect any associated peers. -func arePolicyChangesAffectPeers(ctx context.Context, transaction store.Store, policy *types.Policy) (bool, error) { - for _, rule := range policy.Rules { - if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" { - return true, nil - } - } - - return anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, policy.RuleGroups()) -} - -func arePolicyChangesAffectPeersWithExisting(ctx context.Context, transaction store.Store, policy *types.Policy, existingPolicy *types.Policy) (bool, error) { - if !policy.Enabled && !existingPolicy.Enabled { - return false, nil - } - - for _, rule := range existingPolicy.Rules { - if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" { - return true, nil - } - } - - hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, existingPolicy.RuleGroups()) - if err != nil { - return false, err - } - - if hasPeers { - return true, nil - } - - for _, rule := range policy.Rules { - if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" { - return true, nil - } - } - - return anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, policy.RuleGroups()) -} - // validatePolicy validates the policy and its rules. For updates it returns // the existing policy loaded from the store so callers can avoid a second read. func validatePolicy(ctx context.Context, transaction store.Store, accountID string, policy *types.Policy) (*types.Policy, error) { diff --git a/management/server/policy_test.go b/management/server/policy_test.go index 1eae07e79..6fb573b9e 100644 --- a/management/server/policy_test.go +++ b/management/server/policy_test.go @@ -1319,12 +1319,14 @@ func TestPolicyAccountPeersUpdate(t *testing.T) { } }) - // Updating disabled policy with destination and source groups containing peers should not update account's peers - // or send peer update + // Updating disabled policy with destination and source groups containing peers should still update account's peers + // because affected peer resolution does not filter by policy enabled state t.Run("updating disabled policy with source and destination groups with peers", func(t *testing.T) { + drainPeerUpdates(updMsg) + done := make(chan struct{}) go func() { - peerShouldNotReceiveUpdate(t, updMsg) + peerShouldReceiveUpdate(t, updMsg) close(done) }() @@ -1335,8 +1337,8 @@ func TestPolicyAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") } }) diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go index 56a732bf5..1d962438c 100644 --- a/management/server/posture_checks.go +++ b/management/server/posture_checks.go @@ -7,11 +7,11 @@ import ( "github.com/rs/xid" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -41,9 +41,10 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI return nil, status.NewPermissionDeniedError() } - var updateAccountPeers bool var isUpdate = postureChecks.ID != "" var action = activity.PostureCheckCreated + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{PostureCheckIDs: []string{postureChecks.ID}} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validatePostureChecks(ctx, transaction, accountID, postureChecks); err != nil { @@ -51,11 +52,6 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI } if isUpdate { - updateAccountPeers, err = arePostureCheckChangesAffectPeers(ctx, transaction, accountID, postureChecks.ID) - if err != nil { - return err - } - action = activity.PostureCheckUpdated } @@ -65,6 +61,11 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI } if isUpdate { + // Editing a posture check does not change which policies reference it, + // so loading after the save is fine. + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } return transaction.IncrementNetworkSerial(ctx, accountID) } @@ -76,13 +77,7 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI am.StoreEvent(ctx, userID, postureChecks.ID, accountID, action, postureChecks.EventMeta()) - if updateAccountPeers { - postureOp := types.UpdateOperationCreate - if isUpdate { - postureOp = types.UpdateOperationUpdate - } - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePostureCheck, Operation: postureOp}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return postureChecks, nil } @@ -137,29 +132,6 @@ func (am *DefaultAccountManager) ListPostureChecks(ctx context.Context, accountI return am.Store.GetAccountPostureChecks(ctx, store.LockingStrengthNone, accountID) } -// arePostureCheckChangesAffectPeers checks if the changes in posture checks are affecting peers. -func arePostureCheckChangesAffectPeers(ctx context.Context, transaction store.Store, accountID, postureCheckID string) (bool, error) { - policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return false, err - } - - for _, policy := range policies { - if slices.Contains(policy.SourcePostureChecks, postureCheckID) { - hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, accountID, policy.RuleGroups()) - if err != nil { - return false, err - } - - if hasPeers { - return true, nil - } - } - } - - return false, nil -} - // validatePostureChecks validates the posture checks. func validatePostureChecks(ctx context.Context, transaction store.Store, accountID string, postureChecks *posture.Checks) error { if err := postureChecks.Validate(); err != nil { diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go index 394f0d896..14bc2c45a 100644 --- a/management/server/posture_checks_test.go +++ b/management/server/posture_checks_test.go @@ -503,21 +503,20 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { require.NoError(t, err, "failed to save policy") t.Run("posture check exists and is linked to policy with peers", func(t *testing.T) { - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) - require.NoError(t, err) - assert.True(t, result) + groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) + assert.NotEmpty(t, groupIDs) }) t.Run("posture check exists but is not linked to any policy", func(t *testing.T) { - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckB.ID) - require.NoError(t, err) - assert.False(t, result) + groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckB.ID) + assert.Empty(t, groupIDs) + assert.Empty(t, directPeerIDs) }) t.Run("posture check does not exist", func(t *testing.T) { - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, "unknown") - require.NoError(t, err) - assert.False(t, result) + groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, "unknown") + assert.Empty(t, groupIDs) + assert.Empty(t, directPeerIDs) }) t.Run("posture check is linked to policy with no peers in source groups", func(t *testing.T) { @@ -526,9 +525,8 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { _, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true) require.NoError(t, err, "failed to update policy") - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) - require.NoError(t, err) - assert.True(t, result) + groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) + assert.NotEmpty(t, groupIDs) }) t.Run("posture check is linked to policy with no peers in destination groups", func(t *testing.T) { @@ -537,9 +535,8 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { _, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true) require.NoError(t, err, "failed to update policy") - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) - require.NoError(t, err) - assert.True(t, result) + groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) + assert.NotEmpty(t, groupIDs) }) t.Run("posture check is linked to policy but no peers in groups", func(t *testing.T) { @@ -547,9 +544,9 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { err = manager.UpdateGroup(context.Background(), account.Id, adminUserID, groupA) require.NoError(t, err, "failed to save groups") - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) - require.NoError(t, err) - assert.False(t, result) + // The collector returns groups even if they have no peers — the groups are still referenced + groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) + assert.NotEmpty(t, groupIDs) }) t.Run("posture check is linked to policy with non-existent group", func(t *testing.T) { @@ -558,8 +555,10 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { _, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true) require.NoError(t, err, "failed to update policy") - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) - require.NoError(t, err) - assert.False(t, result) + // Non-existent groups are filtered out during SavePolicy validation, + // so the saved policy has empty Sources/Destinations + groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) + assert.Empty(t, groupIDs) + assert.Empty(t, directPeerIDs) }) } diff --git a/management/server/route.go b/management/server/route.go index 8fd1cb02a..08e1489b2 100644 --- a/management/server/route.go +++ b/management/server/route.go @@ -10,6 +10,7 @@ import ( "github.com/rs/xid" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" @@ -147,7 +148,8 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri } var newRoute *route.Route - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { newRoute = &route.Route{ @@ -173,12 +175,12 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri return err } - updateAccountPeers, err = areRouteChangesAffectPeers(ctx, transaction, newRoute) - if err != nil { + if err = transaction.SaveRoute(ctx, newRoute); err != nil { return err } - if err = transaction.SaveRoute(ctx, newRoute); err != nil { + change = affectedpeers.Change{Routes: []*route.Route{newRoute}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -190,9 +192,7 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri am.StoreEvent(ctx, userID, string(newRoute.ID), accountID, activity.RouteCreated, newRoute.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationCreate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return newRoute, nil } @@ -208,8 +208,8 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI } var oldRoute *route.Route - var oldRouteAffectsPeers bool - var newRouteAffectsPeers bool + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateRoute(ctx, transaction, accountID, routeToSave); err != nil { @@ -221,21 +221,17 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI return err } - oldRouteAffectsPeers, err = areRouteChangesAffectPeers(ctx, transaction, oldRoute) - if err != nil { - return err - } - - newRouteAffectsPeers, err = areRouteChangesAffectPeers(ctx, transaction, routeToSave) - if err != nil { - return err - } routeToSave.AccountID = accountID if err = transaction.SaveRoute(ctx, routeToSave); err != nil { return err } + change = affectedpeers.Change{Routes: []*route.Route{routeToSave, oldRoute}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -244,9 +240,7 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI am.StoreEvent(ctx, userID, string(routeToSave.ID), accountID, activity.RouteUpdated, routeToSave.EventMeta()) - if oldRouteAffectsPeers || newRouteAffectsPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationUpdate}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -261,17 +255,19 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri return status.NewPermissionDeniedError() } - var route *route.Route - var updateAccountPeers bool + var rt *route.Route + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - route, err = transaction.GetRouteByID(ctx, store.LockingStrengthUpdate, accountID, string(routeID)) + rt, err = transaction.GetRouteByID(ctx, store.LockingStrengthUpdate, accountID, string(routeID)) if err != nil { return err } - updateAccountPeers, err = areRouteChangesAffectPeers(ctx, transaction, route) - if err != nil { + // Load before delete: pre-state captures everyone referencing the route. + change = affectedpeers.Change{Routes: []*route.Route{rt}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -285,11 +281,9 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri return fmt.Errorf("failed to delete route %s: %w", routeID, err) } - am.StoreEvent(ctx, userID, string(route.ID), accountID, activity.RouteRemoved, route.EventMeta()) + am.StoreEvent(ctx, userID, string(rt.ID), accountID, activity.RouteRemoved, rt.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationDelete}) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -377,25 +371,6 @@ func getPlaceholderIP() netip.Prefix { return netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32) } -// areRouteChangesAffectPeers checks if a given route affects peers by determining -// if it has a routing peer, distribution, or peer groups that include peers. -func areRouteChangesAffectPeers(ctx context.Context, transaction store.Store, route *route.Route) (bool, error) { - if route.Peer != "" { - return true, nil - } - - hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, route.AccountID, route.Groups) - if err != nil { - return false, err - } - - if hasPeers { - return true, nil - } - - return anyGroupHasPeersOrResources(ctx, transaction, route.AccountID, route.PeerGroups) -} - // GetRoutesByPrefixOrDomains return list of routes by account and route prefix func getRoutesByPrefixOrDomains(ctx context.Context, transaction store.Store, accountID string, prefix netip.Prefix, domains domain.List) ([]*route.Route, error) { accountRoutes, err := transaction.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID) diff --git a/management/server/route_test.go b/management/server/route_test.go index 79014790f..5ae18c253 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -1962,8 +1962,10 @@ func TestRouteAccountPeersUpdate(t *testing.T) { }) - // Creating a route with no routing peer and having peers in groups should update account peers and send peer update + // Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update t.Run("creating a route with peers in PeerGroups and Groups", func(t *testing.T) { + drainPeerUpdates(updMsg) + route := route.Route{ ID: "testingRoute2", Network: netip.MustParsePrefix("192.0.2.0/32"), @@ -1979,7 +1981,7 @@ func TestRouteAccountPeersUpdate(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -1992,8 +1994,8 @@ func TestRouteAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") } }) diff --git a/management/server/setupkey_test.go b/management/server/setupkey_test.go index 6eca27efd..2d43ea28b 100644 --- a/management/server/setupkey_test.go +++ b/management/server/setupkey_test.go @@ -426,6 +426,10 @@ func TestSetupKeyAccountPeersUpdate(t *testing.T) { updateManager.CloseChannel(context.Background(), peer1.ID) }) + // The setup policy above dispatches affected-peer updates asynchronously; drain + // any in-flight ones so the assertions only observe the setup-key operations. + settleAffectedUpdates(updMsg) + var setupKey *types.SetupKey // Creating setup key should not update account peers and not send peer update diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index c6ced2642..7d22905dd 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -265,7 +265,8 @@ func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) { return unlock } -// Deprecated: Full account operations are no longer supported +// Deprecated: Full +// account operations are no longer supported func (s *SqlStore) SaveAccount(ctx context.Context, account *types.Account) error { start := time.Now() defer func() { @@ -4912,6 +4913,64 @@ func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, gro return peers, nil } +func (s *SqlStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) { + if len(groupIDs) == 0 { + return nil, nil + } + + var peerIDs []string + result := s.db.Model(&types.GroupPeer{}). + Select("DISTINCT peer_id"). + Where("account_id = ? AND group_id IN ?", accountID, groupIDs). + Pluck("peer_id", &peerIDs) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get peer IDs by groups: %s", result.Error) + } + + return peerIDs, nil +} + +func (s *SqlStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) { + if len(peerIDs) == 0 { + return nil, nil + } + + var groupIDs []string + result := s.db.Model(&types.GroupPeer{}). + Select("DISTINCT group_id"). + Where("account_id = ? AND peer_id IN ?", accountID, peerIDs). + Pluck("group_id", &groupIDs) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get group IDs by peers: %s", result.Error) + } + + return groupIDs, nil +} + +// GetEmbeddedProxyPeerIDsByCluster returns peer IDs of all embedded proxy peers +// in the account, grouped by their ProxyCluster. The map is nil when no embedded +// proxy peers exist. +func (s *SqlStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { + type row struct { + ID string + Cluster string + } + var rows []row + result := s.db.Model(&nbpeer.Peer{}). + Select("id, proxy_meta_cluster AS cluster"). + Where("account_id = ? AND proxy_meta_embedded = ?", accountID, true). + Scan(&rows) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get embedded proxy peers: %s", result.Error) + } + + out := make(map[string][]string, len(rows)) + for _, r := range rows { + out[r.Cluster] = append(out[r.Cluster], r.ID) + } + return out, nil +} + func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error) { tx := s.db if lockStrength != LockingStrengthNone { diff --git a/management/server/store/store.go b/management/server/store/store.go index 746207f27..31f1fea86 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -162,6 +162,9 @@ type Store interface { GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) (*nbpeer.Peer, error) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*nbpeer.Peer, error) GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*nbpeer.Peer, error) + GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) + GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) + GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error) GetAccountPeersWithInactivity(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error) GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*nbpeer.Peer, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index dfd5af78d..706c03f1b 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1925,6 +1925,51 @@ func (mr *MockStoreMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupIDs int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByGroupIDs), ctx, accountID, groupIDs) } +// GetPeerIDsByGroups mocks base method. +func (m *MockStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerIDsByGroups", ctx, accountID, groupIDs) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerIDsByGroups indicates an expected call of GetPeerIDsByGroups. +func (mr *MockStoreMockRecorder) GetPeerIDsByGroups(ctx, accountID, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDsByGroups", reflect.TypeOf((*MockStore)(nil).GetPeerIDsByGroups), ctx, accountID, groupIDs) +} + +// GetGroupIDsByPeerIDs mocks base method. +func (m *MockStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupIDsByPeerIDs", ctx, accountID, peerIDs) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupIDsByPeerIDs indicates an expected call of GetGroupIDsByPeerIDs. +func (mr *MockStoreMockRecorder) GetGroupIDsByPeerIDs(ctx, accountID, peerIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupIDsByPeerIDs", reflect.TypeOf((*MockStore)(nil).GetGroupIDsByPeerIDs), ctx, accountID, peerIDs) +} + +// GetEmbeddedProxyPeerIDsByCluster mocks base method. +func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmbeddedProxyPeerIDsByCluster", ctx, accountID) + ret0, _ := ret[0].(map[string][]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmbeddedProxyPeerIDsByCluster indicates an expected call of GetEmbeddedProxyPeerIDsByCluster. +func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedProxyPeerIDsByCluster", reflect.TypeOf((*MockStore)(nil).GetEmbeddedProxyPeerIDsByCluster), ctx, accountID) +} + // GetPeersByIDs mocks base method. func (m *MockStore) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*peer.Peer, error) { m.ctrl.T.Helper() diff --git a/management/server/user.go b/management/server/user.go index 7cd955000..412f15ce7 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/idp/dex" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/idp" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions/modules" @@ -1157,7 +1158,8 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou } } - err = am.networkMapController.OnPeersUpdated(ctx, accountID, peerIDs) + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, peerIDs) + err = am.networkMapController.OnPeersUpdated(ctx, accountID, peerIDs, affectedPeerIDs) if err != nil { return fmt.Errorf("notify network map controller of peer update: %w", err) } @@ -1273,6 +1275,8 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI var userPeers []*nbpeer.Peer var targetUser *types.User var settings *types.Settings + var snap *affectedpeers.Snapshot + var change affectedpeers.Change var err error err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { @@ -1293,6 +1297,18 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI if len(userPeers) > 0 { updateAccountPeers = true + + var peerIDs []string + for _, peer := range userPeers { + peerIDs = append(peerIDs, peer.ID) + } + // Load before delete so the snapshot still has the peers' group + // memberships; the resolver derives them from the peer IDs during the walk. + change = affectedpeers.Change{ChangedPeerIDs: peerIDs} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + addPeerRemovedEvents, err = deletePeers(ctx, am, transaction, accountID, targetUserInfo.ID, userPeers, settings) if err != nil { return fmt.Errorf("failed to delete user peers: %w", err) @@ -1316,7 +1332,8 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peer.ID, err) } } - if err := am.networkMapController.OnPeersDeleted(ctx, accountID, peerIDs); err != nil { + affectedPeerIDs := snap.Expand(ctx, accountID, change) + if err := am.networkMapController.OnPeersDeleted(ctx, accountID, peerIDs, affectedPeerIDs); err != nil { log.WithContext(ctx).Errorf("failed to delete peers %s from network map: %v", peerIDs, err) } diff --git a/management/server/user_test.go b/management/server/user_test.go index 2a2d7857d..d46519396 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -846,7 +846,7 @@ func TestUser_DeleteUser_regularUser(t *testing.T) { ctrl := gomock.NewController(t) networkMapControllerMock := network_map.NewMockController(ctrl) networkMapControllerMock.EXPECT(). - OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any()). + OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil) permissionsManager := permissions.NewManager(store) @@ -962,7 +962,7 @@ func TestUser_DeleteUser_RegularUsers(t *testing.T) { ctrl := gomock.NewController(t) networkMapControllerMock := network_map.NewMockController(ctrl) networkMapControllerMock.EXPECT(). - OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any()). + OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil). AnyTimes() @@ -1531,11 +1531,14 @@ func TestUserAccountPeersUpdate(t *testing.T) { } }) + // drain any buffered updates from previous subtests + drainPeerUpdates(updMsg) + // deleting user with no linked peers should not update account peers and not send peer update t.Run("deleting user with no linked peers", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2022,7 +2025,7 @@ func TestUser_Operations_WithEmbeddedIDP(t *testing.T) { ctrl := gomock.NewController(t) networkMapControllerMock := network_map.NewMockController(ctrl) networkMapControllerMock.EXPECT(). - OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any()). + OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil). AnyTimes()