From e7c1d364c3b17f9b266d0952abd21b464d5610af Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 15 Jun 2026 17:22:40 +0200 Subject: [PATCH 01/16] [management] treat ci- builds as development for remote jobs (#6436) * fix(management): treat ci- builds as development for remote jobs CI snapshot builds use a "ci-" version string that did not match IsDevelopmentVersion, so the remote-jobs minimum-version gate rejected them. Recognize the "ci-" prefix as a development build. * fix(management): treat dev- builds as development for remote jobs Dev snapshot builds use a "dev-" version string that did not match IsDevelopmentVersion, so the remote-jobs minimum-version gate rejected them. Recognize the "dev-" prefix as a development build, alongside the existing "ci-" prefix. --- version/version.go | 17 ++++++++++++++--- version/version_test.go | 2 ++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/version/version.go b/version/version.go index f33ff133c..074305bd6 100644 --- a/version/version.go +++ b/version/version.go @@ -13,6 +13,14 @@ import ( // string, so it must not change without coordinating those consumers. const DevelopmentVersion = "development" +// CIVersionPrefix marks CI snapshot builds (e.g. "ci-7470fbdd"). Such builds +// are treated as development versions by IsDevelopmentVersion. +const CIVersionPrefix = "ci-" + +// DevVersionPrefix marks dev snapshot builds (e.g. "dev-7470fbdd"). Such builds +// are treated as development versions by IsDevelopmentVersion. +const DevVersionPrefix = "dev-" + // will be replaced with the release version when using goreleaser var version = DevelopmentVersion @@ -69,8 +77,11 @@ func NetbirdCommit() string { // comparing against the "development" literal or ad-hoc substring checks. // // Matches the bare DevelopmentVersion constant as well as any future -// extension such as "development-" or "development--dirty", -// while excluding tagged prereleases like "v0.31.1-dev". +// extension such as "development-" or "development--dirty", and +// CI/dev snapshot builds prefixed with "ci-" or "dev-", while excluding +// tagged prereleases like "v0.31.1-dev". func IsDevelopmentVersion(v string) bool { - return strings.HasPrefix(v, DevelopmentVersion) + return strings.HasPrefix(v, DevelopmentVersion) || + strings.HasPrefix(v, CIVersionPrefix) || + strings.HasPrefix(v, DevVersionPrefix) } diff --git a/version/version_test.go b/version/version_test.go index 47b77b50d..cdba6b804 100644 --- a/version/version_test.go +++ b/version/version_test.go @@ -10,6 +10,8 @@ func TestIsDevelopmentVersion(t *testing.T) { {"development", true}, {"development-0823f3ff9ab1", true}, {"development-0823f3ff9ab1-dirty", true}, + {"ci-7470fbdd", true}, + {"dev-7470fbdd", true}, {"0.50.0", false}, {"v0.31.1-dev", false}, {"1.0.0-dev", false}, 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 02/16] [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() From b3f9e6588ae8271253bc47341f5d968195b7d643 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 15 Jun 2026 17:53:25 +0200 Subject: [PATCH 03/16] [management] sync openapi spec and test for diff on workflows (#6437) * [management] sync openapi spec and test for diff on workflows * [management] pin oapi-codegen version to v2.7.1 --- .github/workflows/release.yml | 2 ++ shared/management/http/api/generate.sh | 2 +- shared/management/http/api/openapi.yml | 35 ------------------------- shared/management/http/api/types.gen.go | 2 +- 4 files changed, 4 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b15185198..b335aad72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,6 +161,8 @@ jobs: ${{ runner.os }}-go-releaser- - name: Install modules run: go mod tidy + - name: run openapi generator + run: bash shared/management/http/api/generate.sh - name: check git status run: git --no-pager diff --exit-code - name: Set up QEMU diff --git a/shared/management/http/api/generate.sh b/shared/management/http/api/generate.sh index 3770ea90f..ba29a6905 100755 --- a/shared/management/http/api/generate.sh +++ b/shared/management/http/api/generate.sh @@ -11,6 +11,6 @@ fi old_pwd=$(pwd) script_path=$(dirname $(realpath "$0")) cd "$script_path" -go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest +go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 oapi-codegen --config cfg.yaml openapi.yml cd "$old_pwd" diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index f8c687b7b..196a0c6b1 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -3086,24 +3086,6 @@ components: - enabled - auth - meta - allOf: - # When private=true, access_groups must be present and non-empty, - # and the service mode must be "http". The bearer-auth mutex is - # enforced at the service-validation layer - # (validatePrivateRequirements) because it sits in a nested - # ServiceAuthConfig and isn't cleanly expressible here. - - if: - required: [private] - properties: - private: - const: true - then: - required: [access_groups] - properties: - access_groups: - minItems: 1 - mode: - const: http ServiceMeta: type: object properties: @@ -3191,23 +3173,6 @@ components: - name - domain - enabled - allOf: - # Mirror of the Service conditional: when private=true the - # request must carry a non-empty access_groups list and the - # mode must be "http". The bearer-auth mutex is enforced at the - # service-validation layer (validatePrivateRequirements). - - if: - required: [private] - properties: - private: - const: true - then: - required: [access_groups] - properties: - access_groups: - minItems: 1 - mode: - const: http ServiceTargetOptions: type: object properties: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index d7945e448..ed5060a86 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1,6 +1,6 @@ // Package api provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. package api import ( From 08a2b636753b3d57387b2b177849dc6df5a6cfaf Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 16 Jun 2026 12:27:58 +0200 Subject: [PATCH 04/16] [client] propagate exit-node deselect to synthesized v6 (::/0) route (#6296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] propagate exit-node deselect to synthesized v6 (::/0) route When a client deselects an IPv4 exit node, the auto-generated IPv6 default route (::/0) was still selected and pushed onto the tunnel interface, even though the user disabled the exit node. On an exit node without a real IPv6 egress this blackholes IPv6 traffic, and because clients prefer IPv6 (happy eyeballs) it can break general connectivity. Root cause: the synthesized v6 route gets a different NetID than its v4 base (base + "-v6"). The route selector keys deselects by NetID and defaults unknown NetIDs to selected, so the "-v6" entry was never matched by the v4 deselect. The effectiveNetID() mirror that solves exactly this is used by HasUserSelectionForRoute and FilterSelectedExitNodes, but categorizeUserSelection called the raw IsSelected(), bypassing it and mis-categorizing the v6 pair as user-selected. Add RouteSelector.IsSelectedForExitNode(), which applies effectiveNetID before the selection check, and use it in categorizeUserSelection. IsSelected() is left untouched so non-exit code paths don't make unrelated "*-v6" routes inherit v4 state. Adds regression tests for the v4/v6 deselect mirror and explicit-v6 override. * [client] add DIAG logging to trace exit-node v6 (::/0) route filtering Temporary diagnostics to find why a deselected v4 exit node's synthesized ::/0 route still reaches the tunnel. Logs the full install path: incoming client networks, route-selector state before/after the management-driven update, what updateExitNodeSelections deselects/selects, and per-route KEEP/SKIP/DROP decisions in FilterSelectedExitNodes and applyExitNodeFilter. To be reverted once the real root cause is confirmed from a client log. * [client] clear orphaned v6 exit selection when v4 pair is toggled Root cause of the leaking ::/0 route, confirmed from client logs: the synthesized "-v6" exit route could stay explicitly selected in the persisted route-selector state while its v4 base was deselected (selected=[...-v6], deselected=[...v4base]). Because the v6 entry then has its own explicit state, effectiveNetID stops mirroring the v4 base, so FilterSelectedExitNodes keeps ::/0 and it is installed on the tunnel even though the user disabled the exit node. This happened because the iOS SDK's deselect only pairs the "-v6" sibling via ExpandV6ExitPairs when the v6 route is present in the current routesMap; a deselect at a moment it wasn't expanded left the v6 selection orphaned. Fix at the selector write path so it is independent of routesMap timing: when a v4 exit NetID is selected or deselected, clear any orphaned explicit state on its "-v6" sibling (clearPairedV6Locked), unless the sibling is part of the same batch (the deliberate ExpandV6ExitPairs case). The v6 then falls back to inheriting the v4 base via effectiveNetID, so a v4 deselect also drops ::/0 and a v4 select brings both back. Adds regression tests: a stale explicit v6 selection is cleared by a later v4 deselect, and an explicit v6 select made in the same batch is preserved. * [ios] compute route connection status in the bridge The iOS bridge exposed a route's Network as a possibly comma-joined string ("0.0.0.0/0, ::/0" for a merged exit node) but no connection status, forcing the UI to infer status by string-matching that joined value against peer routes — which never matched for the merged exit node, leaving it stuck as not-connected. Android already computes status in the core (findBestRoutePeer). Mirror that here: add a Status field to RoutesSelectionInfo and compute it from the connected peers' route tables, matching the route's primary prefix, a merged exit node's extra v6 prefix, or a dynamic route's domain pattern (the key the route manager records). The UI can now read the status directly. * [client] remove exit-node v6 DIAG logging and tidy routeselector Drop the temporary DIAG diagnostics added to trace the leaking ::/0 route (the root cause is fixed and confirmed). Also reorganize routeselector.go so the exit-node helpers (clearPairedV6Locked, isExitNode) sit next to the exit-node code paths and MarshalJSON/UnmarshalJSON are grouped together. * [client] mirror v4 exit selection onto v6 pair at write time The synthesized "-v6" exit route shares its v4 base's NetID plus a "-v6" suffix. Selection state was reconciled at read time via effectiveNetID, a mirror that could only be applied on exit-node code paths, which forced a parallel IsSelectedForExitNode() alongside IsSelected() and a clearPairedV6Locked() orphan cleanup on every toggle. That machinery still missed the case observed in the field: a persisted state with the v4 base deselected but its "-v6" sibling explicitly selected (orphaned). Because effectiveNetID returns the v6 entry itself once it carries explicit state, and clearPairedV6Locked only fires on a live toggle, the loaded orphan survived and the ::/0 route leaked onto the tunnel despite the exit node being disabled, breaking IPv6 (happy eyeballs). Treat the v4/v6 exit pair as a single toggle and keep state consistent at write time instead. RouteSelector.SyncPairedSelection forces the "-v6" entry to match its v4 base unconditionally, resetting any orphaned explicit state. The route manager, which knows the route prefixes, computes the pairs (V6ExitMergeSet) and calls it from updateRouteSelectorFromManagement before selection is read, so both collectExitNodeInfo and FilterSelectedExitNodes see consistent state, including pairs loaded from persisted selector state. This removes effectiveNetID, IsSelectedForExitNode and clearPairedV6Locked; the selector is literal again and no longer needs the "exit-node paths only" caveat. HasUserSelectionForRoute and applyExitNodeFilter use the raw NetID. Adds a selector test for SyncPairedSelection (including the orphaned-v6 case) and a route-manager test reproducing the persisted-orphan scenario from the field log. * [client] add DIAG logging to trace v6 exit-pair mirror The write-time mirror did not eliminate the leak in field testing. Re-add the DIAG diagnostics around the exit-node selection flow to capture a fresh trace: - UpdateRoutes: incoming client networks, selector state before/after the management update, and the networks remaining after FilterSelectedExitNodes. - mirrorV6ExitPairSelections: the NetIDs present in this update and the v6 pairs V6ExitMergeSet derives from them (reveals whether the v4 base and its ::/0 pair are present in the same update so the pair can be matched). - SyncPairedSelection: the base/paired state before and after the sync. - FilterSelectedExitNodes / applyExitNodeFilter: per-route SKIP/KEEP/DROP and the selection lookups behind each decision. - updateExitNodeSelections / logExitNodeUpdate: categorization and deselect set. Temporary; to be removed once the root cause is confirmed. * [client] remove v6 exit-pair mirror DIAG logging Drop the temporary DIAG diagnostics added to trace the v4/v6 exit-pair mirror. The field log confirmed the write-time mirror keeps the pair consistent (the ::/0 route is only ever applied alongside its v4 base and is dropped on deselect), so the diagnostics are no longer needed. --- client/internal/routemanager/manager.go | 21 +++ .../routemanager/manager_v6exit_test.go | 47 +++++ .../internal/routeselector/routeselector.go | 168 +++++++++--------- .../routeselector/routeselector_test.go | 102 +++++++---- client/ios/NetBirdSDK/client.go | 50 ++++++ client/ios/NetBirdSDK/routes.go | 1 + 6 files changed, 273 insertions(+), 116 deletions(-) create mode 100644 client/internal/routemanager/manager_v6exit_test.go diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index f10a2b5e0..0edf4607f 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -9,6 +9,7 @@ import ( "net/url" "runtime" "slices" + "strings" "sync" "sync/atomic" "time" @@ -700,6 +701,8 @@ func resolveURLsToIPs(urls []string) []net.IP { // updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) { + m.mirrorV6ExitPairSelections(clientRoutes) + // An explicit user "deselect all" must not be overridden by management auto-apply. // Auto-applying an exit node here would call SelectRoutes, which clears the // deselect-all flag and re-enables every route the user turned off. @@ -716,6 +719,24 @@ func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HA m.logExitNodeUpdate(exitNodeInfo) } +// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection +// consistent with its v4 base. The v4/v6 exit pair is a single toggle, so the v6 +// entry always follows the base: deselecting the v4 exit node also drops its ::/0 +// pair, and any stale (orphaned) explicit selection on the v6 entry is reset. This +// runs before selection is read so both collectExitNodeInfo and FilterSelectedExitNodes +// see consistent state, including pairs loaded from persisted selector state. +func (m *DefaultManager) mirrorV6ExitPairSelections(clientRoutes route.HAMap) { + routesByNetID := make(map[route.NetID][]*route.Route, len(clientRoutes)) + for haID, routes := range clientRoutes { + routesByNetID[haID.NetID()] = routes + } + + for v6ID := range route.V6ExitMergeSet(routesByNetID) { + baseID := route.NetID(strings.TrimSuffix(string(v6ID), route.V6ExitSuffix)) + m.routeSelector.SyncPairedSelection(baseID, v6ID) + } +} + type exitNodeInfo struct { allIDs []route.NetID selectedByManagement []route.NetID diff --git a/client/internal/routemanager/manager_v6exit_test.go b/client/internal/routemanager/manager_v6exit_test.go new file mode 100644 index 000000000..15ab99cbd --- /dev/null +++ b/client/internal/routemanager/manager_v6exit_test.go @@ -0,0 +1,47 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +// TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair reproduces the bug seen +// in netbird-engine.log: persisted selector state has the v4 exit node deselected +// but its synthesized "-v6" pair explicitly selected (orphaned), so the ::/0 route +// leaked onto the tunnel. The management update must mirror the v4 deselect onto the +// v6 pair so FilterSelectedExitNodes drops it. +func TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair(t *testing.T) { + const ( + v4ID = route.NetID("Exit Node (raspberrypi)") + v6ID = route.NetID("Exit Node (raspberrypi)-v6") + ) + all := []route.NetID{v4ID, v6ID} + + rs := routeselector.NewRouteSelector() + // Orphan the v6 selection: select the pair, then deselect only the v4 base. + require.NoError(t, rs.SelectRoutes([]route.NetID{v4ID, v6ID}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{v4ID}, all)) + require.True(t, rs.IsSelected(v6ID), "precondition: orphaned v6 selection survives v4 deselect") + + m := &DefaultManager{routeSelector: rs} + + v4Route := &route.Route{NetID: v4ID, Network: netip.MustParsePrefix("0.0.0.0/0")} + v6Route := &route.Route{NetID: v6ID, Network: netip.MustParsePrefix("::/0")} + clientRoutes := route.HAMap{ + "Exit Node (raspberrypi)|0.0.0.0/0": {v4Route}, + "Exit Node (raspberrypi)-v6|::/0": {v6Route}, + } + + m.updateRouteSelectorFromManagement(clientRoutes) + + assert.False(t, rs.IsSelected(v6ID), "v6 pair must follow the v4 base deselect after the management update") + + filtered := rs.FilterSelectedExitNodes(clientRoutes) + assert.Empty(t, filtered, "deselected v4 exit node must not leak its ::/0 pair onto the tunnel") +} diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go index b9991cd37..232baf746 100644 --- a/client/internal/routeselector/routeselector.go +++ b/client/internal/routeselector/routeselector.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "slices" - "strings" "sync" "github.com/hashicorp/go-multierror" @@ -132,6 +131,33 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool { return rs.isSelectedLocked(routeID) } +// SyncPairedSelection forces pairedID's explicit selection state to match baseID's, +// so a synthesized "-v6" exit route always follows its v4 base: selecting or +// deselecting the v4 exit node governs the ::/0 pair, and any stale (orphaned) +// explicit state on the v6 entry is reset. The v4/v6 exit pair is treated as a single +// toggle, so the v6 entry carries no independent selection of its own. +func (rs *RouteSelector) SyncPairedSelection(baseID, pairedID route.NetID) { + rs.mu.Lock() + defer rs.mu.Unlock() + + if rs.deselectAll { + return + } + + _, baseSelected := rs.selectedRoutes[baseID] + _, baseDeselected := rs.deselectedRoutes[baseID] + + delete(rs.selectedRoutes, pairedID) + delete(rs.deselectedRoutes, pairedID) + + switch { + case baseSelected: + rs.selectedRoutes[pairedID] = struct{}{} + case baseDeselected: + rs.deselectedRoutes[pairedID] = struct{}{} + } +} + // FilterSelected removes unselected routes from the provided map. func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap { rs.mu.RLock() @@ -151,14 +177,13 @@ func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap { } // HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route. -// Intended for exit-node code paths: a v6 exit-node pair (e.g. "MyExit-v6") with no explicit state of -// its own inherits its v4 base's state, so legacy persisted selections that predate v6 pairing -// transparently apply to the synthesized v6 entry. +// The lookup is literal; v4/v6 exit pairs are kept consistent at write time via SyncPairedSelection, +// so a synthesized "-v6" entry carries the same explicit state as its v4 base. func (rs *RouteSelector) HasUserSelectionForRoute(routeID route.NetID) bool { rs.mu.RLock() defer rs.mu.RUnlock() - return rs.hasUserSelectionForRouteLocked(rs.effectiveNetID(routeID)) + return rs.hasUserSelectionForRouteLocked(routeID) } func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap { @@ -187,83 +212,6 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap return filtered } -// effectiveNetID returns the v4 base for a "-v6" exit pair entry that has no explicit -// state of its own, so selections made on the v4 entry govern the v6 entry automatically. -// Only call this from exit-node-specific code paths: applying it to a non-exit "-v6" route -// would make it inherit unrelated v4 state. Must be called with rs.mu held. -func (rs *RouteSelector) effectiveNetID(id route.NetID) route.NetID { - name := string(id) - if !strings.HasSuffix(name, route.V6ExitSuffix) { - return id - } - if _, ok := rs.selectedRoutes[id]; ok { - return id - } - if _, ok := rs.deselectedRoutes[id]; ok { - return id - } - return route.NetID(strings.TrimSuffix(name, route.V6ExitSuffix)) -} - -func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool { - if rs.deselectAll { - return false - } - _, deselected := rs.deselectedRoutes[routeID] - return !deselected -} - -func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool { - if rs.deselectAll { - return true - } - _, deselected := rs.deselectedRoutes[netID] - return deselected -} - -func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool { - _, selected := rs.selectedRoutes[routeID] - _, deselected := rs.deselectedRoutes[routeID] - return selected || deselected -} - -func isExitNode(rt []*route.Route) bool { - return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network)) -} - -func (rs *RouteSelector) applyExitNodeFilter( - id route.HAUniqueID, - netID route.NetID, - rt []*route.Route, - out route.HAMap, -) { - // Exit-node path: apply the v4/v6 pair mirror so a deselect on the v4 base also - // drops the synthesized v6 entry that lacks its own explicit state. - effective := rs.effectiveNetID(netID) - if rs.hasUserSelectionForRouteLocked(effective) { - if rs.isSelectedLocked(effective) { - out[id] = rt - } - return - } - - // no explicit selection for this route: defer to management's SkipAutoApply flag - sel := collectSelected(rt) - if len(sel) > 0 { - out[id] = sel - } -} - -func collectSelected(rt []*route.Route) []*route.Route { - var sel []*route.Route - for _, r := range rt { - if !r.SkipAutoApply { - sel = append(sel, r) - } - } - return sel -} - // MarshalJSON implements the json.Marshaler interface func (rs *RouteSelector) MarshalJSON() ([]byte, error) { rs.mu.RLock() @@ -317,3 +265,59 @@ func (rs *RouteSelector) UnmarshalJSON(data []byte) error { return nil } + +func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool { + if rs.deselectAll { + return false + } + _, deselected := rs.deselectedRoutes[routeID] + return !deselected +} + +func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool { + if rs.deselectAll { + return true + } + _, deselected := rs.deselectedRoutes[netID] + return deselected +} + +func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool { + _, selected := rs.selectedRoutes[routeID] + _, deselected := rs.deselectedRoutes[routeID] + return selected || deselected +} + +func (rs *RouteSelector) applyExitNodeFilter( + id route.HAUniqueID, + netID route.NetID, + rt []*route.Route, + out route.HAMap, +) { + if rs.hasUserSelectionForRouteLocked(netID) { + if rs.isSelectedLocked(netID) { + out[id] = rt + } + return + } + + // no explicit selection for this route: defer to management's SkipAutoApply flag + sel := collectSelected(rt) + if len(sel) > 0 { + out[id] = sel + } +} + +func isExitNode(rt []*route.Route) bool { + return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network)) +} + +func collectSelected(rt []*route.Route) []*route.Route { + var sel []*route.Route + for _, r := range rt { + if !r.SkipAutoApply { + sel = append(sel, r) + } + } + return sel +} diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index 3f0d9f120..c9d6acb4d 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -330,39 +330,73 @@ func TestRouteSelector_FilterSelectedExitNodes(t *testing.T) { assert.Len(t, filtered, 0) // No routes should be selected } -// TestRouteSelector_V6ExitPairInherits covers the v4/v6 exit-node pair selection -// mirror. The mirror is scoped to exit-node code paths: HasUserSelectionForRoute -// and FilterSelectedExitNodes resolve a "-v6" entry without explicit state to its -// v4 base, so legacy persisted selections that predate v6 pairing transparently -// apply to the synthesized v6 entry. General lookups (IsSelected, FilterSelected) -// stay literal so unrelated routes named "*-v6" don't inherit unrelated state. -func TestRouteSelector_V6ExitPairInherits(t *testing.T) { +// TestRouteSelector_V6ExitPairSync covers SyncPairedSelection, which keeps a v4 +// exit node and its synthesized "-v6" counterpart consistent. The selector itself +// is literal and never infers a v6 entry's state from its v4 base; callers that know +// the pairing (exit-node code paths) call SyncPairedSelection to force the v6 entry +// to follow the base, treating the pair as a single toggle. +func TestRouteSelector_V6ExitPairSync(t *testing.T) { all := []route.NetID{"exit1", "exit1-v6", "exit2", "exit2-v6", "corp", "corp-v6"} - t.Run("HasUserSelectionForRoute mirrors deselected v4 base", func(t *testing.T) { + t.Run("selector lookups stay literal without sync", func(t *testing.T) { rs := routeselector.NewRouteSelector() require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) - assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 pair sees v4 base's user selection") + // The selector does not pair-resolve: the v6 entry is independent until synced. + assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 entry has no state of its own") + assert.True(t, rs.IsSelected("exit1-v6"), "unsynced v6 entry stays selected by default") - // unrelated v6 with no v4 base touched is unaffected - assert.False(t, rs.HasUserSelectionForRoute("exit2-v6")) + // A route literally named "exit1-something" must never pair-resolve either. + assert.False(t, rs.HasUserSelectionForRoute("exit1-something")) }) - t.Run("IsSelected stays literal for non-exit lookups", func(t *testing.T) { - rs := routeselector.NewRouteSelector() - require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all)) - - // A non-exit route literally named "corp-v6" must not inherit "corp"'s state - // via the mirror; the mirror only applies in exit-node code paths. - assert.False(t, rs.IsSelected("corp")) - assert.True(t, rs.IsSelected("corp-v6"), "non-exit *-v6 routes must not inherit unrelated v4 state") - }) - - t.Run("explicit v6 state overrides v4 base in filter", func(t *testing.T) { + t.Run("sync mirrors deselected v4 base onto v6", func(t *testing.T) { rs := routeselector.NewRouteSelector() require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.IsSelected("exit1")) + assert.False(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base deselect") + assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 carries explicit deselect after sync") + }) + + t.Run("sync mirrors selected v4 base onto v6", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1"}, false, all)) + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.True(t, rs.IsSelected("exit1")) + assert.True(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base select") + }) + + t.Run("sync clears v6 state when base has no explicit selection", func(t *testing.T) { + rs := routeselector.NewRouteSelector() require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1-v6"}, true, all)) + require.True(t, rs.HasUserSelectionForRoute("exit1-v6")) + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), + "v6 explicit state is cleared so it follows management like its base") + }) + + // Regression for the observed bug (see netbird-engine.log): persisted state has + // the v4 base deselected but the v6 sibling explicitly selected (orphaned). The + // sync must reset the orphan so the ::/0 route does not leak onto the tunnel. + t.Run("sync clears orphaned explicit v6 selection on deselected base", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + + // Prior state: both explicitly selected, then only the v4 base deselected, + // leaving the v6 entry as a stale explicit selection. + require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1", "exit1-v6"}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + require.True(t, rs.IsSelected("exit1-v6"), "precondition: orphaned v6 selection") + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.IsSelected("exit1-v6"), "orphaned v6 selection reset to follow v4 deselect") v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")} v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")} @@ -370,23 +404,14 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) { "exit1|0.0.0.0/0": {v4Route}, "exit1-v6|::/0": {v6Route}, } - filtered := rs.FilterSelectedExitNodes(routes) - assert.NotContains(t, filtered, route.HAUniqueID("exit1|0.0.0.0/0")) - assert.Contains(t, filtered, route.HAUniqueID("exit1-v6|::/0"), "explicit v6 select wins over v4 base") + assert.Empty(t, filtered, "deselecting v4 base must drop the v6 pair even if it was explicitly selected before") }) - t.Run("non-v6-suffix routes unaffected", func(t *testing.T) { - rs := routeselector.NewRouteSelector() - require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) - - // A route literally named "exit1-something" must not pair-resolve. - assert.False(t, rs.HasUserSelectionForRoute("exit1-something")) - }) - - t.Run("filter v6 paired with deselected v4 base", func(t *testing.T) { + t.Run("filter drops synced v6 pair of deselected v4 base", func(t *testing.T) { rs := routeselector.NewRouteSelector() require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all)) + rs.SyncPairedSelection("exit1", "exit1-v6") v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")} v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")} @@ -399,6 +424,15 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) { assert.Empty(t, filtered, "deselecting v4 base must also drop the v6 pair") }) + t.Run("deselectAll makes sync a no-op", func(t *testing.T) { + rs := routeselector.NewRouteSelector() + rs.DeselectAllRoutes() + + rs.SyncPairedSelection("exit1", "exit1-v6") + + assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "sync must not write explicit state under deselectAll") + }) + t.Run("non-exit *-v6 routes pass through FilterSelectedExitNodes", func(t *testing.T) { rs := routeselector.NewRouteSelector() require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all)) diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index bafbb0031..bfcef6331 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -54,6 +54,7 @@ type selectRoute struct { Network netip.Prefix Domains domain.List Selected bool + Status string extraNetworks []netip.Prefix } @@ -377,9 +378,57 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { routes := buildSelectRoutes(routesMap, routeSelector.IsSelected, v6ExitMerged) resolvedDomains := c.recorder.GetResolvedDomainsStates() + // Compute each route's connection status in the core (mirroring the Android + // bridge), so the UI doesn't have to infer it by string-matching the joined + // Network value against peer routes. For a merged exit node the status reflects + // whichever of the v4/v6 prefixes is served by a connected peer; for dynamic + // (DNS) routes the peer route key is the domain pattern (see dynamic.Route.String). + connectedRoutes := c.connectedRouteSet() + for _, r := range routes { + r.Status = routeStatus(r, connectedRoutes) + } + return prepareRouteSelectionDetails(routes, resolvedDomains), nil } +// connectedRouteSet returns the set of route keys (as strings) currently served by a +// connected peer, gathered across all connected peers' route tables. The keys match +// what the route manager records: a prefix string for static routes (e.g. "0.0.0.0/0") +// and the domain pattern for dynamic routes (e.g. "*.example.com"). +func (c *Client) connectedRouteSet() map[string]struct{} { + connected := map[string]struct{}{} + for _, p := range c.recorder.GetFullStatus().Peers { + if p.ConnStatus != peer.StatusConnected { + continue + } + for r := range p.GetRoutes() { + connected[r] = struct{}{} + } + } + return connected +} + +// routeStatus reports "Connected" if any of the route's keys is served by a connected +// peer: the primary Network prefix, an extra v6 network of a merged exit node, or the +// domain pattern for a dynamic DNS route. Otherwise "Idle". +func routeStatus(r *selectRoute, connectedRoutes map[string]struct{}) string { + keys := make([]string, 0, 1+len(r.extraNetworks)) + if len(r.Domains) > 0 { + keys = append(keys, r.Domains.SafeString()) + } else { + keys = append(keys, r.Network.String()) + } + for _, extra := range r.extraNetworks { + keys = append(keys, extra.String()) + } + for _, k := range keys { + if _, ok := connectedRoutes[k]; ok { + return peer.StatusConnected.String() + } + } + return peer.StatusIdle.String() +} + func buildSelectRoutes(routesMap map[route.NetID][]*route.Route, isSelected func(route.NetID) bool, v6Merged map[route.NetID]struct{}) []*selectRoute { var routes []*selectRoute for id, rt := range routesMap { @@ -462,6 +511,7 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom Network: netStr, Domains: &domainDetails, Selected: r.Selected, + Status: r.Status, }) } diff --git a/client/ios/NetBirdSDK/routes.go b/client/ios/NetBirdSDK/routes.go index 025313bfa..56af2a1ad 100644 --- a/client/ios/NetBirdSDK/routes.go +++ b/client/ios/NetBirdSDK/routes.go @@ -20,6 +20,7 @@ type RoutesSelectionInfo struct { Network string Domains *DomainDetails Selected bool + Status string } type DomainCollection interface { From 01aa49433e021bd5e1ee4aa593ce82a2bbe6a43b Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:33:24 +0200 Subject: [PATCH 05/16] [management] delete targets when deleting exposed service (#6442) --- .../reverseproxy/service/manager/manager.go | 12 ++++ .../service/manager/manager_test.go | 70 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index e6b006759..365fbab40 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -918,6 +918,10 @@ func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID strin } for _, svc := range services { + if err = transaction.DeleteServiceTargets(ctx, accountID, svc.ID); err != nil { + return fmt.Errorf("failed to delete service targets: %w", err) + } + if err = transaction.DeleteService(ctx, accountID, svc.ID); err != nil { return fmt.Errorf("failed to delete service: %w", err) } @@ -1270,6 +1274,10 @@ func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serv return status.Errorf(status.PermissionDenied, "cannot delete service exposed by another peer") } + if err = transaction.DeleteServiceTargets(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("delete service targets: %w", err) + } + if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil { return fmt.Errorf("delete service: %w", err) } @@ -1319,6 +1327,10 @@ func (m *Manager) deleteExpiredPeerService(ctx context.Context, accountID, peerI return nil } + if err = transaction.DeleteServiceTargets(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("delete service targets: %w", err) + } + if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil { return fmt.Errorf("delete service: %w", err) } diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index 0497415b7..ace105b31 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -458,6 +458,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { txMock.EXPECT(). GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). Return(newEphemeralService(), nil) + txMock.EXPECT(). + DeleteServiceTargets(ctx, accountID, serviceID). + Return(nil) txMock.EXPECT(). DeleteService(ctx, accountID, serviceID). Return(nil) @@ -560,6 +563,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { txMock.EXPECT(). GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). Return(newEphemeralService(), nil) + txMock.EXPECT(). + DeleteServiceTargets(ctx, accountID, serviceID). + Return(nil) txMock.EXPECT(). DeleteService(ctx, accountID, serviceID). Return(nil) @@ -604,6 +610,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { txMock.EXPECT(). GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). Return(newEphemeralService(), nil) + txMock.EXPECT(). + DeleteServiceTargets(ctx, accountID, serviceID). + Return(nil) txMock.EXPECT(). DeleteService(ctx, accountID, serviceID). Return(nil) @@ -1192,6 +1201,67 @@ func TestDeleteService_DeletesTargets(t *testing.T) { assert.Len(t, targets, 0, "All targets should be deleted when service is deleted") } +func TestDeleteExpiredPeerService_DeletesTargets(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + + targets, err := testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID) + require.NoError(t, err) + require.Len(t, targets, 1, "ephemeral peer-exposed service should have exactly one persisted target before reaping") + + expireEphemeralService(t, testStore, testAccountID, resp.Domain) + err = mgr.deleteExpiredPeerService(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.Error(t, err, "expired peer-exposed service should be deleted") + s, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, s.Type()) + + targets, err = testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID) + require.NoError(t, err) + assert.Len(t, targets, 0, "orphaned target rows must be deleted when an expired peer-exposed service is reaped") +} + +func TestDeleteServiceFromPeer_DeletesTargets(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + + targets, err := testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID) + require.NoError(t, err) + require.Len(t, targets, 1, "ephemeral peer-exposed service should have exactly one persisted target before stopping") + + err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.Error(t, err, "stopped peer-exposed service should be deleted") + s, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, s.Type()) + + targets, err = testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID) + require.NoError(t, err) + assert.Len(t, targets, 0, "orphaned target rows must be deleted when a peer stops its exposed service") +} + func TestValidateProtocolChange(t *testing.T) { tests := []struct { name string From 38ad2b67e816cffab47019c634881a1b864fe654 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:41:17 +0200 Subject: [PATCH 06/16] [proxy] fix context for udprelay (#6444) --- proxy/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy/server.go b/proxy/server.go index 2d4767106..1d8a2451b 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -1989,7 +1989,7 @@ func (s *Server) addUDPRelay(ctx context.Context, mapping *proto.ProxyMapping, t "service_id": svcID, }) - relay := udprelay.New(ctx, udprelay.RelayConfig{ + relay := udprelay.New(s.portRouterContext(ctx), udprelay.RelayConfig{ Logger: entry, Listener: listener, Target: targetAddress, From 3c23700e56527a106a4f1ce6c1e40534d52702ba Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 16 Jun 2026 15:54:46 +0200 Subject: [PATCH 07/16] [client] Add iOS debug bundle support in Go (#6270) * Add iOS debug bundle support in Go Thread cacheDir through NewClient -> RunOniOS -> MobileDependency.TempDir so the iOS client can pass its sandbox-writable cache directory for debug bundle zip file creation instead of os.TempDir(). Move log collection into platform-dispatched addPlatformLog(): - iOS: adds the file-based Go client log (with rotation, stderr/stdout companions and anonymization handled by addLogfile) plus the Swift app log (swift-log.log) written by the iOS app into the same log directory - Other non-Android platforms: existing file-based log + systemd fallback Narrow the debug_nonandroid.go build tag to !android && !ios so iOS no longer attempts the systemd journal fallback. Add a DebugBundle() entry point to the iOS Go client that generates a bundle, uploads it and returns the upload key. It works with or without a running engine: when the engine is up it reuses the live config, sync response and client metrics; otherwise it loads the config from disk (or the preloaded tvOS config). Guard the live config/ConnectClient behind a state mutex since DebugBundle may run on a different thread. * Include the iOS state file in the debug bundle addStateFile() resolved the state path via ServiceManager.GetStatePath(), which on iOS points at a hard-coded default that does not exist in the app sandbox, so the state file was silently skipped. Add an optional StatePath to GeneratorDependencies and use it when set, falling back to the ServiceManager default otherwise. The iOS DebugBundle passes the client's actual state file path (the App Group profile state), matching the Android bundle which includes the state file. * ios: enable sync response persistence for debug bundle Turn on sync response persistence before starting the engine so DebugBundle can include the network map. On iOS the store is disk-backed (see syncstore) to keep the map out of the constrained process memory. * ios: pass log file path through NewClient constructor (#6393) Add logFilePath field to Client struct and expose it as a parameter in NewClient so callers provide the Go log path at construction time. Wire it into DebugBundle via GeneratorDependencies.LogPath so the debug bundle includes client.log and swift-log.log regardless of whether the bundle is triggered by the app or the management server. Co-authored-by: Claude Sonnet 4.6 * ios: pass log file path to engine for remote debug bundles RunOniOS started the engine with an empty LogPath, so EngineConfig.LogPath was never set. Management-triggered (jobs) debug bundles read the log path from the engine config, so they collected no client logs (client.log, rotated logs, swift-log.log). The GUI path was unaffected because it passes c.logFilePath directly to the bundle generator. Thread c.logFilePath through RunOniOS into the engine config so remote bundles include the client logs too. --------- Co-authored-by: evgeniyChepelev <68751844+evgeniyChepelev@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- client/internal/connect.go | 5 +- client/internal/debug/debug.go | 10 +- client/internal/debug/debug_ios.go | 36 ++++++ client/internal/debug/debug_nonandroid.go | 2 +- client/ios/NetBirdSDK/client.go | 131 ++++++++++++++++++++-- 5 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 client/internal/debug/debug_ios.go diff --git a/client/internal/connect.go b/client/internal/connect.go index e38bc2f58..d93b62bb5 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -118,6 +118,8 @@ func (c *ConnectClient) RunOniOS( networkChangeListener listener.NetworkChangeListener, dnsManager dns.IosDnsManager, stateFilePath string, + cacheDir string, + logFilePath string, ) error { // Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension. debug.SetGCPercent(5) @@ -127,8 +129,9 @@ func (c *ConnectClient) RunOniOS( NetworkChangeListener: networkChangeListener, DnsManager: dnsManager, StateFilePath: stateFilePath, + TempDir: cacheDir, } - return c.run(mobileDependency, nil, "") + return c.run(mobileDependency, nil, logFilePath) } func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error { diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 05501320c..a65d8bd05 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -250,6 +250,7 @@ type BundleGenerator struct { syncResponse *mgmProto.SyncResponse logPath string tempDir string + statePath string cpuProfile []byte capturePath string refreshStatus func() // Optional callback to refresh status before bundle generation @@ -276,6 +277,7 @@ type GeneratorDependencies struct { SyncResponse *mgmProto.SyncResponse LogPath string TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. + StatePath string // Path to the state file. If empty, the ServiceManager default path is used. CPUProfile []byte CapturePath string RefreshStatus func() @@ -299,6 +301,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen syncResponse: deps.SyncResponse, logPath: deps.LogPath, tempDir: deps.TempDir, + statePath: deps.StatePath, cpuProfile: deps.CPUProfile, capturePath: deps.CapturePath, refreshStatus: deps.RefreshStatus, @@ -850,8 +853,11 @@ func (g *BundleGenerator) maskSecrets() { } func (g *BundleGenerator) addStateFile() error { - sm := profilemanager.NewServiceManager("") - path := sm.GetStatePath() + path := g.statePath + if path == "" { + sm := profilemanager.NewServiceManager("") + path = sm.GetStatePath() + } if path == "" { return nil } diff --git a/client/internal/debug/debug_ios.go b/client/internal/debug/debug_ios.go new file mode 100644 index 000000000..a07c23dbd --- /dev/null +++ b/client/internal/debug/debug_ios.go @@ -0,0 +1,36 @@ +//go:build ios + +package debug + +import ( + "path/filepath" + + log "github.com/sirupsen/logrus" +) + +// swiftLogFile is the Swift app log written by the iOS app into the same log +// directory as the Go client log, so it can be collected into the bundle. +const swiftLogFile = "swift-log.log" + +// addPlatformLog collects logs for the iOS debug bundle. iOS has no logcat or +// systemd journal, so we rely on file-based logs. addLogfile handles the Go +// client log (logPath) with rotation, the stderr/stdout companions and +// anonymization. The iOS app writes its own Swift log into the same directory, +// so we add it alongside the Go log. +func (g *BundleGenerator) addPlatformLog() error { + if err := g.addLogfile(); err != nil { + return err + } + + if g.logPath == "" { + return nil + } + + swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile) + if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil { + // The Swift log is best-effort: the app may not have written it yet. + log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err) + } + + return nil +} diff --git a/client/internal/debug/debug_nonandroid.go b/client/internal/debug/debug_nonandroid.go index 117238dec..2dfca6ddc 100644 --- a/client/internal/debug/debug_nonandroid.go +++ b/client/internal/debug/debug_nonandroid.go @@ -1,4 +1,4 @@ -//go:build !android +//go:build !android && !ios package debug diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index bfcef6331..132ee8d9d 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -17,6 +17,7 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" @@ -25,6 +26,7 @@ import ( "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" + types "github.com/netbirdio/netbird/upload-server/types" ) // ConnectionListener export internal Listener for mobile @@ -66,6 +68,8 @@ func init() { type Client struct { cfgFile string stateFile string + cacheDir string + logFilePath string recorder *peer.Status ctxCancel context.CancelFunc ctxCancelLock *sync.Mutex @@ -76,16 +80,21 @@ type Client struct { onHostDnsFn func([]string) dnsManager dns.IosDnsManager loginComplete bool - connectClient *internal.ConnectClient // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config + + stateMu sync.RWMutex + connectClient *internal.ConnectClient + config *profilemanager.Config } // NewClient instantiate a new Client -func NewClient(cfgFile, stateFile, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client { +func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client { return &Client{ cfgFile: cfgFile, stateFile: stateFile, + cacheDir: cacheDir, + logFilePath: logFilePath, deviceName: deviceName, osName: osName, osVersion: osVersion, @@ -162,8 +171,13 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { c.onHostDnsFn = func([]string) {} cfg.WgIface = interfaceName - c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder) - return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) + c.setState(cfg, connectClient) + // Persist the latest sync response so DebugBundle can include the network + // map. On iOS this is backed by disk to keep it out of the constrained + // process memory (see the syncstore package). + connectClient.SetSyncResponsePersistence(true) + return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath) } // Stop the internal client and free the resources @@ -175,6 +189,84 @@ func (c *Client) Stop() { } c.ctxCancel() + c.setState(nil, nil) +} + +// DebugBundle generates a debug bundle, uploads it and returns the upload key. +// It works with or without a running engine: when the engine is up it reuses +// the live config, sync response and client metrics; otherwise it loads the +// config from disk (or the preloaded tvOS config). +func (c *Client) DebugBundle(anonymize bool) (string, error) { + cfg, cc := c.stateSnapshot() + + // If the engine hasn't been started, load config so we can reach management. + if cfg == nil { + if c.preloadedConfig != nil { + cfg = c.preloadedConfig + } else { + var err error + // Use DirectUpdateOrCreateConfig to avoid atomic file operations + // (temp file + rename) blocked by the tvOS sandbox. + cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: c.cfgFile, + StateFilePath: c.stateFile, + }) + if err != nil { + return "", fmt.Errorf("load config: %w", err) + } + } + } + + deps := debug.GeneratorDependencies{ + InternalConfig: cfg, + StatusRecorder: c.recorder, + TempDir: c.cacheDir, + StatePath: c.stateFile, + LogPath: c.logFilePath, + } + + if cc != nil { + resp, err := cc.GetLatestSyncResponse() + if err != nil { + log.Warnf("get latest sync response: %v", err) + } + deps.SyncResponse = resp + + if e := cc.Engine(); e != nil { + if cm := e.GetClientMetrics(); cm != nil { + deps.ClientMetrics = cm + } + } + } + + bundleGenerator := debug.NewBundleGenerator( + deps, + debug.BundleConfig{ + Anonymize: anonymize, + IncludeSystemInfo: true, + }, + ) + + path, err := bundleGenerator.Generate() + if err != nil { + return "", fmt.Errorf("generate debug bundle: %w", err) + } + defer func() { + if err := os.Remove(path); err != nil { + log.Errorf("failed to remove debug bundle file: %v", err) + } + }() + + uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path) + if err != nil { + return "", fmt.Errorf("upload debug bundle: %w", err) + } + + log.Infof("debug bundle uploaded with key %s", key) + return key, nil } // SetTraceLogLevel configure the logger to trace level @@ -355,11 +447,12 @@ func (c *Client) ClearLoginComplete() { } func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) { - if c.connectClient == nil { + _, connectClient := c.stateSnapshot() + if connectClient == nil { return nil, fmt.Errorf("not connected") } - engine := c.connectClient.Engine() + engine := connectClient.Engine() if engine == nil { return nil, fmt.Errorf("not connected") } @@ -520,11 +613,12 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom } func (c *Client) SelectRoute(id string) error { - if c.connectClient == nil { + _, connectClient := c.stateSnapshot() + if connectClient == nil { return fmt.Errorf("not connected") } - engine := c.connectClient.Engine() + engine := connectClient.Engine() if engine == nil { return fmt.Errorf("not connected") } @@ -550,10 +644,11 @@ func (c *Client) SelectRoute(id string) error { } func (c *Client) DeselectRoute(id string) error { - if c.connectClient == nil { + _, connectClient := c.stateSnapshot() + if connectClient == nil { return fmt.Errorf("not connected") } - engine := c.connectClient.Engine() + engine := connectClient.Engine() if engine == nil { return fmt.Errorf("not connected") } @@ -577,6 +672,22 @@ func (c *Client) DeselectRoute(id string) error { return nil } +// setState stores the running engine state so DebugBundle can reuse the live +// config and ConnectClient. It is cleared on Stop. +func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + c.config = cfg + c.connectClient = cc +} + +// stateSnapshot returns the current config and ConnectClient under the lock. +func (c *Client) stateSnapshot() (*profilemanager.Config, *internal.ConnectClient) { + c.stateMu.RLock() + defer c.stateMu.RUnlock() + return c.config, c.connectClient +} + func formatDuration(d time.Duration) string { ds := d.String() dotIndex := strings.Index(ds, ".") From 6df01756079d3aa771ab335fe4e949ec48b78ef9 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 16 Jun 2026 16:15:19 +0200 Subject: [PATCH 08/16] [client] Add IsLoginRequiredCached for iOS mobile client (#6447) Expose a network-free login-required check backed by the in-memory status recorder. Unlike IsLoginRequired(), which creates a fresh auth client and performs a blocking network call, IsLoginRequiredCached() reports whether the LAST observed management error was an auth failure (PermissionDenied/ InvalidArgument). This lets the iOS connection listener detect a mid-session token expiry from within onDisconnected during teardown without blocking on a slow or unavailable network. --- client/ios/NetBirdSDK/client.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 132ee8d9d..359a83556 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -320,6 +320,16 @@ func (c *Client) RemoveConnectionListener() { c.recorder.RemoveConnectionListener() } +// IsLoginRequiredCached reports whether the LAST observed management error was an +// auth failure (PermissionDenied/InvalidArgument), using the in-memory status +// recorder. Unlike IsLoginRequired() it performs NO network call, so it is safe to +// call from the connection listener during teardown (e.g. onDisconnected) without +// blocking on a slow or unavailable network. Returns false while connected to +// management or when the last error was not auth-related. +func (c *Client) IsLoginRequiredCached() bool { + return c.recorder.IsLoginRequired() +} + func (c *Client) IsLoginRequired() bool { var ctx context.Context //nolint From 5095e17cc5ab3c960be07853e569934ed5552958 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:00:50 +0200 Subject: [PATCH 09/16] [management] fix flaky Test_SaveAccount_Large from random IP collision (#6452) --- management/server/store/sql_store_test.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 0c90eaf5f..ac136987e 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -6,7 +6,6 @@ import ( b64 "encoding/base64" "encoding/binary" "fmt" - "math/rand" "net" "net/netip" "os" @@ -92,7 +91,7 @@ func runLargeTest(t *testing.T, store Store) { account.SetupKeys[setupKey.Key] = setupKey const numPerAccount = 6000 for n := 0; n < numPerAccount; n++ { - netIP := randomIPv4() + netIP := sequentialIPv4(n) peerID := fmt.Sprintf("%s-peer-%d", account.Id, n) addr, _ := netip.AddrFromSlice(netIP) @@ -216,12 +215,12 @@ func runLargeTest(t *testing.T, store Store) { } } -func randomIPv4() net.IP { - rand.New(rand.NewSource(time.Now().UnixNano())) +// sequentialIPv4 returns a unique IPv4 address for the given index, avoiding +// the random collisions that would otherwise violate the unique (account_id, ip) +// index when generating a large number of peers. +func sequentialIPv4(n int) net.IP { b := make([]byte, 4) - for i := range b { - b[i] = byte(rand.Intn(256)) - } + binary.BigEndian.PutUint32(b, 0x0A000000+uint32(n)) return net.IP(b) } From 6fbc90b4d376f6f8e82d43e71d8f9ae938b5c8ed Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:41:48 +0900 Subject: [PATCH 10/16] [client, relay] Expose relay transport and connection errors in status and metrics (#6342) --- client/internal/peer/status.go | 26 +++++--- client/internal/relay/relay.go | 3 + client/proto/daemon.pb.go | 23 +++++-- client/proto/daemon.proto | 3 + client/status/status.go | 12 +++- client/status/status_test.go | 10 +++ relay/metrics/realy.go | 9 +-- relay/server/listener/conn.go | 2 + relay/server/listener/quic/conn.go | 5 ++ relay/server/listener/ws/conn.go | 5 ++ relay/server/relay.go | 5 +- shared/relay/client/client.go | 21 ++++++ shared/relay/client/dialer/quic/conn.go | 5 ++ shared/relay/client/dialer/quic/quic.go | 8 +-- shared/relay/client/dialer/race_dialer.go | 38 +++++++++-- shared/relay/client/dialer/ws/conn.go | 5 ++ shared/relay/client/dialer/ws/ws.go | 9 ++- shared/relay/client/dialers_generic_test.go | 18 +++--- shared/relay/client/guard.go | 22 +++++++ shared/relay/client/manager.go | 72 +++++++++++++++++++++ shared/relay/client/picker.go | 22 +++++-- 21 files changed, 277 insertions(+), 46 deletions(-) diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 31e0d6e25..3e5c56dd2 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -1024,14 +1024,17 @@ func (d *Status) GetRelayStates() []relay.ProbeResult { return d.relayStates } - // extend the list of stun, turn servers with relay address + // extend the list of stun, turn servers with the relay server connections relayStates := slices.Clone(d.relayStates) - // if the server connection is not established then we will use the general address - // in case of connection we will use the instance specific address - instanceAddr, _, err := d.relayMgr.RelayInstanceAddress() - if err != nil { - // TODO add their status + states := d.relayMgr.RelayStates() + if len(states) == 0 { + // no relay connection tracked yet; surface configured servers as + // unavailable with the real reconnect error when known + err := relayClient.ErrRelayClientNotConnected + if connErr := d.relayMgr.RelayConnectError(); connErr != nil { + err = connErr + } for _, r := range d.relayMgr.ServerURLs() { relayStates = append(relayStates, relay.ProbeResult{ URI: r, @@ -1041,10 +1044,14 @@ func (d *Status) GetRelayStates() []relay.ProbeResult { return relayStates } - relayState := relay.ProbeResult{ - URI: instanceAddr, + for _, rs := range states { + relayStates = append(relayStates, relay.ProbeResult{ + URI: rs.URL, + Err: rs.Err, + Transport: rs.Transport, + }) } - return append(relayStates, relayState) + return relayStates } func (d *Status) ForwardingRules() []firewall.ForwardRule { @@ -1405,6 +1412,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus { pbRelayState := &proto.RelayState{ URI: relayState.URI, Available: relayState.Err == nil, + Transport: relayState.Transport, } if err := relayState.Err; err != nil { pbRelayState.Error = err.Error() diff --git a/client/internal/relay/relay.go b/client/internal/relay/relay.go index f00a8d93a..051717608 100644 --- a/client/internal/relay/relay.go +++ b/client/internal/relay/relay.go @@ -32,6 +32,9 @@ type ProbeResult struct { URI string Err error Addr string + // Transport is the negotiated relay transport, empty + // for stun/turn probes or when not connected. + Transport string } type StunTurnProbe struct { diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 70d9e8212..6b5a37658 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1849,10 +1849,13 @@ func (x *ManagementState) GetError() string { // RelayState contains the latest state of the relay type RelayState struct { - state protoimpl.MessageState `protogen:"open.v1"` - URI string `protobuf:"bytes,1,opt,name=URI,proto3" json:"URI,omitempty"` - Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"` - Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + URI string `protobuf:"bytes,1,opt,name=URI,proto3" json:"URI,omitempty"` + Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + // transport is the negotiated relay transport (e.g. "ws", "quic"), + // empty for stun/turn probes or when not connected. + Transport string `protobuf:"bytes,4,opt,name=transport,proto3" json:"transport,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1908,6 +1911,13 @@ func (x *RelayState) GetError() string { return "" } +func (x *RelayState) GetTransport() string { + if x != nil { + return x.Transport + } + return "" +} + type NSGroupState struct { state protoimpl.MessageState `protogen:"open.v1"` Servers []string `protobuf:"bytes,1,rep,name=servers,proto3" json:"servers,omitempty"` @@ -6486,12 +6496,13 @@ const file_daemon_proto_rawDesc = "" + "\x0fManagementState\x12\x10\n" + "\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" + "\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\"R\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"p\n" + "\n" + "RelayState\x12\x10\n" + "\x03URI\x18\x01 \x01(\tR\x03URI\x12\x1c\n" + "\tavailable\x18\x02 \x01(\bR\tavailable\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\"r\n" + + "\x05error\x18\x03 \x01(\tR\x05error\x12\x1c\n" + + "\ttransport\x18\x04 \x01(\tR\ttransport\"r\n" + "\fNSGroupState\x12\x18\n" + "\aservers\x18\x01 \x03(\tR\aservers\x12\x18\n" + "\adomains\x18\x02 \x03(\tR\adomains\x12\x18\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 265ab40bb..ea668f629 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -378,6 +378,9 @@ message RelayState { string URI = 1; bool available = 2; string error = 3; + // transport is the negotiated relay transport (e.g. "ws", "quic"), + // empty for stun/turn probes or when not connected. + string transport = 4; } message NSGroupState { diff --git a/client/status/status.go b/client/status/status.go index e7e8ee11c..5b815aaa3 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -98,6 +98,7 @@ type RelayStateOutputDetail struct { URI string `json:"uri" yaml:"uri"` Available bool `json:"available" yaml:"available"` Error string `json:"error" yaml:"error"` + Transport string `json:"transport,omitempty" yaml:"transport,omitempty"` } type RelayStateOutput struct { @@ -219,7 +220,8 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput { RelayStateOutputDetail{ URI: relay.URI, Available: available, - Error: relay.GetError(), + Error: relayErrorString(relay.GetError()), + Transport: relay.GetTransport(), }, ) @@ -235,6 +237,12 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput { } } +// relayErrorString flattens a newline-joined aggregated relay error onto a +// single line for status output. +func relayErrorString(s string) string { + return strings.ReplaceAll(s, "\n", "; ") +} + func mapNSGroups(servers []*proto.NSGroupState) []NsServerGroupStateOutput { mappedNSGroups := make([]NsServerGroupStateOutput, 0, len(servers)) for _, pbNsGroupServer := range servers { @@ -441,6 +449,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS available = "Unavailable" reason = fmt.Sprintf(", reason: %s", relay.Error) } + } else if relay.Transport != "" { + available = fmt.Sprintf("%s via %s", available, relay.Transport) } relaysString += fmt.Sprintf("\n [%s] is %s%s", relay.URI, available, reason) diff --git a/client/status/status_test.go b/client/status/status_test.go index 1ae7157c0..44fc30baf 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -647,3 +647,13 @@ func TestTimeAgo(t *testing.T) { }) } } + +func TestMapRelaysTransport(t *testing.T) { + out := mapRelays([]*proto.RelayState{ + {URI: "rels://relay.example:443", Available: true, Transport: "quic"}, + {URI: "rels://relay2.example:443", Available: true, Transport: "ws"}, + }) + require.Len(t, out.Details, 2) + assert.Equal(t, "quic", out.Details[0].Transport) + assert.Equal(t, "ws", out.Details[1].Transport) +} diff --git a/relay/metrics/realy.go b/relay/metrics/realy.go index efb597ff5..49a357557 100644 --- a/relay/metrics/realy.go +++ b/relay/metrics/realy.go @@ -6,6 +6,7 @@ import ( "time" log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" ) @@ -119,8 +120,8 @@ func NewMetrics(ctx context.Context, meter metric.Meter) (*Metrics, error) { } // PeerConnected increments the number of connected peers and increments number of idle connections -func (m *Metrics) PeerConnected(id string) { - m.peers.Add(m.ctx, 1) +func (m *Metrics) PeerConnected(id, transport string) { + m.peers.Add(m.ctx, 1, metric.WithAttributes(attribute.String("transport", transport))) m.mutexActivity.Lock() defer m.mutexActivity.Unlock() @@ -138,8 +139,8 @@ func (m *Metrics) RecordPeerStoreTime(duration time.Duration) { } // PeerDisconnected decrements the number of connected peers and decrements number of idle or active connections -func (m *Metrics) PeerDisconnected(id string) { - m.peers.Add(m.ctx, -1) +func (m *Metrics) PeerDisconnected(id, transport string) { + m.peers.Add(m.ctx, -1, metric.WithAttributes(attribute.String("transport", transport))) m.mutexActivity.Lock() defer m.mutexActivity.Unlock() diff --git a/relay/server/listener/conn.go b/relay/server/listener/conn.go index ef0869594..d86f7f58b 100644 --- a/relay/server/listener/conn.go +++ b/relay/server/listener/conn.go @@ -11,4 +11,6 @@ type Conn interface { Write(ctx context.Context, b []byte) (n int, err error) RemoteAddr() net.Addr Close() error + // Protocol returns the transport name. + Protocol() string } diff --git a/relay/server/listener/quic/conn.go b/relay/server/listener/quic/conn.go index d8dafcd1f..da5e12d36 100644 --- a/relay/server/listener/quic/conn.go +++ b/relay/server/listener/quic/conn.go @@ -42,6 +42,11 @@ func (c *Conn) RemoteAddr() net.Addr { return c.session.RemoteAddr() } +// Protocol returns the transport name for this connection. +func (c *Conn) Protocol() string { + return "quic" +} + func (c *Conn) Close() error { c.closedMu.Lock() if c.closed { diff --git a/relay/server/listener/ws/conn.go b/relay/server/listener/ws/conn.go index c22b5719d..b1b64fe8e 100644 --- a/relay/server/listener/ws/conn.go +++ b/relay/server/listener/ws/conn.go @@ -64,6 +64,11 @@ func (c *Conn) RemoteAddr() net.Addr { return c.rAddr } +// Protocol returns the transport name for this connection. +func (c *Conn) Protocol() string { + return "ws" +} + func (c *Conn) Close() error { c.closedMu.Lock() c.closed = true diff --git a/relay/server/relay.go b/relay/server/relay.go index 56add8bea..84c424b8e 100644 --- a/relay/server/relay.go +++ b/relay/server/relay.go @@ -154,15 +154,16 @@ func (r *Relay) Accept(conn listener.Conn) { } r.notifier.PeerCameOnline(peer.ID()) + transport := conn.Protocol() r.metrics.RecordPeerStoreTime(time.Since(storeTime)) - r.metrics.PeerConnected(peer.String()) + r.metrics.PeerConnected(peer.String(), transport) go func() { peer.Work() if deleted := r.store.DeletePeer(peer); deleted { r.notifier.PeerWentOffline(peer.ID()) } peer.log.Debugf("relay connection closed") - r.metrics.PeerDisconnected(peer.String()) + r.metrics.PeerDisconnected(peer.String(), transport) }() if err := h.handshakeResponse(hsCtx); err != nil { diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go index 002b8d134..8d4aa6020 100644 --- a/shared/relay/client/client.go +++ b/shared/relay/client/client.go @@ -145,6 +145,11 @@ func (cc *connContainer) close() { } } +// transportConn is implemented by relay connections that know their transport. +type transportConn interface { + Protocol() string +} + // Client is a client for the relay server. It is responsible for establishing a connection to the relay server and // managing connections to other peers. All exported functions are safe to call concurrently. After close the connection, // the client can be reused by calling Connect again. When the client is closed, all connections are closed too. @@ -182,6 +187,18 @@ type Client struct { // datagramFallbackTriggered guards a single fallback per connection so a // burst of oversized datagrams triggers one reconnect, not many. datagramFallbackTriggered atomic.Bool + + // transport is the negotiated relay transport of the + // current connection, guarded by mu. + transport string +} + +// Transport returns the negotiated relay transport of the current connection, +// or an empty string when not connected. +func (c *Client) Transport() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.transport } // SetTransportFallback wires the shared datagram-transport fallback tracker. @@ -402,6 +419,9 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) { } c.relayConn = conn c.datagramFallbackTriggered.Store(false) + if tc, ok := conn.(transportConn); ok { + c.transport = tc.Protocol() + } instanceURL, err := c.handShake(ctx) if err != nil { @@ -792,6 +812,7 @@ func (c *Client) close(gracefullyExit bool) error { return nil } c.serviceIsRunning = false + c.transport = "" c.muInstanceURL.Lock() c.instanceURL = nil diff --git a/shared/relay/client/dialer/quic/conn.go b/shared/relay/client/dialer/quic/conn.go index a5c982551..e5ad77b29 100644 --- a/shared/relay/client/dialer/quic/conn.go +++ b/shared/relay/client/dialer/quic/conn.go @@ -57,6 +57,11 @@ func (c *Conn) Write(b []byte) (int, error) { return len(b), nil } +// Protocol returns the transport name for this connection. +func (c *Conn) Protocol() string { + return Network +} + func (c *Conn) RemoteAddr() net.Addr { return c.session.RemoteAddr() } diff --git a/shared/relay/client/dialer/quic/quic.go b/shared/relay/client/dialer/quic/quic.go index 5e1758a1c..2e8de8af3 100644 --- a/shared/relay/client/dialer/quic/quic.go +++ b/shared/relay/client/dialer/quic/quic.go @@ -59,14 +59,12 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{Port: 0}) if err != nil { - log.Errorf("failed to listen on UDP: %s", err) - return nil, err + return nil, fmt.Errorf("listen udp: %w", err) } udpAddr, err := net.ResolveUDPAddr("udp", quicURL) if err != nil { - log.Errorf("failed to resolve UDP address: %s", err) - return nil, err + return nil, fmt.Errorf("resolve %s: %w", quicURL, err) } session, err := quic.Dial(ctx, udpConn, udpAddr, tlsClientConfig, quicConfig) @@ -74,7 +72,7 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, if errors.Is(err, context.Canceled) { return nil, err } - log.Errorf("failed to dial to Relay server via QUIC '%s': %s", quicURL, err) + log.Debugf("failed to dial to Relay server via QUIC '%s': %s", quicURL, err) return nil, err } diff --git a/shared/relay/client/dialer/race_dialer.go b/shared/relay/client/dialer/race_dialer.go index aef1ef464..d183802d0 100644 --- a/shared/relay/client/dialer/race_dialer.go +++ b/shared/relay/client/dialer/race_dialer.go @@ -3,6 +3,7 @@ package dialer import ( "context" "errors" + "fmt" "net" "time" @@ -71,6 +72,7 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) { connChan := make(chan dialResult, len(r.dialerFns)) winnerConn := make(chan net.Conn, 1) + errChan := make(chan error, 1) abortCtx, abort := context.WithCancel(ctx) defer abort() @@ -78,11 +80,11 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) { go r.dial(dfn, abortCtx, connChan) } - go r.processResults(connChan, winnerConn, abort) + go r.processResults(connChan, winnerConn, errChan, abort) conn, ok := <-winnerConn if !ok { - return nil, errors.New("failed to dial to Relay server on any protocol") + return nil, <-errChan } return conn, nil } @@ -90,6 +92,7 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) { // dialSequential tries each dialer in order, returning the first connection and // falling back to the next on failure. func (r *RaceDial) dialSequential(ctx context.Context) (net.Conn, error) { + var errs []error for _, dfn := range r.dialerFns { if err := ctx.Err(); err != nil { return nil, err @@ -103,12 +106,13 @@ func (r *RaceDial) dialSequential(ctx context.Context) (net.Conn, error) { return nil, err } r.log.Errorf("failed to dial via %s: %s", dfn.Protocol(), err) + errs = append(errs, fmt.Errorf("%s: %w", dfn.Protocol(), err)) continue } r.log.Infof("successfully dialed via: %s", dfn.Protocol()) return conn, nil } - return nil, errors.New("failed to dial to Relay server on any protocol") + return nil, dialErr(errs) } func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dialResult) { @@ -120,8 +124,9 @@ func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dia connChan <- dialResult{Conn: conn, Protocol: dfn.Protocol(), Err: err} } -func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.Conn, abort context.CancelFunc) { +func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net.Conn, errChan chan error, abort context.CancelFunc) { var hasWinner bool + errsByProtocol := make(map[string]error) for i := 0; i < len(r.dialerFns); i++ { dr := <-connChan if dr.Err != nil { @@ -129,6 +134,7 @@ func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net. r.log.Infof("connection attempt aborted via: %s", dr.Protocol) } else { r.log.Errorf("failed to dial via %s: %s", dr.Protocol, dr.Err) + errsByProtocol[dr.Protocol] = fmt.Errorf("%s: %w", dr.Protocol, dr.Err) } continue } @@ -146,5 +152,29 @@ func (r *RaceDial) processResults(connChan chan dialResult, winnerConn chan net. hasWinner = true winnerConn <- dr.Conn } + if !hasWinner { + errChan <- dialErr(r.orderedErrs(errsByProtocol)) + } close(winnerConn) } + +// orderedErrs returns the per-protocol errors in dialer order, so the combined +// error is stable regardless of which attempt failed first. +func (r *RaceDial) orderedErrs(byProtocol map[string]error) []error { + errs := make([]error, 0, len(byProtocol)) + for _, dfn := range r.dialerFns { + if err, ok := byProtocol[dfn.Protocol()]; ok { + errs = append(errs, err) + } + } + return errs +} + +// dialErr combines per-dialer failures, preserving the underlying reasons +// (e.g. "connection refused") rather than a generic message. +func dialErr(errs []error) error { + if len(errs) == 0 { + return errors.New("no relay transport available") + } + return errors.Join(errs...) +} diff --git a/shared/relay/client/dialer/ws/conn.go b/shared/relay/client/dialer/ws/conn.go index 9497fab89..eec417c50 100644 --- a/shared/relay/client/dialer/ws/conn.go +++ b/shared/relay/client/dialer/ws/conn.go @@ -33,6 +33,11 @@ func NewConn(wsConn *websocket.Conn, serverAddress string, underlying net.Conn) } } +// Protocol returns the transport name for this connection. +func (c *Conn) Protocol() string { + return Network +} + func (c *Conn) Read(b []byte) (n int, err error) { t, ioReader, err := c.Conn.Reader(c.ctx) if err != nil { diff --git a/shared/relay/client/dialer/ws/ws.go b/shared/relay/client/dialer/ws/ws.go index 8a13ba126..6b310b73d 100644 --- a/shared/relay/client/dialer/ws/ws.go +++ b/shared/relay/client/dialer/ws/ws.go @@ -22,7 +22,7 @@ type Dialer struct { } func (d Dialer) Protocol() string { - return "WS" + return Network } func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) { @@ -39,7 +39,12 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, if errors.Is(err, context.Canceled) { return nil, err } - log.Errorf("failed to dial to Relay server '%s': %s", wsURL, err) + // websocket.Dial wraps the cause in verbose layers; surface the + // underlying network error when present. + var opErr *net.OpError + if errors.As(err, &opErr) { + return nil, opErr + } return nil, err } if resp.Body != nil { diff --git a/shared/relay/client/dialers_generic_test.go b/shared/relay/client/dialers_generic_test.go index c4ef9cc59..f6c885108 100644 --- a/shared/relay/client/dialers_generic_test.go +++ b/shared/relay/client/dialers_generic_test.go @@ -41,14 +41,14 @@ func TestGetDialers(t *testing.T) { preferWS bool want []string }{ - {name: "auto races quic and ws", mode: "auto", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}}, - {name: "ws pinned", mode: "ws", mtu: iface.DefaultMTU, want: []string{"WS"}}, + {name: "auto races quic and ws", mode: "auto", mtu: iface.DefaultMTU, want: []string{"quic", "ws"}}, + {name: "ws pinned", mode: "ws", mtu: iface.DefaultMTU, want: []string{"ws"}}, {name: "quic pinned", mode: "quic", mtu: iface.DefaultMTU, want: []string{"quic"}}, - {name: "prefer-quic orders quic first", mode: "prefer-quic", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}}, - {name: "prefer-ws orders ws first", mode: "prefer-ws", mtu: iface.DefaultMTU, want: []string{"WS", "quic"}}, - {name: "mtu above default forces ws", mode: "auto", mtu: iface.DefaultMTU + 100, want: []string{"WS"}}, - {name: "sticky fallback forces ws in auto", mode: "auto", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}}, - {name: "sticky fallback forces ws in prefer-quic", mode: "prefer-quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}}, + {name: "prefer-quic orders quic first", mode: "prefer-quic", mtu: iface.DefaultMTU, want: []string{"quic", "ws"}}, + {name: "prefer-ws orders ws first", mode: "prefer-ws", mtu: iface.DefaultMTU, want: []string{"ws", "quic"}}, + {name: "mtu above default forces ws", mode: "auto", mtu: iface.DefaultMTU + 100, want: []string{"ws"}}, + {name: "sticky fallback forces ws in auto", mode: "auto", mtu: iface.DefaultMTU, preferWS: true, want: []string{"ws"}}, + {name: "sticky fallback forces ws in prefer-quic", mode: "prefer-quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"ws"}}, {name: "quic pin overrides sticky fallback", mode: "quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"quic"}}, } @@ -91,11 +91,11 @@ func TestStickyFallbackAfterDatagramTooLarge(t *testing.T) { } // First dial races both transports. - assert.Equal(t, []string{"quic", "WS"}, protocols(c.getDialers(transportModeFromEnv()))) + assert.Equal(t, []string{"quic", "ws"}, protocols(c.getDialers(transportModeFromEnv()))) // An oversized datagram records the fallback for this server. c.onDatagramTooLarge(&closeTrackingConn{}, netErr.ErrDatagramTooLarge) // The reconnect now sticks to WebSocket. - assert.Equal(t, []string{"WS"}, protocols(c.getDialers(transportModeFromEnv()))) + assert.Equal(t, []string{"ws"}, protocols(c.getDialers(transportModeFromEnv()))) } diff --git a/shared/relay/client/guard.go b/shared/relay/client/guard.go index d7892d0ce..98b1b333e 100644 --- a/shared/relay/client/guard.go +++ b/shared/relay/client/guard.go @@ -2,6 +2,7 @@ package client import ( "context" + "sync/atomic" "time" "github.com/cenkalti/backoff/v4" @@ -20,6 +21,10 @@ type Guard struct { // maxBackoffInterval caps the exponential backoff between reconnect // attempts. maxBackoffInterval time.Duration + + // lastErr is the error from the most recent failed reconnect attempt, + // surfaced as the home relay status while disconnected. + lastErr atomic.Pointer[error] } // NewGuard creates a new guard for the relay client. A non-positive @@ -37,6 +42,15 @@ func NewGuard(sp *ServerPicker, maxBackoffInterval time.Duration) *Guard { return g } +// LastError returns the error from the most recent failed reconnect attempt, or +// nil if reconnection last succeeded. +func (g *Guard) LastError() error { + if p := g.lastErr.Load(); p != nil { + return *p + } + return nil +} + // StartReconnectTrys is called when the relay client is disconnected from the relay server. // It attempts to reconnect to the relay server. The function first tries a quick reconnect // to the same server that was used before, if the server URL is still valid. If the quick @@ -63,6 +77,7 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) { case <-ticker.C: if err := g.retry(ctx); err != nil { log.Errorf("failed to pick new Relay server: %s", err) + g.setLastError(err) continue } return @@ -72,6 +87,10 @@ func (g *Guard) StartReconnectTrys(ctx context.Context, relayClient *Client) { } } +func (g *Guard) setLastError(err error) { + g.lastErr.Store(&err) +} + func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool { if rc == nil { return false @@ -89,6 +108,7 @@ func (g *Guard) tryToQuickReconnect(parentCtx context.Context, rc *Client) bool if err := rc.Connect(parentCtx); err != nil { log.Errorf("failed to reconnect to relay server: %s", err) + g.setLastError(err) return false } return true @@ -100,6 +120,7 @@ func (g *Guard) retry(ctx context.Context) error { if err != nil { return err } + g.setLastError(nil) // prevent to work with a deprecated Relay client instance g.drainRelayClientChan() @@ -125,6 +146,7 @@ func (g *Guard) isServerURLStillValid(rc *Client) bool { } func (g *Guard) notifyReconnected() { + g.setLastError(nil) select { case g.OnReconnected <- struct{}{}: default: diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go index f87da15de..e1515401e 100644 --- a/shared/relay/client/manager.go +++ b/shared/relay/client/manager.go @@ -43,6 +43,17 @@ type OnServerCloseListener func() // ManagerOption configures a Manager at construction time. type ManagerOption func(*Manager) +// RelayConnState is the connection state of a single relay server. +type RelayConnState struct { + // URL is the server's instance address when connected, otherwise the + // configured server URL. + URL string + // Transport is the negotiated transport, empty if not connected. + Transport string + // Err is set when the relay is not connected. + Err error +} + // WithMaxBackoffInterval caps the exponential backoff between reconnect // attempts to the home relay. A non-positive value keeps the default. func WithMaxBackoffInterval(d time.Duration) ManagerOption { @@ -130,6 +141,9 @@ func (m *Manager) Serve() error { client, err := m.serverPicker.PickServer(m.ctx) if err != nil { + // record the initial failure so status shows the real reason before + // the guard's first retry tick + m.reconnectGuard.setLastError(err) go m.reconnectGuard.StartReconnectTrys(m.ctx, nil) } else { m.storeClient(client) @@ -242,6 +256,56 @@ func (m *Manager) ServerURLs() []string { return m.serverPicker.ServerURLs.Load().([]string) } +// RelayConnectError returns the error from the most recent failed home relay +// reconnect attempt, or nil if the relay last connected successfully. +func (m *Manager) RelayConnectError() error { + return m.reconnectGuard.LastError() +} + +// RelayStates returns the connection state of the home relay and every foreign +// relay the manager currently tracks. +func (m *Manager) RelayStates() []RelayConnState { + var states []RelayConnState + + m.relayClientMu.RLock() + home := m.relayClient + m.relayClientMu.RUnlock() + if home != nil { + st := relayConnState(home) + // The home relay reconnects through the guard, so the real failure + // reason lives there rather than on the (stale) client. + if st.Err != nil { + if gErr := m.reconnectGuard.LastError(); gErr != nil { + st.Err = gErr + } + } + states = append(states, st) + } + + // Snapshot the tracks, then query each outside the map lock: a track can be + // held by an in-progress Connect, and blocking on it must not stall other + // relay operations. + m.relayClientsMutex.RLock() + tracks := make([]*RelayTrack, 0, len(m.relayClients)) + for _, rt := range m.relayClients { + tracks = append(tracks, rt) + } + m.relayClientsMutex.RUnlock() + + // Only connected foreign relays carry state; a failed connect is evicted + // immediately (openConnVia), so there is no error state to surface. + for _, rt := range tracks { + rt.RLock() + rc := rt.relayClient + rt.RUnlock() + if rc != nil { + states = append(states, relayConnState(rc)) + } + } + + return states +} + // HasRelayAddress returns true if the manager is serving. With this method can check if the peer can communicate with // Relay service. func (m *Manager) HasRelayAddress() bool { @@ -460,3 +524,11 @@ func (m *Manager) notifyOnDisconnectListeners(serverAddress string) { } delete(m.onDisconnectedListeners, serverAddress) } + +func relayConnState(c *Client) RelayConnState { + addr, err := c.ServerInstanceURL() + if err != nil { + return RelayConnState{URL: c.connectionURL, Err: err} + } + return RelayConnState{URL: addr, Transport: c.Transport()} +} diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go index 992e48114..bb721e4ad 100644 --- a/shared/relay/client/picker.go +++ b/shared/relay/client/picker.go @@ -40,6 +40,7 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) { connResultChan := make(chan connResult, totalServers) successChan := make(chan connResult, 1) + errChan := make(chan error, 1) concurrentLimiter := make(chan struct{}, maxConcurrentServers) log.Debugf("pick server from list: %v", sp.ServerURLs.Load().([]string)) @@ -54,17 +55,17 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) { }(url) } - go sp.processConnResults(connResultChan, successChan) + go sp.processConnResults(connResultChan, successChan, errChan) select { case cr, ok := <-successChan: if !ok { - return nil, errors.New("failed to connect to any relay server: all attempts failed") + return nil, <-errChan } log.Infof("chosen home Relay server: %s", cr.Url) return cr.RelayClient, nil case <-ctx.Done(): - return nil, fmt.Errorf("failed to connect to any relay server: %w", ctx.Err()) + return nil, fmt.Errorf("connect to relay server: %w", ctx.Err()) } } @@ -80,12 +81,14 @@ func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan con } } -func (sp *ServerPicker) processConnResults(resultChan chan connResult, successChan chan connResult) { +func (sp *ServerPicker) processConnResults(resultChan chan connResult, successChan chan connResult, errChan chan error) { var hasSuccess bool + var errs []error for numOfResults := 0; numOfResults < cap(resultChan); numOfResults++ { cr := <-resultChan if cr.Err != nil { log.Tracef("failed to connect to Relay server: %s: %v", cr.Url, cr.Err) + errs = append(errs, cr.Err) continue } log.Infof("connected to Relay server: %s", cr.Url) @@ -101,5 +104,16 @@ func (sp *ServerPicker) processConnResults(resultChan chan connResult, successCh hasSuccess = true successChan <- cr } + if !hasSuccess { + errChan <- pickErr(errs) + } close(successChan) } + +// pickErr combines per-server connection failures into a single error. +func pickErr(errs []error) error { + if len(errs) == 0 { + return errors.New("no relay server available") + } + return errors.Join(errs...) +} From e4397d4d4614295343e9935b51b9bd10e0b794f1 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:37:24 +0200 Subject: [PATCH 11/16] [management] remove nmap calc from login (#6449) --- .../network_map/controller/controller.go | 26 ++-- .../controllers/network_map/interface.go | 2 +- .../controllers/network_map/interface_mock.go | 19 ++- management/internals/modules/peers/manager.go | 2 +- management/internals/shared/grpc/server.go | 8 +- management/server/account/manager.go | 4 +- management/server/account/manager_mock.go | 18 ++- management/server/account_test.go | 28 ++-- management/server/affected_peers_test.go | 2 +- management/server/dns_test.go | 4 +- management/server/group_ipv6_test.go | 2 +- .../http/handlers/peers/peers_handler.go | 2 +- management/server/management_proto_test.go | 4 +- management/server/mock_server/account_mock.go | 12 +- management/server/nameserver_test.go | 4 +- management/server/peer.go | 138 ++++++++++-------- management/server/peer_test.go | 64 ++++---- management/server/types/account.go | 41 ++++++ .../networkmap_components_correctness_test.go | 94 ++++++++++++ management/server/user_test.go | 2 +- 20 files changed, 318 insertions(+), 158 deletions(-) diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 9adf594cd..d271c499d 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -585,66 +585,66 @@ func (b *bufferAffectedUpdate) setTimer(d time.Duration, f func()) { 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) { +func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) { if isRequiresApproval { network, err := c.repo.GetAccountNetwork(ctx, accountID) if err != nil { - return nil, nil, nil, 0, err + return nil, nil, 0, err } emptyMap := &types.NetworkMap{ Network: network.Copy(), } - return peer, emptyMap, nil, 0, nil + return emptyMap, nil, 0, nil } account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) if err != nil { - return nil, nil, nil, 0, err + return nil, nil, 0, err } account.InjectProxyPolicies(ctx) approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) if err != nil { - return nil, nil, nil, 0, err + return nil, nil, 0, err } startPosture := time.Now() - postureChecks, err := c.getPeerPostureChecks(account, peer.ID) + postureChecks, err := c.getPeerPostureChecks(account, peerID) if err != nil { - return nil, nil, nil, 0, err + return nil, nil, 0, err } log.WithContext(ctx).Debugf("getPeerPostureChecks took %s", time.Since(startPosture)) accountZones, err := c.repo.GetAccountZones(ctx, account.Id) if err != nil { log.WithContext(ctx).Errorf("failed to get account zones: %v", err) - return nil, nil, nil, 0, err + return nil, nil, 0, err } dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers) + proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers) if err != nil { log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return nil, nil, nil, 0, err + return nil, nil, 0, err } resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() - networkMap := account.GetPeerNetworkMapFromComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) - proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] + proxyNetworkMap, ok := proxyNetworkMaps[peerID] if ok { networkMap.Merge(proxyNetworkMap) } dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) - return peer, networkMap, postureChecks, dnsFwdPort, nil + return networkMap, postureChecks, dnsFwdPort, nil } // GetDNSDomain returns the configured dnsDomain diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index dbdd87708..14b12aba6 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -23,7 +23,7 @@ type Controller interface { 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) + GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) GetDNSDomain(settings *types.Settings) string StartWarmup(context.Context) GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go index a67156719..bfff32e6f 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -127,21 +127,20 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal } // GetValidatedPeerWithMap mocks base method. -func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { +func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, p) - ret0, _ := ret[0].(*peer.Peer) - ret1, _ := ret[1].(*types.NetworkMap) - ret2, _ := ret[2].([]*posture.Checks) - ret3, _ := ret[3].(int64) - ret4, _ := ret[4].(error) - return ret0, ret1, ret2, ret3, ret4 + ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID) + ret0, _ := ret[0].(*types.NetworkMap) + ret1, _ := ret[1].([]*posture.Checks) + ret2, _ := ret[2].(int64) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 } // GetValidatedPeerWithMap indicates an expected call of GetValidatedPeerWithMap. -func (mr *MockControllerMockRecorder) GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, p any) *gomock.Call { +func (mr *MockControllerMockRecorder) GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithMap", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithMap), ctx, isRequiresApproval, accountID, p) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithMap", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithMap), ctx, isRequiresApproval, accountID, peerID) } // OnPeerConnected mocks base method. diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 8f3253063..e22d1e6e0 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -242,7 +242,7 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee }, } - _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, true) + _, _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, true) if err != nil { return fmt.Errorf("failed to create proxy peer: %w", err) } diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 2d19ca32b..7283cae6c 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -778,7 +778,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto sshKey = loginReq.GetPeerKeys().GetSshPubKey() } - peer, netMap, postureChecks, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{ + peer, network, postureChecks, enableSSH, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{ WireGuardPubKey: peerKey.String(), SSHKey: string(sshKey), Meta: peerMeta, @@ -792,7 +792,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto return nil, mapError(ctx, err) } - loginResp, err := s.prepareLoginResponse(ctx, peer, netMap, postureChecks) + loginResp, err := s.prepareLoginResponse(ctx, peer, network, postureChecks, enableSSH) if err != nil { log.WithContext(ctx).Warnf("failed preparing login response for peer %s: %s", peerKey, err) return nil, status.Errorf(codes.Internal, "failed logging in peer") @@ -895,7 +895,7 @@ func (s *Server) ExtendAuthSession(ctx context.Context, req *proto.EncryptedMess }, nil } -func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, netMap *types.NetworkMap, postureChecks []*posture.Checks) (*proto.LoginResponse, error) { +func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, network *types.Network, postureChecks []*posture.Checks, enableSSH bool) (*proto.LoginResponse, error) { var relayToken *Token var err error if s.config.Relay != nil && len(s.config.Relay.Addresses) > 0 { @@ -914,7 +914,7 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne // if peer has reached this point then it has logged in loginResp := &proto.LoginResponse{ NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil), - PeerConfig: toPeerConfig(peer, netMap.Network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, netMap.EnableSSH), + PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH), Checks: toProtocolChecks(ctx, postureChecks), } diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 2fdfdba5a..784e432f6 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -70,7 +70,7 @@ type Manager interface { UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error) - AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) + AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) CreatePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error) @@ -109,7 +109,7 @@ type Manager interface { GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) 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 + LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) // used by peer gRPC API 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 diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index 0e06ebf91..145e6e00f 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -80,14 +80,15 @@ func (mr *MockManagerMockRecorder) AccountExists(ctx, accountID interface{}) *go } // AddPeer mocks base method. -func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "AddPeer", ctx, accountID, setupKey, userID, p, temporary) ret0, _ := ret[0].(*peer.Peer) - ret1, _ := ret[1].(*types.NetworkMap) + ret1, _ := ret[1].(*types.Network) ret2, _ := ret[2].([]*posture.Checks) - ret3, _ := ret[3].(error) - return ret0, ret1, ret2, ret3 + ret3, _ := ret[3].(bool) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 } // AddPeer indicates an expected call of AddPeer. @@ -1289,14 +1290,15 @@ func (mr *MockManagerMockRecorder) ListUsers(ctx, accountID interface{}) *gomock } // LoginPeer mocks base method. -func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "LoginPeer", ctx, login) ret0, _ := ret[0].(*peer.Peer) - ret1, _ := ret[1].(*types.NetworkMap) + ret1, _ := ret[1].(*types.Network) ret2, _ := ret[2].([]*posture.Checks) - ret3, _ := ret[3].(error) - return ret0, ret1, ret2, ret3 + ret3, _ := ret[3].(bool) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 } // LoginPeer indicates an expected call of LoginPeer. diff --git a/management/server/account_test.go b/management/server/account_test.go index 51f079a57..256b71f18 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -84,7 +84,7 @@ func verifyCanAddPeerToAccount(t *testing.T, manager nbAccount.Manager, account setupKey = key.Key } - _, _, _, err := manager.AddPeer(context.Background(), "", setupKey, userID, peer, false) + _, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey, userID, peer, false) if err != nil { t.Error("expected to add new peer successfully after creating new account, but failed", err) } @@ -1092,7 +1092,7 @@ func TestAccountManager_AddPeer(t *testing.T) { } expectedPeerKey := key.PublicKey().String() - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) @@ -1156,7 +1156,7 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) { expectedPeerKey := key.PublicKey().String() expectedUserID := userID - peer, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) @@ -1504,7 +1504,7 @@ func TestAccountManager_DeletePeer(t *testing.T) { peerKey := key.PublicKey().String() - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey, Meta: nbpeer.PeerSystemMeta{Hostname: peerKey}, }, false) @@ -1826,7 +1826,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) { key, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - peer, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, LoginExpirationEnabled: true, @@ -1882,7 +1882,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. key, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, LoginExpirationEnabled: true, @@ -1927,7 +1927,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { require.NoError(t, err, "unable to generate WireGuard key") peerPubKey := key.PublicKey().String() - _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: peerPubKey, Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, }, false) @@ -2017,7 +2017,7 @@ func TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace(t *testing.T) { require.NoError(t, err, "unable to generate WireGuard key") peerPubKey := key.PublicKey().String() - _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: peerPubKey, Meta: nbpeer.PeerSystemMeta{Hostname: "race-peer"}, }, false) @@ -2080,7 +2080,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test key, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, LoginExpirationEnabled: true, @@ -3276,7 +3276,7 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel. } expectedPeerKey := key.PublicKey().String() - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, Status: &nbpeer.PeerStatus{ @@ -3444,7 +3444,7 @@ func BenchmarkLoginPeer_ExistingPeer(b *testing.B) { b.ResetTimer() start := time.Now() for i := 0; i < b.N; i++ { - _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ + _, _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ WireGuardPubKey: account.Peers["peer-1"].Key, SSHKey: "someKey", Meta: nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, @@ -3513,7 +3513,7 @@ func BenchmarkLoginPeer_NewPeer(b *testing.B) { b.ResetTimer() start := time.Now() for i := 0; i < b.N; i++ { - _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ + _, _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ WireGuardPubKey: "some-new-key" + strconv.Itoa(i), SSHKey: "someKey", Meta: nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, @@ -3908,13 +3908,13 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) { key2, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) require.NoError(t, err, "unable to add peer1") - peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go index b66eeb3b5..e2dcd830b 100644 --- a/management/server/affected_peers_test.go +++ b/management/server/affected_peers_test.go @@ -1663,7 +1663,7 @@ func addPeerToAccount(t *testing.T, manager *DefaultAccountManager, _, setupKeyK key, err := wgtypes.GeneratePrivateKey() require.NoError(t, err) - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: key.PublicKey().String()}, }, false) diff --git a/management/server/dns_test.go b/management/server/dns_test.go index c443223c6..8917902d9 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -298,11 +298,11 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account return nil, err } - savedPeer1, _, _, err := am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer1, false) + savedPeer1, _, _, _, err := am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer1, false) if err != nil { return nil, err } - _, _, _, err = am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer2, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer2, false) if err != nil { return nil, err } diff --git a/management/server/group_ipv6_test.go b/management/server/group_ipv6_test.go index e4603c879..dfb436060 100644 --- a/management/server/group_ipv6_test.go +++ b/management/server/group_ipv6_test.go @@ -55,7 +55,7 @@ func TestGroupIPv6Assignment(t *testing.T) { key, err := wgtypes.GeneratePrivateKey() require.NoError(t, err) - peer, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "ipv6-test-host"}, }, false) diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 1d4af95e9..310f90653 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -479,7 +479,7 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) return } - peer, _, _, err := h.accountManager.AddPeer(r.Context(), userAuth.AccountId, "", userAuth.UserId, newPeer, true) + peer, _, _, _, err := h.accountManager.AddPeer(r.Context(), userAuth.AccountId, "", userAuth.UserId, newPeer, true) if err != nil { util.WriteError(r.Context(), err, w) return diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index 1b77ea335..45d4ab8c9 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -728,7 +728,7 @@ func Test_LoginPerformance(t *testing.T) { } login := func() error { - _, _, _, err = am.LoginPeer(context.Background(), peerLogin) + _, _, _, _, err = am.LoginPeer(context.Background(), peerLogin) if err != nil { t.Logf("failed to login peer: %v", err) return err @@ -746,7 +746,7 @@ func Test_LoginPerformance(t *testing.T) { go func(peerLogin types.PeerLogin, counterStart *int32) { defer wgPeer.Done() - _, _, _, err = am.LoginPeer(context.Background(), peerLogin) + _, _, _, _, err = am.LoginPeer(context.Background(), peerLogin) if err != nil { t.Logf("failed to login peer: %v", err) return diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 15eb9b190..f81139f24 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -45,7 +45,7 @@ type MockAccountManager struct { DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error GetNetworkMapFunc func(ctx context.Context, peerKey string) (*types.NetworkMap, error) GetPeerNetworkFunc func(ctx context.Context, peerKey string) (*types.Network, error) - AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) + AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) GetGroupFunc func(ctx context.Context, accountID, groupID, userID string) (*types.Group, error) GetAllGroupsFunc func(ctx context.Context, accountID, userID string) ([]*types.Group, error) GetGroupByNameFunc func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) @@ -98,7 +98,7 @@ type MockAccountManager struct { SaveDNSSettingsFunc func(ctx context.Context, accountID, userID string, dnsSettingsToSave *types.DNSSettings) error GetPeerFunc func(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) UpdateAccountSettingsFunc func(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) - LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) + LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) ExtendPeerSessionFunc func(ctx context.Context, peerPubKey, userID string) (time.Time, error) SyncPeerFunc func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) InviteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserEmail string) error @@ -424,11 +424,11 @@ func (am *MockAccountManager) AddPeer( userId string, peer *nbpeer.Peer, temporary bool, -) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if am.AddPeerFunc != nil { return am.AddPeerFunc(ctx, accountID, setupKey, userId, peer, temporary) } - return nil, nil, nil, status.Errorf(codes.Unimplemented, "method AddPeer is not implemented") + return nil, nil, nil, false, status.Errorf(codes.Unimplemented, "method AddPeer is not implemented") } // GetGroupByName mock implementation of GetGroupByName from server.AccountManager interface @@ -862,11 +862,11 @@ func (am *MockAccountManager) UpdateAccountSettings(ctx context.Context, account } // LoginPeer mocks LoginPeer of the AccountManager interface -func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if am.LoginPeerFunc != nil { return am.LoginPeerFunc(ctx, login) } - return nil, nil, nil, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented") + return nil, nil, nil, false, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented") } // ExtendPeerSession mocks ExtendPeerSession of the AccountManager interface diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index b2c8300d6..e13b0bb19 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -896,11 +896,11 @@ func initTestNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account, return nil, err } - _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer1, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer1, false) if err != nil { return nil, err } - _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer2, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer2, false) if err != nil { return nil, err } diff --git a/management/server/peer.go b/management/server/peer.go index baf62a7eb..9d78f597b 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -718,10 +718,10 @@ func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, en // to it. We also add the User ID to the peer metadata to identify registrant. If no userID provided, then fail with status.PermissionDenied // Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused). // The peer property is just a placeholder for the Peer properties to pass further -func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if setupKey == "" && userID == "" && !peer.ProxyMeta.Embedded { // no auth method provided => reject access - return nil, nil, nil, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") + return nil, nil, nil, false, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") } upperKey := strings.ToUpper(setupKey) @@ -737,7 +737,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe // The connecting peer should be able to recover with a retry. _, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peer.Key) if err == nil { - return nil, nil, nil, status.Errorf(status.PreconditionFailed, "peer has been already registered") + return nil, nil, nil, false, status.Errorf(status.PreconditionFailed, "peer has been already registered") } opEvent := &activity.Event{ @@ -748,7 +748,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe peerAddConfig, err := am.processPeerAddAuth(ctx, accountID, userID, encodedHashedKey, peer, temporary, addedByUser, addedBySetupKey, opEvent) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } accountID = peerAddConfig.AccountID ephemeral := peerAddConfig.Ephemeral @@ -763,7 +763,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } if err := domain.ValidateDomainsList(peer.ExtraDNSLabels); err != nil { - return nil, nil, nil, status.Errorf(status.InvalidArgument, "invalid extra DNS labels: %v", err) + return nil, nil, nil, false, status.Errorf(status.InvalidArgument, "invalid extra DNS labels: %v", err) } registrationTime := time.Now().UTC() @@ -789,7 +789,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get account settings: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get account settings: %w", err) } if am.geo != nil && newPeer.Location.ConnectionIP != nil { @@ -807,30 +807,30 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe network, err := am.Store.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) if err != nil { - return nil, nil, nil, fmt.Errorf("failed getting network: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed getting network: %w", err) } maxAttempts := 10 for attempt := 1; attempt <= maxAttempts; attempt++ { netPrefix, err := netip.ParsePrefix(network.Net.String()) if err != nil { - return nil, nil, nil, fmt.Errorf("parse network prefix: %w", err) + return nil, nil, nil, false, fmt.Errorf("parse network prefix: %w", err) } freeIP, err := types.AllocateRandomPeerIP(netPrefix) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get free IP: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get free IP: %w", err) } var freeLabel string if ephemeral || attempt > 1 { freeLabel, err = getPeerIPDNSLabel(freeIP, peer.Meta.Hostname) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get free DNS label: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err) } } else { freeLabel, err = nbdns.GetParsedDomainLabel(peer.Meta.Hostname) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get free DNS label: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err) } } newPeer.DNSLabel = freeLabel @@ -852,11 +852,11 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe if allocate { v6Prefix, err := netip.ParsePrefix(network.NetV6.String()) if err != nil { - return nil, nil, nil, fmt.Errorf("parse IPv6 prefix: %w", err) + return nil, nil, nil, false, fmt.Errorf("parse IPv6 prefix: %w", err) } freeIPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { - return nil, nil, nil, fmt.Errorf("allocate peer IPv6: %w", err) + return nil, nil, nil, false, fmt.Errorf("allocate peer IPv6: %w", err) } newPeer.IPv6 = freeIPv6 } @@ -929,10 +929,10 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe continue } - return nil, nil, nil, fmt.Errorf("failed to add peer to database: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", err) } if newPeer == nil { - return nil, nil, nil, fmt.Errorf("new peer is nil") + return nil, nil, nil, false, fmt.Errorf("new peer is nil") } opEvent.TargetID = newPeer.ID @@ -940,7 +940,8 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe if !addedByUser { opEvent.Meta["setup_key_name"] = peerAddConfig.SetupKeyName } - if newPeer.Status != nil && newPeer.Status.RequiresApproval { + requiresApproval := newPeer.Status != nil && newPeer.Status.RequiresApproval + if requiresApproval { opEvent.Meta["pending_approval"] = true } @@ -948,18 +949,18 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta) } - p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, false, accountID, newPeer) + network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, newPeer, !requiresApproval) if err != nil { - return p, nmap, pc, err + return nil, nil, nil, false, err } changedPeerIDs := []string{newPeer.ID} - affectedPeerIDs := affectedPeerIDsFromNetworkMap(nmap, newPeer.ID) + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) 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) } - return p, nmap, pc, nil + return newPeer, network, postureChecks, enableSSH, nil } func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) { @@ -1041,7 +1042,7 @@ 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) + nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer.ID) if err != nil { return nil, nil, nil, 0, err } @@ -1054,7 +1055,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy } } - return resPeer, nmap, resPostureChecks, dnsFwdPort, nil + return peer, nmap, resPostureChecks, dnsFwdPort, nil } // syncPeerAffectedPeers resolves the peers affected by a SyncPeer change. The @@ -1085,7 +1086,7 @@ func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context, return affectedPeerIDsFromNetworkMap(nmap, peerID) } -func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if errStatus, ok := status.FromError(err); ok && errStatus.Type() == status.NotFound { // we couldn't find this peer by its public key which can mean that peer hasn't been registered yet. // Try registering it. @@ -1101,12 +1102,12 @@ func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, lo } log.WithContext(ctx).Errorf("failed while logging in peer %s: %v", login.WireGuardPubKey, err) - return nil, nil, nil, status.Errorf(status.Internal, "failed while logging in peer") + return nil, nil, nil, false, status.Errorf(status.Internal, "failed while logging in peer") } // LoginPeer logs in or registers a peer. // If peer doesn't exist the function checks whether a setup key or a user is present and registers a new peer if so. -func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, login.WireGuardPubKey) if err != nil { return am.handlePeerLoginNotFound(ctx, login, err) @@ -1118,20 +1119,17 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer if login.UserID == "" { err = am.checkIFPeerNeedsLoginWithoutLock(ctx, accountID, login) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } } var peer *nbpeer.Peer - var updateRemotePeers bool - var isPeerUpdated bool - var ipv6CapabilityChanged bool - var postureChecks []*posture.Checks + var shouldStorePeer bool var peerGroupIDs []string settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { @@ -1140,9 +1138,6 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return err } - // this flag prevents unnecessary calls to the persistent store. - shouldStorePeer := false - if login.UserID != "" { if peer.UserID != login.UserID { log.Warnf("user mismatch when logging in peer %s: peer user %s, login user %s ", peer.ID, peer.UserID, login.UserID) @@ -1156,7 +1151,6 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer if changed { shouldStorePeer = true - updateRemotePeers = true } } @@ -1165,23 +1159,9 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return err } - oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) - isPeerUpdated, _ = peer.UpdateMetaIfNew(login.Meta) - ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) - if isPeerUpdated { - am.metrics.AccountManagerMetrics().CountPeerMetUpdate() - shouldStorePeer = true - - postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peer.ID) - if err != nil { - return err - } - } - if peer.SSHKey != login.SSHKey { peer.SSHKey = login.SSHKey shouldStorePeer = true - updateRemotePeers = true } if !peer.AllowExtraDNSLabels && len(login.ExtraDNSLabels) > 0 { @@ -1197,28 +1177,28 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return nil }) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } isRequiresApproval, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } - p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer) + network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, peer, !isRequiresApproval) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } - if updateRemotePeers || isStatusChanged || ipv6CapabilityChanged || (isPeerUpdated && len(postureChecks) > 0) { + if isStatusChanged || shouldStorePeer { changedPeerIDs := []string{peer.ID} - affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, isRequiresApproval, isPeerUpdated, len(postureChecks) > 0) + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) 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) + return nil, nil, nil, false, fmt.Errorf("notify network map controller of peer update: %w", err) } } - return p, nmap, pc, nil + return peer, network, postureChecks, enableSSH, nil } // ExtendPeerSession refreshes the peer's SSO session deadline by updating @@ -1294,6 +1274,50 @@ func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubK return refreshed.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), nil } +// getPeerLoginInfo computes the login/register response data (network, posture +// checks, SSH) from the store without building the peer's full network map. +func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*posture.Checks, bool, error) { + network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, nil, false, fmt.Errorf("get account network: %w", err) + } + + if !isValid { + return network, nil, false, nil + } + + postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peer.ID) + if err != nil { + return nil, nil, false, err + } + + enableSSH, err := isPeerSSHEnabled(ctx, transaction, accountID, peer) + if err != nil { + return nil, nil, false, err + } + + return network, postureChecks, enableSSH, nil +} + +func isPeerSSHEnabled(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer) (bool, error) { + policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return false, err + } + + peerGroups, err := transaction.GetPeerGroups(ctx, store.LockingStrengthNone, accountID, peer.ID) + if err != nil { + return false, err + } + + peerGroupIDs := make(map[string]struct{}, len(peerGroups)) + for _, g := range peerGroups { + peerGroupIDs[g.ID] = struct{}{} + } + + return types.PeerSSHEnabledFromPolicies(policies, peer.ID, peerGroupIDs, peer.SSHEnabled), nil +} + // getPeerPostureChecks returns the posture checks for the peer. func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string) ([]*posture.Checks, error) { policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index ee1b33da2..98cf10acf 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -205,7 +205,7 @@ func testGetNetworkMapGeneral(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -219,7 +219,7 @@ func testGetNetworkMapGeneral(t *testing.T) { t.Fatal(err) return } - _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -278,7 +278,7 @@ func TestAccountManager_GetNetworkMapWithPolicy(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -292,7 +292,7 @@ func TestAccountManager_GetNetworkMapWithPolicy(t *testing.T) { t.Fatal(err) return } - peer2, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -454,7 +454,7 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -468,7 +468,7 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) { t.Fatal(err) return } - _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -526,7 +526,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -542,7 +542,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) { } // the second peer added with a setup key - peer2, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -698,7 +698,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } - _, _, _, err = manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -707,7 +707,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } - _, _, _, err = manager.AddPeer(context.Background(), "", "", adminUser, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", adminUser, &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -1332,7 +1332,7 @@ func Test_RegisterPeerByUser(t *testing.T) { }, } - addedPeer, _, _, err := am.AddPeer(context.Background(), "", "", existingUserID, newPeer, false) + addedPeer, _, _, _, err := am.AddPeer(context.Background(), "", "", existingUserID, newPeer, false) require.NoError(t, err) assert.Equal(t, newPeer.ExtraDNSLabels, addedPeer.ExtraDNSLabels) @@ -1465,7 +1465,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { ExtraDNSLabels: newPeerTemplate.ExtraDNSLabels, } - addedPeer, _, _, err := am.AddPeer(context.Background(), "", tc.existingSetupKeyID, "", currentPeer, false) + addedPeer, _, _, _, err := am.AddPeer(context.Background(), "", tc.existingSetupKeyID, "", currentPeer, false) if tc.expectAddPeerError { require.Error(t, err, "Expected an error when adding peer with setup key: %s", tc.existingSetupKeyID) @@ -1577,7 +1577,7 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { SSHEnabled: false, } - _, _, _, err = am.AddPeer(context.Background(), "", faultyKey, "", newPeer, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", faultyKey, "", newPeer, false) require.Error(t, err) _, err = s.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, newPeer.Key) @@ -1723,7 +1723,7 @@ func Test_LoginPeer(t *testing.T) { if sk.AllowExtraDNSLabels { currentPeer.ExtraDNSLabels = newPeerTemplate.ExtraDNSLabels } - _, _, _, err = am.AddPeer(context.Background(), "", tc.setupKey, "", currentPeer, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", tc.setupKey, "", currentPeer, false) require.NoError(t, err, "Expected no error when adding peer with setup key: %s", tc.setupKey) loginInput := types.PeerLogin{ @@ -1739,12 +1739,12 @@ func Test_LoginPeer(t *testing.T) { loginInput.ExtraDNSLabels = tc.extraDNSLabels } - loggedinPeer, networkMap, postureChecks, loginErr := am.LoginPeer(context.Background(), loginInput) + loggedinPeer, network, postureChecks, _, loginErr := am.LoginPeer(context.Background(), loginInput) if tc.expectLoginError { require.Error(t, loginErr, "Expected an error during LoginPeer with setup key: %s", tc.setupKey) assert.Contains(t, loginErr.Error(), tc.expectedErrorMsgSubstring, "Error message mismatch") assert.Nil(t, loggedinPeer, "LoggedinPeer should be nil on error") - assert.Nil(t, networkMap, "NetworkMap should be nil on error") + assert.Nil(t, network, "Network should be nil on error") assert.Nil(t, postureChecks, "PostureChecks should be empty or nil on error") return } @@ -1757,7 +1757,7 @@ func Test_LoginPeer(t *testing.T) { } else { assert.Equal(t, currentPeer.ExtraDNSLabels, loggedinPeer.ExtraDNSLabels, "ExtraDNSLabels mismatch on loggedinPeer") } - assert.NotNil(t, networkMap, "networkMap should not be nil on success") + assert.NotNil(t, network, "network should not be nil on success") assert.Equal(t, existingAccountID, loggedinPeer.AccountID, "AccountID mismatch for logged peer") @@ -1863,7 +1863,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer4, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) @@ -1986,7 +1986,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer4, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ Key: expectedPeerKey, LoginExpirationEnabled: true, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, @@ -2053,7 +2053,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer5, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ Key: expectedPeerKey, LoginExpirationEnabled: true, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, @@ -2108,7 +2108,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer6, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ + peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ Key: expectedPeerKey, LoginExpirationEnabled: true, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, @@ -2286,7 +2286,7 @@ func Test_AddPeer(t *testing.T) { <-start - _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", newPeer, false) + _, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", newPeer, false) if err != nil { errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err) return @@ -2366,7 +2366,7 @@ func TestAddPeer_UserPendingApprovalBlocked(t *testing.T) { }, } - _, _, _, err = manager.AddPeer(context.Background(), "", "", pendingUser.Id, peer, false) + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", pendingUser.Id, peer, false) require.Error(t, err) assert.Contains(t, err.Error(), "user pending approval cannot add peers") } @@ -2401,7 +2401,7 @@ func TestAddPeer_ApprovedUserCanAddPeers(t *testing.T) { }, } - _, _, _, err = manager.AddPeer(context.Background(), "", "", regularUser.Id, peer, false) + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", regularUser.Id, peer, false) require.NoError(t, err, "Regular user should be able to add peers") } @@ -2444,7 +2444,7 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) { WtVersion: "0.28.0", }, } - existingPeer, _, _, err := manager.AddPeer(context.Background(), "", "", pendingUser.Id, newPeer, false) + existingPeer, _, _, _, err := manager.AddPeer(context.Background(), "", "", pendingUser.Id, newPeer, false) require.NoError(t, err) // Now set the user back to pending approval after peer was created @@ -2463,7 +2463,7 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) { }, } - _, _, _, err = manager.LoginPeer(context.Background(), login) + _, _, _, _, err = manager.LoginPeer(context.Background(), login) require.Error(t, err) e, ok := status.FromError(err) require.True(t, ok, "error is not a gRPC status error") @@ -2500,7 +2500,7 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) { WtVersion: "0.28.0", }, } - existingPeer, _, _, err := manager.AddPeer(context.Background(), "", "", regularUser.Id, newPeer, false) + existingPeer, _, _, _, err := manager.AddPeer(context.Background(), "", "", regularUser.Id, newPeer, false) require.NoError(t, err) // Try to login with regular user @@ -2513,7 +2513,7 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) { }, } - _, _, _, err = manager.LoginPeer(context.Background(), login) + _, _, _, _, err = manager.LoginPeer(context.Background(), login) require.NoError(t, err, "Regular user should be able to login peers") } @@ -2837,7 +2837,7 @@ func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) { // Add first peer with hostname that produces DNS label "netbird1" key1, err := wgtypes.GenerateKey() require.NoError(t, err) - peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "netbird1.netbird.cloud"}, }, false) @@ -2847,7 +2847,7 @@ func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) { // Add second peer with a different hostname key2, err := wgtypes.GenerateKey() require.NoError(t, err) - peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "ip-10-29-5-130"}, }, false) @@ -2871,7 +2871,7 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) { key1, err := wgtypes.GenerateKey() require.NoError(t, err) - peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "web-server"}, }, false) @@ -2881,7 +2881,7 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) { // Add second peer and rename it to a unique FQDN whose first label doesn't collide key2, err := wgtypes.GenerateKey() require.NoError(t, err) - peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "old-name"}, }, false) diff --git a/management/server/types/account.go b/management/server/types/account.go index d658f605d..7a0a0054f 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -1156,6 +1156,47 @@ func policyRuleImpliesLegacySSH(rule *PolicyRule) bool { return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges))) } +// PeerSSHEnabledFromPolicies is the network-map-free equivalent of the sshEnabled +// determination in GetPeerConnectionResources / CalculateNetworkMapFromComponents. +func PeerSSHEnabledFromPolicies(policies []*Policy, peerID string, peerGroupIDs map[string]struct{}, peerSSHEnabled bool) bool { + for _, policy := range policies { + if !policy.Enabled { + continue + } + + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + + isSSHRule := rule.Protocol == PolicyRuleProtocolNetbirdSSH || + (policyRuleImpliesLegacySSH(rule) && peerSSHEnabled) + if !isSSHRule { + continue + } + + if ruleHasDestination(rule, peerID, peerGroupIDs) { + return true + } + } + } + + return false +} + +func ruleHasDestination(rule *PolicyRule, peerID string, peerGroupIDs map[string]struct{}) bool { + if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { + return rule.DestinationResource.ID == peerID + } + + for _, groupID := range rule.Destinations { + if _, ok := peerGroupIDs[groupID]; ok { + return true + } + } + return false +} + func portRangeIncludesSSH(portRanges []RulePortRange) bool { for _, pr := range portRanges { if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) { diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go index 3785a7399..1e3035300 100644 --- a/management/server/types/networkmap_components_correctness_test.go +++ b/management/server/types/networkmap_components_correctness_test.go @@ -1233,3 +1233,97 @@ func TestComponents_DisabledRuleInEnabledPolicy(t *testing.T) { assert.True(t, has3000, "enabled rule should generate firewall rule for port 3000") assert.False(t, has3001, "disabled rule should NOT generate firewall rule for port 3001") } + +func peerGroupIDSet(account *types.Account, peerID string) map[string]struct{} { + return account.GetPeerGroups(peerID) +} + +func assertSSHEquivalence(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) { + t.Helper() + nm := componentsNetworkMap(account, peerID, validatedPeers) + require.NotNil(t, nm) + + got := types.PeerSSHEnabledFromPolicies(account.Policies, peerID, peerGroupIDSet(account, peerID), account.Peers[peerID].SSHEnabled) + assert.Equalf(t, nm.EnableSSH, got, "PeerSSHEnabledFromPolicies mismatch for %s", peerID) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_NetbirdSSHProtocol(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + account.Groups["ssh-users"] = &types.Group{ID: "ssh-users", Name: "SSH Users", Peers: []string{}} + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh", Name: "SSH Access", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Bidirectional: false, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + AuthorizedGroups: map[string][]string{"ssh-users": {"root"}}, + }}, + }) + + assertSSHEquivalence(t, account, "peer-10", validatedPeers) + assertSSHEquivalence(t, account, "peer-0", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_NoSSHPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + assertSSHEquivalence(t, account, "peer-0", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_LegacyImpliedSSH(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + account.Peers["peer-10"].SSHEnabled = true + assertSSHEquivalence(t, account, "peer-10", validatedPeers) + assertSSHEquivalence(t, account, "peer-11", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_PeerAsDestinationResource(t *testing.T) { + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh-res", Name: "SSH to peer", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh-res", Name: "SSH to peer-5", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Sources: []string{"group-0"}, + DestinationResource: types.Resource{ID: "peer-5", Type: types.ResourceTypePeer}, + }}, + }) + + assertSSHEquivalence(t, account, "peer-5", validatedPeers) + assertSSHEquivalence(t, account, "peer-6", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_DisabledSSHPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh-off", Name: "SSH disabled", Enabled: false, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh-off", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + }}, + }) + assertSSHEquivalence(t, account, "peer-10", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_Sweep(t *testing.T) { + account, validatedPeers := scalableTestAccount(60, 6) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh-sweep", Name: "SSH sweep", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh-sweep", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Sources: []string{"group-0"}, Destinations: []string{"group-2"}, + }}, + }) + for peerID := range account.Peers { + account.Peers[peerID].SSHEnabled = len(peerID)%2 == 0 + } + + for peerID := range account.Peers { + if _, ok := validatedPeers[peerID]; !ok { + continue + } + assertSSHEquivalence(t, account, peerID, validatedPeers) + } +} diff --git a/management/server/user_test.go b/management/server/user_test.go index d46519396..f32a6b3a1 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -1565,7 +1565,7 @@ func TestUserAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer4, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) From 8ae2cd0a08af0a5311cc3d3d52656448141afbb3 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 17 Jun 2026 18:29:33 +0200 Subject: [PATCH 12/16] [client] Fix ios route notify ordering (#6454) * [client] fix iOS route-update reordering that black-holed IPv6 on exit-node disable On iOS the route notifier delivered each prefix update from its own fire-and-forget goroutine (notify -> `go func`), so Go provided no ordering guarantee between consecutive updates. It also read currentPrefixes inside that goroutine without holding the lock, racing the next OnNewPrefixes write. On exit-node disable the core removes the default routes as two separate prefix updates (0.0.0.0/0, then the synthesized ::/0). When the two goroutines were reordered, the stale snapshot still containing ::/0 was delivered last and clobbered the correct default-free one. iOS then kept the ::/0 default route on the tunnel with no exit node to carry it, black-holing all IPv6 traffic while IPv4 recovered correctly. Fix: deliver updates through a single worker goroutine fed by a buffered channel, preserving production order, and snapshot the joined prefix string under the mutex so it can't race a concurrent update. Buffered so producers (which run under the route manager lock) don't block on the listener callback. * [client] close iOS notifier delivery goroutine on Stop, unbounded queue The delivery goroutine was never stopped, leaking on every engine restart. Add Notifier.Close, called from the route manager Stop after routing cleanup. Replace the buffered update channel with a cond-driven linked-list queue so route-update producers (running under the route manager lock) never block when the listener callback is slow. --- client/internal/routemanager/manager.go | 2 + .../routemanager/notifier/notifier_android.go | 6 +- .../routemanager/notifier/notifier_ios.go | 64 +++++++++++++------ .../routemanager/notifier/notifier_other.go | 4 ++ 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 0edf4607f..22458d575 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -333,6 +333,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) { } } + m.notifier.Close() + m.mux.Lock() defer m.mux.Unlock() m.clientRoutes = nil diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go index 140a583f7..49300dbb2 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -16,7 +16,7 @@ import ( type Notifier struct { initialRoutes []*route.Route currentRoutes []*route.Route - fakeIPRoutes []*route.Route + fakeIPRoutes []*route.Route listener listener.NetworkChangeListener listenerMux sync.Mutex @@ -119,3 +119,7 @@ func (n *Notifier) GetInitialRouteRanges() []string { sort.Strings(initialStrings) return initialStrings } + +func (n *Notifier) Close() { + // unused +} diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index 27a2a722d..d0888f3a1 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -3,6 +3,7 @@ package notifier import ( + "container/list" "net/netip" "slices" "sort" @@ -14,19 +15,26 @@ import ( ) type Notifier struct { + mu sync.Mutex + cond *sync.Cond currentPrefixes []string - - listener listener.NetworkChangeListener - listenerMux sync.Mutex + listener listener.NetworkChangeListener + queue *list.List + closed bool } func NewNotifier() *Notifier { - return &Notifier{} + n := &Notifier{ + queue: list.New(), + } + n.cond = sync.NewCond(&n.mu) + go n.deliverLoop() + return n } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() + n.mu.Lock() + defer n.mu.Unlock() n.listener = listener } @@ -43,32 +51,52 @@ func (n *Notifier) OnNewRoutes(route.HAMap) { } func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { - newNets := make([]string, 0) + newNets := make([]string, 0, len(prefixes)) for _, prefix := range prefixes { newNets = append(newNets, prefix.String()) } sort.Strings(newNets) + n.mu.Lock() if slices.Equal(n.currentPrefixes, newNets) { + n.mu.Unlock() return } - n.currentPrefixes = newNets - n.notify() + routes := strings.Join(n.currentPrefixes, ",") + n.queue.PushBack(routes) + n.cond.Signal() + n.mu.Unlock() } -func (n *Notifier) notify() { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() - if n.listener == nil { - return - } - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) - }(n.listener) +func (n *Notifier) Close() { + n.mu.Lock() + n.closed = true + n.cond.Signal() + n.mu.Unlock() } func (n *Notifier) GetInitialRouteRanges() []string { return nil } + +func (n *Notifier) deliverLoop() { + for { + n.mu.Lock() + for n.queue.Len() == 0 && !n.closed { + n.cond.Wait() + } + if n.closed && n.queue.Len() == 0 { + n.mu.Unlock() + return + } + routes := n.queue.Remove(n.queue.Front()).(string) + l := n.listener + n.mu.Unlock() + + if l != nil { + l.OnNetworkChanged(routes) + } + } +} diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go index f57cadb0b..71b1096c2 100644 --- a/client/internal/routemanager/notifier/notifier_other.go +++ b/client/internal/routemanager/notifier/notifier_other.go @@ -38,3 +38,7 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { func (n *Notifier) GetInitialRouteRanges() []string { return []string{} } + +func (n *Notifier) Close() { + // unused +} From 5bd7c6c7ea0c8cebe78fdf8ecff9b80511660ec0 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:48:09 +0900 Subject: [PATCH 13/16] [client] Detect and recover from a stalled signal receive stream (#6459) --- client/internal/engine.go | 7 ++ shared/signal/client/grpc.go | 121 ++++++++++++++++++++--- shared/signal/client/watchdog_test.go | 84 ++++++++++++++++ shared/signal/proto/signalexchange.pb.go | 64 ++++++------ shared/signal/proto/signalexchange.proto | 1 + 5 files changed, 233 insertions(+), 44 deletions(-) create mode 100644 shared/signal/client/watchdog_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index cf40d8983..42712da92 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1714,6 +1714,13 @@ func (e *Engine) receiveSignalEvents() { return e.ctx.Err() } + // Self-addressed heartbeat: the signal client's receive watchdog + // round-trips this through the server to confirm the receive stream + // is delivering. Liveness is already recorded before this handler. + if msg.GetBody().GetType() == sProto.Body_HEARTBEAT { + return nil + } + conn, ok := e.peerStore.PeerConn(msg.Key) if !ok { return fmt.Errorf("wrongly addressed message %s", msg.Key) diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index b245b2296..eb18cea05 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -2,9 +2,11 @@ package client import ( "context" + "errors" "fmt" "io" "sync" + "sync/atomic" "time" "github.com/cenkalti/backoff/v4" @@ -23,7 +25,23 @@ import ( "github.com/netbirdio/netbird/util/wsproxy" ) -const healthCheckTimeout = 5 * time.Second +const ( + // receiveInactivityThreshold is how long the receive stream may be silent + // before the watchdog actively probes it. The gRPC transport can stay + // healthy (keepalive satisfied) while the server stops delivering messages, + // which the transport layer cannot detect. + receiveInactivityThreshold = 30 * time.Second + // receiveProbeTimeout is how long the watchdog waits for its self-addressed + // probe to round-trip back on the stream before declaring the receive + // direction dead. + receiveProbeTimeout = 10 * time.Second + // receiveWatchdogInterval is how often the watchdog evaluates the stream. + receiveWatchdogInterval = 10 * time.Second +) + +// errReceiveStreamStalled is reported when the receive stream is transport-alive +// but no longer delivering messages, so the stream is torn down to reconnect. +var errReceiveStreamStalled = errors.New("signal receive stream stalled") // ConnStateNotifier is a wrapper interface of the status recorder type ConnStateNotifier interface { @@ -52,6 +70,14 @@ type GrpcClient struct { decryptionWorker *Worker decryptionWorkerCancel context.CancelFunc decryptionWg sync.WaitGroup + + // lastReceived holds the Unix-nano timestamp of the last message read from + // the receive stream, used by the receive watchdog. + lastReceived atomic.Int64 + // receiveStalled is set by the receive watchdog when the stream is + // transport-alive but no longer delivering messages. It is the source of + // truth IsHealthy reads, and is cleared once any frame is received again. + receiveStalled atomic.Bool } // NewClient creates a new Signal client @@ -148,9 +174,9 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes // connect to Signal stream identifying ourselves with a public WireGuard key // todo once the key rotation logic has been implemented, consider changing to some other identifier (received from management) - ctx, cancelStream := context.WithCancel(ctx) + streamCtx, cancelStream := context.WithCancel(ctx) defer cancelStream() - stream, err := c.connect(ctx, c.key.PublicKey().String()) + stream, err := c.connect(streamCtx, c.key.PublicKey().String()) if err != nil { log.Warnf("disconnected from the Signal Exchange due to an error: %v", err) return err @@ -164,9 +190,16 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes // Start worker pool if not already started c.startEncryptionWorker(msgHandler) + // Guard the receive direction: the transport can stay healthy while the + // server stops delivering messages. The watchdog reconnects via cancelStream. + c.markReceived() + go c.watchReceiveStream(streamCtx, cancelStream) + // start receiving messages from the Signal stream (from other peers through signal) err = c.receive(stream) if err != nil { + // Check the parent context, not streamCtx: a watchdog-triggered + // cancelStream must reconnect, only a parent cancel is shutdown. if ctx.Err() != nil { log.Debugf("signal connection context has been canceled, this usually indicates shutdown") return nil @@ -252,7 +285,10 @@ func (c *GrpcClient) Ready() bool { return c.signalConn.GetState() == connectivity.Ready || c.signalConn.GetState() == connectivity.Idle } -// IsHealthy probes the gRPC connection and returns false on errors +// IsHealthy reports whether the Signal connection is usable, based on the +// transport state plus the receive watchdog's verdict, and updates the status +// recorder accordingly. It does not actively probe: the watchdog +// (watchReceiveStream) owns probing the receive path and reconnecting. func (c *GrpcClient) IsHealthy() bool { switch c.signalConn.GetState() { case connectivity.TransientFailure: @@ -265,16 +301,8 @@ func (c *GrpcClient) IsHealthy() bool { case connectivity.Ready: } - ctx, cancel := context.WithTimeout(c.ctx, healthCheckTimeout) - defer cancel() - _, err := c.realClient.Send(ctx, &proto.EncryptedMessage{ - Key: c.key.PublicKey().String(), - RemoteKey: "dummy", - Body: nil, - }) - if err != nil { - c.notifyDisconnected(err) - log.Warnf("health check returned: %s", err) + if c.receiveStalled.Load() { + c.notifyDisconnected(errReceiveStreamStalled) return false } c.notifyConnected() @@ -398,6 +426,68 @@ func (c *GrpcClient) Send(msg *proto.Message) error { return err } +// markReceived records that a frame was just read from the receive stream and +// clears the stalled flag. +func (c *GrpcClient) markReceived() { + c.lastReceived.Store(time.Now().UnixNano()) + c.receiveStalled.Store(false) +} + +// idleSinceReceive returns how long the receive stream has been silent. +func (c *GrpcClient) idleSinceReceive() time.Duration { + return time.Since(time.Unix(0, c.lastReceived.Load())) +} + +// watchReceiveStream guards against a receive stream that is transport-alive but +// no longer delivering messages. While the stream is idle past +// receiveInactivityThreshold it sends a self-addressed probe that the Signal +// server routes back to this client. If the probe does not round-trip within +// receiveProbeTimeout the receive direction is considered dead and cancelStream +// is called so the retry loop reconnects. +func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream context.CancelFunc) { + ticker := time.NewTicker(receiveWatchdogInterval) + defer ticker.Stop() + + var probeSentAt time.Time + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if c.idleSinceReceive() < receiveInactivityThreshold { + probeSentAt = time.Time{} + continue + } + + if !probeSentAt.IsZero() && time.Since(probeSentAt) >= receiveProbeTimeout { + log.Warnf("signal receive stream stalled: no messages for %s and probe did not return, reconnecting", c.idleSinceReceive().Round(time.Second)) + c.receiveStalled.Store(true) + c.notifyDisconnected(errReceiveStreamStalled) + cancelStream() + return + } + + if probeSentAt.IsZero() { + if err := c.sendReceiveProbe(); err != nil { + log.Debugf("failed to send signal receive probe: %v", err) + } + probeSentAt = time.Now() + } + } + } +} + +// sendReceiveProbe sends a self-addressed heartbeat. The Signal server routes it +// back to this client, exercising the exact receive path the watchdog guards. +func (c *GrpcClient) sendReceiveProbe() error { + self := c.key.PublicKey().String() + return c.Send(&proto.Message{ + Key: self, + RemoteKey: self, + Body: &proto.Body{Type: proto.Body_HEARTBEAT}, + }) +} + // receive receives messages from other peers coming through the Signal Exchange // and distributes them to worker threads for processing func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) error { @@ -419,6 +509,9 @@ func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) er return err } + // Any frame from the server proves the receive direction is alive. + c.markReceived() + if msg == nil { continue } diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go new file mode 100644 index 000000000..1905e7562 --- /dev/null +++ b/shared/signal/client/watchdog_test.go @@ -0,0 +1,84 @@ +package client + +import ( + "context" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" + + sigProto "github.com/netbirdio/netbird/shared/signal/proto" + "github.com/netbirdio/netbird/signal/server" +) + +func startTestSignalServer(t *testing.T) string { + t.Helper() + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + s := grpc.NewServer() + srv, err := server.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + sigProto.RegisterSignalExchangeServer(s, srv) + + go func() { + _ = s.Serve(lis) + }() + t.Cleanup(s.Stop) + + return lis.Addr().String() +} + +// TestReceiveProbeRoundTrips verifies that the watchdog's self-addressed heartbeat +// is routed back to the same client through the signal server. This round-trip is +// what lets the watchdog confirm the receive direction is still delivering. +func TestReceiveProbeRoundTrips(t *testing.T) { + addr := startTestSignalServer(t) + + key, err := wgtypes.GenerateKey() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + client, err := NewClient(ctx, addr, key, false) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + received := make(chan struct{}, 1) + go func() { + _ = client.Receive(ctx, func(msg *sigProto.Message) error { + if msg.GetBody().GetType() == sigProto.Body_HEARTBEAT && msg.GetKey() == key.PublicKey().String() { + select { + case received <- struct{}{}: + default: + } + } + return nil + }) + }() + + streamReady := make(chan struct{}) + go func() { + client.WaitStreamConnected() + close(streamReady) + }() + select { + case <-streamReady: + case <-time.After(5 * time.Second): + t.Fatal("signal stream did not connect within timeout") + } + + require.NoError(t, client.sendReceiveProbe()) + + select { + case <-received: + case <-time.After(3 * time.Second): + t.Fatal("self-addressed heartbeat did not round-trip back through the signal server") + } +} diff --git a/shared/signal/proto/signalexchange.pb.go b/shared/signal/proto/signalexchange.pb.go index 0c80fb489..8e07977f0 100644 --- a/shared/signal/proto/signalexchange.pb.go +++ b/shared/signal/proto/signalexchange.pb.go @@ -30,6 +30,7 @@ const ( Body_CANDIDATE Body_Type = 2 Body_MODE Body_Type = 4 Body_GO_IDLE Body_Type = 5 + Body_HEARTBEAT Body_Type = 6 ) // Enum value maps for Body_Type. @@ -40,6 +41,7 @@ var ( 2: "CANDIDATE", 4: "MODE", 5: "GO_IDLE", + 6: "HEARTBEAT", } Body_Type_value = map[string]int32{ "OFFER": 0, @@ -47,6 +49,7 @@ var ( "CANDIDATE": 2, "MODE": 4, "GO_IDLE": 5, + "HEARTBEAT": 6, } ) @@ -463,7 +466,7 @@ var file_signalexchange_proto_rawDesc = []byte{ 0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52, - 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xc3, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d, + 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xd2, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, @@ -491,38 +494,39 @@ var file_signalexchange_proto_rawDesc = []byte{ 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x43, 0x0a, 0x04, 0x54, 0x79, 0x70, + 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44, 0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10, - 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x42, 0x15, - 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, - 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, - 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, - 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, - 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, - 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, - 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, - 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, - 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, - 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, - 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, - 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, - 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, - 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, - 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d, + 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a, + 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f, + 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42, + 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f, + 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a, + 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, + 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, + 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69, + 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04, + 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, + 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69, + 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, + 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, + 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/shared/signal/proto/signalexchange.proto b/shared/signal/proto/signalexchange.proto index 96a4001e3..8c304e37c 100644 --- a/shared/signal/proto/signalexchange.proto +++ b/shared/signal/proto/signalexchange.proto @@ -48,6 +48,7 @@ message Body { CANDIDATE = 2; MODE = 4; GO_IDLE = 5; + HEARTBEAT = 6; } Type type = 1; string payload = 2; From 8d9580e49112857c99e44f3c877ececec2d20e4c Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 17 Jun 2026 20:13:13 +0200 Subject: [PATCH 14/16] [misc] improve goreleaser with RC handling and update docker builds (#6438) - introduce variables to avoid publishing latest docker tags and installers - Refactor .goreleaser.yaml to simplify docker configurations and add environment-driven flags - removed management debug containers (it was doing only log var) - Stopped building arm v6 32bits in favor of v7 32 bits for services (not client) - Add target argument to docker files --- .github/workflows/release.yml | 53 ++- .goreleaser.yaml | 862 ++++++++-------------------------- .goreleaser_ui.yaml | 5 +- client/Dockerfile | 6 +- client/Dockerfile-rootless | 6 +- combined/Dockerfile | 3 +- management/Dockerfile | 3 +- management/Dockerfile.debug | 5 - proxy/Dockerfile | 3 +- relay/Dockerfile | 3 +- signal/Dockerfile | 3 +- upload-server/Dockerfile | 3 +- 12 files changed, 268 insertions(+), 687 deletions(-) delete mode 100644 management/Dockerfile.debug diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b335aad72..bd3514d27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,10 +9,13 @@ on: pull_request: env: - SIGN_PIPE_VER: "v0.1.5" - GORELEASER_VER: "v2.14.3" + SIGN_PIPE_VER: "v0.1.6" + GORELEASER_VER: "v2.16.0" PRODUCT_NAME: "NetBird" COPYRIGHT: "NetBird GmbH" + flags: "" + SKIP_PUBLISH: "true" + SKIP_DOCKER_PUSH: "false" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} @@ -130,8 +133,6 @@ jobs: windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }} macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }} ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }} - env: - flags: "" steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -143,8 +144,27 @@ jobs: id: semver_parser uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 - - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "flags=--snapshot" >> $GITHUB_ENV + - name: Set snapshot flag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "flags=--snapshot" >> $GITHUB_ENV + + - name: Set build vars + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + echo "SKIP_PUBLISH=false" >> $GITHUB_ENV + else + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + fi + + if [[ "x-${{ github.repository }}" != "x-netbirdio/netbird" ]]; then + echo "SKIP_DOCKER_PUSH=true" >> $GITHUB_ENV + fi + - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: @@ -212,6 +232,8 @@ jobs: UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }} + SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }} - name: Verify RPM signatures run: | docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' @@ -334,8 +356,22 @@ jobs: id: semver_parser uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 - - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "flags=--snapshot" >> $GITHUB_ENV + - name: Set snapshot flag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "flags=--snapshot" >> $GITHUB_ENV + + - name: Set build vars + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + echo "SKIP_PUBLISH=false" >> $GITHUB_ENV + else + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + fi - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 @@ -395,6 +431,7 @@ jobs: UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }} - name: Verify RPM signatures run: | docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5ea479148..5031ef446 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,5 +1,7 @@ version: 2 - +env: + - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} + - SKIP_DOCKER_PUSH={{ if index .Env "SKIP_DOCKER_PUSH" }}{{ .Env.SKIP_DOCKER_PUSH }}{{ else }}false{{ end }} project_name: netbird builds: - id: netbird-wasm @@ -74,6 +76,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -88,6 +92,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -102,6 +108,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -122,6 +130,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -136,6 +146,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -150,6 +162,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}} mod_timestamp: "{{ .CommitTimestamp }}" @@ -170,6 +184,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -222,670 +238,192 @@ nfpms: rpm: signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' -dockers: - - image_templates: - - netbirdio/netbird:{{ .Version }}-amd64 - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - ids: - - netbird - goarch: amd64 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - ids: - - netbird - goarch: arm64 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - ids: - - netbird - goarch: arm - goarm: 6 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - ids: - - netbird - goarch: amd64 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - ids: - - netbird - goarch: arm64 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - ids: - - netbird - goarch: arm - goarm: 6 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - - image_templates: - - netbirdio/relay:{{ .Version }}-amd64 - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - ids: - - netbird-relay - goarch: amd64 - use: buildx - dockerfile: relay/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - ids: - - netbird-relay - goarch: arm64 - use: buildx - dockerfile: relay/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - ids: - - netbird-relay - goarch: arm - goarm: 6 - use: buildx - dockerfile: relay/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/signal:{{ .Version }}-amd64 - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - ids: - - netbird-signal - goarch: amd64 - use: buildx - dockerfile: signal/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - ids: - - netbird-signal - goarch: arm64 - use: buildx - dockerfile: signal/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - ids: - - netbird-signal - goarch: arm - goarm: 6 - use: buildx - dockerfile: signal/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-amd64 - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - ids: - - netbird-mgmt - goarch: amd64 - use: buildx - dockerfile: management/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - ids: - - netbird-mgmt - goarch: arm64 - use: buildx - dockerfile: management/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-arm - ids: - - netbird-mgmt - goarch: arm - goarm: 6 - use: buildx - dockerfile: management/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-debug-amd64 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64 - ids: - - netbird-mgmt - goarch: amd64 - use: buildx - dockerfile: management/Dockerfile.debug - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-debug-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8 - ids: - - netbird-mgmt - goarch: arm64 - use: buildx - dockerfile: management/Dockerfile.debug - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - - image_templates: - - netbirdio/management:{{ .Version }}-debug-arm - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm - ids: - - netbird-mgmt - goarch: arm - goarm: 6 - use: buildx - dockerfile: management/Dockerfile.debug - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/upload:{{ .Version }}-amd64 - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - ids: - - netbird-upload - goarch: amd64 - use: buildx - dockerfile: upload-server/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - ids: - - netbird-upload - goarch: arm64 - use: buildx - dockerfile: upload-server/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - ids: - - netbird-upload - goarch: arm - goarm: 6 - use: buildx - dockerfile: upload-server/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird-server:{{ .Version }}-amd64 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 - ids: - - netbird-server - goarch: amd64 - use: buildx - dockerfile: combined/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 - ids: - - netbird-server - goarch: arm64 - use: buildx - dockerfile: combined/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm - ids: - - netbird-server - goarch: arm - goarm: 6 - use: buildx - dockerfile: combined/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-amd64 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 - ids: - - netbird-proxy - goarch: amd64 - use: buildx - dockerfile: proxy/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - ids: - - netbird-proxy - goarch: arm64 - use: buildx - dockerfile: proxy/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm - ids: - - netbird-proxy - goarch: arm - goarm: 6 - use: buildx - dockerfile: proxy/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" -docker_manifests: - - name_template: netbirdio/netbird:{{ .Version }} - image_templates: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - netbirdio/netbird:{{ .Version }}-arm - - netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird:latest - image_templates: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - netbirdio/netbird:{{ .Version }}-arm - - netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird:{{ .Version }}-rootless - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - netbirdio/netbird:{{ .Version }}-rootless-arm - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: netbirdio/netbird:rootless-latest - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - netbirdio/netbird:{{ .Version }}-rootless-arm - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: netbirdio/relay:{{ .Version }} - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - netbirdio/relay:{{ .Version }}-arm - - netbirdio/relay:{{ .Version }}-amd64 - - - name_template: netbirdio/relay:latest - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - netbirdio/relay:{{ .Version }}-arm - - netbirdio/relay:{{ .Version }}-amd64 - - - name_template: netbirdio/signal:{{ .Version }} - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - netbirdio/signal:{{ .Version }}-arm - - netbirdio/signal:{{ .Version }}-amd64 - - - name_template: netbirdio/signal:latest - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - netbirdio/signal:{{ .Version }}-arm - - netbirdio/signal:{{ .Version }}-amd64 - - - name_template: netbirdio/management:{{ .Version }} - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - netbirdio/management:{{ .Version }}-arm - - netbirdio/management:{{ .Version }}-amd64 - - - name_template: netbirdio/management:latest - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - netbirdio/management:{{ .Version }}-arm - - netbirdio/management:{{ .Version }}-amd64 - - - name_template: netbirdio/management:debug-latest - image_templates: - - netbirdio/management:{{ .Version }}-debug-arm64v8 - - netbirdio/management:{{ .Version }}-debug-arm - - netbirdio/management:{{ .Version }}-debug-amd64 - - name_template: netbirdio/upload:{{ .Version }} - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - netbirdio/upload:{{ .Version }}-arm - - netbirdio/upload:{{ .Version }}-amd64 - - - name_template: netbirdio/upload:latest - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - netbirdio/upload:{{ .Version }}-arm - - netbirdio/upload:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird-server:{{ .Version }} - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm64v8 - - netbirdio/netbird-server:{{ .Version }}-arm - - netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird-server:latest - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm64v8 - - netbirdio/netbird-server:{{ .Version }}-arm - - netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:latest - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:{{ .Version }}-rootless - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: ghcr.io/netbirdio/netbird:rootless-latest - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: ghcr.io/netbirdio/relay:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/relay:latest - image_templates: - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/signal:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/signal:latest - image_templates: - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:latest - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:debug-latest - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm - - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64 - - - name_template: ghcr.io/netbirdio/upload:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/upload:latest - image_templates: - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird-server:latest - image_templates: - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: netbirdio/reverse-proxy:{{ .Version }} - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - netbirdio/reverse-proxy:{{ .Version }}-arm - - netbirdio/reverse-proxy:{{ .Version }}-amd64 - - - name_template: netbirdio/reverse-proxy:latest - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - netbirdio/reverse-proxy:{{ .Version }}-arm - - netbirdio/reverse-proxy:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/reverse-proxy:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/reverse-proxy:latest - image_templates: - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 +dockers_v2: + - id: netbird + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: client/Dockerfile + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm/6 + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-rootless + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "v{{ .Version }}-rootless" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: client/Dockerfile-rootless + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm/6 + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: relay + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-relay + images: + - netbirdio/relay + - ghcr.io/netbirdio/relay + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: relay/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: signal + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-signal + images: + - netbirdio/signal + - ghcr.io/netbirdio/signal + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: signal/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: management + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-mgmt + images: + - netbirdio/management + - ghcr.io/netbirdio/management + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: management/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: upload + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-upload + images: + - netbirdio/upload + - ghcr.io/netbirdio/upload + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: upload-server/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-server + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-server + images: + - netbirdio/netbird-server + - ghcr.io/netbirdio/netbird-server + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: combined/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-proxy + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-proxy + images: + - netbirdio/reverse-proxy + - ghcr.io/netbirdio/reverse-proxy + tags: + - "v{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: proxy/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" brews: - ids: - default + skip_upload: "{{ .Env.SKIP_PUBLISH }}" repository: owner: netbirdio name: homebrew-tap @@ -902,6 +440,7 @@ brews: uploads: - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_deb mode: archive @@ -910,6 +449,7 @@ uploads: method: PUT - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_rpm mode: archive diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 470f1deaa..6f9b7c059 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -1,5 +1,6 @@ version: 2 - +env: + - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} project_name: netbird-ui builds: - id: netbird-ui @@ -101,6 +102,7 @@ nfpms: uploads: - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_ui_deb mode: archive @@ -109,6 +111,7 @@ uploads: method: PUT - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_ui_rpm mode: archive diff --git a/client/Dockerfile b/client/Dockerfile index 53e4555ef..478b2d0e2 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -4,7 +4,7 @@ # sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client . # sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest -FROM alpine:3.23.3 +FROM alpine:3.24 # iproute2: busybox doesn't display ip rules properly RUN apk add --no-cache \ bash \ @@ -21,7 +21,7 @@ ENV \ NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] - -ARG NETBIRD_BINARY=netbird +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless index 706bf40de..8141af6ed 100644 --- a/client/Dockerfile-rootless +++ b/client/Dockerfile-rootless @@ -4,7 +4,7 @@ # podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client . # podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest -FROM alpine:3.22.0 +FROM alpine:3.24 RUN apk add --no-cache \ bash \ @@ -27,7 +27,7 @@ ENV \ NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] - -ARG NETBIRD_BINARY=netbird +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird diff --git a/combined/Dockerfile b/combined/Dockerfile index 357e10cf8..ac88b8509 100644 --- a/combined/Dockerfile +++ b/combined/Dockerfile @@ -2,4 +2,5 @@ FROM ubuntu:24.04 RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt ENTRYPOINT [ "/go/bin/netbird-server" ] CMD ["--config", "/etc/netbird/config.yaml"] -COPY netbird-server /go/bin/netbird-server \ No newline at end of file +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-server /go/bin/netbird-server diff --git a/management/Dockerfile b/management/Dockerfile index 3b2df2623..fe414158c 100644 --- a/management/Dockerfile +++ b/management/Dockerfile @@ -2,4 +2,5 @@ FROM ubuntu:24.04 RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt ENTRYPOINT [ "/go/bin/netbird-mgmt","management"] CMD ["--log-file", "console"] -COPY netbird-mgmt /go/bin/netbird-mgmt +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-mgmt /go/bin/netbird-mgmt diff --git a/management/Dockerfile.debug b/management/Dockerfile.debug deleted file mode 100644 index 4d9730bd7..000000000 --- a/management/Dockerfile.debug +++ /dev/null @@ -1,5 +0,0 @@ -FROM ubuntu:24.04 -RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt -ENTRYPOINT [ "/go/bin/netbird-mgmt","management","--log-level","debug"] -CMD ["--log-file", "console"] -COPY netbird-mgmt /go/bin/netbird-mgmt diff --git a/proxy/Dockerfile b/proxy/Dockerfile index e64680fd6..22c4cbfaa 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -7,7 +7,8 @@ RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/pas mkdir -p /tmp/certs FROM gcr.io/distroless/base:debug -COPY netbird-proxy /go/bin/netbird-proxy +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-proxy /go/bin/netbird-proxy COPY --from=builder /tmp/passwd /etc/passwd COPY --from=builder /tmp/group /etc/group COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird diff --git a/relay/Dockerfile b/relay/Dockerfile index f750027c3..757ee7b59 100644 --- a/relay/Dockerfile +++ b/relay/Dockerfile @@ -1,4 +1,5 @@ FROM gcr.io/distroless/base:debug ENTRYPOINT [ "/go/bin/netbird-relay" ] ENV NB_LOG_FILE=console -COPY netbird-relay /go/bin/netbird-relay +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-relay /go/bin/netbird-relay diff --git a/signal/Dockerfile b/signal/Dockerfile index 4fd5fe4a3..f6504dc74 100644 --- a/signal/Dockerfile +++ b/signal/Dockerfile @@ -1,4 +1,5 @@ FROM gcr.io/distroless/base:debug ENTRYPOINT [ "/go/bin/netbird-signal","run" ] CMD ["--log-file", "console"] -COPY netbird-signal /go/bin/netbird-signal +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-signal /go/bin/netbird-signal diff --git a/upload-server/Dockerfile b/upload-server/Dockerfile index a38c6fbb8..3713d6f2a 100644 --- a/upload-server/Dockerfile +++ b/upload-server/Dockerfile @@ -1,3 +1,4 @@ FROM gcr.io/distroless/base:debug ENTRYPOINT [ "/go/bin/netbird-upload" ] -COPY netbird-upload /go/bin/netbird-upload +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-upload /go/bin/netbird-upload From ee360963f96f5feec295102f4f8a1cabc71f1410 Mon Sep 17 00:00:00 2001 From: Theodor Midtlien Date: Thu, 18 Jun 2026 08:49:19 +0200 Subject: [PATCH 15/16] [client] Migrate profile identity from display name to ID and allow renaming of profiles (#6367) * Migrate to profile ids * Migrate android profile manager * Clean up * Fix review * Add ID type * Fix test and runes in ShortID() * Fix profile switch on up and android comments * Revert android profile to string id * Fix feedback * Fix UI feedback * Fix id assignment * Add renaming of profiles * Fix review * Remove ui binary * Fix getProfileConfigPath not validating id * Change resolve handle order and fix server merge problems * Fix mdm test --- client/android/profile_manager.go | 102 +-- client/cmd/login.go | 39 +- client/cmd/login_test.go | 2 +- client/cmd/profile.go | 202 ++++-- client/cmd/root.go | 1 + client/cmd/up.go | 18 +- client/cmd/up_daemon_test.go | 4 +- client/internal/debug/debug_test.go | 1 + client/internal/profilemanager/config.go | 14 + client/internal/profilemanager/id.go | 118 ++++ .../internal/profilemanager/profilemanager.go | 61 +- .../profilemanager/profilemanager_test.go | 8 +- client/internal/profilemanager/service.go | 425 ++++++++--- .../internal/profilemanager/service_test.go | 230 ++++++ client/internal/profilemanager/state.go | 18 +- client/proto/daemon.pb.go | 666 +++++++++++------- client/proto/daemon.proto | 42 +- client/proto/daemon_grpc.pb.go | 38 + client/server/login_overrides_test.go | 2 +- client/server/server.go | 247 ++++--- client/server/server_test.go | 6 +- client/server/setconfig_mdm_test.go | 8 +- client/server/setconfig_test.go | 6 +- client/ui/client_ui.go | 14 +- client/ui/profile.go | 64 +- 25 files changed, 1712 insertions(+), 624 deletions(-) create mode 100644 client/internal/profilemanager/id.go create mode 100644 client/internal/profilemanager/service_test.go diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 60e4d5c32..87c001396 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strings" log "github.com/sirupsen/logrus" @@ -24,6 +23,7 @@ const ( // Profile represents a profile for gomobile type Profile struct { + ID string Name string IsActive bool } @@ -53,10 +53,10 @@ func (p *ProfileArray) Get(i int) *Profile { ├── state.json ← Default profile state ├── active_profile.json ← Active profile tracker (JSON with Name + Username) └── profiles/ ← Subdirectory for non-default profiles - ├── work.json ← Work profile config - ├── work.state.json ← Work profile state - ├── personal.json ← Personal profile config - └── personal.state.json ← Personal profile state + ├── work.json ← Legacy work profile config + ├── work.state.json ← Legacy work profile state + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state */ // ProfileManager manages profiles for Android @@ -99,6 +99,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { var profiles []*Profile for _, p := range internalProfiles { profiles = append(profiles, &Profile{ + ID: p.ID.String(), Name: p.Name, IsActive: p.IsActive, }) @@ -108,55 +109,65 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { } // GetActiveProfile returns the currently active profile name -func (pm *ProfileManager) GetActiveProfile() (string, error) { +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { // Use ServiceManager to stay consistent with ListProfiles // ServiceManager uses active_profile.json activeState, err := pm.serviceMgr.GetActiveProfileState() if err != nil { - return "", fmt.Errorf("failed to get active profile: %w", err) + return nil, fmt.Errorf("failed to get active profile: %w", err) } - return activeState.Name, nil + + // ActiveProfileState only stores the ID (and username), not the display + // name. Resolve the ID to the full profile so callers get the real Name. + prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername) + if err != nil { + return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err) + } + return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil } // SwitchProfile switches to a different profile -func (pm *ProfileManager) SwitchProfile(profileName string) error { +func (pm *ProfileManager) SwitchProfile(id string) error { // Use ServiceManager to stay consistent with ListProfiles // ServiceManager uses active_profile.json err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profileName, + ID: profilemanager.ID(id), Username: androidUsername, }) if err != nil { return fmt.Errorf("failed to switch profile: %w", err) } - log.Infof("switched to profile: %s", profileName) + log.Infof("switched to profile: %s", id) return nil } // AddProfile creates a new profile func (pm *ProfileManager) AddProfile(profileName string) error { // Use ServiceManager (creates profile in profiles/ directory) - if err := pm.serviceMgr.AddProfile(profileName, androidUsername); err != nil { + profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername) + if err != nil { return fmt.Errorf("failed to add profile: %w", err) } - log.Infof("created new profile: %s", profileName) + log.Infof("created new profile: %s", profile.ID) return nil } // LogoutProfile logs out from a profile (clears authentication) -func (pm *ProfileManager) LogoutProfile(profileName string) error { - profileName = sanitizeProfileName(profileName) - - configPath, err := pm.getProfileConfigPath(profileName) +func (pm *ProfileManager) LogoutProfile(id string) error { + configPath, err := pm.getProfileConfigPath(id) if err != nil { return err } + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return fmt.Errorf("id '%s' is not valid", id) + } + // Check if profile exists if _, err := os.Stat(configPath); os.IsNotExist(err) { - return fmt.Errorf("profile '%s' does not exist", profileName) + return fmt.Errorf("profile '%s' does not exist", id) } // Read current config using internal profilemanager @@ -174,53 +185,57 @@ func (pm *ProfileManager) LogoutProfile(profileName string) error { return fmt.Errorf("failed to save config: %w", err) } - log.Infof("logged out from profile: %s", profileName) + log.Infof("logged out from profile: %s", id) return nil } // RemoveProfile deletes a profile -func (pm *ProfileManager) RemoveProfile(profileName string) error { +func (pm *ProfileManager) RemoveProfile(id string) error { // Use ServiceManager (removes profile from profiles/ directory) - if err := pm.serviceMgr.RemoveProfile(profileName, androidUsername); err != nil { + if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil { return fmt.Errorf("failed to remove profile: %w", err) } - log.Infof("removed profile: %s", profileName) + log.Infof("removed profile: %s", id) return nil } // getProfileConfigPath returns the config file path for a profile // This is needed for Android-specific path handling (netbird.cfg for default profile) -func (pm *ProfileManager) getProfileConfigPath(profileName string) (string, error) { - if profileName == "" || profileName == profilemanager.DefaultProfileName { +func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) { + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + + if id == profilemanager.DefaultProfileName { // Android uses netbird.cfg for default profile instead of default.json // Default profile is stored in root configDir, not in profiles/ return filepath.Join(pm.configDir, defaultConfigFilename), nil } - // Non-default profiles are stored in profiles subdirectory - // This matches the Java Preferences.java expectation - profileName = sanitizeProfileName(profileName) profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, profileName+".json"), nil + return filepath.Join(profilesDir, id+".json"), nil } -// GetConfigPath returns the config file path for a given profile +// GetConfigPath returns the config file path for a given profile id // Java should call this instead of constructing paths with Preferences.configFile() -func (pm *ProfileManager) GetConfigPath(profileName string) (string, error) { - return pm.getProfileConfigPath(profileName) +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.getProfileConfigPath(id) } // GetStateFilePath returns the state file path for a given profile // Java should call this instead of constructing paths with Preferences.stateFile() -func (pm *ProfileManager) GetStateFilePath(profileName string) (string, error) { - if profileName == "" || profileName == profilemanager.DefaultProfileName { +func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { + if id == "" || id == profilemanager.DefaultProfileName { return filepath.Join(pm.configDir, "state.json"), nil } - profileName = sanitizeProfileName(profileName) + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, profileName+".state.json"), nil + return filepath.Join(profilesDir, id+".state.json"), nil } // GetActiveConfigPath returns the config file path for the currently active profile @@ -230,7 +245,7 @@ func (pm *ProfileManager) GetActiveConfigPath() (string, error) { if err != nil { return "", fmt.Errorf("failed to get active profile: %w", err) } - return pm.GetConfigPath(activeProfile) + return pm.GetConfigPath(activeProfile.ID) } // GetActiveStateFilePath returns the state file path for the currently active profile @@ -240,18 +255,5 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { if err != nil { return "", fmt.Errorf("failed to get active profile: %w", err) } - return pm.GetStateFilePath(activeProfile) -} - -// sanitizeProfileName removes invalid characters from profile name -func sanitizeProfileName(name string) string { - // Keep only alphanumeric, underscore, and hyphen - var result strings.Builder - for _, r := range name { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '_' || r == '-' { - result.WriteRune(r) - } - } - return result.String() + return pm.GetStateFilePath(activeProfile.ID) } diff --git a/client/cmd/login.go b/client/cmd/login.go index bd37e30f1..2f7677901 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -96,17 +96,19 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str dnsLabelsReq = dnsLabelsValidated.ToSafeStringList() } + handle := activeProf.ID.String() + loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, ManagementUrl: managementURL, IsUnixDesktopClient: isUnixRunningDesktop(), Hostname: hostName, DnsLabels: dnsLabelsReq, - ProfileName: &activeProf.Name, + ProfileName: &handle, Username: &username, } - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -170,14 +172,13 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr return activeProf, nil } -func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) error { - err := switchProfile(context.Background(), profileName, username) +func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error { + resolvedID, err := switchProfile(ctx, handle, username) if err != nil { return fmt.Errorf("switch profile on daemon: %v", err) } - err = pm.SwitchProfile(profileName) - if err != nil { + if err := pm.SwitchProfile(resolvedID); err != nil { return fmt.Errorf("switch profile: %v", err) } @@ -205,11 +206,15 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage return nil } -func switchProfile(ctx context.Context, profileName string, username string) error { +// switchProfile asks the daemon to switch to the profile identified by +// handle (a name, ID, or unique ID prefix). Returns the resolved profile +// ID so the caller can update the local active-profile state without +// re-resolving the handle. +func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { //nolint - return fmt.Errorf("failed to connect to daemon error: %v\n"+ + return "", fmt.Errorf("failed to connect to daemon error: %v\n"+ "If the daemon is not running please run: "+ "\nnetbird service install \nnetbird service start\n", err) } @@ -217,15 +222,15 @@ func switchProfile(ctx context.Context, profileName string, username string) err client := proto.NewDaemonServiceClient(conn) - _, err = client.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profileName, + resp, err := client.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, Username: &username, }) if err != nil { - return fmt.Errorf("switch profile failed: %v", err) + return "", fmt.Errorf("switch profile failed: %v", err) } - return nil + return profilemanager.ID(resp.Id), nil } func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, activeProf *profilemanager.Profile) error { @@ -249,7 +254,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, return fmt.Errorf("read config file %s: %v", configFilePath, err) } - err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name) + err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) } @@ -277,7 +282,7 @@ func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.Lo return nil } -func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey, profileName string) error { +func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey string, profileID profilemanager.ID) error { authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config) if err != nil { return fmt.Errorf("failed to create auth client: %v", err) @@ -291,7 +296,7 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman jwtToken := "" if setupKey == "" && needsLogin { - tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileName) + tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileID) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } @@ -306,10 +311,10 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman return nil } -func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileName string) (*auth.TokenInfo, error) { +func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileID profilemanager.ID) (*auth.TokenInfo, error) { hint := "" pm := profilemanager.NewProfileManager() - profileState, err := pm.GetProfileState(profileName) + profileState, err := pm.GetProfileState(profileID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { diff --git a/client/cmd/login_test.go b/client/cmd/login_test.go index 47522e189..0aa1856b1 100644 --- a/client/cmd/login_test.go +++ b/client/cmd/login_test.go @@ -27,7 +27,7 @@ func TestLogin(t *testing.T) { profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json" sm := profilemanager.ServiceManager{} err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "default", + ID: "default", Username: currUser.Username, }) if err != nil { diff --git a/client/cmd/profile.go b/client/cmd/profile.go index d6e81760f..4de2d754e 100644 --- a/client/cmd/profile.go +++ b/client/cmd/profile.go @@ -2,11 +2,16 @@ package cmd import ( "context" + "errors" "fmt" "os/user" + "strings" + "text/tabwriter" "time" "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/profilemanager" @@ -14,6 +19,8 @@ import ( "github.com/netbirdio/netbird/util" ) +var profileListShowID bool + var profileCmd = &cobra.Command{ Use: "profile", Short: "Manage NetBird client profiles", @@ -31,27 +38,40 @@ var profileListCmd = &cobra.Command{ var profileAddCmd = &cobra.Command{ Use: "add ", Short: "Add a new profile", - Long: `Add a new profile to the NetBird client. The profile name must be unique.`, + Long: `Add a new profile. Profile name is free-form, a unique ID is generated for the on-disk config file.`, Args: cobra.ExactArgs(1), RunE: addProfileFunc, } +var profileRenameCmd = &cobra.Command{ + Use: "rename ", + Short: "Renames an existing profile", + Long: `Renames an existing profile (by a name, ID, or unique ID prefix). Profile name is free-form.`, + Args: cobra.ExactArgs(2), + RunE: renameProfileFunc, +} + var profileRemoveCmd = &cobra.Command{ - Use: "remove ", - Short: "Remove a profile", - Long: `Remove a profile from the NetBird client. The profile must not be inactive.`, - Args: cobra.ExactArgs(1), - RunE: removeProfileFunc, + Use: "remove ", + Short: "Remove a profile", + Long: `Remove a profile by name, ID, or unique ID prefix.`, + Aliases: []string{"rm"}, + Args: cobra.ExactArgs(1), + RunE: removeProfileFunc, } var profileSelectCmd = &cobra.Command{ - Use: "select ", + Use: "select ", Short: "Select a profile", - Long: `Make the specified profile active. This will switch the client to use the selected profile's configuration.`, + Long: `Make the specified profile active. Accepts a name, ID, or unique ID prefix.`, Args: cobra.ExactArgs(1), RunE: selectProfileFunc, } +func init() { + profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column") +} + func setupCmd(cmd *cobra.Command) error { SetFlagsFromEnvVars(rootCmd) SetFlagsFromEnvVars(cmd) @@ -65,6 +85,7 @@ func setupCmd(cmd *cobra.Command) error { return nil } + func listProfilesFunc(cmd *cobra.Command, _ []string) error { if err := setupCmd(cmd); err != nil { return err @@ -83,25 +104,33 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error { daemonClient := proto.NewDaemonServiceClient(conn) - profiles, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{ + resp, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{ Username: currUser.Username, }) if err != nil { return err } - // list profiles, add a tick if the profile is active - cmd.Println("Found", len(profiles.Profiles), "profiles:") - for _, profile := range profiles.Profiles { - // use a cross to indicate the passive profiles - activeMarker := "✗" - if profile.IsActive { - activeMarker = "✓" - } - cmd.Println(activeMarker, profile.Name) + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + if profileListShowID { + fmt.Fprintln(tw, "ID\tNAME\tACTIVE") + } else { + fmt.Fprintln(tw, "NAME\tACTIVE") } - - return nil + for _, profile := range resp.Profiles { + marker := "" + if profile.IsActive { + marker = "✓" + } + name := profilemanager.StripCtrlChars(profile.Name) + id := profilemanager.ID(profile.Id) + if profileListShowID { + fmt.Fprintf(tw, "%s\t%s\t%s\n", id.ShortID(), name, marker) + } else { + fmt.Fprintf(tw, "%s\t%s\n", name, marker) + } + } + return tw.Flush() } func addProfileFunc(cmd *cobra.Command, args []string) error { @@ -121,21 +150,82 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { } daemonClient := proto.NewDaemonServiceClient(conn) - profileName := args[0] - _, err = daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{ + resp, err := daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{ ProfileName: profileName, Username: currUser.Username, }) if err != nil { + return fmt.Errorf("add profile request: %w", err) + } + + dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName) + if dupCount > 1 { + cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, profileName) + cmd.Println("Use `netbird profile list --show-id` to disambiguate later.") + } + + id := profilemanager.ID(resp.Id) + cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName)) + return nil + +} + +func renameProfileFunc(cmd *cobra.Command, args []string) error { + if err := setupCmd(cmd); err != nil { return err } - cmd.Println("Profile added successfully:", profileName) + conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) + if err != nil { + return fmt.Errorf("connect to service CLI interface: %w", err) + } + defer conn.Close() + + currUser, err := user.Current() + if err != nil { + return fmt.Errorf("get current user: %w", err) + } + + daemonClient := proto.NewDaemonServiceClient(conn) + handle := args[0] + newProfilename := args[1] + + resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{ + Handle: handle, + Username: currUser.Username, + NewProfileName: newProfilename, + }) + if err != nil { + return wrapAmbiguityError(err, handle) + } + + dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename) + if dupCount > 1 { + cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, newProfilename) + cmd.Println("Use `netbird profile list --show-id` to disambiguate later.") + } + + cmd.Printf("Profile renamed from %s to %s\n", profilemanager.StripCtrlChars(resp.OldProfileName), profilemanager.StripCtrlChars(newProfilename)) + return nil } +func countProfilesWithName(ctx context.Context, c proto.DaemonServiceClient, username, name string) (int, error) { + resp, err := c.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username}) + if err != nil { + return 0, err + } + n := 0 + for _, p := range resp.Profiles { + if p.Name == name { + n++ + } + } + return n, nil +} + func removeProfileFunc(cmd *cobra.Command, args []string) error { if err := setupCmd(cmd); err != nil { return err @@ -153,18 +243,17 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error { } daemonClient := proto.NewDaemonServiceClient(conn) + handle := args[0] - profileName := args[0] - - _, err = daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{ - ProfileName: profileName, + resp, err := daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{ + ProfileName: handle, Username: currUser.Username, }) if err != nil { - return err + return wrapAmbiguityError(err, handle) } - cmd.Println("Profile removed successfully:", profileName) + cmd.Printf("Profile removed: %s\n", resp.Id) return nil } @@ -174,7 +263,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { } profileManager := profilemanager.NewProfileManager() - profileName := args[0] + handle := args[0] currUser, err := user.Current() if err != nil { @@ -191,32 +280,15 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { daemonClient := proto.NewDaemonServiceClient(conn) - profiles, err := daemonClient.ListProfiles(ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, + switchResp, err := daemonClient.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, + Username: &currUser.Username, }) if err != nil { - return fmt.Errorf("list profiles: %w", err) + return wrapAmbiguityError(err, handle) } - var profileExists bool - - for _, profile := range profiles.Profiles { - if profile.Name == profileName { - profileExists = true - break - } - } - - if !profileExists { - return fmt.Errorf("profile %s does not exist", profileName) - } - - if err := switchProfile(cmd.Context(), profileName, currUser.Username); err != nil { - return err - } - - err = profileManager.SwitchProfile(profileName) - if err != nil { + if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil { return err } @@ -231,6 +303,30 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { } } - cmd.Println("Profile switched successfully to:", profileName) + id := profilemanager.ID(switchResp.Id) + cmd.Printf("Profile switched to: %s\n", id.ShortID()) return nil } + +// wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors +// (which carry the resolver's message verbatim) into CLI-friendly text +// that points the user at --show-id. +func wrapAmbiguityError(err error, handle string) error { + if err == nil { + return nil + } + st, ok := gstatus.FromError(err) + if !ok { + return err + } + switch st.Code() { + case codes.InvalidArgument: + msg := st.Message() + if strings.Contains(msg, "ambiguous") { + return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n netbird profile select|remove ") + } + case codes.NotFound: + return fmt.Errorf("profile %q not found", handle) + } + return err +} diff --git a/client/cmd/root.go b/client/cmd/root.go index b1d960bec..f3fde2f1c 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -190,6 +190,7 @@ func init() { // profile commands profileCmd.AddCommand(profileListCmd) profileCmd.AddCommand(profileAddCmd) + profileCmd.AddCommand(profileRenameCmd) profileCmd.AddCommand(profileRemoveCmd) profileCmd.AddCommand(profileSelectCmd) diff --git a/client/cmd/up.go b/client/cmd/up.go index cabd0aacf..2761cf74a 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -128,13 +128,12 @@ func upFunc(cmd *cobra.Command, args []string) error { var profileSwitched bool // switch profile if provided if profileName != "" { - err = switchProfile(cmd.Context(), profileName, username.Username) + resolvedID, err := switchProfile(cmd.Context(), profileName, username.Username) if err != nil { return fmt.Errorf("switch profile: %v", err) } - err = pm.SwitchProfile(profileName) - if err != nil { + if err := pm.SwitchProfile(resolvedID); err != nil { return fmt.Errorf("switch profile: %v", err) } @@ -190,7 +189,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr _, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath) - err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.Name) + err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) } @@ -261,10 +260,10 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager } // set the new config - req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.Name, username.Username) + req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username) if _, err := client.SetConfig(ctx, req); err != nil { if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable { - log.Warnf("setConfig method is not available in the daemon") + log.Warnf("setConfig method is not available in the daemon: %s", st.Message()) } else { return fmt.Errorf("call service setConfig method: %v", err) } @@ -289,10 +288,11 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ return fmt.Errorf("setup login request: %v", err) } - loginRequest.ProfileName = &activeProf.Name + profileID := activeProf.ID.String() + loginRequest.ProfileName = &profileID loginRequest.Username = &username - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -329,7 +329,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ } if _, err := client.Up(ctx, &proto.UpRequest{ - ProfileName: &activeProf.Name, + ProfileName: &profileID, Username: &username, }); err != nil { return fmt.Errorf("call service up method: %v", err) diff --git a/client/cmd/up_daemon_test.go b/client/cmd/up_daemon_test.go index 682a45365..ea4cdf162 100644 --- a/client/cmd/up_daemon_test.go +++ b/client/cmd/up_daemon_test.go @@ -29,14 +29,14 @@ func TestUpDaemon(t *testing.T) { } sm := profilemanager.ServiceManager{} - err = sm.AddProfile("test1", currUser.Username) + created, err := sm.AddProfile("test1", currUser.Username) if err != nil { t.Fatalf("failed to add profile: %v", err) return } err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "test1", + ID: created.ID, Username: currUser.Username, }) if err != nil { diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 76df588a5..ca7785d35 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -843,6 +843,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { "PreSharedKey": "sensitive: WireGuard pre-shared key", "SSHKey": "sensitive: SSH private key", "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", + "Name": "non-config: profile name is not needed for debug purposes", "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", } diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index b0c7fd470..a77f0ff32 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -108,6 +108,10 @@ type ConfigInput struct { // Config Configuration type type Config struct { + // Name is the human-readable profile name shown in CLI/UI listings. + // It is independent of the profile's on-disk filename (which is the ID). + Name string + // Wireguard private key of local peer PrivateKey string PreSharedKey string @@ -270,6 +274,16 @@ func createNewConfig(input ConfigInput) (*Config, error) { } func (config *Config) apply(input ConfigInput) (updated bool, err error) { + if config.Name != "" { + sanitized, err := sanitizeDisplayName(config.Name) + if err != nil { + return false, fmt.Errorf("invalid profile name: %w", err) + } + if sanitized != config.Name { + config.Name = sanitized + updated = true + } + } if config.ManagementURL == nil { log.Infof("using default Management URL %s", DefaultManagementURL) config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL) diff --git a/client/internal/profilemanager/id.go b/client/internal/profilemanager/id.go new file mode 100644 index 000000000..3b82c8779 --- /dev/null +++ b/client/internal/profilemanager/id.go @@ -0,0 +1,118 @@ +package profilemanager + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + // profileIDByteLen is the number of random bytes generated for a new + // profile ID. The resulting hex string is twice this length. + profileIDByteLen = 16 + + // shortIDLen is the number of leading characters of an ID we render in + // list output. Profiles per device are few, so 8 chars is collision-safe + // in practice and easy to type as a prefix. + shortIDLen = 8 + + // maxProfileNameLen caps the human-readable profile name to keep table + // output legible and prevent denial-of-service via huge JSON fields. + maxProfileNameLen = 128 + + // maxProfileIDLen bounds the on-disk filename we'll accept. New + // IDs are 32 hex chars, legacy stems are sanitized profile names. The + // cap is generous enough to cover both without permitting absurdly + // long filenames. + maxProfileIDLen = 64 +) + +type ID string + +// generateProfileID returns a new random hex ID for a profile file. +func generateProfileID() (ID, error) { + buf := make([]byte, profileIDByteLen) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random bytes: %w", err) + } + return ID(hex.EncodeToString(buf)), nil +} + +// IsValidProfileFilenameStem reports whether id is safe to use as the stem +// of a profile JSON filename. +func IsValidProfileFilenameStem(id ID) bool { + s := id.String() + if s == "" || len(s) > maxProfileIDLen { + return false + } + if s == defaultProfileName { + return true + } + if strings.ContainsAny(s, `/\`) || strings.Contains(s, "..") { + return false + } + // filepath.Base catches any leftover separators on platforms with + // exotic path conventions. + if filepath.Base(s) != s { + return false + } + for _, r := range s { + if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-') { + return false + } + } + return true +} + +// sanitizeDisplayName normalizes a user-supplied profile display name for +// storage. It strips ASCII control characters, rejects invalid UTF-8, and +// caps the length. Emojis, spaces, punctuation, and non-ASCII letters are +// preserved. Returns an error if nothing usable remains. +func sanitizeDisplayName(name string) (string, error) { + if !utf8.ValidString(name) { + return "", fmt.Errorf("name is not valid UTF-8") + } + name = StripCtrlChars(name) + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("name is empty after sanitization") + } + if utf8.RuneCountInString(name) > maxProfileNameLen { + return "", fmt.Errorf("name exceeds %d characters", maxProfileNameLen) + } + return name, nil +} + +// StripCtrlChars control characters from a name before printing it. +func StripCtrlChars(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + // Skip C0 controls and DEL, plus C1 controls (0x80–0x9F). + if r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F) { + continue + } + b.WriteRune(r) + } + return b.String() +} + +// ShortID truncates an ID for display. +func (id ID) ShortID() string { + if id == DefaultProfileName { + return DefaultProfileName + } + runes := []rune(id) + if len(runes) <= shortIDLen { + return id.String() + } + return string(runes[:shortIDLen]) +} + +func (id ID) String() string { + return string(id) +} diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go index c87f521cb..e25d493d5 100644 --- a/client/internal/profilemanager/profilemanager.go +++ b/client/internal/profilemanager/profilemanager.go @@ -19,19 +19,41 @@ const ( ) type Profile struct { - Name string + // ID is the on-disk filename stem (without .json). For new profiles + // it is a 32-char hex string; legacy profiles created before the + // ID-keyed layout keep their original name as their ID. The reserved + // value "default" identifies the special default profile. + ID ID + // Name is the human-readable display name. Falls back to ID when the + // underlying JSON has no "name" field set. + Name string + // Path is the absolute path to the profile JSON. Populated by the + // loader so callers do not have to reconstruct it from ID + dir. + Path string IsActive bool } func (p *Profile) FilePath() (string, error) { - if p.Name == "" { - return "", fmt.Errorf("active profile name is empty") + if p.Path != "" { + return p.Path, nil } - if p.Name == defaultProfileName { + id := p.ID + if id == "" { + id = ID(p.Name) + } + if id == "" { + return "", fmt.Errorf("profile ID is empty") + } + + if id == defaultProfileName { return DefaultConfigPath, nil } + if !IsValidProfileFilenameStem(id) { + return "", fmt.Errorf("invalid profile ID: %q", id) + } + username, err := user.Current() if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) @@ -42,10 +64,13 @@ func (p *Profile) FilePath() (string, error) { return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err) } - return filepath.Join(configDir, p.Name+".json"), nil + return filepath.Join(configDir, id.String()+".json"), nil } func (p *Profile) IsDefault() bool { + if p.ID != "" { + return p.ID == defaultProfileName + } return p.Name == defaultProfileName } @@ -57,18 +82,24 @@ func NewProfileManager() *ProfileManager { return &ProfileManager{} } +// GetActiveProfile returns the active profile as recorded in the local +// user state file. Only ID is populated. func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { pm.mu.Lock() defer pm.mu.Unlock() - prof := pm.getActiveProfileState() - return &Profile{Name: prof}, nil + id := pm.getActiveProfileState() + return &Profile{ID: id}, nil } -func (pm *ProfileManager) SwitchProfile(profileName string) error { - profileName = sanitizeProfileName(profileName) +// SwitchProfile records the given profile ID as active in the local user +// state file. +func (pm *ProfileManager) SwitchProfile(id ID) error { + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } - if err := pm.setActiveProfileState(profileName); err != nil { + if err := pm.setActiveProfileState(id); err != nil { return fmt.Errorf("failed to switch profile: %w", err) } return nil @@ -85,7 +116,7 @@ func sanitizeProfileName(name string) string { }, name) } -func (pm *ProfileManager) getActiveProfileState() string { +func (pm *ProfileManager) getActiveProfileState() ID { configDir, err := getConfigDir() if err != nil { @@ -113,10 +144,10 @@ func (pm *ProfileManager) getActiveProfileState() string { return defaultProfileName } - return profileName + return ID(profileName) } -func (pm *ProfileManager) setActiveProfileState(profileName string) error { +func (pm *ProfileManager) setActiveProfileState(id ID) error { configDir, err := getConfigDir() if err != nil { @@ -125,7 +156,7 @@ func (pm *ProfileManager) setActiveProfileState(profileName string) error { statePath := filepath.Join(configDir, activeProfileStateFilename) - err = os.WriteFile(statePath, []byte(profileName), 0600) + err = os.WriteFile(statePath, []byte(id), 0600) if err != nil { return fmt.Errorf("failed to write active profile state: %w", err) } @@ -142,7 +173,7 @@ func GetLoginHint() string { return "" } - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) return "" diff --git a/client/internal/profilemanager/profilemanager_test.go b/client/internal/profilemanager/profilemanager_test.go index 79a7ae650..882a71d0a 100644 --- a/client/internal/profilemanager/profilemanager_test.go +++ b/client/internal/profilemanager/profilemanager_test.go @@ -50,14 +50,14 @@ func TestServiceManager_CreateAndGetDefaultProfile(t *testing.T) { state, err := sm.GetActiveProfileState() assert.NoError(t, err) - assert.Equal(t, state.Name, defaultProfileName) // No active profile state yet + assert.Equal(t, defaultProfileName, state.ID.String()) // No active profile state yet err = sm.SetActiveProfileStateToDefault() assert.NoError(t, err) active, err := sm.GetActiveProfileState() assert.NoError(t, err) - assert.Equal(t, "default", active.Name) + assert.Equal(t, "default", active.ID.String()) }) }) } @@ -92,14 +92,14 @@ func TestServiceManager_SetActiveProfileState(t *testing.T) { currUser, err := user.Current() assert.NoError(t, err) sm := &ServiceManager{} - state := &ActiveProfileState{Name: "foo", Username: currUser.Username} + state := &ActiveProfileState{ID: "foo", Username: currUser.Username} err = sm.SetActiveProfileState(state) assert.NoError(t, err) // Should error on nil or incomplete state err = sm.SetActiveProfileState(nil) assert.Error(t, err) - err = sm.SetActiveProfileState(&ActiveProfileState{Name: "", Username: ""}) + err = sm.SetActiveProfileState(&ActiveProfileState{ID: "", Username: ""}) assert.Error(t, err) }) }) diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index ef3eb1114..5ddd11b04 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -2,6 +2,7 @@ package profilemanager import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -23,12 +24,43 @@ var ( DefaultConfigPathDir = "" DefaultConfigPath = "" ActiveProfileStatePath = "" -) -var ( ErrorOldDefaultConfigNotFound = errors.New("old default config not found") ) +// ErrAmbiguousHandle is returned when a profile handle (ID prefix or name) +// matches more than one profile. Callers can render Candidates to help the +// user disambiguate. +type ErrAmbiguousHandle struct { + Handle string + Candidates []Profile + Kind AmbiguityKind +} + +// AmbiguityKind describes which matcher produced the ambiguity, so callers +// can tailor the error message. +type AmbiguityKind int + +const ( + AmbiguityKindIDPrefix AmbiguityKind = iota + AmbiguityKindName +) + +// profileMeta is the minimal slice of a profile JSON we need, so we avoid +// reading all fields +type profileMeta struct { + Name string +} + +func (e *ErrAmbiguousHandle) Error() string { + switch e.Kind { + case AmbiguityKindIDPrefix: + return fmt.Sprintf("ID prefix %q is ambiguous (matches %d profiles)", e.Handle, len(e.Candidates)) + default: + return fmt.Sprintf("name %q is ambiguous (%d profiles share this name)", e.Handle, len(e.Candidates)) + } +} + func init() { DefaultConfigPathDir = "/var/lib/netbird/" @@ -54,25 +86,34 @@ func init() { } type ActiveProfileState struct { - Name string `json:"name"` + // ID is the on-disk filename stem of the active profile. The JSON tag stays + // as "name" for backwards compatibility with active state files written + // before the ID-based config files. Legacy values were profile names, which + // were also the legacy filename stems, so they still resolve to the correct + // file on disk. + ID ID `json:"name"` Username string `json:"username"` } func (a *ActiveProfileState) FilePath() (string, error) { - if a.Name == "" { - return "", fmt.Errorf("active profile name is empty") + if a.ID == "" { + return "", fmt.Errorf("active profile ID is empty") } - if a.Name == defaultProfileName { + if a.ID == defaultProfileName { return DefaultConfigPath, nil } + if !IsValidProfileFilenameStem(a.ID) { + return "", fmt.Errorf("invalid profile ID: %q", a.ID) + } + configDir, err := getConfigDirForUser(a.Username) if err != nil { return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err) } - return filepath.Join(configDir, a.Name+".json"), nil + return filepath.Join(configDir, a.ID.String()+".json"), nil } type ServiceManager struct { @@ -178,7 +219,7 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) { return nil, fmt.Errorf("failed to set active profile to default: %w", err) } return &ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }, nil } else { @@ -186,12 +227,12 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) { } } - if activeProfile.Name == "" { + if activeProfile.ID == "" { if err := s.SetActiveProfileStateToDefault(); err != nil { return nil, fmt.Errorf("failed to set active profile to default: %w", err) } return &ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }, nil } @@ -216,25 +257,29 @@ func (s *ServiceManager) setDefaultActiveState() error { } func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error { - if a == nil || a.Name == "" { + if a == nil || a.ID == "" { return errors.New("invalid active profile state") } - if a.Name != defaultProfileName && a.Username == "" { - return fmt.Errorf("username must be set for non-default profiles, got: %s", a.Name) + if a.ID != defaultProfileName && a.Username == "" { + return fmt.Errorf("username must be set for non-default profiles, got: %s", a.ID) + } + + if a.ID != defaultProfileName && !IsValidProfileFilenameStem(a.ID) { + return fmt.Errorf("invalid profile ID: %q", a.ID) } if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil { return fmt.Errorf("failed to write active profile state: %w", err) } - log.Infof("active profile set to %s for %s", a.Name, a.Username) + log.Infof("active profile set to %s for %s", a.ID, a.Username) return nil } func (s *ServiceManager) SetActiveProfileStateToDefault() error { return s.SetActiveProfileState(&ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }) } @@ -243,57 +288,117 @@ func (s *ServiceManager) DefaultProfilePath() string { return DefaultConfigPath } -func (s *ServiceManager) AddProfile(profileName, username string) error { +// AddProfile creates a new profile with a generated ID. The user-supplied +// displayName is stored inside the JSON's name field, the on-disk filename +// uses the generated ID. +// +// The returned Profile carries the freshly-generated ID so callers can +// show it to the user (and so the gRPC AddProfileResponse can include +// it). +func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) { configDir, err := s.getConfigDir(username) if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) + return nil, fmt.Errorf("failed to get config directory: %w", err) } - profileName = sanitizeProfileName(profileName) - - if profileName == defaultProfileName { - return fmt.Errorf("cannot create profile with reserved name: %s", defaultProfileName) - } - - profPath := filepath.Join(configDir, profileName+".json") - profileExists, err := fileExists(profPath) + displayName, err = sanitizeDisplayName(displayName) if err != nil { - return fmt.Errorf("failed to check if profile exists: %w", err) - } - if profileExists { - return ErrProfileAlreadyExists + return nil, fmt.Errorf("invalid profile name: %w", err) } + id, err := generateProfileID() + if err != nil { + return nil, fmt.Errorf("generate profile id: %w", err) + } + + profPath := filepath.Join(configDir, id.String()+".json") cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath}) if err != nil { - return fmt.Errorf("failed to create new config: %w", err) + return nil, fmt.Errorf("failed to create new config: %w", err) + } + cfg.Name = displayName + + if err := util.WriteJson(context.Background(), profPath, cfg); err != nil { + return nil, fmt.Errorf("failed to write profile config: %w", err) } - err = util.WriteJson(context.Background(), profPath, cfg) + return &Profile{ + ID: id, + Name: displayName, + Path: profPath, + }, nil +} + +func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error { + displayName, err := sanitizeDisplayName(newName) if err != nil { - return fmt.Errorf("failed to write profile config: %w", err) + return fmt.Errorf("invalid profile name: %w", err) } + if !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } + + profiles, err := s.loadAllProfiles(username) + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + + var target *Profile + for i := range profiles { + if profiles[i].ID == id { + target = &profiles[i] + break + } + } + if target == nil { + return ErrProfileNotFound + } + + data, err := os.ReadFile(target.Path) + if err != nil { + return err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + cfg.Name = displayName + + if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil { + return fmt.Errorf("failed to write profile name: %w", err) + } return nil } -func (s *ServiceManager) RemoveProfile(profileName, username string) error { - configDir, err := s.getConfigDir(username) - if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) +// RemoveProfile deletes the profile identified by id. Callers must have +// already resolved any user-supplied handle to a concrete ID via +// ResolveProfile. +func (s *ServiceManager) RemoveProfile(id ID, username string) error { + if id == defaultProfileName { + defaultName := readProfileName(DefaultConfigPath) + if defaultName == "" { + defaultName = defaultProfileName + } + return fmt.Errorf("cannot remove default profile with name: %s", defaultName) + } + if !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) } - profileName = sanitizeProfileName(profileName) - - if profileName == defaultProfileName { - return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName) - } - profPath := filepath.Join(configDir, profileName+".json") - profileExists, err := fileExists(profPath) + profiles, err := s.loadAllProfiles(username) if err != nil { - return fmt.Errorf("failed to check if profile exists: %w", err) + return fmt.Errorf("load profiles: %w", err) } - if !profileExists { + + var target *Profile + for i := range profiles { + if profiles[i].ID == id { + target = &profiles[i] + break + } + } + if target == nil { return ErrProfileNotFound } @@ -301,57 +406,26 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error { if err != nil && !errors.Is(err, ErrNoActiveProfile) { return fmt.Errorf("failed to get active profile: %w", err) } - - if activeProf != nil && activeProf.Name == profileName { - return fmt.Errorf("cannot remove active profile: %s", profileName) + if activeProf != nil && activeProf.ID == id { + return fmt.Errorf("cannot remove active profile: %s", id) } - err = util.RemoveJson(profPath) - if err != nil { + if err := util.RemoveJson(target.Path); err != nil { return fmt.Errorf("failed to remove profile config: %w", err) } + + stateFile := filepath.Join(filepath.Dir(target.Path), id.String()+".state.json") + if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { + log.Warnf("failed to remove profile state file %s: %v", stateFile, err) + } + return nil } +// ListProfiles returns every profile for the given user, including the +// default profile, with IsActive flags set. func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) { - configDir, err := s.getConfigDir(username) - if err != nil { - return nil, fmt.Errorf("failed to get config directory: %w", err) - } - - files, err := util.ListFiles(configDir, "*.json") - if err != nil { - return nil, fmt.Errorf("failed to list profile files: %w", err) - } - - var filtered []string - for _, file := range files { - if strings.HasSuffix(file, "state.json") { - continue // skip state files - } - filtered = append(filtered, file) - } - sort.Strings(filtered) - - var activeProfName string - activeProf, err := s.GetActiveProfileState() - if err == nil { - activeProfName = activeProf.Name - } - - var profiles []Profile - // add default profile always - profiles = append(profiles, Profile{Name: defaultProfileName, IsActive: activeProfName == "" || activeProfName == defaultProfileName}) - for _, file := range filtered { - profileName := strings.TrimSuffix(filepath.Base(file), ".json") - var isActive bool - if activeProfName != "" && activeProfName == profileName { - isActive = true - } - profiles = append(profiles, Profile{Name: profileName, IsActive: isActive}) - } - - return profiles, nil + return s.loadAllProfiles(username) } // GetStatePath returns the path to the state file based on the operating system @@ -369,7 +443,12 @@ func (s *ServiceManager) GetStatePath() string { return defaultStatePath } - if activeProf.Name == defaultProfileName { + if activeProf.ID == defaultProfileName { + return defaultStatePath + } + + if !IsValidProfileFilenameStem(activeProf.ID) { + log.Warnf("invalid active profile ID %q, using default state path", activeProf.ID) return defaultStatePath } @@ -379,7 +458,7 @@ func (s *ServiceManager) GetStatePath() string { return defaultStatePath } - return filepath.Join(configDir, activeProf.Name+".state.json") + return filepath.Join(configDir, activeProf.ID.String()+".state.json") } // getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser @@ -390,3 +469,169 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) { return getConfigDirForUser(username) } + +// loadAllProfiles returns every profile visible to the daemon for the +// given user, including the default profile. The returned slice is sorted +// by ID for a stable display order. +// +// Each Profile is fully populated: ID is the filename stem, Name comes +// from the JSON's "name" field (falling back to the filename stem when absent) +// and Path is built from a basename read off disk. +func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) { + activeID, activeIsDefault := s.activeProfileID() + defaultName := readProfileName(DefaultConfigPath) + if defaultName == "" { + defaultName = defaultProfileName + } + + profiles := []Profile{{ + ID: defaultProfileName, + Name: defaultName, + Path: DefaultConfigPath, + IsActive: activeIsDefault, + }} + + configDir, err := s.getConfigDir(username) + if err != nil { + return nil, fmt.Errorf("get config directory: %w", err) + } + + entries, err := os.ReadDir(configDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return profiles, nil + } + return nil, fmt.Errorf("read profile directory: %w", err) + } + + var fileProfiles []Profile + for _, entry := range entries { + if entry.IsDir() { + continue + } + base := entry.Name() + if !strings.HasSuffix(base, ".json") { + continue + } + if strings.HasSuffix(base, ".state.json") { + continue + } + stem := ID(strings.TrimSuffix(base, ".json")) + if stem == defaultProfileName { + // default lives at the top-level config dir, not under / + continue + } + if !IsValidProfileFilenameStem(ID(stem)) { + continue + } + path := filepath.Join(configDir, base) + name := readProfileName(path) + if name == "" { + name = stem.String() + } + fileProfiles = append(fileProfiles, Profile{ + ID: stem, + Name: name, + Path: path, + IsActive: stem == ID(activeID), + }) + } + + sort.Slice(fileProfiles, func(i, j int) bool { + if fileProfiles[i].Name != fileProfiles[j].Name { + return fileProfiles[i].Name < fileProfiles[j].Name + } + // Sort tie-break on ID so duplicate names always render in the same order. + return fileProfiles[i].ID < fileProfiles[j].ID + }) + profiles = append(profiles, fileProfiles...) + return profiles, nil +} + +// readProfileName parses just the "name" field from the profile Json. +func readProfileName(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var meta profileMeta + if err := json.Unmarshal(data, &meta); err != nil { + return "" + } + return meta.Name +} + +// activeProfileID returns the currently-active profile's ID. The second +// return value is true when the active profile is the default one. +func (s *ServiceManager) activeProfileID() (ID, bool) { + state, err := s.GetActiveProfileState() + if err != nil || state == nil { + return defaultProfileName, true + } + if state.ID == "" || state.ID == defaultProfileName { + return defaultProfileName, true + } + return state.ID, false +} + +// ResolveProfile turns a user-supplied handle into a Profile. Resolution +// precedence is: exact ID match, then unique exact name, then unique ID +// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can +// surface the candidates. +func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, error) { + if handle == "" { + return nil, fmt.Errorf("profile handle is empty") + } + + profiles, err := s.loadAllProfiles(username) + if err != nil { + return nil, err + } + + for i := range profiles { + if profiles[i].ID == ID(handle) { + return &profiles[i], nil + } + } + + var nameMatches []Profile + for i := range profiles { + if profiles[i].Name == handle { + nameMatches = append(nameMatches, profiles[i]) + } + } + if len(nameMatches) == 1 { + return &nameMatches[0], nil + } + if len(nameMatches) > 1 { + return nil, &ErrAmbiguousHandle{ + Handle: handle, + Candidates: nameMatches, + Kind: AmbiguityKindName, + } + } + + // ID prefix match. Skip the default profile so `select d` does not + // accidentally pick it via prefix. + var prefixMatches []Profile + for i := range profiles { + if profiles[i].ID == defaultProfileName { + continue + } + if strings.HasPrefix(profiles[i].ID.String(), handle) { + prefixMatches = append(prefixMatches, profiles[i]) + } + } + if len(prefixMatches) == 1 { + return &prefixMatches[0], nil + } + if len(prefixMatches) > 1 { + return nil, &ErrAmbiguousHandle{ + Handle: handle, + Candidates: prefixMatches, + Kind: AmbiguityKindIDPrefix, + } + } + + return nil, ErrProfileNotFound +} diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go new file mode 100644 index 000000000..5e051b15d --- /dev/null +++ b/client/internal/profilemanager/service_test.go @@ -0,0 +1,230 @@ +package profilemanager + +import ( + "context" + "errors" + "os" + "os/user" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/util" +) + +// withTestSM wires up patched globals + a clean config dir and returns a +// fully initialized ServiceManager plus the username we are scoped to. +func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) { + t.Helper() + withTempConfigDir(t, func(configDir string) { + withPatchedGlobals(t, configDir, func() { + u, err := user.Current() + require.NoError(t, err) + sm := &ServiceManager{} + require.NoError(t, sm.CreateDefaultProfile()) + fn(sm, u.Username) + }) + }) +} + +func TestServiceProfile_ExactID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + got, err := sm.ResolveProfile(created.ID.String(), username) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, "work", got.Name) + }) +} + +func TestServiceProfile_IDPrefix(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefix := created.ID[:4] + got, err := sm.ResolveProfile(prefix.String(), username) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + }) +} + +func TestServiceProfile_AmbiguousPrefix(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + // Plant two profiles whose IDs share a known prefix by writing + // the files directly, since generated IDs are random. + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} { + path := filepath.Join(configDir, id+".json") + require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id})) + } + + _, err = sm.ResolveProfile("abcd", username) + var amb *ErrAmbiguousHandle + require.ErrorAs(t, err, &amb) + assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind) + assert.Len(t, amb.Candidates, 2) + }) +} + +func TestServiceProfile_ExactNameUnique(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.AddProfile("work", username) + require.NoError(t, err) + + got, err := sm.ResolveProfile("work", username) + require.NoError(t, err) + assert.Equal(t, "work", got.Name) + }) +} + +func TestServiceProfile_AmbiguousName(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.AddProfile("work", username) + require.NoError(t, err) + _, err = sm.AddProfile("work", username) + require.NoError(t, err) + + _, err = sm.ResolveProfile("work", username) + var amb *ErrAmbiguousHandle + require.ErrorAs(t, err, &amb) + assert.Equal(t, AmbiguityKindName, amb.Kind) + assert.Len(t, amb.Candidates, 2) + }) +} + +func TestServiceProfile_NotFound(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.ResolveProfile("nope", username) + assert.ErrorIs(t, err, ErrProfileNotFound) + }) +} + +func TestServiceProfile_DefaultByExactID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + got, err := sm.ResolveProfile(defaultProfileName, username) + require.NoError(t, err) + assert.Equal(t, defaultProfileName, got.ID.String()) + }) +} + +func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) { + // Legacy profiles stored as .json with no "name" JSON field + // should still be discoverable by name and removable by name. + withTestSM(t, func(sm *ServiceManager, username string) { + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + path := filepath.Join(configDir, "legacy.json") + require.NoError(t, util.WriteJson(context.Background(), path, &Config{})) + + got, err := sm.ResolveProfile("legacy", username) + require.NoError(t, err) + assert.Equal(t, "legacy", got.ID.String()) + // Name falls back to the filename stem when JSON omits it. + assert.Equal(t, "legacy", got.Name) + }) +} + +func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + first, err := sm.AddProfile("work", username) + require.NoError(t, err) + + second, err := sm.AddProfile("work", username) + require.NoError(t, err) + assert.NotEqual(t, first.ID, second.ID) + assert.Equal(t, "work", second.Name) + }) +} + +func TestAddProfile_RejectsInvalidNames(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + cases := []string{ + "", // empty + "\x00\x01", // only control chars (becomes empty) + strings.Repeat("a", maxProfileNameLen+1), // too long + } + for _, name := range cases { + _, err := sm.AddProfile(name, username) + assert.Error(t, err, "expected error for %q", name) + } + }) +} + +func TestRemoveProfile_RejectsInvalidID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + err := sm.RemoveProfile("../escape", username) + assert.Error(t, err) + }) +} + +func TestSanitizeDisplayName(t *testing.T) { + cases := []struct { + in string + want string + wantErr bool + }{ + {"work", "work", false}, + {"My Work Account", "My Work Account", false}, + {"emoji 🚀 ok", "emoji 🚀 ok", false}, + {"漢字テスト", "漢字テスト", false}, + {"with\x00null", "withnull", false}, + {"\x01\x02\x03", "", true}, + {"", "", true}, + } + for _, tc := range cases { + got, err := sanitizeDisplayName(tc.in) + if tc.wantErr { + assert.Error(t, err, "case %q", tc.in) + continue + } + assert.NoError(t, err, "case %q", tc.in) + assert.Equal(t, tc.want, got, "case %q", tc.in) + } +} + +func TestIsValidProfileFilenameStem(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"default", true}, + {"abc123def456", true}, + {"legacy-name", true}, + {"legacy_name", true}, + {"", false}, + {"..", false}, + {"../etc", false}, + {"foo/bar", false}, + {`foo\bar`, false}, + {"with space", false}, + {"with.dot", false}, + {strings.Repeat("a", maxProfileIDLen+1), false}, + } + for _, tc := range cases { + got := IsValidProfileFilenameStem(ID(tc.in)) + assert.Equal(t, tc.want, got, "case %q", tc.in) + } +} + +func TestRemoveProfile_DeletesStateFile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + statePath := filepath.Join(configDir, created.ID.String()+".state.json") + require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600)) + + require.NoError(t, sm.RemoveProfile(created.ID, username)) + _, err = os.Stat(statePath) + assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed") + }) +} diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go index f09391ede..1bf3318af 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -13,13 +13,20 @@ type ProfileState struct { Email string `json:"email"` } -func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, error) { +// GetProfileState reads the per-profile state file keyed by profile ID. +// The state file lives in the user's config directory. Legacy state files +// keyed by the old profile name remain readable. +func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) { configDir, err := getConfigDir() if err != nil { return nil, fmt.Errorf("get config directory: %w", err) } - stateFile := filepath.Join(configDir, profileName+".state.json") + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return nil, fmt.Errorf("invalid profile ID: %q", id) + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") stateFileExists, err := fileExists(stateFile) if err != nil { return nil, fmt.Errorf("failed to check if profile state file exists: %w", err) @@ -51,7 +58,12 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { return fmt.Errorf("get active profile: %w", err) } - stateFile := filepath.Join(configDir, activeProf.Name+".state.json") + id := activeProf.ID + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid active profile ID: %q", id) + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state) if err != nil { return fmt.Errorf("write profile state: %w", err) diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 6b5a37658..488b0186c 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -3954,9 +3954,11 @@ func (x *GetEventsResponse) GetEvents() []*SystemEvent { } type SwitchProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` - Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // profileName is treated as a handle: exact ID, unique ID prefix, or + // unique display name. The daemon resolves it server-side. + ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` + Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4006,7 +4008,11 @@ func (x *SwitchProfileRequest) GetUsername() string { } type SwitchProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the resolved on-disk ID of the profile that became active. + // Lets CLI clients update their local active-profile state without + // duplicating the resolution logic. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4041,6 +4047,13 @@ func (*SwitchProfileResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{55} } +func (x *SwitchProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type SetConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` @@ -4397,9 +4410,11 @@ func (*SetConfigResponse) Descriptor() ([]byte, []int) { } type AddProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` - ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // profileName carries the human-readable display name for the new + // profile. The on-disk filename is a separately-generated ID. + ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4449,7 +4464,10 @@ func (x *AddProfileRequest) GetProfileName() string { } type AddProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the generated on-disk ID of the new profile. CLI clients + // display a truncated form, UI clients can ignore it. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4484,17 +4502,133 @@ func (*AddProfileResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{59} } +func (x *AddProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type RenameProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // handle: an exact ID, a unique ID prefix, or a unique display name. + Handle string `protobuf:"bytes,2,opt,name=handle,proto3" json:"handle,omitempty"` + // newProfileName is the new human-readable display name for the profile. + NewProfileName string `protobuf:"bytes,3,opt,name=newProfileName,proto3" json:"newProfileName,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameProfileRequest) Reset() { + *x = RenameProfileRequest{} + mi := &file_daemon_proto_msgTypes[60] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameProfileRequest) ProtoMessage() {} + +func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[60] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenameProfileRequest.ProtoReflect.Descriptor instead. +func (*RenameProfileRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{60} +} + +func (x *RenameProfileRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *RenameProfileRequest) GetHandle() string { + if x != nil { + return x.Handle + } + return "" +} + +func (x *RenameProfileRequest) GetNewProfileName() string { + if x != nil { + return x.NewProfileName + } + return "" +} + +type RenameProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // confirm the old profile name after resolving handle. + OldProfileName string `protobuf:"bytes,1,opt,name=oldProfileName,proto3" json:"oldProfileName,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameProfileResponse) Reset() { + *x = RenameProfileResponse{} + mi := &file_daemon_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameProfileResponse) ProtoMessage() {} + +func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[61] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenameProfileResponse.ProtoReflect.Descriptor instead. +func (*RenameProfileResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{61} +} + +func (x *RenameProfileResponse) GetOldProfileName() string { + if x != nil { + return x.OldProfileName + } + return "" +} + type RemoveProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` - ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // profileName is treated as a handle: an exact ID, a unique ID + // prefix, or a unique display name. Resolution happens server-side. + ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RemoveProfileRequest) Reset() { *x = RemoveProfileRequest{} - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4506,7 +4640,7 @@ func (x *RemoveProfileRequest) String() string { func (*RemoveProfileRequest) ProtoMessage() {} func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4519,7 +4653,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead. func (*RemoveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{60} + return file_daemon_proto_rawDescGZIP(), []int{62} } func (x *RemoveProfileRequest) GetUsername() string { @@ -4537,14 +4671,17 @@ func (x *RemoveProfileRequest) GetProfileName() string { } type RemoveProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the full resolved ID of the removed profile, so callers can + // confirm exactly which profile a name/prefix handle resolved to. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RemoveProfileResponse) Reset() { *x = RemoveProfileResponse{} - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4556,7 +4693,7 @@ func (x *RemoveProfileResponse) String() string { func (*RemoveProfileResponse) ProtoMessage() {} func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4569,7 +4706,14 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead. func (*RemoveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{61} + return file_daemon_proto_rawDescGZIP(), []int{63} +} + +func (x *RemoveProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" } type ListProfilesRequest struct { @@ -4581,7 +4725,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4593,7 +4737,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4606,7 +4750,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{62} + return file_daemon_proto_rawDescGZIP(), []int{64} } func (x *ListProfilesRequest) GetUsername() string { @@ -4625,7 +4769,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4637,7 +4781,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4650,7 +4794,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{63} + return file_daemon_proto_rawDescGZIP(), []int{65} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -4664,13 +4808,14 @@ type Profile struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` IsActive bool `protobuf:"varint,2,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"` + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Profile) Reset() { *x = Profile{} - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4682,7 +4827,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4695,7 +4840,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{64} + return file_daemon_proto_rawDescGZIP(), []int{66} } func (x *Profile) GetName() string { @@ -4712,6 +4857,13 @@ func (x *Profile) GetIsActive() bool { return false } +func (x *Profile) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type GetActiveProfileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4720,7 +4872,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4732,7 +4884,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4745,20 +4897,21 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{65} + return file_daemon_proto_rawDescGZIP(), []int{67} } type GetActiveProfileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ProfileName string `protobuf:"bytes,1,opt,name=profileName,proto3" json:"profileName,omitempty"` Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetActiveProfileResponse) Reset() { *x = GetActiveProfileResponse{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4770,7 +4923,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4783,7 +4936,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + return file_daemon_proto_rawDescGZIP(), []int{68} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -4800,6 +4953,13 @@ func (x *GetActiveProfileResponse) GetUsername() string { return "" } +func (x *GetActiveProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type LogoutRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` @@ -4810,7 +4970,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4822,7 +4982,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4835,7 +4995,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{69} } func (x *LogoutRequest) GetProfileName() string { @@ -4860,7 +5020,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4872,7 +5032,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4885,7 +5045,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{70} } type GetFeaturesRequest struct { @@ -4896,7 +5056,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4908,7 +5068,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4921,7 +5081,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{69} + return file_daemon_proto_rawDescGZIP(), []int{71} } type GetFeaturesResponse struct { @@ -4935,7 +5095,7 @@ type GetFeaturesResponse struct { func (x *GetFeaturesResponse) Reset() { *x = GetFeaturesResponse{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4947,7 +5107,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4960,7 +5120,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{70} + return file_daemon_proto_rawDescGZIP(), []int{72} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -4998,7 +5158,7 @@ type MDMManagedFieldsViolation struct { func (x *MDMManagedFieldsViolation) Reset() { *x = MDMManagedFieldsViolation{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5010,7 +5170,7 @@ func (x *MDMManagedFieldsViolation) String() string { func (*MDMManagedFieldsViolation) ProtoMessage() {} func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5023,7 +5183,7 @@ func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { // Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead. func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{73} } func (x *MDMManagedFieldsViolation) GetFields() []string { @@ -5041,7 +5201,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5053,7 +5213,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5066,7 +5226,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{74} } type TriggerUpdateResponse struct { @@ -5079,7 +5239,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5091,7 +5251,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5104,7 +5264,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{75} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5132,7 +5292,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5144,7 +5304,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5157,7 +5317,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5184,7 +5344,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5196,7 +5356,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5209,7 +5369,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{77} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5251,7 +5411,7 @@ type RequestJWTAuthRequest struct { func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5263,7 +5423,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5276,7 +5436,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{78} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5309,7 +5469,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5321,7 +5481,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5334,7 +5494,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5399,7 +5559,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5411,7 +5571,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5424,7 +5584,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{80} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5456,7 +5616,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5468,7 +5628,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5481,7 +5641,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{81} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5514,7 +5674,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5526,7 +5686,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5539,7 +5699,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{82} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5551,7 +5711,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5563,7 +5723,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5576,7 +5736,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{83} } // StopCPUProfileRequest for stopping CPU profiling @@ -5588,7 +5748,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5600,7 +5760,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5613,7 +5773,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{84} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5625,7 +5785,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5637,7 +5797,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5650,7 +5810,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{85} } type InstallerResultRequest struct { @@ -5661,7 +5821,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5673,7 +5833,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5686,7 +5846,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{86} } type InstallerResultResponse struct { @@ -5699,7 +5859,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5711,7 +5871,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5724,7 +5884,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{87} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5757,7 +5917,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5769,7 +5929,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5782,7 +5942,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{88} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -5853,7 +6013,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5865,7 +6025,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5878,7 +6038,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{89} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -5919,7 +6079,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5931,7 +6091,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5944,7 +6104,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{90} } func (x *ExposeServiceReady) GetServiceName() string { @@ -5989,7 +6149,7 @@ type StartCaptureRequest struct { func (x *StartCaptureRequest) Reset() { *x = StartCaptureRequest{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6001,7 +6161,7 @@ func (x *StartCaptureRequest) String() string { func (*StartCaptureRequest) ProtoMessage() {} func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6014,7 +6174,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead. func (*StartCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{91} } func (x *StartCaptureRequest) GetTextOutput() bool { @@ -6068,7 +6228,7 @@ type CapturePacket struct { func (x *CapturePacket) Reset() { *x = CapturePacket{} - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6080,7 +6240,7 @@ func (x *CapturePacket) String() string { func (*CapturePacket) ProtoMessage() {} func (x *CapturePacket) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6093,7 +6253,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message { // Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead. func (*CapturePacket) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{90} + return file_daemon_proto_rawDescGZIP(), []int{92} } func (x *CapturePacket) GetData() []byte { @@ -6114,7 +6274,7 @@ type StartBundleCaptureRequest struct { func (x *StartBundleCaptureRequest) Reset() { *x = StartBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6126,7 +6286,7 @@ func (x *StartBundleCaptureRequest) String() string { func (*StartBundleCaptureRequest) ProtoMessage() {} func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6139,7 +6299,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{91} + return file_daemon_proto_rawDescGZIP(), []int{93} } func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { @@ -6157,7 +6317,7 @@ type StartBundleCaptureResponse struct { func (x *StartBundleCaptureResponse) Reset() { *x = StartBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6169,7 +6329,7 @@ func (x *StartBundleCaptureResponse) String() string { func (*StartBundleCaptureResponse) ProtoMessage() {} func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6182,7 +6342,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{92} + return file_daemon_proto_rawDescGZIP(), []int{94} } type StopBundleCaptureRequest struct { @@ -6193,7 +6353,7 @@ type StopBundleCaptureRequest struct { func (x *StopBundleCaptureRequest) Reset() { *x = StopBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6205,7 +6365,7 @@ func (x *StopBundleCaptureRequest) String() string { func (*StopBundleCaptureRequest) ProtoMessage() {} func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6218,7 +6378,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{93} + return file_daemon_proto_rawDescGZIP(), []int{95} } type StopBundleCaptureResponse struct { @@ -6229,7 +6389,7 @@ type StopBundleCaptureResponse struct { func (x *StopBundleCaptureResponse) Reset() { *x = StopBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6241,7 +6401,7 @@ func (x *StopBundleCaptureResponse) String() string { func (*StopBundleCaptureResponse) ProtoMessage() {} func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6254,7 +6414,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{94} + return file_daemon_proto_rawDescGZIP(), []int{96} } type PortInfo_Range struct { @@ -6267,7 +6427,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6279,7 +6439,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6672,8 +6832,9 @@ const file_daemon_proto_rawDesc = "" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + - "\t_username\"\x17\n" + - "\x15SwitchProfileResponse\"\x98\x11\n" + + "\t_username\"'\n" + + "\x15SwitchProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -6742,23 +6903,33 @@ const file_daemon_proto_rawDesc = "" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + - "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x14\n" + - "\x12AddProfileResponse\"T\n" + + "\vprofileName\x18\x02 \x01(\tR\vprofileName\"$\n" + + "\x12AddProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"r\n" + + "\x14RenameProfileRequest\x12\x1a\n" + + "\busername\x18\x01 \x01(\tR\busername\x12\x16\n" + + "\x06handle\x18\x02 \x01(\tR\x06handle\x12&\n" + + "\x0enewProfileName\x18\x03 \x01(\tR\x0enewProfileName\"?\n" + + "\x15RenameProfileResponse\x12&\n" + + "\x0eoldProfileName\x18\x01 \x01(\tR\x0eoldProfileName\"T\n" + "\x14RemoveProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + - "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x17\n" + - "\x15RemoveProfileResponse\"1\n" + + "\vprofileName\x18\x02 \x01(\tR\vprofileName\"'\n" + + "\x15RemoveProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"1\n" + "\x13ListProfilesRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\"C\n" + "\x14ListProfilesResponse\x12+\n" + - "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\":\n" + + "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\"J\n" + "\aProfile\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1b\n" + - "\tis_active\x18\x02 \x01(\bR\bisActive\"\x19\n" + - "\x17GetActiveProfileRequest\"X\n" + + "\tis_active\x18\x02 \x01(\bR\bisActive\x12\x0e\n" + + "\x02id\x18\x03 \x01(\tR\x02id\"\x19\n" + + "\x17GetActiveProfileRequest\"h\n" + "\x18GetActiveProfileResponse\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"t\n" + + "\busername\x18\x02 \x01(\tR\busername\x12\x0e\n" + + "\x02id\x18\x03 \x01(\tR\x02id\"t\n" + "\rLogoutRequest\x12%\n" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + @@ -6869,7 +7040,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xaf\x17\n" + + "EXPOSE_TLS\x10\x042\xff\x17\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -6900,6 +7071,7 @@ const file_daemon_proto_rawDesc = "" + "\tSetConfig\x12\x18.daemon.SetConfigRequest\x1a\x19.daemon.SetConfigResponse\"\x00\x12E\n" + "\n" + "AddProfile\x12\x19.daemon.AddProfileRequest\x1a\x1a.daemon.AddProfileResponse\"\x00\x12N\n" + + "\rRenameProfile\x12\x1c.daemon.RenameProfileRequest\x1a\x1d.daemon.RenameProfileResponse\"\x00\x12N\n" + "\rRemoveProfile\x12\x1c.daemon.RemoveProfileRequest\x1a\x1d.daemon.RemoveProfileResponse\"\x00\x12K\n" + "\fListProfiles\x12\x1b.daemon.ListProfilesRequest\x1a\x1c.daemon.ListProfilesResponse\"\x00\x12W\n" + "\x10GetActiveProfile\x12\x1f.daemon.GetActiveProfileRequest\x1a .daemon.GetActiveProfileResponse\"\x00\x129\n" + @@ -6927,7 +7099,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 98) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 100) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -6993,53 +7165,55 @@ var file_daemon_proto_goTypes = []any{ (*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse (*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest (*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse - (*RemoveProfileRequest)(nil), // 64: daemon.RemoveProfileRequest - (*RemoveProfileResponse)(nil), // 65: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 66: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 67: daemon.ListProfilesResponse - (*Profile)(nil), // 68: daemon.Profile - (*GetActiveProfileRequest)(nil), // 69: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 70: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 71: daemon.LogoutRequest - (*LogoutResponse)(nil), // 72: daemon.LogoutResponse - (*GetFeaturesRequest)(nil), // 73: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 74: daemon.GetFeaturesResponse - (*MDMManagedFieldsViolation)(nil), // 75: daemon.MDMManagedFieldsViolation - (*TriggerUpdateRequest)(nil), // 76: daemon.TriggerUpdateRequest - (*TriggerUpdateResponse)(nil), // 77: daemon.TriggerUpdateResponse - (*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse - (*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse - (*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest - (*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent - (*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady - (*StartCaptureRequest)(nil), // 93: daemon.StartCaptureRequest - (*CapturePacket)(nil), // 94: daemon.CapturePacket - (*StartBundleCaptureRequest)(nil), // 95: daemon.StartBundleCaptureRequest - (*StartBundleCaptureResponse)(nil), // 96: daemon.StartBundleCaptureResponse - (*StopBundleCaptureRequest)(nil), // 97: daemon.StopBundleCaptureRequest - (*StopBundleCaptureResponse)(nil), // 98: daemon.StopBundleCaptureResponse - nil, // 99: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 100: daemon.PortInfo.Range - nil, // 101: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 102: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp + (*RenameProfileRequest)(nil), // 64: daemon.RenameProfileRequest + (*RenameProfileResponse)(nil), // 65: daemon.RenameProfileResponse + (*RemoveProfileRequest)(nil), // 66: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 67: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 68: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 69: daemon.ListProfilesResponse + (*Profile)(nil), // 70: daemon.Profile + (*GetActiveProfileRequest)(nil), // 71: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 72: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 73: daemon.LogoutRequest + (*LogoutResponse)(nil), // 74: daemon.LogoutResponse + (*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse + (*MDMManagedFieldsViolation)(nil), // 77: daemon.MDMManagedFieldsViolation + (*TriggerUpdateRequest)(nil), // 78: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 79: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 80: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 81: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 82: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 83: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 84: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 85: daemon.WaitJWTTokenResponse + (*StartCPUProfileRequest)(nil), // 86: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 87: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 88: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 89: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 90: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 91: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 95: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 96: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 97: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 98: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 99: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 100: daemon.StopBundleCaptureResponse + nil, // 101: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 102: daemon.PortInfo.Range + nil, // 103: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 104: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 102, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 104, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 103, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 103, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 102, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 105, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 105, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 104, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState @@ -7050,8 +7224,8 @@ var file_daemon_proto_depIdxs = []int32{ 55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState 31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 99, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 100, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 101, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 102, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range 32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo 32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo 33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule @@ -7062,15 +7236,15 @@ var file_daemon_proto_depIdxs = []int32{ 52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage 2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity 3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 103, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 101, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 105, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 103, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 102, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 104, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 70, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 92, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 102, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 102, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 94, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 104, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 104, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration 30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList 5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest 7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest @@ -7090,68 +7264,70 @@ var file_daemon_proto_depIdxs = []int32{ 46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest 48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest 51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 93, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest - 95, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest - 97, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 95, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 97, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 99, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest 54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest 56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest 58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest 60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest 62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 64, // 63: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 66, // 64: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 76, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 78, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 80, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 82, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 84, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 86, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 88, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 90, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 79: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 14, // 80: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 81: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 27, // 82: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 29, // 83: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 29, // 84: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 34, // 85: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 36, // 86: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 38, // 87: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 40, // 88: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 43, // 89: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 45, // 90: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 94, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 96, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 98, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse - 55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 61, // 100: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 63, // 101: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 65, // 102: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 67, // 103: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 77, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 79, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 81, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 83, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 85, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 87, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 89, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 91, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 76, // [76:115] is the sub-list for method output_type - 37, // [37:76] is the sub-list for method input_type + 64, // 63: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest + 66, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 68, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 71, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 73, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 75, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 78, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 80, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 82, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 84, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 86, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 88, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 90, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 92, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 80: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 14, // 81: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 82: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 27, // 83: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 29, // 84: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 29, // 85: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 34, // 86: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 36, // 87: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 38, // 88: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 40, // 89: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 43, // 90: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 45, // 91: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 47, // 92: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 49, // 93: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 53, // 94: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 96, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 98, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 100, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 55, // 98: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 57, // 99: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 59, // 100: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 61, // 101: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 63, // 102: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 65, // 103: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse + 67, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 69, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 72, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 74, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 76, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 79, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 81, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 83, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 85, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 87, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 89, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 91, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 93, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 77, // [77:117] is the sub-list for method output_type + 37, // [37:77] is the sub-list for method input_type 37, // [37:37] is the sub-list for extension type_name 37, // [37:37] is the sub-list for extension extendee 0, // [0:37] is the sub-list for field type_name @@ -7173,9 +7349,9 @@ func file_daemon_proto_init() { file_daemon_proto_msgTypes[48].OneofWrappers = []any{} file_daemon_proto_msgTypes[54].OneofWrappers = []any{} file_daemon_proto_msgTypes[56].OneofWrappers = []any{} - file_daemon_proto_msgTypes[67].OneofWrappers = []any{} - file_daemon_proto_msgTypes[76].OneofWrappers = []any{} - file_daemon_proto_msgTypes[87].OneofWrappers = []any{ + file_daemon_proto_msgTypes[69].OneofWrappers = []any{} + file_daemon_proto_msgTypes[78].OneofWrappers = []any{} + file_daemon_proto_msgTypes[89].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -7184,7 +7360,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 98, + NumMessages: 100, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index ea668f629..c1e3fe513 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -85,6 +85,8 @@ service DaemonService { rpc AddProfile(AddProfileRequest) returns (AddProfileResponse) {} + rpc RenameProfile(RenameProfileRequest) returns (RenameProfileResponse) {} + rpc RemoveProfile(RemoveProfileRequest) returns (RemoveProfileResponse) {} rpc ListProfiles(ListProfilesRequest) returns (ListProfilesResponse) {} @@ -625,11 +627,18 @@ message GetEventsResponse { } message SwitchProfileRequest { + // profileName is treated as a handle: exact ID, unique ID prefix, or + // unique display name. The daemon resolves it server-side. optional string profileName = 1; optional string username = 2; } -message SwitchProfileResponse {} +message SwitchProfileResponse { + // id is the resolved on-disk ID of the profile that became active. + // Lets CLI clients update their local active-profile state without + // duplicating the resolution logic. + string id = 1; +} message SetConfigRequest { string username = 1; @@ -696,17 +705,42 @@ message SetConfigResponse{} message AddProfileRequest { string username = 1; + // profileName carries the human-readable display name for the new + // profile. The on-disk filename is a separately-generated ID. string profileName = 2; } -message AddProfileResponse {} +message AddProfileResponse { + // id is the generated on-disk ID of the new profile. CLI clients + // display a truncated form, UI clients can ignore it. + string id = 1; +} + +message RenameProfileRequest { + string username = 1; + // handle: an exact ID, a unique ID prefix, or a unique display name. + string handle = 2; + // newProfileName is the new human-readable display name for the profile. + string newProfileName = 3; +} + +message RenameProfileResponse { + // confirm the old profile name after resolving handle. + string oldProfileName = 1; +} message RemoveProfileRequest { string username = 1; + // profileName is treated as a handle: an exact ID, a unique ID + // prefix, or a unique display name. Resolution happens server-side. string profileName = 2; } -message RemoveProfileResponse {} +message RemoveProfileResponse { + // id is the full resolved ID of the removed profile, so callers can + // confirm exactly which profile a name/prefix handle resolved to. + string id = 1; +} message ListProfilesRequest { string username = 1; @@ -719,6 +753,7 @@ message ListProfilesResponse { message Profile { string name = 1; bool is_active = 2; + string id = 3; } message GetActiveProfileRequest {} @@ -726,6 +761,7 @@ message GetActiveProfileRequest {} message GetActiveProfileResponse { string profileName = 1; string username = 2; + string id = 3; } message LogoutRequest { diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 66a8efcc3..5f585aafc 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -45,6 +45,7 @@ const ( DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile" DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig" DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile" + DaemonService_RenameProfile_FullMethodName = "/daemon.DaemonService/RenameProfile" DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile" DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles" DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile" @@ -112,6 +113,7 @@ type DaemonServiceClient interface { SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error) AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error) + RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error) GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error) @@ -422,6 +424,16 @@ func (c *daemonServiceClient) AddProfile(ctx context.Context, in *AddProfileRequ return out, nil } +func (c *daemonServiceClient) RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RenameProfileResponse) + err := c.cc.Invoke(ctx, DaemonService_RenameProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RemoveProfileResponse) @@ -613,6 +625,7 @@ type DaemonServiceServer interface { SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) + RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) @@ -723,6 +736,9 @@ func (UnimplementedDaemonServiceServer) SetConfig(context.Context, *SetConfigReq func (UnimplementedDaemonServiceServer) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method AddProfile not implemented") } +func (UnimplementedDaemonServiceServer) RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RenameProfile not implemented") +} func (UnimplementedDaemonServiceServer) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method RemoveProfile not implemented") } @@ -1237,6 +1253,24 @@ func _DaemonService_AddProfile_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _DaemonService_RenameProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RenameProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RenameProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RenameProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RenameProfile(ctx, req.(*RenameProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_RemoveProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(RemoveProfileRequest) if err := dec(in); err != nil { @@ -1567,6 +1601,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "AddProfile", Handler: _DaemonService_AddProfile_Handler, }, + { + MethodName: "RenameProfile", + Handler: _DaemonService_RenameProfile_Handler, + }, { MethodName: "RemoveProfile", Handler: _DaemonService_RemoveProfile_Handler, diff --git a/client/server/login_overrides_test.go b/client/server/login_overrides_test.go index c45557c59..5a2298764 100644 --- a/client/server/login_overrides_test.go +++ b/client/server/login_overrides_test.go @@ -79,7 +79,7 @@ func TestPersistLoginOverrides(t *testing.T) { _, err := profilemanager.UpdateOrCreateConfig(seed) require.NoError(t, err, "seed config") - activeProf := &profilemanager.ActiveProfileState{Name: "default"} + activeProf := &profilemanager.ActiveProfileState{ID: "default"} err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK) require.NoError(t, err, "persistLoginOverrides") diff --git a/client/server/server.go b/client/server/server.go index 32daf7718..a4d53a823 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -78,7 +78,7 @@ type Server struct { // changed by connectWithRetryRuns goroutine exit — for that // (goroutine-still-alive) check, see connectionGoroutineRunning() which // derives from clientGiveUpChan close state. Protected by s.mutex. - clientRunning bool + clientRunning bool clientRunningChan chan struct{} clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits @@ -375,7 +375,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } - config, err := setConfigInputFromRequest(msg) + config, err := s.setConfigInputFromRequest(msg) if err != nil { return nil, err } @@ -398,17 +398,17 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques // field is its own optional case. Returns the resolved ConfigInput // and a non-nil error only when the active profile file path cannot // be determined. -func setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) { +func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) { var config profilemanager.ConfigInput - profState := profilemanager.ActiveProfileState{ - Name: msg.ProfileName, - Username: msg.Username, - } - profPath, err := profState.FilePath() + resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username) if err != nil { - log.Errorf("failed to get active profile file path: %v", err) - return config, fmt.Errorf("failed to get active profile file path: %w", err) + log.Errorf("failed to resolve profile %q: %v", msg.ProfileName, err) + return config, err + } + profPath := resolved.Path + if profPath == "" { + profPath = profilemanager.DefaultConfigPath } config.ConfigPath = profPath @@ -535,30 +535,9 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro } if msg.ProfileName != nil { - if *msg.ProfileName != "default" && (msg.Username == nil || *msg.Username == "") { - log.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName) - return nil, fmt.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName) - } - - var username string - if *msg.ProfileName != "default" { - username = *msg.Username - } - - if *msg.ProfileName != activeProf.Name && username != activeProf.Username { - if s.checkProfilesDisabled() { - log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled") - return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) - } - - log.Infof("switching to profile %s for user '%s'", *msg.ProfileName, username) - if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: *msg.ProfileName, - Username: username, - }); err != nil { - log.Errorf("failed to set active profile state: %v", err) - return nil, fmt.Errorf("failed to set active profile state: %w", err) - } + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + log.Errorf("failed to switch profile: %v", err) + return nil, err } } @@ -568,7 +547,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro return nil, fmt.Errorf("failed to get active profile state: %w", err) } - log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username) + log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) s.mutex.Lock() @@ -806,10 +785,10 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR } if msg != nil && msg.ProfileName != nil { - if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { s.mutex.Unlock() log.Errorf("failed to switch profile: %v", err) - return nil, fmt.Errorf("failed to switch profile: %w", err) + return nil, err } } @@ -820,7 +799,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return nil, fmt.Errorf("failed to get active profile state: %w", err) } - log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username) + log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) config, _, err := s.getConfig(activeProf) if err != nil { @@ -864,34 +843,60 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error) } } -func (s *Server) switchProfileIfNeeded(profileName string, userName *string, activeProf *profilemanager.ActiveProfileState) error { - if profileName != "default" && (userName == nil || *userName == "") { - log.Errorf("profile name is set to %s, but username is not provided", profileName) - return fmt.Errorf("profile name is set to %s, but username is not provided", profileName) +// resolveProfileHandle resolves a wire-level profile handle (display +// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC +// status errors so handlers can return them directly. +func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.Profile, error) { + p, err := s.profileManager.ResolveProfile(handle, username) + if err == nil { + return p, nil + } + var amb *profilemanager.ErrAmbiguousHandle + if errors.As(err, &amb) { + return nil, gstatus.Errorf(codes.InvalidArgument, "%v", amb) + } + if errors.Is(err, profilemanager.ErrProfileNotFound) { + return nil, gstatus.Errorf(codes.NotFound, "profile %q not found", handle) + } + return nil, fmt.Errorf("resolve profile: %w", err) +} + +// switchProfileIfNeeded resolves the user-supplied handle, updates the +// active profile state if it differs from the current one, and returns +// the resolved profile so callers can include its ID in RPC responses. +func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) { + if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") { + log.Errorf("profile name is set to %s, but username is not provided", handle) + return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle) } var username string - if profileName != "default" { + if handle != profilemanager.DefaultProfileName { username = *userName } - if profileName != activeProf.Name || username != activeProf.Username { + resolved, err := s.resolveProfileHandle(handle, username) + if err != nil { + return nil, err + } + + if resolved.ID != activeProf.ID || username != activeProf.Username { if s.checkProfilesDisabled() { log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled") - return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) } - log.Infof("switching to profile %s for user %s", profileName, username) + log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username) if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profileName, + ID: resolved.ID, Username: username, }); err != nil { log.Errorf("failed to set active profile state: %v", err) - return fmt.Errorf("failed to set active profile state: %w", err) + return nil, fmt.Errorf("failed to set active profile state: %w", err) } } - return nil + return resolved, nil } // SwitchProfile switches the active profile in the daemon. @@ -906,9 +911,9 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi } if msg != nil && msg.ProfileName != nil { - if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { log.Errorf("failed to switch profile: %v", err) - return nil, fmt.Errorf("failed to switch profile: %w", err) + return nil, err } } activeProf, err = s.profileManager.GetActiveProfileState() @@ -924,7 +929,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi s.config = config - return &proto.SwitchProfileResponse{}, nil + return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil } // Down engine work in the daemon. @@ -1014,22 +1019,27 @@ func (s *Server) Logout(ctx context.Context, msg *proto.LogoutRequest) (*proto.L } func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutRequest) (*proto.LogoutResponse, error) { - if err := s.validateProfileOperation(*msg.ProfileName, true); err != nil { - return nil, err - } - if msg.Username == nil || *msg.Username == "" { return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided when profile name is specified") } username := *msg.Username - if err := s.logoutFromProfile(ctx, *msg.ProfileName, username); err != nil { - log.Errorf("failed to logout from profile %s: %v", *msg.ProfileName, err) + resolved, err := s.resolveProfileHandle(*msg.ProfileName, username) + if err != nil { + return nil, err + } + + if err := s.validateProfileOperation(resolved.ID, true); err != nil { + return nil, err + } + + if err := s.logoutFromProfile(ctx, resolved); err != nil { + log.Errorf("failed to logout from profile %s: %v", resolved.ID, err) return nil, gstatus.Errorf(codes.Internal, "logout: %v", err) } activeProf, _ := s.profileManager.GetActiveProfileState() - if activeProf != nil && activeProf.Name == *msg.ProfileName { + if activeProf != nil && activeProf.ID == resolved.ID { if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) { log.Errorf("failed to cleanup connection: %v", err) } @@ -1091,30 +1101,30 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof return config, configExisted, nil } -func (s *Server) canRemoveProfile(profileName string) error { - if profileName == profilemanager.DefaultProfileName { +func (s *Server) canRemoveProfile(id profilemanager.ID) error { + if id == profilemanager.DefaultProfileName { return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName) } activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.Name == profileName { - return fmt.Errorf("remove active profile: %s", profileName) + if err == nil && activeProf.ID == id { + return fmt.Errorf("remove active profile: %s", id) } return nil } -func (s *Server) validateProfileOperation(profileName string, allowActiveProfile bool) error { +func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error { if s.checkProfilesDisabled() { return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) } - if profileName == "" { + if id == "" { return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") } if !allowActiveProfile { - if err := s.canRemoveProfile(profileName); err != nil { + if err := s.canRemoveProfile(id); err != nil { return gstatus.Errorf(codes.InvalidArgument, "%v", err) } } @@ -1122,25 +1132,20 @@ func (s *Server) validateProfileOperation(profileName string, allowActiveProfile return nil } -// logoutFromProfile logs out from a specific profile by loading its config and sending logout request -func (s *Server) logoutFromProfile(ctx context.Context, profileName, username string) error { +func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error { activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.Name == profileName && s.connectClient != nil { + if err == nil && activeProf.ID == profile.ID && s.connectClient != nil { return s.sendLogoutRequest(ctx) } - profileState := &profilemanager.ActiveProfileState{ - Name: profileName, - Username: username, - } - profilePath, err := profileState.FilePath() - if err != nil { - return fmt.Errorf("get profile path: %w", err) + cfgPath := profile.Path + if cfgPath == "" { + cfgPath = profilemanager.DefaultConfigPath } - config, err := profilemanager.GetConfig(profilePath) + config, err := profilemanager.GetConfig(cfgPath) if err != nil { - return fmt.Errorf("profile '%s' not found", profileName) + return fmt.Errorf("profile '%s' not found", profile.ID) } return s.sendLogoutRequestWithConfig(ctx, config) @@ -1558,15 +1563,14 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p return nil, ctx.Err() } - prof := profilemanager.ActiveProfileState{ - Name: req.ProfileName, - Username: req.Username, - } - - cfgPath, err := prof.FilePath() + resolved, err := s.resolveProfileHandle(req.ProfileName, req.Username) if err != nil { - log.Errorf("failed to get active profile file path: %v", err) - return nil, fmt.Errorf("failed to get active profile file path: %w", err) + log.Errorf("failed to resolve profile %q: %v", req.ProfileName, err) + return nil, err + } + cfgPath := resolved.Path + if cfgPath == "" { + cfgPath = profilemanager.DefaultConfigPath } cfg, err := profilemanager.GetConfig(cfgPath) @@ -1671,12 +1675,39 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) ( return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided") } - if err := s.profileManager.AddProfile(msg.ProfileName, msg.Username); err != nil { + created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username) + if err != nil { log.Errorf("failed to create profile: %v", err) return nil, fmt.Errorf("failed to create profile: %w", err) } - return &proto.AddProfileResponse{}, nil + return &proto.AddProfileResponse{Id: created.ID.String()}, nil +} + +func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequest) (*proto.RenameProfileResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.checkProfilesDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + } + + if msg.Handle == "" || msg.Username == "" || msg.NewProfileName == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided") + } + + resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username) + if err != nil { + return nil, err + } + + err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName) + if err != nil { + log.Errorf("failed to rename profile: %v", err) + return nil, fmt.Errorf("failed to rename profile: %w", err) + } + + return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil } // RemoveProfile removes a profile from the daemon. @@ -1684,20 +1715,29 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ s.mutex.Lock() defer s.mutex.Unlock() - if err := s.validateProfileOperation(msg.ProfileName, false); err != nil { + if s.checkProfilesDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + } + + if msg.ProfileName == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") + } + + resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username) + if err != nil { return nil, err } - if err := s.logoutFromProfile(ctx, msg.ProfileName, msg.Username); err != nil { - log.Warnf("failed to logout from profile %s before removal: %v", msg.ProfileName, err) + if err := s.logoutFromProfile(ctx, resolved); err != nil { + log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err) } - if err := s.profileManager.RemoveProfile(msg.ProfileName, msg.Username); err != nil { + if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil { log.Errorf("failed to remove profile: %v", err) return nil, fmt.Errorf("failed to remove profile: %w", err) } - return &proto.RemoveProfileResponse{}, nil + return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil } // ListProfiles lists all profiles in the daemon. @@ -1720,6 +1760,7 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques } for i, profile := range profiles { response.Profiles[i] = &proto.Profile{ + Id: profile.ID.String(), Name: profile.Name, IsActive: profile.IsActive, } @@ -1728,7 +1769,9 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques return response, nil } -// GetActiveProfile returns the active profile in the daemon. +// GetActiveProfile returns the active profile in the daemon. The ProfileName +// field carries the display name for backwards compatibility with UI clients, +// new callers should prefer Id. func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfileRequest) (*proto.GetActiveProfileResponse, error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -1739,9 +1782,23 @@ func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfi return nil, fmt.Errorf("failed to get active profile state: %w", err) } + // Fallback to legacy name == ID + displayName := activeProfile.ID.String() + if activeProfile.ID != profilemanager.DefaultProfileName { + if profiles, lerr := s.profileManager.ListProfiles(activeProfile.Username); lerr == nil { + for _, p := range profiles { + if p.ID == activeProfile.ID { + displayName = p.Name + break + } + } + } + } + return &proto.GetActiveProfileResponse{ - ProfileName: activeProfile.Name, + ProfileName: displayName, Username: activeProfile.Username, + Id: activeProfile.ID.String(), }, nil } diff --git a/client/server/server_test.go b/client/server/server_test.go index 66e0fcc4c..fa9599818 100644 --- a/client/server/server_test.go +++ b/client/server/server_test.go @@ -97,7 +97,7 @@ func TestConnectWithRetryRuns(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "test-profile", + ID: "test-profile", Username: currUser.Username, }) if err != nil { @@ -158,7 +158,7 @@ func TestServer_Up(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, }) if err != nil { @@ -228,7 +228,7 @@ func TestServer_SubcribeEvents(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "default", + ID: "default", Username: currUser.Username, }) if err != nil { diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 53232c70d..9818f9fdf 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -62,7 +62,7 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN pm := profilemanager.ServiceManager{} require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, })) @@ -107,9 +107,9 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) { func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { withMDMPolicy(t, mdm.NewPolicy(map[string]any{ - mdm.KeyManagementURL: "https://mdm.example.com:443", - mdm.KeyBlockInbound: true, - mdm.KeyRosenpassEnabled: true, + mdm.KeyManagementURL: "https://mdm.example.com:443", + mdm.KeyBlockInbound: true, + mdm.KeyRosenpassEnabled: true, })) s, ctx, profName, username, _ := setupServerWithProfile(t) diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 553d4ad71..7c85d16ce 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -47,7 +47,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, }) require.NoError(t, err) @@ -96,7 +96,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { DisableNotifications: &disableNotifications, LazyConnectionEnabled: &lazyConnectionEnabled, BlockInbound: &blockInbound, - DisableIpv6: &disableIPv6, + DisableIpv6: &disableIPv6, NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"}, CleanNATExternalIPs: false, CustomDNSAddress: []byte("1.1.1.1:53"), @@ -112,7 +112,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.NoError(t, err) profState := profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, } cfgPath, err := profState.FilePath() diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 5814ad9b4..d2f38cfd7 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -645,7 +645,7 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) ( } req := &proto.SetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, } @@ -818,13 +818,15 @@ func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginRe return nil, fmt.Errorf("get current user: %w", err) } + handle := activeProf.ID.String() + loginReq := &proto.LoginRequest{ IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd", - ProfileName: &activeProf.Name, + ProfileName: &handle, Username: &currUser.Username, } - profileState, err := s.profileManager.GetProfileState(activeProf.Name) + profileState, err := s.profileManager.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -1367,7 +1369,7 @@ func (s *serviceClient) getSrvConfig() { } srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, }) if err != nil { @@ -1613,7 +1615,7 @@ func (s *serviceClient) loadSettings() { } cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, }) if err != nil { @@ -1813,7 +1815,7 @@ func (s *serviceClient) updateConfig() error { } req := proto.SetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, DisableAutoConnect: &disableAutoStart, ServerSSHAllowed: &sshAllowed, diff --git a/client/ui/profile.go b/client/ui/profile.go index d3db17855..83b0ec18b 100644 --- a/client/ui/profile.go +++ b/client/ui/profile.go @@ -66,7 +66,7 @@ func (s *serviceClient) showProfilesUI() { } else { indicator.SetText("") } - nameLabel.SetText(profile.Name) + nameLabel.SetText(formatProfileLabel(profile, profiles)) // Configure Select/Active button selectBtn.SetText(func() string { @@ -88,7 +88,7 @@ func (s *serviceClient) showProfilesUI() { return } // switch - err = s.switchProfile(profile.Name) + err = s.switchProfile(profile.ID) if err != nil { log.Errorf("failed to switch profile: %v", err) dialog.ShowError(errors.New("failed to select profile"), s.wProfiles) @@ -130,7 +130,7 @@ func (s *serviceClient) showProfilesUI() { logoutBtn.Show() logoutBtn.SetText("Deregister") logoutBtn.OnTapped = func() { - s.handleProfileLogout(profile.Name, refresh) + s.handleProfileLogout(profile, refresh) } // Remove profile @@ -144,7 +144,7 @@ func (s *serviceClient) showProfilesUI() { return } - err = s.removeProfile(profile.Name) + err = s.removeProfile(profile.ID) if err != nil { log.Errorf("failed to remove profile: %v", err) dialog.ShowError(fmt.Errorf("failed to remove profile"), s.wProfiles) @@ -250,7 +250,7 @@ func (s *serviceClient) addProfile(profileName string) error { return nil } -func (s *serviceClient) switchProfile(profileName string) error { +func (s *serviceClient) switchProfile(handle string) error { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { return fmt.Errorf(getClientFMT, err) @@ -261,15 +261,15 @@ func (s *serviceClient) switchProfile(profileName string) error { return fmt.Errorf("get current user: %w", err) } - if _, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{ - ProfileName: &profileName, + resp, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, Username: &currUser.Username, - }); err != nil { + }) + if err != nil { return fmt.Errorf("switch profile failed: %w", err) } - err = s.profileManager.SwitchProfile(profileName) - if err != nil { + if err := s.profileManager.SwitchProfile(profilemanager.ID(resp.Id)); err != nil { return fmt.Errorf("switch profile: %w", err) } @@ -299,10 +299,27 @@ func (s *serviceClient) removeProfile(profileName string) error { } type Profile struct { + ID string Name string IsActive bool } +// formatProfileLabel returns the display label for a profile. Profiles can +// share the same Name, so when more than one profile in profiles carries this +// Name, a short form of the ID is appended to disambiguate the entries. +func formatProfileLabel(profile Profile, profiles []Profile) string { + count := 0 + for _, p := range profiles { + if p.Name == profile.Name { + count++ + } + } + if count <= 1 { + return profile.Name + } + return fmt.Sprintf("%s (%s)", profile.Name, profilemanager.ID(profile.ID).ShortID()) +} + func (s *serviceClient) getProfiles() ([]Profile, error) { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { @@ -324,6 +341,7 @@ func (s *serviceClient) getProfiles() ([]Profile, error) { for _, profile := range profilesResp.Profiles { profiles = append(profiles, Profile{ + ID: profile.Id, Name: profile.Name, IsActive: profile.IsActive, }) @@ -332,10 +350,10 @@ func (s *serviceClient) getProfiles() ([]Profile, error) { return profiles, nil } -func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback func()) { +func (s *serviceClient) handleProfileLogout(profile Profile, refreshCallback func()) { dialog.ShowConfirm( "Deregister", - fmt.Sprintf("Are you sure you want to deregister from '%s'?", profileName), + fmt.Sprintf("Are you sure you want to deregister from '%s'?", profile.Name), func(confirm bool) { if !confirm { return @@ -356,8 +374,10 @@ func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback } username := currUser.Username + // ProfileName is treated as a handle; send the ID so the + // daemon resolves to exactly this profile. _, err = conn.Logout(s.ctx, &proto.LogoutRequest{ - ProfileName: &profileName, + ProfileName: &profile.ID, Username: &username, }) if err != nil { @@ -368,7 +388,7 @@ func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback dialog.ShowInformation( "Deregistered", - fmt.Sprintf("Successfully deregistered from '%s'", profileName), + fmt.Sprintf("Successfully deregistered from '%s'", profile.Name), s.wProfiles, ) @@ -461,6 +481,7 @@ func (p *profileMenu) getProfiles() ([]Profile, error) { for _, profile := range profilesResp.Profiles { profiles = append(profiles, Profile{ + ID: profile.Id, Name: profile.Name, IsActive: profile.IsActive, }) @@ -501,7 +522,7 @@ func (p *profileMenu) refresh() { } if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - activeProfState, err := p.profileManager.GetProfileState(activeProf.ProfileName) + activeProfState, err := p.profileManager.GetProfileState(profilemanager.ID(activeProf.Id)) if err != nil { log.Warnf("failed to get active profile state: %v", err) p.emailMenuItem.Hide() @@ -512,7 +533,7 @@ func (p *profileMenu) refresh() { } for _, profile := range profiles { - item := p.profileMenuItem.AddSubMenuItem(profile.Name, "") + item := p.profileMenuItem.AddSubMenuItem(formatProfileLabel(profile, profiles), "") if profile.IsActive { item.Check() } @@ -541,8 +562,8 @@ func (p *profileMenu) refresh() { return } - _, err = conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profile.Name, + switchResp, err := conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &profile.ID, Username: &currUser.Username, }) if err != nil { @@ -552,7 +573,7 @@ func (p *profileMenu) refresh() { return } - err = p.profileManager.SwitchProfile(profile.Name) + err = p.profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)) if err != nil { log.Errorf("failed to switch profile '%s': %v", profile.Name, err) return @@ -727,7 +748,10 @@ func (p *profileMenu) updateMenu() { } sort.Slice(profiles, func(i, j int) bool { - return profiles[i].Name < profiles[j].Name + if profiles[i].Name != profiles[j].Name { + return profiles[i].Name < profiles[j].Name + } + return profiles[i].ID < profiles[j].ID }) p.mu.Lock() From d3710d4bb2cfd7dc17aa0c004304a8bb96f27f39 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:00:19 +0900 Subject: [PATCH 16/16] [signal] Serialize concurrent sends to a peer signal stream (#6463) --- signal/peer/peer.go | 11 +++++ signal/server/concurrent_send_test.go | 67 +++++++++++++++++++++++++++ signal/server/signal.go | 2 +- 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 signal/server/concurrent_send_test.go diff --git a/signal/peer/peer.go b/signal/peer/peer.go index c9dd60fc0..c04654b8b 100644 --- a/signal/peer/peer.go +++ b/signal/peer/peer.go @@ -26,6 +26,10 @@ type Peer struct { // a gRpc connection stream to the Peer Stream proto.SignalExchange_ConnectStreamServer + // sendMu serializes writes to Stream. gRPC forbids concurrent SendMsg on + // the same ServerStream, and a peer can be the target of many senders at + // once. + sendMu sync.Mutex // registration time RegisteredAt time.Time @@ -33,6 +37,13 @@ type Peer struct { Cancel context.CancelFunc } +// Send writes a message to the peer's stream, serializing concurrent senders. +func (p *Peer) Send(msg *proto.EncryptedMessage) error { + p.sendMu.Lock() + defer p.sendMu.Unlock() + return p.Stream.Send(msg) +} + // NewPeer creates a new instance of a connected Peer func NewPeer(id string, stream proto.SignalExchange_ConnectStreamServer, cancel context.CancelFunc) *Peer { return &Peer{ diff --git a/signal/server/concurrent_send_test.go b/signal/server/concurrent_send_test.go new file mode 100644 index 000000000..b3830482d --- /dev/null +++ b/signal/server/concurrent_send_test.go @@ -0,0 +1,67 @@ +package server + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + + "github.com/netbirdio/netbird/shared/signal/proto" + "github.com/netbirdio/netbird/signal/peer" +) + +// concurrencyCheckStream records the maximum number of Send calls in flight at +// once. gRPC forbids concurrent SendMsg on the same ServerStream, so a correct +// server must never have more than one in flight per peer. +type concurrencyCheckStream struct { + proto.SignalExchange_ConnectStreamServer + ctx context.Context + inflight atomic.Int32 + maxSeen atomic.Int32 +} + +func (s *concurrencyCheckStream) Send(*proto.EncryptedMessage) error { + n := s.inflight.Add(1) + for { + old := s.maxSeen.Load() + if n <= old || s.maxSeen.CompareAndSwap(old, n) { + break + } + } + // Widen the window so overlapping callers are reliably observed. + time.Sleep(time.Millisecond) + s.inflight.Add(-1) + return nil +} + +func (s *concurrencyCheckStream) Context() context.Context { return s.ctx } + +// TestForwardMessageToPeerSerializesSend verifies that concurrent forwards to the +// same peer never call Stream.Send concurrently, which would violate the gRPC +// ServerStream contract. +func TestForwardMessageToPeerSerializesSend(t *testing.T) { + s, err := NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + + const peerID = "peerX" + stream := &concurrencyCheckStream{ctx: context.Background()} + _, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + require.NoError(t, s.registry.Register(peer.NewPeer(peerID, stream, cancel))) + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s.forwardMessageToPeer(context.Background(), &proto.EncryptedMessage{Key: "sender", RemoteKey: peerID}) + }() + } + wg.Wait() + + require.Equal(t, int32(1), stream.maxSeen.Load(), "Stream.Send must never run concurrently on the same peer stream") +} diff --git a/signal/server/signal.go b/signal/server/signal.go index c46df56d2..7edbb4d34 100644 --- a/signal/server/signal.go +++ b/signal/server/signal.go @@ -179,7 +179,7 @@ func (s *Server) forwardMessageToPeer(ctx context.Context, msg *proto.EncryptedM sendResultChan := make(chan error, 1) go func() { select { - case sendResultChan <- dstPeer.Stream.Send(msg): + case sendResultChan <- dstPeer.Send(msg): return case <-dstPeer.Stream.Context().Done(): return