From 211a26019a0a5b96d35d758faaec7b7ad5fcf24e Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:42:04 +0200 Subject: [PATCH 01/26] [management] validate meta change against posture checks (#6510) --- management/internals/shared/grpc/server.go | 2 +- management/server/account.go | 8 +- management/server/account/manager.go | 4 +- management/server/account/manager_mock.go | 16 +-- management/server/account_test.go | 16 +-- management/server/mock_server/account_mock.go | 12 +- management/server/peer.go | 51 +++---- management/server/peer/peer.go | 124 +++++++++++++++--- management/server/posture/checks.go | 29 ++++ management/server/store/sql_store.go | 22 ---- management/server/store/sql_store_test.go | 50 ------- management/server/store/store.go | 1 - management/server/store/store_mock.go | 14 -- management/server/types/peer.go | 3 + 14 files changed, 192 insertions(+), 160 deletions(-) diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 7283cae6c..8ee7722be 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -1205,7 +1205,7 @@ func (s *Server) SyncMeta(ctx context.Context, req *proto.EncryptedMessage) (*pr return nil, msg } - err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta())) + err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta()), realIP) if err != nil { return nil, mapError(ctx, err) } diff --git a/management/server/account.go b/management/server/account.go index f58c797b7..34220ed3f 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -1889,12 +1889,12 @@ func domainIsUpToDate(domain string, domainCategory string, userAuth auth.UserAu // concurrent stream that started earlier loses the optimistic-lock race // in MarkPeerConnected and bails without writing. func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { - peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta}, accountID) + peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP}, accountID) if err != nil { return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err) } - if err := am.MarkPeerConnected(ctx, peerPubKey, realIP, accountID, syncTime.UnixNano(), netMap); err != nil { + if err := am.MarkPeerConnected(ctx, peerPubKey, accountID, syncTime.UnixNano(), netMap); err != nil { log.WithContext(ctx).Warnf("failed marking peer as connected %s %v", peerPubKey, err) } @@ -1914,13 +1914,13 @@ func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, account return nil } -func (am *DefaultAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error { +func (am *DefaultAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error { accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, peerPubKey) if err != nil { return err } - _, _, _, _, err = am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, UpdateAccountPeers: true}, accountID) + _, _, _, _, err = am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP, UpdateAccountPeers: true}, accountID) if err != nil { return err } diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 784e432f6..1e738c274 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -62,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, nmap *types.NetworkMap) error + MarkPeerConnected(ctx context.Context, peerKey string, 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) @@ -123,7 +123,7 @@ type Manager interface { GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error - SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error + SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) GetAccountIDForPeerKey(ctx context.Context, peerKey string) (string, error) GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index 145e6e00f..274e4c683 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -1323,17 +1323,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, nmap *types.NetworkMap) error { +func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, realIP, accountID, sessionStartedAt, nmap) + ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, 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, nmap interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, 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, nmap) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, accountID, sessionStartedAt, nmap) } // MarkPeerDisconnected mocks base method. @@ -1586,17 +1586,17 @@ func (mr *MockManagerMockRecorder) SyncPeer(ctx, sync, accountID interface{}) *g } // SyncPeerMeta mocks base method. -func (m *MockManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta peer.PeerSystemMeta) error { +func (m *MockManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta peer.PeerSystemMeta, realIP net.IP) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SyncPeerMeta", ctx, peerPubKey, meta) + ret := m.ctrl.Call(m, "SyncPeerMeta", ctx, peerPubKey, meta, realIP) ret0, _ := ret[0].(error) return ret0 } // SyncPeerMeta indicates an expected call of SyncPeerMeta. -func (mr *MockManagerMockRecorder) SyncPeerMeta(ctx, peerPubKey, meta interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SyncPeerMeta(ctx, peerPubKey, meta, realIP interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeerMeta", reflect.TypeOf((*MockManager)(nil).SyncPeerMeta), ctx, peerPubKey, meta) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeerMeta", reflect.TypeOf((*MockManager)(nil).SyncPeerMeta), ctx, peerPubKey, meta, realIP) } // SyncUserJWTGroups mocks base method. diff --git a/management/server/account_test.go b/management/server/account_test.go index 2e26ac222..e99e5861f 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(), nil) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), 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(), nil) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), 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(), nil) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, 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(), nil) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, 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(), nil) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, 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(), nil) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, 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, nil) + errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, 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(), nil) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), accountID, time.Now().UTC().UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") wg := &sync.WaitGroup{} diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index f81139f24..071e3771b 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -39,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, nmap *types.NetworkMap) error + MarkPeerConnectedFunc func(ctx context.Context, peerKey string, 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 @@ -114,7 +114,7 @@ type MockAccountManager struct { GetIdpManagerFunc func() idp.Manager UpdateIntegratedValidatorFunc func(ctx context.Context, accountID, userID, validator string, groups []string) error GroupValidationFunc func(ctx context.Context, accountId string, groups []string) (bool, error) - SyncPeerMetaFunc func(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error + SyncPeerMetaFunc func(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error FindExistingPostureCheckFunc func(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) GetAccountIDForPeerKeyFunc func(ctx context.Context, peerKey string) (string, error) GetAccountByIDFunc func(ctx context.Context, accountID string, userID string) (*types.Account, error) @@ -345,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, nmap *types.NetworkMap) error { +func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { if am.MarkPeerConnectedFunc != nil { - return am.MarkPeerConnectedFunc(ctx, peerKey, realIP, accountID, sessionStartedAt, nmap) + return am.MarkPeerConnectedFunc(ctx, peerKey, accountID, sessionStartedAt, nmap) } return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented") } @@ -975,9 +975,9 @@ func (am *MockAccountManager) GroupValidation(ctx context.Context, accountId str } // SyncPeerMeta mocks SyncPeerMeta of the AccountManager interface -func (am *MockAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error { +func (am *MockAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error { if am.SyncPeerMetaFunc != nil { - return am.SyncPeerMetaFunc(ctx, peerPubKey, meta) + return am.SyncPeerMetaFunc(ctx, peerPubKey, meta, realIP) } return status.Errorf(codes.Unimplemented, "method SyncPeerMeta is not implemented") } diff --git a/management/server/peer.go b/management/server/peer.go index f219d761c..91fafa830 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -74,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, nmap *types.NetworkMap) error { +func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { start := time.Now() defer func() { am.metrics.AccountManagerMetrics().RecordPeerStatusUpdateDuration(telemetry.PeerStatusConnect, time.Since(start)) @@ -102,10 +102,6 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK } am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusApplied) - if am.geo != nil && realIP != nil { - am.updatePeerLocationIfChanged(ctx, accountID, peer, realIP) - } - if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil { return err } @@ -195,24 +191,28 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP return nil } -// updatePeerLocationIfChanged refreshes the geolocation on a separate -// row update, only when the connection IP actually changed. Geo lookups -// are expensive so we skip same-IP reconnects. -func (am *DefaultAccountManager) updatePeerLocationIfChanged(ctx context.Context, accountID string, peer *nbpeer.Peer, realIP net.IP) { +// resolvePeerLocation looks up the geo location for realIP, returning nil when +// there is nothing to apply: geo disabled, no real IP, the IP is unchanged from +// what the peer already has, or the lookup failed. Geo lookups are skipped on +// same-IP reconnects since they are comparatively expensive. The returned value +// is applied by Peer.UpdateMetaIfNew so the change is persisted by its peer save. +func (am *DefaultAccountManager) resolvePeerLocation(ctx context.Context, peer *nbpeer.Peer, realIP net.IP) *nbpeer.Location { + if am.geo == nil || realIP == nil { + return nil + } if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) { - return + return nil } location, err := am.geo.Lookup(realIP) if err != nil { log.WithContext(ctx).Warnf("failed to get location for peer %s realip: [%s]: %v", peer.ID, realIP.String(), err) - return + return nil } - peer.Location.ConnectionIP = realIP - peer.Location.CountryCode = location.Country.ISOCode - peer.Location.CityName = location.City.Names.En - peer.Location.GeoNameID = location.City.GeonameID - if err := am.Store.SavePeerLocation(ctx, accountID, peer); err != nil { - log.WithContext(ctx).Warnf("could not store location for peer %s: %s", peer.ID, err) + return &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: location.Country.ISOCode, + CityName: location.City.Names.En, + GeoNameID: location.City.GeonameID, } } @@ -980,7 +980,8 @@ func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) { // SyncPeer checks whether peer is eligible for receiving NetworkMap (authenticated) and returns its NetworkMap if eligible func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { var peer *nbpeer.Peer - var updated, versionChanged, ipv6CapabilityChanged bool + var ipv6CapabilityChanged bool + var metaDiff nbpeer.MetaDiff var err error settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) @@ -1010,9 +1011,10 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy } oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) - updated, versionChanged = peer.UpdateMetaIfNew(ctx, sync.Meta) + newLocation := am.resolvePeerLocation(ctx, peer, sync.RealIP) + metaDiff = peer.UpdateMetaIfNew(ctx, sync.Meta, newLocation) ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) - if updated { + if metaDiff.Updated() { am.metrics.AccountManagerMetrics().CountPeerMetUpdate() log.WithContext(ctx).Tracef("peer %s metadata updated", peer.ID) if err = transaction.SavePeer(ctx, accountID, peer); err != nil { @@ -1040,9 +1042,10 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } - if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(resPostureChecks) > 0 || versionChanged)) { + metaDiffAffectsPosture := posture.AffectsPosture(&metaDiff, resPostureChecks) + if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || metaDiffAffectsPosture || metaDiff.VersionChanged || metaDiff.Hostname { changedPeerIDs := []string{peer.ID} - affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, updated, len(resPostureChecks) > 0) + affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, metaDiffAffectsPosture) 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) } @@ -1059,8 +1062,8 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy // 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) { +func (am *DefaultAccountManager) syncPeerAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap, peerNotValid, metaChangeAffectedPosture bool) []string { + if peerNotValid || metaChangeAffectedPosture { return am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, []string{peerID}) } return affectedPeerIDsFromNetworkMap(nmap, peerID) diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 591ac074e..4a846ebdd 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -256,14 +256,18 @@ func (p *Peer) Copy() *Peer { } } -// UpdateMetaIfNew updates peer's system metadata if new information is provided -// returns true if meta was updated, false otherwise -func (p *Peer) UpdateMetaIfNew(ctx context.Context, meta PeerSystemMeta) (updated, versionChanged bool) { +// UpdateMetaIfNew updates peer's system metadata and connection geo location if +// new information is provided. newLocation is the geo location resolved from the +// peer's current connection IP, or nil when there is nothing to apply (geo +// disabled, no real IP, or the IP is unchanged); the caller owns the expensive +// lookup and the same-IP guard. It returns a MetaDiff describing what changed; +// diff.Updated() reports whether the peer needs to be persisted. +func (p *Peer) UpdateMetaIfNew(ctx context.Context, meta PeerSystemMeta, newLocation *Location) MetaDiff { if meta.isEmpty() { - return updated, versionChanged + return MetaDiff{} } - versionChanged = p.Meta.WtVersion != meta.WtVersion + versionChanged := p.Meta.WtVersion != meta.WtVersion // Avoid overwriting UIVersion if the update was triggered sole by the CLI client if meta.UIVersion == "" { @@ -272,97 +276,177 @@ func (p *Peer) UpdateMetaIfNew(ctx context.Context, meta PeerSystemMeta) (update oldVersion := p.Meta.WtVersion - diff := metaDiff(p.Meta, meta) - if len(diff) != 0 { + diff := diffMeta(p.Meta, meta) + if diff.Any() { p.Meta = meta - updated = true + } + diff.VersionChanged = versionChanged + + locationInfo := "" + if newLocation != nil { + p.Location = *newLocation + diff.LocationChanged = true + locationInfo = fmt.Sprintf("location changed to %s, ", newLocation.ConnectionIP) } versionInfo := "" - if versionChanged { + if diff.VersionChanged { versionInfo = fmt.Sprintf("version changed: %s -> %s, ", oldVersion, meta.WtVersion) } - if len(diff) > 0 || versionChanged { + if diff.Any() || diff.VersionChanged || diff.LocationChanged { log.WithContext(ctx). - Debugf("peer meta updated, %s%d field(s) changed: %s", versionInfo, len(diff), strings.Join(diff, ", ")) + Debugf("peer meta updated, %s%s%d field(s) changed: %s", versionInfo, locationInfo, len(diff.Changed), strings.Join(diff.Changed, ", ")) } - return updated, versionChanged + return diff +} + +// MetaDiff records which PeerSystemMeta fields differ between two metas. Each bool +// maps to a single struct field, except Environment, which is split into Cloud and +// Platform. Changed holds the human-readable `field: -> ` entries so the +// existing log line and isEqual can be derived from the same comparison. +// +// VersionChanged and LocationChanged sit outside the per-meta-field set: +// VersionChanged tracks the WireGuard client version specifically (compared before +// the UIVersion fixup, to signal client upgrades) and LocationChanged tracks the +// peer's connection geo location, which lives on Peer rather than PeerSystemMeta. +// Neither contributes an entry to Changed, so the field-coverage accounting stays +// driven purely by the PeerSystemMeta comparison. +type MetaDiff struct { + Hostname bool + GoOS bool + Kernel bool + KernelVersion bool + Core bool + Platform bool + OS bool + OSVersion bool + WtVersion bool + UIVersion bool + SystemSerialNumber bool + SystemProductName bool + SystemManufacturer bool + EnvironmentCloud bool + EnvironmentPlatform bool + Flags bool + Capabilities bool + NetworkAddresses bool + Files bool + + VersionChanged bool + LocationChanged bool + + Changed []string +} + +// Any reports whether any PeerSystemMeta field changed. +func (d MetaDiff) Any() bool { + return len(d.Changed) != 0 +} + +// Updated reports whether the peer needs to be persisted: any meta field changed +// or the geo location changed. The version flag alone does not imply a write, +// since a version change is also reflected in the WtVersion meta field. +func (d MetaDiff) Updated() bool { + return d.Any() || d.LocationChanged || d.VersionChanged } -// metaDiff returns a human-readable list of the fields that differ between the -// old and new meta, each formatted as `field: -> `. It is the single -// source of truth for meta comparison: isEqual reports equality as an empty -// diff, so the log line can never disagree with the change decision. Slices are -// cloned before sorting, so callers' meta is not mutated. func metaDiff(oldMeta, newMeta PeerSystemMeta) []string { - var diff []string + return diffMeta(oldMeta, newMeta).Changed +} + +// diffMeta compares two metas field by field, returning both a per-field flag set +// (for callers that need to know exactly what changed, e.g. matching against +// posture checks) and the human-readable Changed list. It is the single source of +// truth for meta comparison: isEqual reports equality as an empty diff, so the log +// line, the change decision, and the flags can never disagree. +func diffMeta(oldMeta, newMeta PeerSystemMeta) MetaDiff { + var d MetaDiff add := func(field string, oldVal, newVal any) { - diff = append(diff, fmt.Sprintf("%s: %v -> %v", field, oldVal, newVal)) + d.Changed = append(d.Changed, fmt.Sprintf("%s: %v -> %v", field, oldVal, newVal)) } if oldMeta.Hostname != newMeta.Hostname { + d.Hostname = true add("hostname", oldMeta.Hostname, newMeta.Hostname) } if oldMeta.GoOS != newMeta.GoOS { + d.GoOS = true add("goos", oldMeta.GoOS, newMeta.GoOS) } if oldMeta.Kernel != newMeta.Kernel { + d.Kernel = true add("kernel", oldMeta.Kernel, newMeta.Kernel) } if oldMeta.KernelVersion != newMeta.KernelVersion { + d.KernelVersion = true add("kernel_version", oldMeta.KernelVersion, newMeta.KernelVersion) } if oldMeta.Core != newMeta.Core { + d.Core = true add("core", oldMeta.Core, newMeta.Core) } if oldMeta.Platform != newMeta.Platform { + d.Platform = true add("platform", oldMeta.Platform, newMeta.Platform) } if oldMeta.OS != newMeta.OS { + d.OS = true add("os", oldMeta.OS, newMeta.OS) } if oldMeta.OSVersion != newMeta.OSVersion { + d.OSVersion = true add("os_version", oldMeta.OSVersion, newMeta.OSVersion) } if oldMeta.WtVersion != newMeta.WtVersion { + d.WtVersion = true add("wt_version", oldMeta.WtVersion, newMeta.WtVersion) } if oldMeta.UIVersion != newMeta.UIVersion { + d.UIVersion = true add("ui_version", oldMeta.UIVersion, newMeta.UIVersion) } if oldMeta.SystemSerialNumber != newMeta.SystemSerialNumber { + d.SystemSerialNumber = true add("system_serial_number", oldMeta.SystemSerialNumber, newMeta.SystemSerialNumber) } if oldMeta.SystemProductName != newMeta.SystemProductName { + d.SystemProductName = true add("system_product_name", oldMeta.SystemProductName, newMeta.SystemProductName) } if oldMeta.SystemManufacturer != newMeta.SystemManufacturer { + d.SystemManufacturer = true add("system_manufacturer", oldMeta.SystemManufacturer, newMeta.SystemManufacturer) } if oldMeta.Environment.Cloud != newMeta.Environment.Cloud { + d.EnvironmentCloud = true add("environment_cloud", oldMeta.Environment.Cloud, newMeta.Environment.Cloud) } if oldMeta.Environment.Platform != newMeta.Environment.Platform { + d.EnvironmentPlatform = true add("environment_platform", oldMeta.Environment.Platform, newMeta.Environment.Platform) } if !oldMeta.Flags.isEqual(newMeta.Flags) { + d.Flags = true add("flags", fmt.Sprintf("%+v", oldMeta.Flags), fmt.Sprintf("%+v", newMeta.Flags)) } if !capabilitiesEqual(oldMeta.Capabilities, newMeta.Capabilities) { + d.Capabilities = true add("capabilities", oldMeta.Capabilities, newMeta.Capabilities) } if !sameMultiset(oldMeta.NetworkAddresses, newMeta.NetworkAddresses) { + d.NetworkAddresses = true add("network_addresses", fmt.Sprintf("%v", oldMeta.NetworkAddresses), fmt.Sprintf("%v", newMeta.NetworkAddresses)) } if !sameMultiset(oldMeta.Files, newMeta.Files) { + d.Files = true add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files)) } - return diff + return d } // sameMultiset reports whether two slices contain the same elements with the diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go index f0bbbc32e..6a98edb99 100644 --- a/management/server/posture/checks.go +++ b/management/server/posture/checks.go @@ -7,6 +7,7 @@ import ( "regexp" "github.com/hashicorp/go-version" + nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" @@ -51,6 +52,34 @@ type Checks struct { Checks ChecksDefinition `gorm:"serializer:json"` } +// AffectsPosture reports whether the peer metadata changes described by diff can +// alter the outcome of any of the given posture checks. It maps each check kind to +// the metadata fields it inspects, so an unrelated change (e.g. a hostname update) +// does not force a posture re-evaluation. +func AffectsPosture(diff *nbpeer.MetaDiff, checks []*Checks) bool { + if diff == nil { + return false + } + for _, c := range checks { + if c.Checks.ProcessCheck != nil && diff.Files { + return true + } + if c.Checks.OSVersionCheck != nil && (diff.OSVersion || diff.OS || diff.KernelVersion) { + return true + } + if c.Checks.NBVersionCheck != nil && diff.WtVersion { + return true + } + if c.Checks.GeoLocationCheck != nil && diff.LocationChanged { + return true + } + if c.Checks.PeerNetworkRangeCheck != nil && diff.NetworkAddresses { + return true + } + } + return false +} + // ChecksDefinition contains definition of actual check type ChecksDefinition struct { NBVersionCheck *NBVersionCheck `json:",omitempty"` diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 7d22905dd..8bc4bcd7d 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -581,28 +581,6 @@ func (s *SqlStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accoun return result.RowsAffected > 0, nil } -func (s *SqlStore) SavePeerLocation(ctx context.Context, accountID string, peerWithLocation *nbpeer.Peer) error { - // To maintain data integrity, we create a copy of the peer's location to prevent unintended updates to other fields. - var peerCopy nbpeer.Peer - // Since the location field has been migrated to JSON serialization, - // updating the struct ensures the correct data format is inserted into the database. - peerCopy.Location = peerWithLocation.Location - - result := s.db.Model(&nbpeer.Peer{}). - Where(accountAndIDQueryCondition, accountID, peerWithLocation.ID). - Updates(peerCopy) - - if result.Error != nil { - return status.Errorf(status.Internal, "failed to save peer locations to store: %v", result.Error) - } - - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, peerNotFoundFMT, peerWithLocation.ID) - } - - return nil -} - // ApproveAccountPeers marks all peers that currently require approval in the given account as approved. func (s *SqlStore) ApproveAccountPeers(ctx context.Context, accountID string) (int, error) { result := s.db.Model(&nbpeer.Peer{}). diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index ac136987e..92784af83 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -618,56 +618,6 @@ func TestSqlStore_SavePeerStatus(t *testing.T) { assert.WithinDurationf(t, newStatus.LastSeen, actual.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") } -func TestSqlStore_SavePeerLocation(t *testing.T) { - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") - require.NoError(t, err) - - peer := &nbpeer.Peer{ - AccountID: account.Id, - ID: "testpeer", - Location: nbpeer.Location{ - ConnectionIP: net.ParseIP("0.0.0.0"), - CountryCode: "YY", - CityName: "City", - GeoNameID: 1, - }, - CreatedAt: time.Now().UTC(), - Meta: nbpeer.PeerSystemMeta{}, - } - // error is expected as peer is not in store yet - err = store.SavePeerLocation(context.Background(), account.Id, peer) - assert.Error(t, err) - - account.Peers[peer.ID] = peer - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) - - peer.Location.ConnectionIP = net.ParseIP("35.1.1.1") - peer.Location.CountryCode = "DE" - peer.Location.CityName = "Berlin" - peer.Location.GeoNameID = 2950159 - - err = store.SavePeerLocation(context.Background(), account.Id, account.Peers[peer.ID]) - assert.NoError(t, err) - - account, err = store.GetAccount(context.Background(), account.Id) - require.NoError(t, err) - - actual := account.Peers[peer.ID].Location - assert.Equal(t, peer.Location, actual) - - peer.ID = "non-existing-peer" - err = store.SavePeerLocation(context.Background(), account.Id, peer) - assert.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") -} - func Test_TestGetAccountByPrivateDomain(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") diff --git a/management/server/store/store.go b/management/server/store/store.go index 31f1fea86..066ab285d 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -185,7 +185,6 @@ type Store interface { // recorded by the database. Returns true when the update happened, // false when a newer session has taken over. MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) - SavePeerLocation(ctx context.Context, accountID string, peer *nbpeer.Peer) error ApproveAccountPeers(ctx context.Context, accountID string) (int, error) DeletePeer(ctx context.Context, accountID string, peerID string) error diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 706c03f1b..fdd2d0900 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -2968,20 +2968,6 @@ func (mr *MockStoreMockRecorder) SavePeer(ctx, accountID, peer interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeer", reflect.TypeOf((*MockStore)(nil).SavePeer), ctx, accountID, peer) } -// SavePeerLocation mocks base method. -func (m *MockStore) SavePeerLocation(ctx context.Context, accountID string, peer *peer.Peer) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SavePeerLocation", ctx, accountID, peer) - ret0, _ := ret[0].(error) - return ret0 -} - -// SavePeerLocation indicates an expected call of SavePeerLocation. -func (mr *MockStoreMockRecorder) SavePeerLocation(ctx, accountID, peer interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeerLocation", reflect.TypeOf((*MockStore)(nil).SavePeerLocation), ctx, accountID, peer) -} - // SavePeerStatus mocks base method. func (m *MockStore) SavePeerStatus(ctx context.Context, accountID, peerID string, status peer.PeerStatus) error { m.ctrl.T.Helper() diff --git a/management/server/types/peer.go b/management/server/types/peer.go index 15d343793..885d67bba 100644 --- a/management/server/types/peer.go +++ b/management/server/types/peer.go @@ -12,6 +12,9 @@ type PeerSync struct { WireGuardPubKey string // Meta is the system information passed by peer, must be always present Meta nbpeer.PeerSystemMeta + // RealIP is the peer's connection IP, used to refresh its geo location. + // May be nil when the request has no associated connection IP. + RealIP net.IP // UpdateAccountPeers indicate updating account peers, // which occurs when the peer's metadata is updated UpdateAccountPeers bool From 2ebf26006a6c6be1c9b824012a57224d6eb53c96 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:54:38 +0200 Subject: [PATCH 02/26] [management] empty file check in nmap on other posturechecks (#6511) --- management/internals/shared/grpc/server.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 8ee7722be..1d734dae7 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -1254,7 +1254,10 @@ func (s *Server) Logout(ctx context.Context, req *proto.EncryptedMessage) (*prot func toProtocolChecks(ctx context.Context, postureChecks []*posture.Checks) []*proto.Checks { protoChecks := make([]*proto.Checks, 0, len(postureChecks)) for _, postureCheck := range postureChecks { - protoChecks = append(protoChecks, toProtocolCheck(postureCheck)) + check := toProtocolCheck(postureCheck) + if check != nil { + protoChecks = append(protoChecks, check) + } } return protoChecks @@ -1278,5 +1281,9 @@ func toProtocolCheck(postureCheck *posture.Checks) *proto.Checks { } } + if len(protoCheck.Files) == 0 { + return nil + } + return protoCheck } From 51b6f6291b9f82101980e773e22649deddb73b7b Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:01:49 +0200 Subject: [PATCH 03/26] Fixup debug config (#6514) --- client/cmd/debug.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/cmd/debug.go b/client/cmd/debug.go index bc7b0e98c..57e75f663 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -130,7 +130,7 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error { client := proto.NewDaemonServiceClient(conn) resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: string(activeProf.ID), Username: currUser.Username, }) if err != nil { From 07101c59ac4352179e1aae7868cb5d4b7a762453 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 23 Jun 2026 17:44:32 +0300 Subject: [PATCH 04/26] [management] Reschedule inactivity expiration when a peer disconnects (#6523) --- management/server/account_test.go | 111 ++++++++++++++++++++++++++++++ management/server/peer.go | 9 +++ 2 files changed, 120 insertions(+) diff --git a/management/server/account_test.go b/management/server/account_test.go index e99e5861f..585e267d5 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -1916,6 +1916,117 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. } } +func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiration(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + peerPubKey := key.PublicKey().String() + + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + InactivityExpirationEnabled: true, + }, false) + require.NoError(t, err, "unable to add peer") + + _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + PeerInactivityExpiration: time.Hour, + PeerInactivityExpirationEnabled: true, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err, "expecting to update account settings successfully but got error") + + // Establish a session so the matching-token disconnect is actually applied. + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) + require.NoError(t, err, "unable to mark peer connected") + + // Install the mock only now, so the assertion observes the disconnect, not + // the earlier connect. + scheduled := make(chan struct{}, 1) + manager.peerInactivityExpiry = &MockScheduler{ + CancelFunc: func(ctx context.Context, IDs []string) {}, + ScheduleFunc: func(ctx context.Context, in time.Duration, ID string, job func() (nextRunIn time.Duration, reschedule bool)) { + select { + case scheduled <- struct{}{}: + default: + } + }, + } + + err = manager.MarkPeerDisconnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano()) + require.NoError(t, err, "unable to mark peer disconnected") + + select { + case <-scheduled: + // expected: disconnect re-armed the inactivity expiry timer + case <-time.After(time.Second): + t.Fatal("expected inactivity expiration to be rescheduled when an eligible peer disconnects") + } +} + +func TestDefaultAccountManager_MarkPeerDisconnected_SkipsInactivityExpirationWhenDisabled(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + peerPubKey := key.PublicKey().String() + + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + InactivityExpirationEnabled: true, + }, false) + require.NoError(t, err, "unable to add peer") + + // Peer is eligible (SSO + inactivity enabled) but the account-level setting + // stays disabled, so disconnect must not schedule anything. + _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + PeerInactivityExpiration: time.Hour, + PeerInactivityExpirationEnabled: false, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err, "expecting to update account settings successfully but got error") + + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) + require.NoError(t, err, "unable to mark peer connected") + + scheduled := make(chan struct{}, 1) + manager.peerInactivityExpiry = &MockScheduler{ + CancelFunc: func(ctx context.Context, IDs []string) {}, + ScheduleFunc: func(ctx context.Context, in time.Duration, ID string, job func() (nextRunIn time.Duration, reschedule bool)) { + select { + case scheduled <- struct{}{}: + default: + } + }, + } + + err = manager.MarkPeerDisconnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano()) + require.NoError(t, err, "unable to mark peer disconnected") + + select { + case <-scheduled: + t.Fatal("inactivity expiration must not be scheduled while the account-level setting is disabled") + case <-time.After(200 * time.Millisecond): + // expected: nothing scheduled + } +} + func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") diff --git a/management/server/peer.go b/management/server/peer.go index 91fafa830..a5da29243 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -188,6 +188,15 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP } } + if peer.AddedWithSSOLogin() && peer.InactivityExpirationEnabled { + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + log.WithContext(ctx).Warnf("failed getting account settings to schedule inactivity expiration for peer %s: %v", peer.ID, err) + } else if settings.PeerInactivityExpirationEnabled { + am.checkAndSchedulePeerInactivityExpiration(ctx, accountID) + } + } + return nil } From 17b20445968847e8ae257ab4b820b9c54f3d36a2 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:55:57 +0900 Subject: [PATCH 05/26] [client] Skip re-resolving cached management cache domains (#6518) --- client/internal/dns/mgmt/mgmt.go | 108 ++++++++++- client/internal/dns/mgmt/mgmt_refresh_test.go | 11 ++ client/internal/dns/mgmt/mgmt_resolve_test.go | 183 ++++++++++++++++++ 3 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 client/internal/dns/mgmt/mgmt_resolve_test.go diff --git a/client/internal/dns/mgmt/mgmt.go b/client/internal/dns/mgmt/mgmt.go index 988e427fb..ddc8cf585 100644 --- a/client/internal/dns/mgmt/mgmt.go +++ b/client/internal/dns/mgmt/mgmt.go @@ -51,13 +51,20 @@ type cachedRecord struct { } // Resolver caches critical NetBird infrastructure domains. -// records, refreshing, mgmtDomain and serverDomains are all guarded by mutex. +// records, refreshing, failedResolves, mgmtDomain and serverDomains are all +// guarded by mutex. type Resolver struct { records map[dns.Question]*cachedRecord mgmtDomain *domain.Domain serverDomains *dnsconfig.ServerDomains mutex sync.RWMutex + // failedResolves records the last failed initial resolve per domain so a + // domain that never resolves isn't retried on every server-domains update + // until refreshBackoff elapses. Entries are cleared on success and pruned + // to the current server-domains set. + failedResolves map[domain.Domain]time.Time + chain ChainResolver chainMaxPriority int refreshGroup singleflight.Group @@ -76,9 +83,10 @@ type Resolver struct { // NewResolver creates a new management domains cache resolver. func NewResolver() *Resolver { return &Resolver{ - records: make(map[dns.Question]*cachedRecord), - refreshing: make(map[dns.Question]*atomic.Bool), - cacheTTL: resolveCacheTTL(), + records: make(map[dns.Question]*cachedRecord), + refreshing: make(map[dns.Question]*atomic.Bool), + failedResolves: make(map[domain.Domain]time.Time), + cacheTTL: resolveCacheTTL(), } } @@ -173,7 +181,9 @@ func (m *Resolver) continueToNext(w dns.ResponseWriter, r *dns.Msg) { // AddDomain resolves a domain and stores its A/AAAA records in the cache. // A family that resolves NODATA (nil err, zero records) evicts any stale -// entry for that qtype. +// entry for that qtype. When one family hard-errors while the other succeeds, +// the resolved family is still cached but AddDomain returns an error so the +// caller retries the incomplete resolve rather than treating it as complete. func (m *Resolver) AddDomain(ctx context.Context, d domain.Domain) error { dnsName := strings.ToLower(dns.Fqdn(d.PunycodeString())) @@ -203,6 +213,10 @@ func (m *Resolver) AddDomain(ctx context.Context, d domain.Domain) error { log.Debugf("added/updated domain=%s with %d A records and %d AAAA records", d.SafeString(), len(aRecords), len(aaaaRecords)) + if errA != nil || errAAAA != nil { + return fmt.Errorf("resolve %s: incomplete, a family failed: %w", d.SafeString(), errors.Join(errA, errAAAA)) + } + return nil } @@ -462,6 +476,7 @@ func (m *Resolver) RemoveDomain(d domain.Domain) error { delete(m.records, qAAAA) delete(m.refreshing, qA) delete(m.refreshing, qAAAA) + delete(m.failedResolves, d) log.Debugf("removed domain=%s from cache", d.SafeString()) return nil @@ -505,6 +520,7 @@ func (m *Resolver) UpdateFromServerDomains(ctx context.Context, serverDomains dn allDomains := m.extractDomainsFromServerDomains(updatedServerDomains) currentDomains := m.GetCachedDomains() removedDomains = m.removeStaleDomains(currentDomains, allDomains) + m.pruneFailedResolves(allDomains) } m.addNewDomains(ctx, newDomains) @@ -577,13 +593,85 @@ func (m *Resolver) isManagementDomain(domain domain.Domain) bool { return m.mgmtDomain != nil && domain == *m.mgmtDomain } -// addNewDomains resolves and caches all domains from the update +// addNewDomains resolves and caches domains that are not yet in the cache, +// running the lookups concurrently. Domains already cached are skipped and left +// to the stale-while-revalidate refresh path, so a sync never re-resolves them +// synchronously: once NetBird owns the OS resolver the resolve runs through the +// handler chain and would otherwise dial the managed upstreams under the engine +// sync lock on every update. func (m *Resolver) addNewDomains(ctx context.Context, newDomains domain.List) { + var wg sync.WaitGroup + seen := make(map[domain.Domain]struct{}, len(newDomains)) for _, newDomain := range newDomains { - if err := m.AddDomain(ctx, newDomain); err != nil { - log.Warnf("failed to add/update domain=%s: %v", newDomain.SafeString(), err) - } else { - log.Debugf("added/updated management cache domain=%s", newDomain.SafeString()) + if _, dup := seen[newDomain]; dup { + continue + } + seen[newDomain] = struct{}{} + + if !m.needsResolve(newDomain) { + continue + } + + wg.Add(1) + go func(d domain.Domain) { + defer wg.Done() + if err := m.AddDomain(ctx, d); err != nil { + m.markResolveFailed(d) + log.Warnf("failed to add/update domain=%s: %v", d.SafeString(), err) + return + } + m.clearResolveFailed(d) + log.Debugf("added/updated management cache domain=%s", d.SafeString()) + }(newDomain) + } + wg.Wait() +} + +// needsResolve reports whether d should be resolved now. A recent failed or +// incomplete resolve gates retries on the backoff even when one family is +// already cached, so a transiently-failed family is retried instead of being +// treated as fully resolved. Otherwise a domain with any cached record is left +// to the stale-while-revalidate refresh path. +func (m *Resolver) needsResolve(d domain.Domain) bool { + dnsName := strings.ToLower(dns.Fqdn(d.PunycodeString())) + + m.mutex.RLock() + defer m.mutex.RUnlock() + + if failedAt, ok := m.failedResolves[d]; ok { + return time.Since(failedAt) >= refreshBackoff + } + + for _, qtype := range []uint16{dns.TypeA, dns.TypeAAAA} { + q := dns.Question{Name: dnsName, Qtype: qtype, Qclass: dns.ClassINET} + if _, ok := m.records[q]; ok { + return false + } + } + return true +} + +func (m *Resolver) markResolveFailed(d domain.Domain) { + m.mutex.Lock() + m.failedResolves[d] = time.Now() + m.mutex.Unlock() +} + +func (m *Resolver) clearResolveFailed(d domain.Domain) { + m.mutex.Lock() + delete(m.failedResolves, d) + m.mutex.Unlock() +} + +// pruneFailedResolves drops failure markers for domains no longer present in +// the server-domains set, keeping the map bounded to the current set (a +// failed-only domain has no cached record, so RemoveDomain never sees it). +func (m *Resolver) pruneFailedResolves(domains domain.List) { + m.mutex.Lock() + defer m.mutex.Unlock() + for d := range m.failedResolves { + if !slices.Contains(domains, d) { + delete(m.failedResolves, d) } } } diff --git a/client/internal/dns/mgmt/mgmt_refresh_test.go b/client/internal/dns/mgmt/mgmt_refresh_test.go index 9faa5a0b8..64a5342e2 100644 --- a/client/internal/dns/mgmt/mgmt_refresh_test.go +++ b/client/internal/dns/mgmt/mgmt_refresh_test.go @@ -21,6 +21,7 @@ type fakeChain struct { mu sync.Mutex calls map[string]int answers map[string][]dns.RR + qErr map[string]error err error hasRoot bool onLookup func() @@ -30,6 +31,7 @@ func newFakeChain() *fakeChain { return &fakeChain{ calls: map[string]int{}, answers: map[string][]dns.RR{}, + qErr: map[string]error{}, hasRoot: true, } } @@ -47,6 +49,9 @@ func (f *fakeChain) ResolveInternal(ctx context.Context, msg *dns.Msg, maxPriori f.calls[key]++ answers := f.answers[key] err := f.err + if err == nil { + err = f.qErr[key] + } onLookup := f.onLookup f.mu.Unlock() @@ -75,6 +80,12 @@ func (f *fakeChain) setAnswer(name string, qtype uint16, ip string) { } } +func (f *fakeChain) setErr(name string, qtype uint16, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.qErr[name+"|"+dns.TypeToString[qtype]] = err +} + func (f *fakeChain) callCount(name string, qtype uint16) int { f.mu.Lock() defer f.mu.Unlock() diff --git a/client/internal/dns/mgmt/mgmt_resolve_test.go b/client/internal/dns/mgmt/mgmt_resolve_test.go new file mode 100644 index 000000000..5cfbac8f0 --- /dev/null +++ b/client/internal/dns/mgmt/mgmt_resolve_test.go @@ -0,0 +1,183 @@ +package mgmt + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" + "github.com/netbirdio/netbird/shared/management/domain" +) + +// A domain already in the cache must not be re-resolved on a subsequent server +// domains update; it is left to the stale-while-revalidate refresh path. +func TestResolver_UpdateFromServerDomains_SkipsCached(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("signal.example.com.", dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + + sd := dnsconfig.ServerDomains{Signal: domain.Domain("signal.example.com")} + + _, err := r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + require.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "first update must resolve the domain") + + _, err = r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + assert.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "cached domain must not be re-resolved on a subsequent update") +} + +// New domains in a single update must resolve concurrently rather than serially. +func TestResolver_AddNewDomains_ResolvesConcurrently(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + + var inflight, maxInflight atomic.Int32 + chain.onLookup = func() { + n := inflight.Add(1) + for { + old := maxInflight.Load() + if n <= old || maxInflight.CompareAndSwap(old, n) { + break + } + } + time.Sleep(50 * time.Millisecond) + inflight.Add(-1) + } + + relays := []domain.Domain{"a.example.com", "b.example.com", "c.example.com", "d.example.com"} + for _, d := range relays { + chain.setAnswer(dns.Fqdn(string(d)), dns.TypeA, "10.0.0.2") + } + r.SetChainResolver(chain, 50) + + start := time.Now() + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: relays}) + require.NoError(t, err) + elapsed := time.Since(start) + + assert.GreaterOrEqual(t, int(maxInflight.Load()), 2, "domains must resolve concurrently") + // Serial resolution of 4 domains would take at least 4*50ms; concurrent is far less. + assert.Less(t, elapsed, 300*time.Millisecond, "resolution should not be serial") +} + +// A domain that fails to resolve must not be retried on every update; the +// failure backoff suppresses re-resolution until it expires. +func TestResolver_UpdateFromServerDomains_BacksOffFailures(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.err = errors.New("resolve boom") + r.SetChainResolver(chain, 50) + + sd := dnsconfig.ServerDomains{Signal: domain.Domain("signal.example.com")} + + _, err := r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + require.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "first update must attempt the resolve") + + _, err = r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + assert.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "failed resolve must back off and not retry on the next update") +} + +// A domain listed under more than one server-domain type (e.g. STUN and TURN on +// the same host) must be resolved once per update, not once per occurrence. +func TestResolver_AddNewDomains_DedupesDuplicateDomains(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("dup.example.com.", dns.TypeA, "10.0.0.9") + r.SetChainResolver(chain, 50) + + sd := dnsconfig.ServerDomains{ + Stuns: []domain.Domain{"dup.example.com"}, + Turns: []domain.Domain{"dup.example.com"}, + } + + _, err := r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + assert.Equal(t, 1, chain.callCount("dup.example.com.", dns.TypeA), + "a domain appearing under multiple server-domain types must resolve once") +} + +// A failure marker must be dropped once its domain leaves the server-domains set +// so the map stays bounded to the current set. +func TestResolver_UpdateFromServerDomains_PrunesFailedResolves(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.err = errors.New("resolve boom") + r.SetChainResolver(chain, 50) + + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Signal: domain.Domain("gone.example.com")}) + require.NoError(t, err) + r.mutex.RLock() + _, marked := r.failedResolves[domain.Domain("gone.example.com")] + r.mutex.RUnlock() + require.True(t, marked, "failed resolve must be recorded") + + _, err = r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Signal: domain.Domain("other.example.com")}) + require.NoError(t, err) + r.mutex.RLock() + _, stillMarked := r.failedResolves[domain.Domain("gone.example.com")] + r.mutex.RUnlock() + assert.False(t, stillMarked, "failure marker for a domain no longer in the set must be pruned") +} + +// When one family hard-errors while the other resolves, the domain is cached +// for the working family but recorded as incomplete so the failed family is +// retried under backoff instead of being treated as fully resolved forever. +func TestResolver_AddNewDomains_RetriesPartialFamilyFailure(t *testing.T) { + d := domain.Domain("relay.example.com") + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("relay.example.com.", dns.TypeA, "10.0.0.2") + chain.setErr("relay.example.com.", dns.TypeAAAA, errors.New("servfail")) + r.SetChainResolver(chain, 50) + + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: []domain.Domain{d}}) + require.NoError(t, err) + + r.mutex.RLock() + _, aCached := r.records[dns.Question{Name: "relay.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}] + _, marked := r.failedResolves[d] + r.mutex.RUnlock() + require.True(t, aCached, "the working family must still be cached") + require.True(t, marked, "a partial failure must be recorded so the failed family is retried") + + assert.False(t, r.needsResolve(d), "within the backoff window the domain is not retried") + + r.mutex.Lock() + r.failedResolves[d] = time.Now().Add(-2 * refreshBackoff) + r.mutex.Unlock() + assert.True(t, r.needsResolve(d), "after the backoff elapses the domain is retried to pick up the missing family") +} + +// A family that returns NODATA (legitimately absent, e.g. an IPv4-only host) is +// not a failure: the domain must not be marked for retry, otherwise it would be +// re-resolved on every sync. +func TestResolver_AddNewDomains_NodataIsNotFailure(t *testing.T) { + d := domain.Domain("v4only.example.com") + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("v4only.example.com.", dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: []domain.Domain{d}}) + require.NoError(t, err) + + r.mutex.RLock() + _, marked := r.failedResolves[d] + r.mutex.RUnlock() + assert.False(t, marked, "a NODATA family must not be recorded as a failure") + assert.False(t, r.needsResolve(d), "an IPv4-only host must not be re-resolved on later syncs") +} From 221b9c012cda50e27b7b70e6c6e52de15974f063 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:02:04 +0200 Subject: [PATCH 06/26] [management] validate posture checks on meta change before account update (#6527) --- .../network_map/controller/controller.go | 2 - management/server/peer.go | 4 +- management/server/peer/peer.go | 145 +++++-------- .../server/posture/affects_posture_test.go | 202 ++++++++++++++++++ management/server/posture/checks.go | 51 +++-- 5 files changed, 291 insertions(+), 113 deletions(-) create mode 100644 management/server/posture/affects_posture_test.go diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index d271c499d..e16796471 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -610,12 +610,10 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr return nil, nil, 0, err } - startPosture := time.Now() postureChecks, err := c.getPeerPostureChecks(account, peerID) if err != nil { 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 { diff --git a/management/server/peer.go b/management/server/peer.go index a5da29243..17067a530 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1051,8 +1051,8 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } - metaDiffAffectsPosture := posture.AffectsPosture(&metaDiff, resPostureChecks) - if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || metaDiffAffectsPosture || metaDiff.VersionChanged || metaDiff.Hostname { + metaDiffAffectsPosture := posture.AffectsPosture(ctx, &metaDiff, resPostureChecks) + if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || metaDiffAffectsPosture || metaDiff.VersionChanged() || metaDiff.HostnameChanged() { changedPeerIDs := []string{peer.ID} affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, metaDiffAffectsPosture) if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index 4a846ebdd..3110cd9c1 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -107,6 +107,15 @@ type Location struct { GeoNameID uint // city level geoname id } +// equal reports whether two locations match. ConnectionIP is a net.IP slice, so it uses +// IP.Equal, not ==. +func (l Location) equal(other Location) bool { + return l.CountryCode == other.CountryCode && + l.CityName == other.CityName && + l.GeoNameID == other.GeoNameID && + l.ConnectionIP.Equal(other.ConnectionIP) +} + // NetworkAddress is the IP address with network and MAC address of a network interface type NetworkAddress struct { NetIP netip.Prefix `gorm:"serializer:json"` @@ -267,185 +276,141 @@ func (p *Peer) UpdateMetaIfNew(ctx context.Context, meta PeerSystemMeta, newLoca return MetaDiff{} } - versionChanged := p.Meta.WtVersion != meta.WtVersion - // Avoid overwriting UIVersion if the update was triggered sole by the CLI client if meta.UIVersion == "" { meta.UIVersion = p.Meta.UIVersion } - oldVersion := p.Meta.WtVersion + effectiveLocation := p.Location + if newLocation != nil { + effectiveLocation = *newLocation + } - diff := diffMeta(p.Meta, meta) - if diff.Any() { + diff := diffMeta(p.Meta, meta, p.Location, effectiveLocation) + if diff.Updated() { p.Meta = meta } - diff.VersionChanged = versionChanged + p.Location = effectiveLocation - locationInfo := "" - if newLocation != nil { - p.Location = *newLocation - diff.LocationChanged = true - locationInfo = fmt.Sprintf("location changed to %s, ", newLocation.ConnectionIP) - } - - versionInfo := "" - if diff.VersionChanged { - versionInfo = fmt.Sprintf("version changed: %s -> %s, ", oldVersion, meta.WtVersion) - } - - if diff.Any() || diff.VersionChanged || diff.LocationChanged { - log.WithContext(ctx). - Debugf("peer meta updated, %s%s%d field(s) changed: %s", versionInfo, locationInfo, len(diff.Changed), strings.Join(diff.Changed, ", ")) + if diff.Updated() { + log.WithContext(ctx).Debug(diff.LogSummary()) } return diff } -// MetaDiff records which PeerSystemMeta fields differ between two metas. Each bool -// maps to a single struct field, except Environment, which is split into Cloud and -// Platform. Changed holds the human-readable `field: -> ` entries so the -// existing log line and isEqual can be derived from the same comparison. -// -// VersionChanged and LocationChanged sit outside the per-meta-field set: -// VersionChanged tracks the WireGuard client version specifically (compared before -// the UIVersion fixup, to signal client upgrades) and LocationChanged tracks the -// peer's connection geo location, which lives on Peer rather than PeerSystemMeta. -// Neither contributes an entry to Changed, so the field-coverage accounting stays -// driven purely by the PeerSystemMeta comparison. +// MetaDiff holds a peer's full before/after state across a sync: both metas and both +// connection locations (the location lives on Peer, not PeerSystemMeta, but posture +// checks read it). Changed lists what moved, for logging and the persistence decision; +// the snapshots let a posture check be replayed against old and new. Everything is derived +// from these fields, so there are no parallel per-field flags to keep in sync. type MetaDiff struct { - Hostname bool - GoOS bool - Kernel bool - KernelVersion bool - Core bool - Platform bool - OS bool - OSVersion bool - WtVersion bool - UIVersion bool - SystemSerialNumber bool - SystemProductName bool - SystemManufacturer bool - EnvironmentCloud bool - EnvironmentPlatform bool - Flags bool - Capabilities bool - NetworkAddresses bool - Files bool - - VersionChanged bool - LocationChanged bool + OldMeta PeerSystemMeta + NewMeta PeerSystemMeta + OldLocation Location + NewLocation Location Changed []string } -// Any reports whether any PeerSystemMeta field changed. -func (d MetaDiff) Any() bool { +// Updated reports whether anything changed and the peer must be persisted. diffMeta fills +// Changed in the pass that builds the diff, so this is a length check, not a re-comparison. +// Pointer receiver: MetaDiff embeds two metas, so copying it per call is wasteful. +func (d *MetaDiff) Updated() bool { return len(d.Changed) != 0 } -// Updated reports whether the peer needs to be persisted: any meta field changed -// or the geo location changed. The version flag alone does not imply a write, -// since a version change is also reflected in the WtVersion meta field. -func (d MetaDiff) Updated() bool { - return d.Any() || d.LocationChanged || d.VersionChanged +// VersionChanged reports whether the WireGuard client version changed (a client upgrade). +func (d *MetaDiff) VersionChanged() bool { + return d.OldMeta.WtVersion != d.NewMeta.WtVersion +} + +// HostnameChanged reports whether the peer's hostname changed. +func (d *MetaDiff) HostnameChanged() bool { + return d.OldMeta.Hostname != d.NewMeta.Hostname +} + +// LogSummary renders the changed fields as a single human-readable line. +func (d *MetaDiff) LogSummary() string { + return fmt.Sprintf("peer meta updated, %d field(s) changed: %s", + len(d.Changed), strings.Join(d.Changed, ", ")) } func metaDiff(oldMeta, newMeta PeerSystemMeta) []string { - return diffMeta(oldMeta, newMeta).Changed + return diffMeta(oldMeta, newMeta, Location{}, Location{}).Changed } -// diffMeta compares two metas field by field, returning both a per-field flag set -// (for callers that need to know exactly what changed, e.g. matching against -// posture checks) and the human-readable Changed list. It is the single source of -// truth for meta comparison: isEqual reports equality as an empty diff, so the log -// line, the change decision, and the flags can never disagree. -func diffMeta(oldMeta, newMeta PeerSystemMeta) MetaDiff { - var d MetaDiff +// diffMeta snapshots a peer's old and new state and records a Changed entry per field that +// moved. It is the single source of truth for the comparison: isEqual is an empty Changed +// list, so the log line and the persistence decision can never disagree. +func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location) MetaDiff { + d := MetaDiff{OldMeta: oldMeta, NewMeta: newMeta, OldLocation: oldLocation, NewLocation: newLocation} add := func(field string, oldVal, newVal any) { d.Changed = append(d.Changed, fmt.Sprintf("%s: %v -> %v", field, oldVal, newVal)) } if oldMeta.Hostname != newMeta.Hostname { - d.Hostname = true add("hostname", oldMeta.Hostname, newMeta.Hostname) } if oldMeta.GoOS != newMeta.GoOS { - d.GoOS = true add("goos", oldMeta.GoOS, newMeta.GoOS) } if oldMeta.Kernel != newMeta.Kernel { - d.Kernel = true add("kernel", oldMeta.Kernel, newMeta.Kernel) } if oldMeta.KernelVersion != newMeta.KernelVersion { - d.KernelVersion = true add("kernel_version", oldMeta.KernelVersion, newMeta.KernelVersion) } if oldMeta.Core != newMeta.Core { - d.Core = true add("core", oldMeta.Core, newMeta.Core) } if oldMeta.Platform != newMeta.Platform { - d.Platform = true add("platform", oldMeta.Platform, newMeta.Platform) } if oldMeta.OS != newMeta.OS { - d.OS = true add("os", oldMeta.OS, newMeta.OS) } if oldMeta.OSVersion != newMeta.OSVersion { - d.OSVersion = true add("os_version", oldMeta.OSVersion, newMeta.OSVersion) } if oldMeta.WtVersion != newMeta.WtVersion { - d.WtVersion = true add("wt_version", oldMeta.WtVersion, newMeta.WtVersion) } if oldMeta.UIVersion != newMeta.UIVersion { - d.UIVersion = true add("ui_version", oldMeta.UIVersion, newMeta.UIVersion) } if oldMeta.SystemSerialNumber != newMeta.SystemSerialNumber { - d.SystemSerialNumber = true add("system_serial_number", oldMeta.SystemSerialNumber, newMeta.SystemSerialNumber) } if oldMeta.SystemProductName != newMeta.SystemProductName { - d.SystemProductName = true add("system_product_name", oldMeta.SystemProductName, newMeta.SystemProductName) } if oldMeta.SystemManufacturer != newMeta.SystemManufacturer { - d.SystemManufacturer = true add("system_manufacturer", oldMeta.SystemManufacturer, newMeta.SystemManufacturer) } if oldMeta.Environment.Cloud != newMeta.Environment.Cloud { - d.EnvironmentCloud = true add("environment_cloud", oldMeta.Environment.Cloud, newMeta.Environment.Cloud) } if oldMeta.Environment.Platform != newMeta.Environment.Platform { - d.EnvironmentPlatform = true add("environment_platform", oldMeta.Environment.Platform, newMeta.Environment.Platform) } if !oldMeta.Flags.isEqual(newMeta.Flags) { - d.Flags = true add("flags", fmt.Sprintf("%+v", oldMeta.Flags), fmt.Sprintf("%+v", newMeta.Flags)) } if !capabilitiesEqual(oldMeta.Capabilities, newMeta.Capabilities) { - d.Capabilities = true add("capabilities", oldMeta.Capabilities, newMeta.Capabilities) } - if !sameMultiset(oldMeta.NetworkAddresses, newMeta.NetworkAddresses) { - d.NetworkAddresses = true add("network_addresses", fmt.Sprintf("%v", oldMeta.NetworkAddresses), fmt.Sprintf("%v", newMeta.NetworkAddresses)) } - if !sameMultiset(oldMeta.Files, newMeta.Files) { - d.Files = true add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files)) } + if !oldLocation.equal(newLocation) { + add("connection_ip", oldLocation.ConnectionIP, newLocation.ConnectionIP) + } + return d } diff --git a/management/server/posture/affects_posture_test.go b/management/server/posture/affects_posture_test.go new file mode 100644 index 000000000..6aa54d892 --- /dev/null +++ b/management/server/posture/affects_posture_test.go @@ -0,0 +1,202 @@ +package posture + +import ( + "context" + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +// diffFrom builds a MetaDiff from the old/new snapshots AffectsPosture replays against. +func diffFrom(oldMeta, newMeta nbpeer.PeerSystemMeta, oldLoc, newLoc nbpeer.Location) *nbpeer.MetaDiff { + return &nbpeer.MetaDiff{ + OldMeta: oldMeta, + NewMeta: newMeta, + OldLocation: oldLoc, + NewLocation: newLoc, + } +} + +func checks(def ChecksDefinition) []*Checks { + return []*Checks{{Checks: def}} +} + +func TestAffectsPosture_NilDiff(t *testing.T) { + assert.False(t, AffectsPosture(context.Background(), nil, checks(ChecksDefinition{ + NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"}, + }))) +} + +func TestAffectsPosture_NBVersion(t *testing.T) { + c := checks(ChecksDefinition{NBVersionCheck: &NBVersionCheck{MinVersion: "1.2.0"}}) + + tests := []struct { + name string + oldVer, newVer string + want bool + }{ + {"both above min, no flip", "1.3.0", "1.4.0", false}, + {"both below min, no flip", "1.0.0", "1.1.0", false}, + {"crosses up below->above", "1.1.0", "1.3.0", true}, + {"crosses down above->below", "1.3.0", "1.1.0", true}, + {"unparsable old only -> flip", "garbage", "1.3.0", true}, + {"unparsable both -> no flip", "garbage", "junk", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff := diffFrom( + nbpeer.PeerSystemMeta{WtVersion: tt.oldVer}, + nbpeer.PeerSystemMeta{WtVersion: tt.newVer}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.Equal(t, tt.want, AffectsPosture(context.Background(), diff, c)) + }) + } +} + +func TestAffectsPosture_OSVersion_KernelBumpWithinMin(t *testing.T) { + c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{ + Linux: &MinKernelVersionCheck{MinKernelVersion: "5.0.0"}, + }}) + + // Kernel moves but stays above the minimum: verdict stays pass -> not affected. + withinMin := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"}, + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.15.0-arch2"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.False(t, AffectsPosture(context.Background(), withinMin, c)) + + // Kernel drops below the minimum: verdict flips pass -> fail -> affected. + crossesDown := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"}, + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0-arch1"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.True(t, AffectsPosture(context.Background(), crossesDown, c)) +} + +func TestAffectsPosture_OSVersion_GoOSSwitchFlipsVerdict(t *testing.T) { + // Only Linux is constrained. An OS outside the switch (freebsd) passes; switching to a + // failing linux kernel flips the verdict pass -> fail. + c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{ + Linux: &MinKernelVersionCheck{MinKernelVersion: "6.0.0"}, + }}) + + diff := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "freebsd"}, + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.True(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_Process_GoOSSwitchFlipsVerdict(t *testing.T) { + // Process runs at a linux path. Switching GoOS to windows (no WindowsPath configured) + // flips the verdict. + c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{ + Processes: []Process{{LinuxPath: "/usr/bin/foo"}}, + }}) + + files := []nbpeer.File{{Path: "/usr/bin/foo", ProcessIsRunning: true}} + diff := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", Files: files}, + nbpeer.PeerSystemMeta{GoOS: "windows", Files: files}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.True(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_Process_UnrelatedFileChange(t *testing.T) { + // A tracked process stays running while an unrelated file is added: the verdict does + // not move, so posture is not affected. + c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{ + Processes: []Process{{LinuxPath: "/usr/bin/foo"}}, + }}) + + diff := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{ + {Path: "/usr/bin/foo", ProcessIsRunning: true}, + }}, + nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{ + {Path: "/usr/bin/foo", ProcessIsRunning: true}, + {Path: "/usr/bin/bar", ProcessIsRunning: true}, + }}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.False(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_GeoLocation(t *testing.T) { + c := checks(ChecksDefinition{GeoLocationCheck: &GeoLocationCheck{ + Action: CheckActionAllow, + Locations: []Location{{CountryCode: "DE"}}, + }}) + + // Moving within allowed countries keeps the verdict; moving out flips it. + stayAllowed := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{CountryCode: "DE", CityName: "Berlin"}, + nbpeer.Location{CountryCode: "DE", CityName: "Munich"}, + ) + assert.False(t, AffectsPosture(context.Background(), stayAllowed, c)) + + moveOut := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{CountryCode: "DE"}, + nbpeer.Location{CountryCode: "FR"}, + ) + assert.True(t, AffectsPosture(context.Background(), moveOut, c)) +} + +func TestAffectsPosture_PeerNetworkRange_ConnectionIP(t *testing.T) { + // The check reads the connection IP. Moving out of the allowed range flips the verdict; + // moving within it does not. + _, allowed, _ := net.ParseCIDR("10.0.0.0/8") + c := checks(ChecksDefinition{PeerNetworkRangeCheck: &PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{netip.MustParsePrefix(allowed.String())}, + }}) + + movesOutOfRange := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")}, + nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")}, + ) + assert.True(t, AffectsPosture(context.Background(), movesOutOfRange, c)) + + staysInRange := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")}, + nbpeer.Location{ConnectionIP: net.ParseIP("10.9.9.9")}, + ) + assert.False(t, AffectsPosture(context.Background(), staysInRange, c)) +} + +func TestAffectsPosture_IrrelevantFieldChange(t *testing.T) { + // Hostname changes but no check reads it: not affected even with checks present. + c := checks(ChecksDefinition{ + NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"}, + GeoLocationCheck: &GeoLocationCheck{Action: CheckActionAllow, Locations: []Location{{CountryCode: "DE"}}}, + }) + + diff := diffFrom( + nbpeer.PeerSystemMeta{Hostname: "old", WtVersion: "1.5.0"}, + nbpeer.PeerSystemMeta{Hostname: "new", WtVersion: "1.5.0"}, + nbpeer.Location{CountryCode: "DE"}, nbpeer.Location{CountryCode: "DE"}, + ) + assert.False(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_NoChecks(t *testing.T) { + diff := diffFrom( + nbpeer.PeerSystemMeta{WtVersion: "1.0.0"}, + nbpeer.PeerSystemMeta{WtVersion: "2.0.0"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.False(t, AffectsPosture(context.Background(), diff, nil)) +} diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go index 6a98edb99..23ae4efa9 100644 --- a/management/server/posture/checks.go +++ b/management/server/posture/checks.go @@ -7,6 +7,7 @@ import ( "regexp" "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/shared/management/http/api" @@ -52,34 +53,46 @@ type Checks struct { Checks ChecksDefinition `gorm:"serializer:json"` } -// AffectsPosture reports whether the peer metadata changes described by diff can -// alter the outcome of any of the given posture checks. It maps each check kind to -// the metadata fields it inspects, so an unrelated change (e.g. a hostname update) -// does not force a posture re-evaluation. -func AffectsPosture(diff *nbpeer.MetaDiff, checks []*Checks) bool { +// AffectsPosture reports whether the change in diff flips the verdict of any check. It +// replays each check against the peer's old and new state and compares verdicts, so a +// change that moves a field but stays the right side of a threshold (e.g. a kernel bump +// still above the minimum) does not force a re-evaluation. See verdictChanged for how an +// evaluation error counts. +func AffectsPosture(ctx context.Context, diff *nbpeer.MetaDiff, checks []*Checks) bool { if diff == nil { return false } + + oldPeer := nbpeer.Peer{Meta: diff.OldMeta, Location: diff.OldLocation} + newPeer := nbpeer.Peer{Meta: diff.NewMeta, Location: diff.NewLocation} + for _, c := range checks { - if c.Checks.ProcessCheck != nil && diff.Files { - return true - } - if c.Checks.OSVersionCheck != nil && (diff.OSVersion || diff.OS || diff.KernelVersion) { - return true - } - if c.Checks.NBVersionCheck != nil && diff.WtVersion { - return true - } - if c.Checks.GeoLocationCheck != nil && diff.LocationChanged { - return true - } - if c.Checks.PeerNetworkRangeCheck != nil && diff.NetworkAddresses { - return true + for _, check := range c.GetChecks() { + if verdictChanged(ctx, check, oldPeer, newPeer) { + return true + } } } return false } +// verdictChanged replays check against old and new state and reports whether the verdict +// differs. Like callers, it treats an evaluation error as deny: two errors are the same +// verdict (no change), an error on one side only is a flip. +func verdictChanged(ctx context.Context, check Check, oldPeer, newPeer nbpeer.Peer) bool { + oldPass, oldErr := check.Check(ctx, oldPeer) + newPass, newErr := check.Check(ctx, newPeer) + + oldVerdict := oldPass && (oldErr == nil) + newVerdict := newPass && (newErr == nil) + changed := oldVerdict != newVerdict + + log.WithContext(ctx).Tracef("posture check %s replay: verdict %t -> %t (changed=%t), errs: %v -> %v", + check.Name(), oldVerdict, newVerdict, changed, oldErr, newErr) + + return changed +} + // ChecksDefinition contains definition of actual check type ChecksDefinition struct { NBVersionCheck *NBVersionCheck `json:",omitempty"` From e2bb328a34326469fc71a2ee126a6b94b489a30a Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:02:43 +0200 Subject: [PATCH 07/26] [management] less strict metaHash when blocking peers (#6531) --- .../internals/shared/grpc/loginfilter.go | 15 ++------ .../internals/shared/grpc/loginfilter_test.go | 36 ++++--------------- management/internals/shared/grpc/server.go | 6 ++-- 3 files changed, 12 insertions(+), 45 deletions(-) diff --git a/management/internals/shared/grpc/loginfilter.go b/management/internals/shared/grpc/loginfilter.go index 59f69dd90..86eaabf10 100644 --- a/management/internals/shared/grpc/loginfilter.go +++ b/management/internals/shared/grpc/loginfilter.go @@ -11,7 +11,7 @@ import ( const ( reconnThreshold = 5 * time.Minute - baseBlockDuration = 10 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit + baseBlockDuration = 30 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit reconnLimitForBan = 30 // Number of reconnections within the reconnTreshold that triggers a ban metaChangeLimit = 3 // Number of reconnections with different metadata that triggers a ban of one peer ) @@ -139,22 +139,13 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) { state.lastSeen = now } -func metaHash(meta nbpeer.PeerSystemMeta, pubip string) uint64 { +func metaHash(meta nbpeer.PeerSystemMeta) uint64 { h := fnv.New64a() - h.Write([]byte(meta.WtVersion)) h.Write([]byte(meta.OSVersion)) h.Write([]byte(meta.KernelVersion)) h.Write([]byte(meta.Hostname)) h.Write([]byte(meta.SystemSerialNumber)) - h.Write([]byte(pubip)) - macs := uint64(0) - for _, na := range meta.NetworkAddresses { - for _, r := range na.Mac { - macs += uint64(r) - } - } - - return h.Sum64() + macs + return h.Sum64() } diff --git a/management/internals/shared/grpc/loginfilter_test.go b/management/internals/shared/grpc/loginfilter_test.go index 797879ae7..d9df26420 100644 --- a/management/internals/shared/grpc/loginfilter_test.go +++ b/management/internals/shared/grpc/loginfilter_test.go @@ -164,9 +164,7 @@ func BenchmarkHashingMethods(b *testing.B) { KernelVersion: "5.15.0-76-generic", Hostname: "prod-server-database-01", SystemSerialNumber: "PC-1234567890", - NetworkAddresses: []nbpeer.NetworkAddress{{Mac: "00:1B:44:11:3A:B7"}, {Mac: "00:1B:44:11:3A:B8"}}, } - pubip := "8.8.8.8" var resultString string var resultUint uint64 @@ -175,7 +173,7 @@ func BenchmarkHashingMethods(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - resultString = builderString(meta, pubip) + resultString = builderString(meta) } }) @@ -183,7 +181,7 @@ func BenchmarkHashingMethods(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - resultString = fnvHashToString(meta, pubip) + resultString = fnvHashToString(meta) } }) @@ -191,7 +189,7 @@ func BenchmarkHashingMethods(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - resultUint = metaHash(meta, pubip) + resultUint = metaHash(meta) } }) @@ -199,29 +197,20 @@ func BenchmarkHashingMethods(b *testing.B) { _ = resultUint } -func fnvHashToString(meta nbpeer.PeerSystemMeta, pubip string) string { +func fnvHashToString(meta nbpeer.PeerSystemMeta) string { h := fnv.New64a() - if len(meta.NetworkAddresses) != 0 { - for _, na := range meta.NetworkAddresses { - h.Write([]byte(na.Mac)) - } - } - h.Write([]byte(meta.WtVersion)) h.Write([]byte(meta.OSVersion)) h.Write([]byte(meta.KernelVersion)) h.Write([]byte(meta.Hostname)) h.Write([]byte(meta.SystemSerialNumber)) - h.Write([]byte(pubip)) return strconv.FormatUint(h.Sum64(), 16) } -func builderString(meta nbpeer.PeerSystemMeta, pubip string) string { - mac := getMacAddress(meta.NetworkAddresses) - estimatedSize := len(meta.WtVersion) + len(meta.OSVersion) + len(meta.KernelVersion) + len(meta.Hostname) + len(meta.SystemSerialNumber) + - len(pubip) + len(mac) + 6 +func builderString(meta nbpeer.PeerSystemMeta) string { + estimatedSize := len(meta.WtVersion) + len(meta.OSVersion) + len(meta.KernelVersion) + len(meta.Hostname) + len(meta.SystemSerialNumber) + 4 var b strings.Builder b.Grow(estimatedSize) @@ -235,23 +224,10 @@ func builderString(meta nbpeer.PeerSystemMeta, pubip string) string { b.WriteString(meta.Hostname) b.WriteByte('|') b.WriteString(meta.SystemSerialNumber) - b.WriteByte('|') - b.WriteString(pubip) return b.String() } -func getMacAddress(nas []nbpeer.NetworkAddress) string { - if len(nas) == 0 { - return "" - } - macs := make([]string, 0, len(nas)) - for _, na := range nas { - macs = append(macs, na.Mac) - } - return strings.Join(macs, "/") -} - func BenchmarkLoginFilter_ParallelLoad(b *testing.B) { filter := newLoginFilterWithCfg(testAdvancedCfg()) numKeys := 100000 diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 1d734dae7..2bd528b8e 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -254,7 +254,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S return mapError(ctx, err) } - metahashed := metaHash(peerMeta, sRealIP) + metahashed := metaHash(peerMeta) if userID == "" && !s.loginFilter.allowLogin(peerKey.String(), metahashed) { if s.appMetrics != nil { s.appMetrics.GRPCMetrics().CountSyncRequestBlocked() @@ -306,7 +306,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S log.WithContext(ctx).Tracef("peer system meta has to be provided on sync. Peer %s, remote addr %s", peerKey.String(), realIP) } - metahash := metaHash(peerMeta, realIP.String()) + metahash := metaHash(peerMeta) s.loginFilter.addLogin(peerKey.String(), metahash) peer, netMap, postureChecks, dnsFwdPort, err := s.accountManager.SyncAndMarkPeer(ctx, accountID, peerKey.String(), peerMeta, realIP, syncStart) @@ -732,7 +732,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto } peerMeta := extractPeerMeta(ctx, loginReq.GetMeta()) - metahashed := metaHash(peerMeta, sRealIP) + metahashed := metaHash(peerMeta) if !s.loginFilter.allowLogin(peerKey.String(), metahashed) { if s.logBlockedPeers { log.WithContext(ctx).Tracef("peer %s with meta hash %d is blocked from login", peerKey.String(), metahashed) From a400828b89db423fae8500e9bcd9575a10f8a428 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:16:54 +0200 Subject: [PATCH 08/26] [management] move some logs to trace (#6541) --- management/internals/shared/grpc/server.go | 6 +++++- .../server/http/handlers/users/users_handler.go | 2 +- management/server/peer.go | 2 +- management/server/user.go | 4 ++-- shared/management/status/error.go | 14 ++++++++++++++ 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 2bd528b8e..476aaa9d6 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -788,7 +788,11 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto ExtraDNSLabels: loginReq.GetDnsLabels(), }) if err != nil { - log.WithContext(ctx).Warnf("failed logging in peer %s: %s", peerKey, err) + if errors.Is(err, internalStatus.ErrNoAuthMethodProvided) { + log.WithContext(ctx).Tracef("failed logging in peer %s: %s", peerKey, err) + } else { + log.WithContext(ctx).Warnf("failed logging in peer %s: %s", peerKey, err) + } return nil, mapError(ctx, err) } diff --git a/management/server/http/handlers/users/users_handler.go b/management/server/http/handlers/users/users_handler.go index 40ad585d2..179d433f1 100644 --- a/management/server/http/handlers/users/users_handler.go +++ b/management/server/http/handlers/users/users_handler.go @@ -220,7 +220,7 @@ func (h *handler) getAllUsers(w http.ResponseWriter, r *http.Request) { } includeServiceUser, err := strconv.ParseBool(serviceUser) - log.WithContext(r.Context()).Debugf("Should include service user: %v", includeServiceUser) + log.WithContext(r.Context()).Tracef("Should include service user: %v", includeServiceUser) if err != nil { util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid service_user query parameter"), w) return diff --git a/management/server/peer.go b/management/server/peer.go index 17067a530..5596e45c2 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -730,7 +730,7 @@ func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, en 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, false, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") + return nil, nil, nil, false, status.ErrNoAuthMethodProvided } upperKey := strings.ToUpper(setupKey) diff --git a/management/server/user.go b/management/server/user.go index 412f15ce7..666d6d178 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -1059,8 +1059,8 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a if err != nil { return nil, err } - log.WithContext(ctx).Debugf("Got %d users from ExternalCache for account %s", len(usersFromIntegration), accountID) - log.WithContext(ctx).Debugf("Got %d users from InternalCache for account %s", len(queriedUsers), accountID) + log.WithContext(ctx).Tracef("Got %d users from ExternalCache for account %s", len(usersFromIntegration), accountID) + log.WithContext(ctx).Tracef("Got %d users from InternalCache for account %s", len(queriedUsers), accountID) queriedUsers = append(queriedUsers, usersFromIntegration...) } diff --git a/shared/management/status/error.go b/shared/management/status/error.go index 78288aef3..1957c5591 100644 --- a/shared/management/status/error.go +++ b/shared/management/status/error.go @@ -48,6 +48,10 @@ type Type int32 var ( ErrExtraSettingsNotFound = errors.New("extra settings not found") ErrPeerAlreadyLoggedIn = errors.New("peer with the same public key is already logged in") + + // ErrNoAuthMethodProvided is returned when a peer login attempt carries neither a + // setup key nor an SSO token. Match it with errors.Is. + ErrNoAuthMethodProvided = Errorf(Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") ) // Error is an internal error @@ -66,6 +70,16 @@ func (e *Error) Error() string { return e.Message } +// Is reports whether target is an *Error with the same type and message, +// enabling matching with errors.Is against sentinel errors. +func (e *Error) Is(target error) bool { + var t *Error + if !errors.As(target, &t) { + return false + } + return e.ErrorType == t.ErrorType && e.Message == t.Message +} + // Errorf returns Error(ErrorType, fmt.Sprintf(format, a...)). func Errorf(errorType Type, format string, a ...interface{}) error { return &Error{ From 18b38943aa5ea23334361720b585bda7d81087dd Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:20:19 +0200 Subject: [PATCH 09/26] disable connect panel on disabled auto connect (#6542) --- client/ui/client_ui.go | 43 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index d2f38cfd7..40fb4169d 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -418,7 +418,14 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient { case args.showProfiles: s.showProfilesUI() case args.showQuickActions: - s.showQuickActionsUI() + // Suppress the on-boot Quick Actions popup when the daemon + // reports DisableAutoConnect=true — that flag carries both the + // user's "Connect on Startup = off" preference AND any MDM- + // enforced override (applyMDMPolicy writes the policy value + // into the same Config field). See netbirdio/netbird#5744. + if !s.disableAutoConnectFromDaemon() { + s.showQuickActionsUI() + } case args.showUpdate: s.showUpdateProgress(ctx, args.showUpdateVersion) } @@ -1338,6 +1345,40 @@ func (s *serviceClient) getFeatures() (*proto.GetFeaturesResponse, error) { return features, nil } +// disableAutoConnectFromDaemon returns true when the daemon reports +// the active profile has DisableAutoConnect=true. Used by the +// --quick-actions startup path to suppress the on-boot popup when the +// user (or an MDM admin) opted out of auto-connecting; both cases +// converge on the same Config field because applyMDMPolicy writes the +// policy value into it. Returns false on any RPC / lookup failure so a +// daemon hiccup does not silently swallow the popup. +func (s *serviceClient) disableAutoConnectFromDaemon() bool { + activeProf, err := s.profileManager.GetActiveProfile() + if err != nil { + log.Warnf("disableAutoConnectFromDaemon: get active profile: %v", err) + return false + } + currUser, err := user.Current() + if err != nil { + log.Warnf("disableAutoConnectFromDaemon: get current user: %v", err) + return false + } + conn, err := s.getSrvClient(failFastTimeout) + if err != nil { + log.Warnf("disableAutoConnectFromDaemon: get daemon client: %v", err) + return false + } + srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ + ProfileName: activeProf.ID.String(), + Username: currUser.Username, + }) + if err != nil { + log.Warnf("disableAutoConnectFromDaemon: GetConfig RPC: %v", err) + return false + } + return srvCfg.GetDisableAutoConnect() +} + // getSrvConfig from the service to show it in the settings window. func (s *serviceClient) getSrvConfig() { s.managementURL = profilemanager.DefaultManagementURL From e20b62ad65f414d731e38949dc9156dff2a171c0 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:30:40 +0200 Subject: [PATCH 10/26] [management] simplify affected peers ignore disabled (#6540) --- .../server/affected_peers_coverage_test.go | 10 +- .../affected_peers_router_paths_test.go | 17 +- .../server/affected_peers_router_test.go | 17 +- management/server/affected_peers_test.go | 141 ++-- management/server/affectedpeers/resolver.go | 702 ++++++++++++------ .../server/affectedpeers/resolver_test.go | 50 +- management/server/group.go | 18 +- management/server/posture_checks_test.go | 1 + 8 files changed, 631 insertions(+), 325 deletions(-) diff --git a/management/server/affected_peers_coverage_test.go b/management/server/affected_peers_coverage_test.go index 56917905f..ae5b92f49 100644 --- a/management/server/affected_peers_coverage_test.go +++ b/management/server/affected_peers_coverage_test.go @@ -41,7 +41,7 @@ func TestAffectedPeers_DependencyCoverageMatrix(t *testing.T) { _, 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} + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} }, }, { @@ -106,11 +106,9 @@ func TestAffectedPeers_DependencyCoverageMatrix(t *testing.T) { 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") + assert.ElementsMatch(t, affected, mustContain, "expected peer to be affected") + for _, peerID := range mustExclude { + assert.NotContains(t, affected, peerID, "peer must not be affected") } }) } diff --git a/management/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go index 11313c387..5d83367fd 100644 --- a/management/server/affected_peers_router_paths_test.go +++ b/management/server/affected_peers_router_paths_test.go @@ -251,7 +251,9 @@ func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSou } } -func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t *testing.T) { +// A disabled sibling router routes to nobody, so updating a resource on its network +// must NOT refresh its peer (the enabled router carries the bridge instead). +func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouterNotBridged(t *testing.T) { s := setupRouterScenario(t, true) ctx := context.Background() @@ -274,13 +276,18 @@ func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t * require.NoError(t, err) disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID) - t.Cleanup(func() { s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) }) + enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) - settleAffectedUpdates(disabledCh) + settleAffectedUpdates(disabledCh, enabledCh) done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, disabledCh) + peerShouldReceiveUpdate(t, enabledCh) + peerShouldNotReceiveUpdate(t, disabledCh) close(done) }() @@ -298,7 +305,7 @@ func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t * select { case <-done: case <-time.After(peerUpdateTimeout): - t.Error("timeout: resource update did not refresh the disabled sibling router's peer") + t.Error("timeout") } } diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go index dc064e787..cc9df0a6a 100644 --- a/management/server/affected_peers_router_test.go +++ b/management/server/affected_peers_router_test.go @@ -682,6 +682,9 @@ func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) { assert.Contains(t, affected, secondRouterPeer.ID, "second routing peer on the same network must also be affected") } +// A disabled router in the snapshot routes to nobody, so it is skipped when the +// walk scans existing account data: a policy edit still folds the literal source +// group, but not the disabled router's peer. func TestAffectedPeers_DisabledRouter(t *testing.T) { s := setupRouterScenario(t, true) ctx := context.Background() @@ -694,11 +697,13 @@ func TestAffectedPeers_DisabledRouter(t *testing.T) { 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") + assert.Contains(t, affected, s.sourcePeerID, "source peer (literal policy source group) must be affected") + assert.NotContains(t, affected, s.routerPeerID, + "a disabled router routes to nobody, so its peer must not be folded from snapshot data") } +// A disabled resource in the snapshot is skipped: the policy edit still folds the +// literal source group, but the resource no longer bridges to its network's router. func TestAffectedPeers_DisabledResource(t *testing.T) { s := setupRouterScenario(t, true) ctx := context.Background() @@ -710,9 +715,9 @@ func TestAffectedPeers_DisabledResource(t *testing.T) { 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") + assert.Contains(t, affected, s.sourcePeerID, "source peer (literal policy source group) must be affected") + assert.NotContains(t, affected, s.routerPeerID, + "a disabled resource routes to nobody, so its network's router must not be folded from snapshot data") } func TestAffectedPeers_DisabledRule(t *testing.T) { diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go index e2dcd830b..235128693 100644 --- a/management/server/affected_peers_test.go +++ b/management/server/affected_peers_test.go @@ -96,33 +96,54 @@ 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) + 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, + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + SourceResource: types.Resource{ID: peerIDs[0], Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: peerIDs[1], Type: types.ResourceTypePeer}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + SourceResource: types.Resource{ID: peerIDs[2], Type: types.ResourceTypeHost}, + DestinationResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypeHost}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + SourceResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + 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, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[1]}) - groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) - assert.Contains(t, groups, groupIDs[0]) - assert.Contains(t, groups, groupIDs[1]) + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[0]}) - groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) assert.Empty(t, groups) + assert.Empty(t, directPeers) } func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) { @@ -133,20 +154,44 @@ func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) { 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, + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: peerIDs[4], Type: types.ResourceTypePeer}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: peerIDs[1], Type: types.ResourceTypeHost}, + DestinationResource: types.Resource{ID: peerIDs[2], Type: types.ResourceTypeHost}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: "", 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]) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[4]}) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[3]}) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + assert.Empty(t, groups) + assert.Empty(t, directPeers) } func TestCollectGroupChange_PolicyWithNonPeerResource_NoDirectPeers(t *testing.T) { @@ -168,8 +213,7 @@ func TestCollectGroupChange_PolicyWithNonPeerResource_NoDirectPeers(t *testing.T 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.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) assert.Empty(t, directPeers, "non-peer resources should not produce direct peer IDs") } @@ -294,6 +338,7 @@ func TestCollectGroupChange_NetworkRouterLinked(t *testing.T) { AccountID: accountID, PeerGroups: []string{groupIDs[0]}, Peer: peerIDs[3], + Enabled: true, }) require.NoError(t, err) @@ -324,6 +369,7 @@ func TestCollectGroupChange_NetworkRouterPeerOnlyNoGroups(t *testing.T) { NetworkID: net1.ID, AccountID: accountID, Peer: peerIDs[4], + Enabled: true, }) require.NoError(t, err) @@ -373,17 +419,11 @@ func TestCollectGroupChange_MultipleEntities(t *testing.T) { 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.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) 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.ElementsMatch(t, groups, []string{groupIDs[2], groupIDs[3]}) assert.Empty(t, directPeers) } @@ -452,8 +492,9 @@ func TestResolveAffectedPeers_PolicyBetweenTwoGroups(t *testing.T) { result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]}) assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + // peerIDs[2] is unrelated to the route; only its own map can change. result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) - assert.Empty(t, result) + assert.ElementsMatch(t, []string{peerIDs[2]}, result) } func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) { @@ -474,7 +515,7 @@ func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) { require.NoError(t, err) result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) - assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2]}, result) } func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) { @@ -506,8 +547,9 @@ func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) { result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]}) assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + // peerIDs[2] is in no policy; only its own map can change, so it refreshes itself. result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) - assert.Empty(t, result) + assert.ElementsMatch(t, []string{peerIDs[2]}, result) } func TestResolveAffectedPeers_RouteWithDirectPeer(t *testing.T) { @@ -564,9 +606,9 @@ func TestResolveAffectedPeers_RouteWithAccessControlGroups(t *testing.T) { result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result) - // peer3 is unrelated + // peer3 is unrelated to the route; only its own map can change. result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[3]}) - assert.Empty(t, result) + assert.ElementsMatch(t, []string{peerIDs[3]}, result) } func TestResolveAffectedPeers_NetworkRouter(t *testing.T) { @@ -587,6 +629,7 @@ func TestResolveAffectedPeers_NetworkRouter(t *testing.T) { AccountID: accountID, PeerGroups: []string{groupIDs[0]}, Peer: peerIDs[3], + Enabled: true, }) require.NoError(t, err) @@ -659,9 +702,13 @@ func TestResolveAffectedPeers_PeerInMultipleGroups(t *testing.T) { }, true) require.NoError(t, err) - // peer0 is in group0 AND group1, so both policies apply + // peer0 is in group0 AND group1, so both policies apply. A peer change folds + // only the changed peer plus the opposite side of each rule: group2 (peer2) via + // the group0 policy and group3 (peer3) via the group1 policy. peer1, a co-member + // of group1, is a sibling of the changed peer and must NOT refresh. result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) - assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2], peerIDs[3]}, result) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2], peerIDs[3]}, result) + assert.NotContains(t, result, peerIDs[1], "co-member of the changed peer's group must not refresh") } func TestResolveAffectedPeers_MultipleChangedPeers(t *testing.T) { @@ -697,7 +744,7 @@ func TestResolveAffectedPeers_MultipleChangedPeers(t *testing.T) { 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) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2], peerIDs[1], peerIDs[3]}, result) } func TestResolveAffectedPeers_SharedGroupAcrossPolicyAndRoute(t *testing.T) { @@ -854,8 +901,9 @@ func TestAffectedPeers_IsolatedPolicies(t *testing.T) { assert.NotContains(t, result, peerIDs[0]) assert.NotContains(t, result, peerIDs[1]) + // peerIDs[4] is in neither isolated policy; only its own map can change. result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[4]}) - assert.Empty(t, result) + assert.ElementsMatch(t, []string{peerIDs[4]}, result) } func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) { @@ -977,12 +1025,13 @@ func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(t *testing.T) { }) } -func TestAffectedPeers_UnlinkedGroupChange_NoUpdates(t *testing.T) { +// A peer in no policy/route refreshes only itself — no other peer is affected. +func TestAffectedPeers_UnlinkedPeerChange_RefreshesSelfOnly(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) + assert.ElementsMatch(t, []string{peerIDs[0]}, result) } // TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate verifies that creating/deleting a @@ -1332,6 +1381,7 @@ func TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate(t *testing.T) { NetworkID: net1.ID, AccountID: accountID, PeerGroups: []string{"nr-grpA"}, + Enabled: true, }) require.NoError(t, err) @@ -1755,7 +1805,9 @@ func TestCollectAffectedFromProxyServices_GroupContainingTargetPeerChanged(t *te assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed") } -func TestCollectAffectedFromProxyServices_DisabledServiceStillMatches(t *testing.T) { +// A disabled service in the snapshot proxies nothing, so it is skipped: a changed +// target peer does not pull in the service's proxy peer. +func TestCollectAffectedFromProxyServices_DisabledServiceSkipped(t *testing.T) { manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) ctx := context.Background() @@ -1781,8 +1833,7 @@ func TestCollectAffectedFromProxyServices_DisabledServiceStillMatches(t *testing 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") + assert.NotContains(t, directPeers, peerIDs[0], "a disabled service proxies nothing, so its proxy peer must not be folded") } func TestCollectAffectedFromProxyServices_NonPeerTargetType(t *testing.T) { diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go index 4ef986345..94e24ced6 100644 --- a/management/server/affectedpeers/resolver.go +++ b/management/server/affectedpeers/resolver.go @@ -6,7 +6,12 @@ // 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. +// Enabled handling differs by source. Disabled objects in the SNAPSHOT (existing +// account policies/resources/routers/routes/proxy services and their rules/targets) +// route to nobody and are skipped — they cannot affect any peer's map. Objects in +// the CHANGE itself are processed regardless of Enabled, so disabling one still +// refreshes the peers that lose access (the toggle is the observable change, and the +// update carries the old∪new state). package affectedpeers import ( @@ -61,7 +66,8 @@ func Load(ctx context.Context, s store.Store, accountID string, c Change) (*Snap // 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 + // LinkGroups drive the same policy/route/dns walk as a changed group or peer. + hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 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 @@ -76,7 +82,7 @@ func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accoun return err } } - if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 { + if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 { if err := snap.loadDNS(ctx, s, accountID); err != nil { return err } @@ -174,6 +180,24 @@ type Change struct { // 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 + + // OutputPeerIDs are peers folded straight into the result without seeding their + // group memberships into the walk. Use for the peer whose group membership changed: + // the peer itself must refresh, but its OTHER groups did not change, so they must + // not be walked. Contrast ChangedPeerIDs, which seeds ALL of the peer's groups + // (correct when the peer's own attributes changed, e.g. IP/status). + OutputPeerIDs []string + + // LinkGroups are groups used ONLY to match policies/routes/routers and walk to the + // OPPOSITE side — they are never expanded to their own members. Use this when a + // peer's group membership changed: pass the peer in ChangedPeerIDs and its + // group(s) here. The opposite side of the policies the group participates in + // refreshes, but the group's other members (siblings) do not — nothing changed for + // them. For an intra-group policy (A→A) the opposite side IS the group, so its + // members still refresh via the opposite-side fold, exactly when they genuinely + // gain/lose the changed peer. Unlike ChangedGroupIDs, a LinkGroup is not added to + // the output, so a one-sided membership change never wakes the whole group. + LinkGroups []string } func (c Change) isEmpty() bool { @@ -186,7 +210,9 @@ func (c Change) isEmpty() bool { len(c.Networks) == 0 && len(c.PostureCheckIDs) == 0 && len(c.DistributionGroupIDs) == 0 && - len(c.RemovedPeersByGroup) == 0 + len(c.RemovedPeersByGroup) == 0 && + len(c.LinkGroups) == 0 && + len(c.OutputPeerIDs) == 0 } // Expand returns the deduplicated affected peer IDs from the preloaded Snapshot, @@ -197,8 +223,8 @@ func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) [] 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) + log.WithContext(ctx).Tracef("affectedpeers expand start: account=%s changedGroups=%v changedPeers=%v linkGroups=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v", + accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, c.LinkGroups, len(c.Policies), len(c.Routes), len(c.Routers), len(c.Resources), len(c.Networks), c.PostureCheckIDs, c.DistributionGroupIDs) r.walk() return r.expand() } @@ -216,57 +242,84 @@ func Collect(ctx context.Context, s store.Store, accountID string, c Change) (gr } r := newResolver(ctx, snap, accountID, c) r.walk() - return setToSlice(r.groupSet), setToSlice(r.peerSet) + return setToSlice(r.affectedGroups), setToSlice(r.affectedPeers) } 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{}), + ctx: ctx, + snap: snap, + accountID: accountID, + change: c, + linkGroups: toSet(c.ChangedGroupIDs), + outputGroups: toSet(c.ChangedGroupIDs), + changedPeers: toSet(c.ChangedPeerIDs), + affectedGroups: make(map[string]struct{}), + affectedPeers: make(map[string]struct{}), } + // LinkGroups match policies/routes to find the opposite side but are NOT output: + // they go into linkGroups only, never outputGroups, so their members never fold in. + addAll(r.linkGroups, c.LinkGroups) // 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 +// seedChangedGroupsFromPeers adds each changed peer's groups to linkGroups so // the group-driven walkers fire for memberships, not just direct peer references. +// These seeded groups are for MATCHING only — folding the changed entity's own +// side is gated on outputGroups (the caller-reported groups), so a seeded group +// never folds its whole membership; only the changed peer itself folds in. func (r *resolver) seedChangedGroupsFromPeers() { - if len(r.changedPeerSet) == 0 { + if len(r.changedPeers) == 0 { return } for groupID, members := range r.snap.groupPeers { - for pID := range r.changedPeerSet { + for pID := range r.changedPeers { if _, ok := members[pID]; ok { - r.changedGroupSet[groupID] = struct{}{} + r.linkGroups[groupID] = struct{}{} break } } } } +// policySide selects which side of a policy rule to walk. +type policySide int + +const ( + sideSource policySide = iota + sideDestination +) + +func (s policySide) opposite() policySide { + if s == sideSource { + return sideDestination + } + return sideSource +} + +// walk resolves affected peers in two buckets, by how far each change propagates. +// +// BOTH-SIDES — the rule itself changed (an explicit policy edit, or a policy whose +// posture check changed). Source AND destination refresh, so each such policy is +// walked on both sides. +// +// OPPOSITE-SIDE — an endpoint moved but no rule changed. For each policy the change +// touches we fold only the side AWAY from the change: +// - a changed peer/group sits ON a policy side -> fold the opposite side; +// - a changed router/resource/network sits on a NETWORK -> fold the SOURCE side of +// the policies whose destination reaches it (and the routers it implies). +// +// Routes, nameserver groups, DNS and embedded-proxy services distribute to their own +// member peers, outside the policy graph, and are folded here too. 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) + for _, policy := range r.bothSidesPolicies() { + r.foldPolicySide(policy, sideSource) + r.foldPolicySide(policy, sideDestination) + } - // 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 { + if len(r.linkGroups) > 0 || len(r.changedPeers) > 0 { r.collectFromPolicies() r.collectFromRoutes() r.collectFromNameServers() @@ -275,7 +328,31 @@ func (r *resolver) walk() { r.collectFromProxyServices() } - r.collectResourceRouterBridge() + r.collectFromChangedRoutes(r.change.Routes) + r.collectFromChangedRouters(r.change.Routers) + r.collectFromChangedResources(r.change.Resources) + r.collectFromChangedNetworks(r.change.Networks) + + // The explicitly changed peers always refresh their own maps. OnPeersUpdated only + // refreshes the resolver's output (it ignores the separately-passed changed peers), + // so the changed peer reaches its own new map only via here. An offline/deleted + // peer in the set is filtered downstream (filterConnectedAffectedPeers). + addAll(r.affectedPeers, setToSlice(r.changedPeers)) + // OutputPeerIDs refresh themselves too, but unlike changedPeers their group + // memberships were not seeded into the walk (only the changed group was). + addAll(r.affectedPeers, r.change.OutputPeerIDs) + + // Distribution groups (nameserver/DNS) affect only their member peers: fold them + // straight into affectedGroups so expand() maps them to members, without the + // policy/route walk that linkGroups would trigger. + addAll(r.affectedGroups, r.change.DistributionGroupIDs) +} + +// bothSidesPolicies are the policies whose rule changed: the explicitly edited ones +// plus those gated by a changed posture check. walk folds both their sides. +func (r *resolver) bothSidesPolicies() []*types.Policy { + policies := append([]*types.Policy(nil), r.change.Policies...) + return r.appendPoliciesForPostureChecks(policies, r.change.PostureCheckIDs) } type resolver struct { @@ -284,27 +361,71 @@ type resolver struct { accountID string change Change - changedGroupSet map[string]struct{} - changedPeerSet map[string]struct{} + // Inputs — what changed. Set once at construction, read-only during the walk + // (except linkGroups, which collectFromExplicitResources also seeds). + // + // linkGroups is the MATCH set: caller-changed groups ∪ the groups of changed + // peers ∪ changed-resource groups. A rule/route/router matches the change when + // one of its groups is here — used only to find the opposite side to fold. + // + // outputGroups is the FOLD-WHOLE-GROUP set: ONLY Change.ChangedGroupIDs. When a + // matched group is here, its whole membership is affected. A peer-seeded group + // is in linkGroups but NOT outputGroups, so it folds only the changed peer + // (changedPeers), never its siblings. + linkGroups map[string]struct{} + outputGroups map[string]struct{} + changedPeers map[string]struct{} - groupSet map[string]struct{} - peerSet map[string]struct{} - - matchedPolicies []*types.Policy - networkIDs map[string]struct{} + // Outputs — the answer. The only sets the walk accumulates into. affectedGroups + // is expanded to its member peers in expand(). + affectedGroups map[string]struct{} + affectedPeers map[string]struct{} } -func (r *resolver) policies() []*types.Policy { return r.snap.policies } +// policies returns the account's ENABLED policies from the snapshot. Disabled +// policies grant no access, so the walk skips them when scanning existing account +// data. Explicitly changed policies (Change.Policies, via bothSidesPolicies) are +// processed regardless of Enabled, so disabling one still refreshes its peers. +func (r *resolver) policies() []*types.Policy { + enabled := make([]*types.Policy, 0, len(r.snap.policies)) + for _, policy := range r.snap.policies { + if policy != nil && policy.Enabled { + enabled = append(enabled, policy) + } + } + return enabled +} -func (r *resolver) networkResources() []*resourceTypes.NetworkResource { return r.snap.resources } +// networkResources / networkRouters return the account's ENABLED resources/routers +// from the snapshot. Disabled objects route to nobody, so the walk skips them when +// it scans existing account data. The explicitly changed objects in the Change are +// processed regardless of Enabled (collectFromChanged*), so disabling one still +// refreshes the peers that lose access. +func (r *resolver) networkResources() []*resourceTypes.NetworkResource { + enabled := make([]*resourceTypes.NetworkResource, 0, len(r.snap.resources)) + for _, resource := range r.snap.resources { + if resource.Enabled { + enabled = append(enabled, resource) + } + } + return enabled +} -func (r *resolver) networkRouters() []*routerTypes.NetworkRouter { return r.snap.routers } +func (r *resolver) networkRouters() []*routerTypes.NetworkRouter { + enabled := make([]*routerTypes.NetworkRouter, 0, len(r.snap.routers)) + for _, router := range r.snap.routers { + if router.Enabled { + enabled = append(enabled, router) + } + } + return enabled +} // peerIDsForGroups maps a group set to its member peer IDs via the preloaded index. -func (r *resolver) peerIDsForGroups(groupSet map[string]struct{}) []string { +func (r *resolver) peerIDsForGroups(groups map[string]struct{}) []string { seen := make(map[string]struct{}) var ids []string - for gID := range groupSet { + for gID := range groups { for pID := range r.snap.groupPeers[gID] { if _, ok := seen[pID]; ok { continue @@ -317,25 +438,25 @@ func (r *resolver) peerIDsForGroups(groupSet map[string]struct{}) []string { } func (r *resolver) expand() []string { - peerIDs := r.peerIDsForGroups(r.groupSet) + peerIDs := r.peerIDsForGroups(r.affectedGroups) 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)) + r.accountID, setToSlice(r.affectedGroups), len(peerIDs), setToSlice(r.affectedPeers)) seen := make(map[string]struct{}, len(peerIDs)) for _, id := range peerIDs { seen[id] = struct{}{} } - for id := range r.peerSet { + for id := range r.affectedPeers { if _, ok := seen[id]; !ok { peerIDs = append(peerIDs, id) seen[id] = struct{}{} } } - // Fold in removed peers only when their group is linked (in groupSet). + // Fold in removed peers only when their group is linked (in affectedGroups). for groupID, removed := range r.change.RemovedPeersByGroup { - if _, linked := r.groupSet[groupID]; !linked { + if _, linked := r.affectedGroups[groupID]; !linked { continue } for _, id := range removed { @@ -351,169 +472,349 @@ func (r *resolver) expand() []string { return peerIDs } -func (r *resolver) collectFromExplicitPolicies() { - for _, policy := range r.matchedPolicies { - if policy == nil { - continue +// ruleSideGroups / ruleSideResource return the groups and the resource on the given +// side of a rule. +func ruleSideGroups(rule *types.PolicyRule, side policySide) []string { + if side == sideDestination { + return rule.Destinations + } + return rule.Sources +} + +func ruleSideResource(rule *types.PolicyRule, side policySide) types.Resource { + if side == sideDestination { + return rule.DestinationResource + } + return rule.SourceResource +} + +// foldPolicySide folds one side of a policy down to affected peers: its groups +// (resolved to members in expand) and its direct peer. When the side is the +// DESTINATION and references a network resource (directly or via a destination +// group's resources), it also folds the routers that serve that resource's network +// — a destination resource is reached through its routers. A resource on the SOURCE +// side routes to nobody (GetPoliciesForNetworkResource matches destinations only), +// so the router hop is destination-only. +func (r *resolver) foldPolicySide(policy *types.Policy, side policySide) { + if policy == nil { + return + } + for _, rule := range policy.Rules { + addAll(r.affectedGroups, ruleSideGroups(rule, side)) + res := ruleSideResource(rule, side) + if res.Type == types.ResourceTypePeer && res.ID != "" { + r.affectedPeers[res.ID] = struct{}{} } - 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) + } + if side == sideDestination { + r.foldRoutersForResources(r.policyDestinationResourceIDs(policy)) } } -func (r *resolver) collectFromExplicitRoutes(routes []*route.Route) { +// appendPoliciesForPostureChecks appends every policy that references a changed +// posture check (a rule change, so walk both sides). +func (r *resolver) appendPoliciesForPostureChecks(policies []*types.Policy, postureCheckIDs []string) []*types.Policy { + if len(postureCheckIDs) == 0 { + return policies + } + ids := toSet(postureCheckIDs) + for _, policy := range r.policies() { + if !policyReferencesPostureChecks(policy, ids) || !policy.Enabled { + continue + } + log.WithContext(r.ctx).Tracef("appendPoliciesForPostureChecks: policy %s (%s) references changed posture checks %v -> both-sides policy", + policy.ID, policy.Name, postureCheckIDs) + policies = append(policies, policy) + } + return policies +} + +// collectFromPolicies folds, for every policy whose rule a changed group or peer +// touches, only the OPPOSITE side (down to peers, incl. destination routers), plus +// the changed entity's own side: the changed group's whole membership when the +// group itself changed (outputGroups), or the changed peer alone when matched via a +// peer-seeded group (never its co-members). +func (r *resolver) collectFromPolicies() { + for _, policy := range r.policies() { + for _, rule := range policy.Rules { + if !rule.Enabled { + continue // a disabled rule grants no access + } + r.foldRuleSideIfChanged(policy, rule, sideSource) + r.foldRuleSideIfChanged(policy, rule, sideDestination) + } + } +} + +// foldRuleSideIfChanged: when a changed group or direct peer sits on `side` of the +// rule, fold the opposite side fully (groups/peers + destination routers) and fold +// the changed entity's own side (the whole changed group, or the changed peer alone). +func (r *resolver) foldRuleSideIfChanged(policy *types.Policy, rule *types.PolicyRule, side policySide) { + nearGroups := ruleSideGroups(rule, side) + nearResource := ruleSideResource(rule, side) + + matchedByGroup := anyInSet(nearGroups, r.linkGroups) + matchedByPeer := isDirectPeerInSet(nearResource, r.changedPeers) + if !matchedByGroup && !matchedByPeer { + return + } + + // Opposite side, fully down to peers (a destination opposite also folds routers). + r.foldPolicySideForRule(policy, rule, side.opposite()) + + // Own side: fold the whole changed group's members only when the group itself + // changed (outputGroups). A peer-seeded or link-only group is not folded here — + // its siblings never refresh. The changed peers themselves are folded once, after + // the walk (see walk()). + for _, gID := range nearGroups { + if _, ok := r.outputGroups[gID]; ok { + r.affectedGroups[gID] = struct{}{} + } + } + + // When the changed side IS a destination, the resources it targets are reached + // through their network's routers, so those routers refresh too (e.g. attaching a + // resource to a destination group, or a changed destination group/resource). + if side == sideDestination { + r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule)) + } +} + +// foldPolicySideForRule folds one side of a single rule (groups + direct peer), and +// for a destination side the routers of that rule's destination resources. +func (r *resolver) foldPolicySideForRule(policy *types.Policy, rule *types.PolicyRule, side policySide) { + addAll(r.affectedGroups, ruleSideGroups(rule, side)) + res := ruleSideResource(rule, side) + if res.Type == types.ResourceTypePeer && res.ID != "" { + r.affectedPeers[res.ID] = struct{}{} + } + if side == sideDestination { + r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule)) + } +} + +// collectFromChangedRoutes folds an explicitly changed route's own groups and peer. +func (r *resolver) collectFromChangedRoutes(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", + log.WithContext(r.ctx).Tracef("collectFromChangedRoutes: 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) + addAll(r.affectedGroups, rt.Groups, rt.PeerGroups, rt.AccessControlGroups) if rt.Peer != "" { - r.peerSet[rt.Peer] = struct{}{} + r.affectedPeers[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) { +// collectFromChangedRouters: a changed router refreshes its OWN backing peer/groups +// (the changed entity) and the SOURCE side of every policy reaching a resource on +// its network (the router serves the whole network). Sibling routers on the network +// are independent and are NOT folded. Passing the old router state keeps a repointed +// router's previous backing affected without a post-commit read. +func (r *resolver) collectFromChangedRouters(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", + log.WithContext(r.ctx).Tracef("collectFromChangedRouters: changed router %s on network %s -> folding its own peerGroups=%v peer=%q + sources reaching network resources", router.ID, router.NetworkID, router.PeerGroups, router.Peer) - addAll(r.groupSet, router.PeerGroups) + addAll(r.affectedGroups, router.PeerGroups) if router.Peer != "" { - r.peerSet[router.Peer] = struct{}{} + r.affectedPeers[router.Peer] = struct{}{} } if router.NetworkID != "" { - r.networkIDs[router.NetworkID] = struct{}{} + r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID)) } } } -// 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) { +// collectFromChangedResources: a changed resource refreshes the SOURCE side of the +// policies targeting EXACTLY that resource — directly, or via one of the resource's +// own groups (old∪new across the change, so a now-detached group's sources still +// refresh) — plus the routers serving its network (the resource is reached through +// them). It does not touch sibling resources on the same network. +func (r *resolver) collectFromChangedResources(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", + log.WithContext(r.ctx).Tracef("collectFromChangedResources: changed resource %s on network %s (groups %v) -> folding sources of policies targeting it + its network's routers", resource.ID, resource.NetworkID, resource.GroupIDs) - addAll(r.changedGroupSet, resource.GroupIDs) + r.foldPolicySourcesForResource(resource.ID, resource.GroupIDs) if resource.NetworkID != "" { - r.networkIDs[resource.NetworkID] = struct{}{} + r.foldRoutersOnNetworks(map[string]struct{}{resource.NetworkID: {}}) } } } -// 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 { +// foldPolicySourcesForResource folds the source side of every policy whose +// destination is the given resource — referenced directly, or via any of the given +// groups (the resource's own old∪new groups, which captures a detached group). +func (r *resolver) foldPolicySourcesForResource(resourceID string, groupIDs []string) { + groups := toSet(groupIDs) + for _, policy := range r.policies() { + if !policyTargetsResourceOrGroups(policy, resourceID, groups) { continue } - log.WithContext(r.ctx).Tracef("collectFromExplicitNetworks: changed network %s -> marking for bridge", network.ID) - if network.ID != "" { - r.networkIDs[network.ID] = struct{}{} - } + log.WithContext(r.ctx).Tracef("foldPolicySourcesForResource: policy %s (%s) targets changed resource %s -> folding its source groups/peers", policy.ID, policy.Name, resourceID) + collectPolicySources(policy, r.affectedGroups, r.affectedPeers) } } -func (r *resolver) collectFromPostureChecks(postureCheckIDs []string) { - if len(postureCheckIDs) == 0 { +// policyTargetsResourceOrGroups reports whether a policy's destination is the given +// resource directly, or one of the given destination groups. +func policyTargetsResourceOrGroups(policy *types.Policy, resourceID string, groups map[string]struct{}) bool { + if policy == nil { + return false + } + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID == resourceID && resourceID != "" { + return true + } + if anyInSet(rule.Destinations, groups) { + return true + } + } + return false +} + +// collectFromChangedNetworks: a changed network refreshes the SOURCE side of the +// policies reaching any of its resources, plus its routers. A network has no +// groups/peers of its own. +func (r *resolver) collectFromChangedNetworks(networks []*networkTypes.Network) { + for _, network := range networks { + if network == nil || network.ID == "" { + continue + } + log.WithContext(r.ctx).Tracef("collectFromChangedNetworks: changed network %s -> folding sources reaching its resources + its routers", network.ID) + resourceIDs := r.networkResourceIDs(network.ID) + r.foldPolicySourcesForResources(resourceIDs) + r.foldRoutersOnNetworks(map[string]struct{}{network.ID: {}}) + } +} + +// foldPolicySourcesForResources folds the source groups/peers of every policy whose +// destination targets one of resourceIDs (directly or via a destination group). +func (r *resolver) foldPolicySourcesForResources(resourceIDs map[string]struct{}) { + if len(resourceIDs) == 0 { return } - ids := toSet(postureCheckIDs) for _, policy := range r.policies() { - if !policyReferencesPostureChecks(policy, ids) { - continue + if r.policyTargetsResources(policy, resourceIDs) { + log.WithContext(r.ctx).Tracef("foldPolicySourcesForResources: policy %s (%s) targets a changed resource -> folding its source groups/peers", policy.ID, policy.Name) + collectPolicySources(policy, r.affectedGroups, r.affectedPeers) } - 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) } } +// collectFromRoutes folds, per matched route, the OPPOSITE side(s) fully and the +// matched side's own groups only on a whole-group change (outputGroups). A route has +// three peer sides — routing (Peer/PeerGroups), consumer (Groups) and ACL +// (AccessControlGroups) — that each refresh the others; the changed side's own group +// folds its siblings only when the group itself changed, never on a one-peer move. 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 { + if !rt.Enabled { + continue // disabled routes route to nobody; skip existing account data + } + routing := anyInSet(rt.PeerGroups, r.linkGroups) || (rt.Peer != "" && isInSet(rt.Peer, r.changedPeers)) + consumer := anyInSet(rt.Groups, r.linkGroups) + acl := anyInSet(rt.AccessControlGroups, r.linkGroups) + if !routing && !consumer && !acl { 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{}{} + log.WithContext(r.ctx).Tracef("collectFromRoutes: route %s matched (routing=%t consumer=%t acl=%t) -> folding opposite sides; own side gated on outputGroups", + rt.ID, routing, consumer, acl) + r.foldRouteSide(rt.PeerGroups, routing) + r.foldRouteSide(rt.Groups, consumer) + r.foldRouteSide(rt.AccessControlGroups, acl) + // The single routing Peer folds when the routing side is the OPPOSITE of the + // match (consumer/acl need it), or when that very peer is the change. + if rt.Peer != "" && (consumer || acl || isInSet(rt.Peer, r.changedPeers)) { + r.affectedPeers[rt.Peer] = struct{}{} + } + } +} + +// foldRouteSide folds a route side: when this side is the one that matched, fold its +// groups only on a whole-group change (outputGroups) so siblings of a single moved +// peer stay put; otherwise it is an opposite side and folds fully. +func (r *resolver) foldRouteSide(groups []string, matchedHere bool) { + if matchedHere { + r.foldOutputGroups(groups) + return + } + addAll(r.affectedGroups, groups) +} + +// foldOutputGroups folds only the groups that the caller reported as wholly changed +// (outputGroups). Used for a matched object's OWN side, where a peer-seeded or +// link-only group must not pull in its siblings. +func (r *resolver) foldOutputGroups(groups ...[]string) { + for _, gs := range groups { + for _, gID := range gs { + if _, ok := r.outputGroups[gID]; ok { + r.affectedGroups[gID] = struct{}{} + } } } } func (r *resolver) collectFromNameServers() { - if len(r.changedGroupSet) == 0 { + if len(r.linkGroups) == 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) + if anyInSet(ns.Groups, r.linkGroups) { + // A nameserver group has no opposite side: a peer's DNS config depends only + // on its own membership, so a one-peer move refreshes that peer alone (folded + // elsewhere). Fold the referenced groups only on a whole-group change. + log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a linked group -> folding its groups %v (outputGroups only)", ns.ID, ns.Groups) + r.foldOutputGroups(ns.Groups) } } } func (r *resolver) collectFromDNSSettings() { - if len(r.changedGroupSet) == 0 || r.snap.dnsSettings == nil { + if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil { return } for _, gID := range r.snap.dnsSettings.DisabledManagementGroups { - if _, ok := r.changedGroupSet[gID]; ok { + if _, ok := r.linkGroups[gID]; ok { log.WithContext(r.ctx).Tracef("collectFromDNSSettings: changed group %s is in DisabledManagementGroups -> folding it", gID) - r.groupSet[gID] = struct{}{} + r.affectedGroups[gID] = struct{}{} } } } +// collectFromNetworkRouters handles a changed group/peer that BACKS a router (the +// routing peer set moved): the router's own peers refresh and so do the sources of +// the policies reaching its network's resources. Sibling routers on the network are +// independent and are not folded. 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) + matchedByGroup := anyInSet(router.PeerGroups, r.linkGroups) + matchedByPeer := router.Peer != "" && len(r.changedPeers) > 0 && isInSet(router.Peer, r.changedPeers) 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", + log.WithContext(r.ctx).Tracef("collectFromNetworkRouters: router %s on network %s matched (byGroup=%t byPeer=%t) -> folding its peerGroups=%v peer=%q (own groups on outputGroups) + sources reaching network resources", router.ID, router.NetworkID, matchedByGroup, matchedByPeer, router.PeerGroups, router.Peer) - addAll(r.groupSet, router.PeerGroups) + // The backing PeerGroups are the matched (own) side: fold them only on a + // whole-group change so a one-peer move does not wake sibling backing peers. The + // opposite side (policy sources reaching the network) is folded below. + r.foldOutputGroups(router.PeerGroups) if router.Peer != "" { - r.peerSet[router.Peer] = struct{}{} + r.affectedPeers[router.Peer] = struct{}{} + } + if router.NetworkID != "" { + r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID)) } - r.networkIDs[router.NetworkID] = struct{}{} } } @@ -526,42 +827,48 @@ func (r *resolver) collectFromProxyServices() { expanded := r.expandChangedPeersWithGroups() for _, svc := range services { - if svc == nil { - continue + if svc == nil || !svc.Enabled { + continue // a disabled service proxies nothing; skip existing account data } proxyPeers := proxyByCluster[svc.ProxyCluster] if len(proxyPeers) == 0 { continue } matchedByPeer := serviceMatchesChangedPeers(svc, proxyPeers, expanded) - matchedByAccessGroup := anyInSet(svc.AccessGroups, r.changedGroupSet) + matchedByAccessGroup := anyInSet(svc.AccessGroups, r.linkGroups) 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", + log.WithContext(r.ctx).Tracef("collectFromProxyServices: service %s (cluster=%s) matched (byProxyOrTargetPeer=%t byAccessGroup=%t) -> folding %d proxy peers, peer targets; access groups %v on outputGroups only", svc.ID, svc.ProxyCluster, matchedByPeer, matchedByAccessGroup, len(proxyPeers), svc.AccessGroups) for _, pid := range proxyPeers { - r.peerSet[pid] = struct{}{} + r.affectedPeers[pid] = struct{}{} } for _, target := range svc.Targets { + if !target.Enabled { + continue // a disabled target forwards nothing + } if target.TargetType == rpservice.TargetTypePeer && target.TargetId != "" { - r.peerSet[target.TargetId] = struct{}{} + r.affectedPeers[target.TargetId] = struct{}{} } } - addAll(r.groupSet, svc.AccessGroups) + // AccessGroups are the matched (own) side with no opposite to fold: a member's + // proxy access is self-contained, so a one-peer move refreshes that peer alone. + // Fold the groups only on a whole-group change. + r.foldOutputGroups(svc.AccessGroups) } } func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} { - if len(r.changedGroupSet) == 0 { - return r.changedPeerSet + if len(r.linkGroups) == 0 { + return r.changedPeers } - ids := r.peerIDsForGroups(r.changedGroupSet) + ids := r.peerIDsForGroups(r.linkGroups) if len(ids) == 0 { - return r.changedPeerSet + return r.changedPeers } - merged := make(map[string]struct{}, len(r.changedPeerSet)+len(ids)) - for id := range r.changedPeerSet { + merged := make(map[string]struct{}, len(r.changedPeers)+len(ids)) + for id := range r.changedPeers { merged[id] = struct{}{} } for _, id := range ids { @@ -570,54 +877,36 @@ func (r *resolver) expandChangedPeersWithGroups() map[string]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...) +// foldRoutersForResources folds the routers serving the networks of the given +// resources (a destination resource is reached through its network's routers). It is +// the resource -> network -> router hop used by foldPolicySide for a destination. +func (r *resolver) foldRoutersForResources(resourceIDs map[string]struct{}) { 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{}{} - } + r.foldRoutersOnNetworks(r.resourceNetworkIDs(resourceIDs)) } -func (r *resolver) bridgeRoutersToSources() { - if len(r.networkIDs) == 0 { - return +// ruleDestinationResourceIDs returns the destination resource IDs of a single rule: +// the direct DestinationResource plus the resources of its destination groups. +func (r *resolver) ruleDestinationResourceIDs(rule *types.PolicyRule) map[string]struct{} { + resourceIDs := make(map[string]struct{}) + if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" { + resourceIDs[rule.DestinationResource.ID] = struct{}{} } + r.addGroupResourceIDs(toSet(rule.Destinations), resourceIDs) + return resourceIDs +} - 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) - +// networkResourceIDs returns the IDs of all resources on the given network. +func (r *resolver) networkResourceIDs(networkID string) map[string]struct{} { resourceIDs := make(map[string]struct{}) for _, resource := range r.networkResources() { - if _, ok := r.networkIDs[resource.NetworkID]; ok { + if resource.NetworkID == networkID { 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) - } - } + return resourceIDs } func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) { @@ -627,9 +916,9 @@ func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) { } 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) + addAll(r.affectedGroups, router.PeerGroups) if router.Peer != "" { - r.peerSet[router.Peer] = struct{}{} + r.affectedPeers[router.Peer] = struct{}{} } } } @@ -650,6 +939,9 @@ func (r *resolver) policyTargetsResources(policy *types.Policy, resourceIDs map[ } destGroupSet := make(map[string]struct{}) for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } if rule.DestinationResource.Type != types.ResourceTypePeer && isInSet(rule.DestinationResource.ID, resourceIDs) { return true } @@ -714,44 +1006,20 @@ func (r *resolver) addGroupResourceIDs(groupIDs map[string]struct{}, resourceIDs } } -func collectPolicyDirectPeers(policy *types.Policy, peerSet map[string]struct{}) { +// collectPolicySources folds the source groups/peers of a snapshot policy's enabled +// rules (a disabled rule grants no access). +func collectPolicySources(policy *types.Policy, groups, peers map[string]struct{}) { for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + addAll(groups, rule.Sources) 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{}{} + peers[rule.SourceResource.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 { @@ -776,7 +1044,7 @@ func serviceMatchesChangedPeers(svc *rpservice.Service, proxyPeers []string, cha } } for _, target := range svc.Targets { - if target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" { + if !target.Enabled || target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" { continue } if _, ok := changedPeers[target.TargetId]; ok { diff --git a/management/server/affectedpeers/resolver_test.go b/management/server/affectedpeers/resolver_test.go index dcd304a56..fe6ada347 100644 --- a/management/server/affectedpeers/resolver_test.go +++ b/management/server/affectedpeers/resolver_test.go @@ -10,8 +10,8 @@ import ( "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. +// policyGroupsAndPeers mirrors the both-sides extraction (RuleGroups + direct peers) +// the resolver folds in for a changed policy, for asserting the pure logic. func policyGroupsAndPeers(policies ...*types.Policy) (groups []string, peers []string) { peerSet := map[string]struct{}{} for _, p := range policies { @@ -19,7 +19,14 @@ func policyGroupsAndPeers(policies ...*types.Policy) (groups []string, peers []s continue } groups = append(groups, p.RuleGroups()...) - collectPolicyDirectPeers(p, peerSet) + for _, rule := range p.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{}{} + } + } } for id := range peerSet { peers = append(peers, id) @@ -80,26 +87,6 @@ func TestChangeIsEmpty(t *testing.T) { 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"}} @@ -107,24 +94,9 @@ func TestPolicyReferencesPostureChecks(t *testing.T) { 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{{ + Enabled: true, Sources: []string{"g1"}, SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"}, Destinations: []string{"g2"}, diff --git a/management/server/group.go b/management/server/group.go index 070344c61..460b51274 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -520,7 +520,12 @@ func collectDeletableGroups(ctx context.Context, transaction store.Store, accoun // GroupAddPeer appends peer to the group func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, groupID, peerID string) error { var snap *affectedpeers.Snapshot - change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}} + // A membership change affects only the peer itself and the opposite side of THIS + // group's policies — not the group's other members, and not the peer's other + // groups. LinkGroups walks only this group (matched, not expanded); OutputPeerIDs + // refreshes the peer without seeding its other group memberships. For an + // intra-group policy the opposite side is the group, so its members still refresh. + change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}} err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { @@ -586,10 +591,11 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID // GroupDeletePeer removes peer from the group func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, groupID, peerID string) error { var snap *affectedpeers.Snapshot - change := affectedpeers.Change{ - ChangedGroupIDs: []string{groupID}, - RemovedPeersByGroup: map[string][]string{groupID: {peerID}}, - } + // Same as GroupAddPeer: the removed peer and the opposite side of THIS group's + // policies refresh, not the group's other members or the peer's other groups. The + // peer is no longer in the group's index, but LinkGroups still drives the + // opposite-side walk, and OutputPeerIDs refreshes the removed peer itself. + change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}} err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil { @@ -600,8 +606,6 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, return err } - // 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 diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go index 14bc2c45a..abf0b3237 100644 --- a/management/server/posture_checks_test.go +++ b/management/server/posture_checks_test.go @@ -489,6 +489,7 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { policy := &types.Policy{ AccountID: account.Id, + Enabled: true, Rules: []*types.PolicyRule{ { Enabled: true, From ff2787e184ca06ffe66e5c01b936cd97c4be6c74 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 25 Jun 2026 17:15:28 +0200 Subject: [PATCH 11/26] [management] Optimize affected posture checks and add logs (#6522) --- .../network_map/controller/controller.go | 2 +- management/server/peer.go | 31 +++- management/server/peer_test.go | 139 ++++++++++++++++++ 3 files changed, 167 insertions(+), 5 deletions(-) diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index e16796471..0d8fb3c47 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -497,7 +497,7 @@ func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID st 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()) + log.WithContext(ctx).Tracef("buffer updating %d affected peers for account %s from %s with reason %s/%s", len(peerIDs), accountID, util.GetCallerName(), reason.Operation, reason.Resource) bufUpd, _ := c.affectedPeerUpdateLocks.LoadOrStore(accountID, &bufferAffectedUpdate{ peerIDs: make(map[string]struct{}), diff --git a/management/server/peer.go b/management/server/peer.go index 5596e45c2..440e90044 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -209,14 +209,14 @@ func (am *DefaultAccountManager) resolvePeerLocation(ctx context.Context, peer * if am.geo == nil || realIP == nil { return nil } - if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) { - return nil - } location, err := am.geo.Lookup(realIP) if err != nil { log.WithContext(ctx).Warnf("failed to get location for peer %s realip: [%s]: %v", peer.ID, realIP.String(), err) return nil } + if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) && peer.Location.GeoNameID == location.City.GeonameID { + return nil + } return &nbpeer.Location{ ConnectionIP: realIP, CountryCode: location.Country.ISOCode, @@ -1052,7 +1052,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy } metaDiffAffectsPosture := posture.AffectsPosture(ctx, &metaDiff, resPostureChecks) - if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || metaDiffAffectsPosture || metaDiff.VersionChanged() || metaDiff.HostnameChanged() { + if requiresPeerUpdate(ctx, isStatusChanged, sync.UpdateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, metaDiff.VersionChanged(), metaDiff.HostnameChanged()) { changedPeerIDs := []string{peer.ID} affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, metaDiffAffectsPosture) if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { @@ -1063,6 +1063,29 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return peer, nmap, resPostureChecks, dnsFwdPort, nil } +func requiresPeerUpdate(ctx context.Context, isStatusChanged, updateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, versionChanged, hostname bool) bool { + var reason string + switch { + case isStatusChanged: + reason = "status changed" + case updateAccountPeers: + reason = "update account peers" + case ipv6CapabilityChanged: + reason = "ipv6 capability changed" + case metaDiffAffectsPosture: + reason = "meta diff affects posture" + case versionChanged: + reason = "version changed" + case hostname: + reason = "hostname changed" + default: + return false + } + + log.WithContext(ctx).Tracef("peer update required: %s", reason) + return true +} + // 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 diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 98cf10acf..6f139e43f 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -49,6 +49,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/geolocation" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/store" @@ -2893,3 +2894,141 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) { require.NoError(t, err, "renaming to unique FQDN should succeed") assert.Equal(t, "api-server", updated.DNSLabel, "DNS label should be first label of FQDN") } + +// fakeGeo is a configurable geolocation.Geolocation implementation for tests. It +// returns a record built from the configured city geoname id, or an error when set. +type fakeGeo struct { + geoNameID uint + isoCode string + cityName string + err error +} + +func (g *fakeGeo) Lookup(net.IP) (*geolocation.Record, error) { + if g.err != nil { + return nil, g.err + } + record := &geolocation.Record{} + record.City.GeonameID = g.geoNameID + record.City.Names.En = g.cityName + record.Country.ISOCode = g.isoCode + return record, nil +} + +func (g *fakeGeo) GetAllCountries() ([]geolocation.Country, error) { return nil, nil } + +func (g *fakeGeo) GetCitiesByCountry(string) ([]geolocation.City, error) { return nil, nil } + +func (g *fakeGeo) Stop() error { return nil } + +func TestResolvePeerLocation(t *testing.T) { + realIP := net.ParseIP("203.0.113.10") + + tests := []struct { + name string + geo geolocation.Geolocation + peer *nbpeer.Peer + realIP net.IP + want *nbpeer.Location + wantNil bool + }{ + { + name: "no geo configured returns nil", + geo: nil, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + wantNil: true, + }, + { + name: "nil real IP returns nil", + geo: &fakeGeo{geoNameID: 100}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: nil, + wantNil: true, + }, + { + name: "lookup error returns nil", + geo: &fakeGeo{err: fmt.Errorf("lookup boom")}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + wantNil: true, + }, + { + name: "same IP and same geoname returns nil", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: realIP, + GeoNameID: 100, + }, + }, + realIP: realIP, + wantNil: true, + }, + { + name: "same IP but changed geoname returns location", + geo: &fakeGeo{geoNameID: 200, isoCode: "US", cityName: "City B"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: realIP, + GeoNameID: 100, + }, + }, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City B", + GeoNameID: 200, + }, + }, + { + name: "different IP returns location", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: net.ParseIP("198.51.100.7"), + GeoNameID: 100, + }, + }, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City A", + GeoNameID: 100, + }, + }, + { + name: "no prior location returns location", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City A", + GeoNameID: 100, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + am := &DefaultAccountManager{geo: tt.geo} + got := am.resolvePeerLocation(context.Background(), tt.peer, tt.realIP) + if tt.wantNil { + assert.Nil(t, got, "resolved location should be nil") + return + } + require.NotNil(t, got, "resolved location should not be nil") + assert.True(t, tt.want.ConnectionIP.Equal(got.ConnectionIP), "connection IP should match") + assert.Equal(t, tt.want.CountryCode, got.CountryCode, "country code should match") + assert.Equal(t, tt.want.CityName, got.CityName, "city name should match") + assert.Equal(t, tt.want.GeoNameID, got.GeoNameID, "geoname id should match") + }) + } +} From f4daf59bcde0ae09ea16d5495f194179d0cc0ff8 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:36:50 +0200 Subject: [PATCH 12/26] [management] bring back client version check on login filter hash (#6552) --- management/internals/shared/grpc/loginfilter.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/management/internals/shared/grpc/loginfilter.go b/management/internals/shared/grpc/loginfilter.go index 86eaabf10..cc69b7d6e 100644 --- a/management/internals/shared/grpc/loginfilter.go +++ b/management/internals/shared/grpc/loginfilter.go @@ -11,9 +11,9 @@ import ( const ( reconnThreshold = 5 * time.Minute - baseBlockDuration = 30 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit + baseBlockDuration = 10 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit reconnLimitForBan = 30 // Number of reconnections within the reconnTreshold that triggers a ban - metaChangeLimit = 3 // Number of reconnections with different metadata that triggers a ban of one peer + metaChangeLimit = 5 // Number of reconnections with different metadata that triggers a ban of one peer ) type lfConfig struct { @@ -142,6 +142,7 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) { func metaHash(meta nbpeer.PeerSystemMeta) uint64 { h := fnv.New64a() + h.Write([]byte(meta.WtVersion)) h.Write([]byte(meta.OSVersion)) h.Write([]byte(meta.KernelVersion)) h.Write([]byte(meta.Hostname)) From 615631567a5cafcb2b7b5fb20a01d7781358e2f3 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Fri, 26 Jun 2026 19:59:15 +0200 Subject: [PATCH 13/26] small gh workflow fixes (#6546) Signed-off-by: Dmitri Dolguikh --- .github/workflows/golang-test-linux.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index cd34d1696..f9d2755bf 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -579,10 +579,11 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - GIT_BRANCH=${{ github.ref_name }} \ go test -tags devcert -run=^$ -bench=. \ -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE,GIT_BRANCH,GITHUB_RUN_ID' \ -timeout 20m ./management/... ./shared/management/... $(go list ./management/... ./shared/management/... | grep -v -e /management/server/http) + env: + GIT_BRANCH: ${{ github.ref_name }} api_benchmark: name: "Management / Benchmark (API)" @@ -673,12 +674,13 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - GIT_BRANCH=${{ github.ref_name }} \ go test -tags=benchmark \ -run=^$ \ -bench=. \ -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE,GIT_BRANCH,GITHUB_RUN_ID' \ -timeout 20m ./management/server/http/... + env: + GIT_BRANCH: ${{ github.ref_name }} api_integration_test: name: "Management / Integration" From d1422dcf092111c6e25159896063279e2e5dd6e0 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Sat, 27 Jun 2026 23:00:41 +0200 Subject: [PATCH 14/26] [misc] Add agent-network readme (#6562) --- README.md | 5 +++++ agent-network/README.md | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 agent-network/README.md diff --git a/README.md b/README.md index cc27e2d28..63271926d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ **Secure.** NetBird enables secure remote access by applying granular access policies while allowing you to manage them intuitively from a single place. Works universally on any infrastructure. +> ### 🤖 NetBird Agent Network (Beta) +> Identity-aware access control for AI agents — keyless access to LLM APIs and private +> resources over the encrypted NetBird tunnel. See [`agent-network/`](agent-network/) or +> read the docs at **[docs.netbird.io/agent-network](https://docs.netbird.io/agent-network)**. + https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2 ### Self-host NetBird (video) diff --git a/agent-network/README.md b/agent-network/README.md new file mode 100644 index 000000000..a09d3979e --- /dev/null +++ b/agent-network/README.md @@ -0,0 +1,39 @@ +# NetBird Agent Network + +Agent Network is NetBird's access control layer for AI agents and the people who run +them. It gives every agent a real identity, tied to your identity provider (IdP), and +governs what it can reach — the LLM APIs and AI gateways it can call, and the internal +resources it can access. Traffic flows only over the encrypted NetBird tunnel, scoped by +policy, with no API keys to leak. + +> **Beta.** Agent Network is open source and can be self-hosted on your own +> infrastructure. + +## How it works + +Agent Network is built on two existing NetBird capabilities: + +- **Overlay network** — the encrypted WireGuard mesh between peers. +- **Reverse proxy** — a NetBird peer that terminates LLM requests, establishes the + caller's identity, evaluates policies/limits/guardrails, injects the upstream provider + key server-side, forwards to the API or gateway, and records usage. + +LLM traffic is routed through the proxy's identity-aware pipeline, while internal +resources (databases, internal APIs, self-hosted models) are reached directly over +peer-to-peer WireGuard tunnels, governed by the same identities and access policies. + +## Where the code lives + +There is no separate "agent-network" service — it reuses the reverse-proxy and management +components: + +- [`proxy/`](../proxy) — the NetBird reverse proxy that serves the agent network endpoint + and runs the per-request middleware pipeline. +- [`management/internals/modules/reverseproxy/`](../management/internals/modules/reverseproxy) + — the management-side control plane: providers, policies, guardrails, limits, routing, + and usage/access logs. + +## Documentation + +Full documentation, architecture, and quickstart: +**https://docs.netbird.io/agent-network** From 6dd6c3f39811e33007910308a382c2619ff21fa0 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Sun, 28 Jun 2026 12:20:55 +0200 Subject: [PATCH 15/26] [Doc] Point Agent Network banner to netbird.ai (#6564) --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 63271926d..c9a51b6f1 100644 --- a/README.md +++ b/README.md @@ -37,17 +37,17 @@

+> ### 🤖 NetBird Agent Network (Beta) +> Identity-aware access control for AI agents — keyless access to LLM APIs and private +> resources over the encrypted NetBird tunnel. See [`agent-network/`](agent-network/) or +> read the docs at **[netbird.ai](https://netbird.ai)**. + **NetBird combines a configuration-free peer-to-peer private network and a centralized access control system in a single platform, making it easy to create secure private networks for your organization or home.** **Connect.** NetBird creates a WireGuard-based overlay network that automatically connects your machines over an encrypted tunnel, leaving behind the hassle of opening ports, complex firewall rules, VPN gateways, and so forth. **Secure.** NetBird enables secure remote access by applying granular access policies while allowing you to manage them intuitively from a single place. Works universally on any infrastructure. -> ### 🤖 NetBird Agent Network (Beta) -> Identity-aware access control for AI agents — keyless access to LLM APIs and private -> resources over the encrypted NetBird tunnel. See [`agent-network/`](agent-network/) or -> read the docs at **[docs.netbird.io/agent-network](https://docs.netbird.io/agent-network)**. - https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2 ### Self-host NetBird (video) From fd96b8c12fd9601b597372660baacaa63ba1f13e Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Sun, 28 Jun 2026 12:44:40 +0200 Subject: [PATCH 16/26] [client] Improve network addresses filter (#6515) * [client] Filter link-local and multicast from network addresses Skip IPv6 link-local and multicast addresses when building the peer network_addresses list on non-iOS platforms, matching the existing iOS behavior. A flapping NIC's link-local address otherwise churns the peer meta on every interface up/down. * [client] Skip engine restart when default route is unchanged After the network monitor's debounce window, re-check the default next hop before triggering a client restart. A flapping NIC that returns to the same default route no longer forces a restart, avoiding redundant sync stream reconnects and peer meta churn. * [client] Exclude own overlay address from reported network addresses The peer's own WireGuard overlay address (v4 and v6) was reported in network_addresses. As the interface comes and goes during reconnects it churned the peer meta on the management server. Drop it in GetInfoWithChecks, matching the IP regardless of prefix length since the engine knows the overlay address with the network mask while the interface reports it as a host address. * [client] Treat missing default route per protocol in next-hop check A failed GetNextHop lookup is now treated as an absent route (zero Nexthop) and compared per protocol, instead of forcing a restart. In a single-stack network the missing IPv6 default route no longer counts as a change on every debounce, which previously defeated the unchanged-route check. * [client] Make next-hop check injectable for network monitor tests Move the next-hop comparison behind a NetworkMonitor field set by New(), so tests can supply a stub instead of hitting the host's real default route. Fixes the Event/MultiEvent tests hanging after the unchanged-route check was added. * Revert "[client] Make next-hop check injectable for network monitor tests" This reverts commit 88a9d96e8f26c987c93ca62811f2f5b106c31147. * Revert "[client] Treat missing default route per protocol in next-hop check" This reverts commit 0fb531e4bc8227eaac7ca6d9c9dca34a89b8f531. * Revert "[client] Skip engine restart when default route is unchanged" This reverts commit a071b55f35a7d5eb6f83d0a9b3bb17600c5217f8. --- client/internal/engine.go | 18 ++++++++++-- client/system/info.go | 23 ++++++++++++++- client/system/info_test.go | 40 ++++++++++++++++++++++++++ client/system/network_addr.go | 4 ++- client/system/network_addr_test.go | 45 ++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 client/system/network_addr_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index 452075da8..e7f1c0501 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1066,7 +1066,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { } e.checks = checks - info, err := system.GetInfoWithChecks(e.ctx, checks) + info, err := system.GetInfoWithChecks(e.ctx, checks, e.overlayAddresses()...) if err != nil { log.Warnf("failed to get system info with checks: %v", err) info = system.GetInfo(e.ctx) @@ -1097,6 +1097,20 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { return nil } +// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it +// can be excluded from the reported network addresses; the interface coming and +// going otherwise churns the peer meta on the management server. +func (e *Engine) overlayAddresses() []netip.Addr { + var ips []netip.Addr + if e.config.WgAddr.IP.IsValid() { + ips = append(ips, e.config.WgAddr.IP) + } + if e.config.WgAddr.HasIPv6() { + ips = append(ips, e.config.WgAddr.IPv6) + } + return ips +} + func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { if e.wgInterface == nil { return errors.New("wireguard interface is not initialized") @@ -1240,7 +1254,7 @@ func (e *Engine) receiveManagementEvents() { e.shutdownWg.Add(1) go func() { defer e.shutdownWg.Done() - info, err := system.GetInfoWithChecks(e.ctx, e.checks) + info, err := system.GetInfoWithChecks(e.ctx, e.checks, e.overlayAddresses()...) if err != nil { log.Warnf("failed to get system info with checks: %v", err) info = system.GetInfo(e.ctx) diff --git a/client/system/info.go b/client/system/info.go index 477d5162b..27588859e 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -3,6 +3,7 @@ package system import ( "context" "net/netip" + "slices" "strings" log "github.com/sirupsen/logrus" @@ -121,6 +122,23 @@ func (i *Info) SetFlags( } } +// removeAddresses drops network addresses whose IP matches any of the given +// addresses, regardless of prefix length. Used to exclude the NetBird overlay +// address, which otherwise churns the meta as the interface comes and goes. +func (i *Info) removeAddresses(ips ...netip.Addr) { + if len(ips) == 0 { + return + } + filtered := i.NetworkAddresses[:0] + for _, addr := range i.NetworkAddresses { + if slices.Contains(ips, addr.NetIP.Addr()) { + continue + } + filtered = append(filtered, addr) + } + i.NetworkAddresses = filtered +} + // extractUserAgent extracts Netbird's agent (client) name and version from the outgoing context func extractUserAgent(ctx context.Context) string { md, hasMeta := metadata.FromOutgoingContext(ctx) @@ -147,7 +165,9 @@ func extractDeviceName(ctx context.Context, defaultName string) string { } // GetInfoWithChecks retrieves and parses the system information with applied checks. -func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, error) { +// excludeIPs are dropped from the reported network addresses (e.g. our own +// WireGuard overlay address, which otherwise churns the peer meta). +func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, error) { log.Debugf("gathering system information with checks: %d", len(checks)) processCheckPaths := make([]string, 0) for _, check := range checks { @@ -162,6 +182,7 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, erro info := GetInfo(ctx) info.Files = files + info.removeAddresses(excludeIPs...) log.Debugf("all system information gathered successfully") return info, nil diff --git a/client/system/info_test.go b/client/system/info_test.go index 27821f3c5..dcda18e61 100644 --- a/client/system/info_test.go +++ b/client/system/info_test.go @@ -2,6 +2,7 @@ package system import ( "context" + "net/netip" "testing" "github.com/stretchr/testify/assert" @@ -43,3 +44,42 @@ func Test_NetAddresses(t *testing.T) { t.Errorf("no network addresses found") } } + +func TestInfo_RemoveAddresses(t *testing.T) { + addr := func(cidr string) NetworkAddress { + return NetworkAddress{NetIP: netip.MustParsePrefix(cidr)} + } + + info := &Info{ + NetworkAddresses: []NetworkAddress{ + addr("192.168.1.7/24"), + addr("100.76.70.97/32"), // overlay v4 (host mask /32) + addr("2001:818:c51b:4800:845:a65d:ae6f:623f/64"), // real global v6 + addr("fd00:1234::1/64"), // overlay v6 + }, + } + + // Overlay addresses as the engine knows them, with a different mask (/16, /64). + info.removeAddresses( + netip.MustParseAddr("100.76.70.97"), + netip.MustParseAddr("fd00:1234::1"), + ) + + want := []string{"192.168.1.7/24", "2001:818:c51b:4800:845:a65d:ae6f:623f/64"} + if len(info.NetworkAddresses) != len(want) { + t.Fatalf("got %d addresses, want %d: %v", len(info.NetworkAddresses), len(want), info.NetworkAddresses) + } + for i, w := range want { + if got := info.NetworkAddresses[i].NetIP.String(); got != w { + t.Errorf("address[%d] = %s, want %s", i, got, w) + } + } +} + +func TestInfo_RemoveAddresses_NoOp(t *testing.T) { + info := &Info{NetworkAddresses: []NetworkAddress{{NetIP: netip.MustParsePrefix("10.0.0.1/24")}}} + info.removeAddresses() + if len(info.NetworkAddresses) != 1 { + t.Errorf("expected no change with empty input, got %v", info.NetworkAddresses) + } +} diff --git a/client/system/network_addr.go b/client/system/network_addr.go index 5423cf8ad..44260a938 100644 --- a/client/system/network_addr.go +++ b/client/system/network_addr.go @@ -46,7 +46,9 @@ func toNetworkAddress(address net.Addr, mac string) (NetworkAddress, bool) { if !ok { return NetworkAddress{}, false } - if ipNet.IP.IsLoopback() { + // Skip link-local and multicast: they carry no routable peer info and the + // IPv6 link-local of a flapping NIC churns the meta on every up/down. + if ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() || ipNet.IP.IsMulticast() { return NetworkAddress{}, false } prefix, err := netip.ParsePrefix(ipNet.String()) diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go new file mode 100644 index 000000000..a5f9c4279 --- /dev/null +++ b/client/system/network_addr_test.go @@ -0,0 +1,45 @@ +//go:build !ios + +package system + +import ( + "net" + "testing" +) + +func mustIPNet(t *testing.T, cidr string) *net.IPNet { + t.Helper() + ip, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + t.Fatalf("parse %q: %v", cidr, err) + } + ipNet.IP = ip + return ipNet +} + +func TestToNetworkAddress_Filtering(t *testing.T) { + const mac = "c8:4b:d6:b6:04:ac" + + tests := []struct { + name string + cidr string + want bool + }{ + {"ipv4 global", "10.65.16.181/23", true}, + {"ipv6 global", "2620:52:0:4110:102d:6a98:ee75:8b92/64", true}, + {"ipv4 loopback", "127.0.0.1/8", false}, + {"ipv6 loopback", "::1/128", false}, + {"ipv6 link-local", "fe80::871:4c25:23d7:2529/64", false}, + {"ipv4 link-local", "169.254.1.2/16", false}, + {"ipv6 multicast", "ff02::1/128", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, got := toNetworkAddress(mustIPNet(t, tt.cidr), mac) + if got != tt.want { + t.Errorf("toNetworkAddress(%s) ok = %v, want %v", tt.cidr, got, tt.want) + } + }) + } +} From 1b29995ecec132bec6d1b61420375fba6ebb1082 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Sun, 28 Jun 2026 12:45:33 +0200 Subject: [PATCH 17/26] [client] Fix blocked status lock via relay manager path (#6547) * peer/status: move relay-state reads off the main mux GetRelayStates held d.mux (RLock) while calling into the relay Manager (RelayStates/RelayConnectError/ServerURLs). Those calls can be slow or block on the relay manager's own locks while it is reconnecting, which kept the central Status mutex held and stalled every peer state writer (UpdatePeerState, ReplaceOfflinePeers, etc.) contending for it. Guard relayMgr/relayStates with a dedicated muxRelays mutex and release it before invoking the relay Manager, so the relay read path no longer contends with the hot peer-state writers on d.mux. * peer/status: clone relay states in nil-manager path Return a cloned snapshot of d.relayStates when relayMgr is nil so callers cannot mutate the shared cached state, matching the non-nil path. --- client/internal/peer/status.go | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 3e5c56dd2..e48ac333c 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -192,6 +192,7 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState { // Pure read methods take RLock; anything that mutates state takes Lock. type Status struct { mux sync.RWMutex + muxRelays sync.RWMutex peers map[string]State ipToKey map[string]string changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription @@ -244,8 +245,8 @@ func NewRecorder(mgmAddress string) *Status { } func (d *Status) SetRelayMgr(manager *relayClient.Manager) { - d.mux.Lock() - defer d.mux.Unlock() + d.muxRelays.Lock() + defer d.muxRelays.Unlock() d.relayMgr = manager } @@ -906,8 +907,8 @@ func (d *Status) MarkSignalConnected() { } func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) { - d.mux.Lock() - defer d.mux.Unlock() + d.muxRelays.Lock() + defer d.muxRelays.Unlock() d.relayStates = relayResults } @@ -1018,24 +1019,26 @@ func (d *Status) GetSignalState() SignalState { // GetRelayStates returns the stun/turn/permanent relay states func (d *Status) GetRelayStates() []relay.ProbeResult { - d.mux.RLock() - defer d.mux.RUnlock() + d.muxRelays.RLock() if d.relayMgr == nil { - return d.relayStates + defer d.muxRelays.RUnlock() + return slices.Clone(d.relayStates) } + relayMgr := d.relayMgr // extend the list of stun, turn servers with the relay server connections relayStates := slices.Clone(d.relayStates) + d.muxRelays.RUnlock() - states := d.relayMgr.RelayStates() + states := 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 { + if connErr := relayMgr.RelayConnectError(); connErr != nil { err = connErr } - for _, r := range d.relayMgr.ServerURLs() { + for _, r := range relayMgr.ServerURLs() { relayStates = append(relayStates, relay.ProbeResult{ URI: r, Err: err, From 62f5467cd85d395449f09ad9506cef9d04da651b Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Sun, 28 Jun 2026 14:22:19 +0200 Subject: [PATCH 18/26] [client] Eliminate packet loss during lazy connections. (#6355) * [client] Remove peer deletion on lazy activity detection Updated WireGuard dependency with a patch and removed the RemovePeer call on lazy activity detection to force a new handshake initiation to the updated endpoint. This also flushed the staged queue, dropping the first packet. Since UpdatePeer (called after ICE/relay negotiation) triggers SendStagedPackets via IpcSet/handlePostConfig, the peer removal is no longer necessary. The staged packet survives and the handshake is initiated on the real endpoint automatically. This also eliminates the transient state where the peer's endpoint and routes were absent between the lazy idle and connected states. * Update WireGuard dependency * Update WireGuard dependencies * Update WireGuard dependency --- client/internal/lazyconn/activity/listener_bind.go | 4 ---- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/client/internal/lazyconn/activity/listener_bind.go b/client/internal/lazyconn/activity/listener_bind.go index 60b8baadb..666c3bc28 100644 --- a/client/internal/lazyconn/activity/listener_bind.go +++ b/client/internal/lazyconn/activity/listener_bind.go @@ -119,10 +119,6 @@ func (d *BindListener) ReadPackets() { } d.peerCfg.Log.Debugf("removing lazy endpoint for peer %s", d.peerCfg.PublicKey) - if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil { - d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err) - } - _ = d.lazyConn.Close() d.bind.RemoveEndpoint(d.fakeIP) d.done.Done() diff --git a/go.mod b/go.mod index 2858d2044..fa5b431bf 100644 --- a/go.mod +++ b/go.mod @@ -341,7 +341,7 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024 replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949 -replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f +replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 diff --git a/go.sum b/go.sum index 1768ee069..3e1d7c97a 100644 --- a/go.sum +++ b/go.sum @@ -510,8 +510,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= -github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f h1:ff2D57RBjWtyQ2wVwJOxOgXAXOe/J2lJWtSX0Bz/BRk= -github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw= +github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= From 998ade6e6dc656eb4dd80cbba9c3218151ada818 Mon Sep 17 00:00:00 2001 From: MAAZIZ Adel Ayoub Date: Sun, 28 Jun 2026 13:51:21 +0100 Subject: [PATCH 19/26] [client] fix nil pointer panic when applying SSH server setting to an existing config (#6556) --- client/internal/profilemanager/config.go | 2 +- client/internal/profilemanager/config_test.go | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index a77f0ff32..5a71a981e 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -433,7 +433,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed { + if input.ServerSSHAllowed != nil && (config.ServerSSHAllowed == nil || *input.ServerSSHAllowed != *config.ServerSSHAllowed) { if *input.ServerSSHAllowed { log.Infof("enabling SSH server") } else { diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go index 5216f2423..736ff3412 100644 --- a/client/internal/profilemanager/config_test.go +++ b/client/internal/profilemanager/config_test.go @@ -242,6 +242,35 @@ func TestWireguardPortDefaultVsExplicit(t *testing.T) { } } +func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) { + // Configs written before ServerSSHAllowed was introduced lack the field and + // unmarshal to nil. Supplying the SSH server flag on top of such a config must + // apply the value instead of panicking on a nil pointer dereference. + tests := []struct { + name string + input *bool + want bool + }{ + {"enable", util.True(), true}, + {"disable", util.False(), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600)) + + config, err := UpdateConfig(ConfigInput{ + ConfigPath: configPath, + ServerSSHAllowed: tt.input, + }) + require.NoError(t, err) + require.NotNil(t, config.ServerSSHAllowed, "ServerSSHAllowed should be set from input") + assert.Equal(t, tt.want, *config.ServerSSHAllowed) + }) + } +} + func TestUpdateOldManagementURL(t *testing.T) { origProber := newMgmProber newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) { From 2bb54216313e7fcd4c47a86115339818370289a6 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:52:41 +0200 Subject: [PATCH 20/26] These logs are needed for troubleshooting (debug) (#6565) --- client/internal/peer/handshaker.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index 1d44096b6..56e82e6e3 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -195,14 +195,14 @@ func (h *Handshaker) sendOffer() error { } offer := h.buildOfferAnswer() - h.log.Infof("sending offer with serial: %s", offer.SessionIDString()) + h.log.Debugf("sending offer with serial: %s", offer.SessionIDString()) return h.signaler.SignalOffer(offer, h.config.Key) } func (h *Handshaker) sendAnswer() error { answer := h.buildOfferAnswer() - h.log.Infof("sending answer with serial: %s", answer.SessionIDString()) + h.log.Debugf("sending answer with serial: %s", answer.SessionIDString()) return h.signaler.SignalAnswer(answer, h.config.Key) } From 739e36a31326ef1acad4fcb7bd3fb8024b3330f1 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 28 Jun 2026 14:56:42 +0200 Subject: [PATCH 21/26] [self-hosted] Add agent-network preset with dedicated configurations (#6569) --- infrastructure_files/getting-started.sh | 96 +++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 770cecc44..46bef5a1f 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -351,6 +351,11 @@ initialize_default_values() { NETBIRD_STUN_PORT=3478 # Docker images + # Record whether the operator explicitly pinned the server/proxy images via + # env vars, so the agent-network preset can pick its own defaults without + # clobbering an explicit override. + NETBIRD_SERVER_IMAGE_EXPLICIT=${NETBIRD_SERVER_IMAGE:+true} + NETBIRD_PROXY_IMAGE_EXPLICIT=${NETBIRD_PROXY_IMAGE:+true} DASHBOARD_IMAGE=${DASHBOARD_IMAGE:-"netbirdio/dashboard:latest"} # Combined server replaces separate signal, relay, and management containers NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"} @@ -398,7 +403,53 @@ configure_domain() { return 0 } +apply_agent_network_preset() { + # Agent-network turnkey install: built-in Traefik + NetBird Proxy with + # NB_PROXY_PRIVATE=true, dashboard locked to agent-network-only mode. + # Bypasses every reverse-proxy / proxy / CrowdSec prompt. The only + # inputs we still need from the operator are the domain (handled by + # configure_domain via NETBIRD_DOMAIN env var or interactive prompt) + # and the ACME email — both honor env vars first and fall back to a + # prompt only when unset. CrowdSec is intentionally off. + REVERSE_PROXY_TYPE="0" + ENABLE_PROXY="true" + ENABLE_CROWDSEC="false" + + # Agent-network ships dedicated server/proxy images. Honor an explicit + # env override; otherwise pin the agent-network builds. + if [[ "${NETBIRD_SERVER_IMAGE_EXPLICIT}" != "true" ]]; then + NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:0.74.0-rc.2" + fi + if [[ "${NETBIRD_PROXY_IMAGE_EXPLICIT}" != "true" ]]; then + NETBIRD_PROXY_IMAGE="netbirdio/reverse-proxy:0.74.0-rc.2" + fi + + if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL}" ]]; then + TRAEFIK_ACME_EMAIL="${NETBIRD_LETSENCRYPT_EMAIL}" + else + TRAEFIK_ACME_EMAIL=$(read_traefik_acme_email) + fi + + echo "" > /dev/stderr + echo "Agent-network preset enabled (NETBIRD_AGENT_NETWORK=true):" > /dev/stderr + echo " - reverse proxy: built-in Traefik" > /dev/stderr + echo " - NetBird Proxy: enabled with NB_PROXY_PRIVATE=true" > /dev/stderr + echo " - server image: ${NETBIRD_SERVER_IMAGE}" > /dev/stderr + echo " - proxy image: ${NETBIRD_PROXY_IMAGE}" > /dev/stderr + echo " - dashboard: NETBIRD_AGENT_NETWORK_ONLY=true" > /dev/stderr + echo " - CrowdSec: disabled" > /dev/stderr + echo " - Let's Encrypt email: ${TRAEFIK_ACME_EMAIL}" > /dev/stderr + echo "" > /dev/stderr +} + configure_reverse_proxy() { + # Short-circuit: agent-network preset locks every reverse-proxy / + # proxy / CrowdSec choice and bypasses the interactive prompts. + if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then + apply_agent_network_preset + return 0 + fi + # Prompt for reverse proxy type REVERSE_PROXY_TYPE=$(read_reverse_proxy_type) @@ -910,6 +961,15 @@ NGINX_SSL_PORT=443 # Letsencrypt LETSENCRYPT_DOMAIN=none EOF + + if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then + cat < Date: Sun, 28 Jun 2026 15:00:05 +0200 Subject: [PATCH 22/26] Bump the actions group across 1 directory with 4 updates (#6550) Bumps the actions group with 4 updates in the / directory: [actions/setup-go](https://github.com/actions/setup-go), [actions/cache](https://github.com/actions/cache), [actions/cache/restore](https://github.com/actions/cache) and [actions/setup-java](https://github.com/actions/setup-java). Updates `actions/setup-go` from 6.4.0 to 6.5.0 - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/4a3601121dd01d1626a1e23e37211e3254c1c06c...924ae3a1cded613372ab5595356fb5720e22ba16) Updates `actions/cache` from 5.0.5 to 6.0.0 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...2c8a9bd7457de244a408f35966fab2fb45fda9c8) Updates `actions/cache/restore` from 5.0.5 to 6.0.0 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...2c8a9bd7457de244a408f35966fab2fb45fda9c8) Updates `actions/setup-java` from 5.3.0 to 5.4.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/ad2b38190b15e4d6bdf0c97fb4fca8412226d287...1bcf9fb12cf4aa7d266a90ae39939e61372fe520) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: 6.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: actions/cache dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/cache/restore dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/setup-java dependency-version: 5.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../workflows/check-license-dependencies.yml | 2 +- .github/workflows/golang-test-darwin.yml | 4 +- .github/workflows/golang-test-linux.yml | 40 +++++++++---------- .github/workflows/golang-test-windows.yml | 4 +- .github/workflows/golangci-lint.yml | 2 +- .github/workflows/mobile-build-validation.yml | 8 ++-- .github/workflows/release.yml | 12 +++--- .../workflows/test-infrastructure-files.yml | 4 +- .github/workflows/wasm-build-validation.yml | 4 +- 9 files changed, 40 insertions(+), 40 deletions(-) diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml index 50510368b..17c9fdc8d 100644 --- a/.github/workflows/check-license-dependencies.yml +++ b/.github/workflows/check-license-dependencies.yml @@ -64,7 +64,7 @@ jobs: persist-credentials: false - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: true diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index 7ecec0e92..b27ff24e4 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -21,13 +21,13 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/go/pkg/mod key: macos-gotest-${{ hashFiles('**/go.sum') }} diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index f9d2755bf..da1603b79 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -30,7 +30,7 @@ jobs: - 'management/**' - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -41,7 +41,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: cache with: path: | @@ -124,7 +124,7 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -135,7 +135,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -180,7 +180,7 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -192,7 +192,7 @@ jobs: echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: cache-restore with: path: | @@ -251,7 +251,7 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -266,7 +266,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -311,7 +311,7 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -325,7 +325,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -368,7 +368,7 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -383,7 +383,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -429,7 +429,7 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -440,7 +440,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -534,7 +534,7 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -545,7 +545,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -629,7 +629,7 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -640,7 +640,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -699,7 +699,7 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -710,7 +710,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index a6064d574..d587e8b6e 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -23,7 +23,7 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 id: go with: go-version-file: "go.mod" @@ -35,7 +35,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 66882ac05..5d26d678d 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -48,7 +48,7 @@ jobs: run: | ! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep . - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml index 778462a21..44e912c73 100644 --- a/.github/workflows/mobile-build-validation.yml +++ b/.github/workflows/mobile-build-validation.yml @@ -20,7 +20,7 @@ jobs: with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Setup Android SDK @@ -28,13 +28,13 @@ jobs: with: cmdline-tools-version: 8512546 - name: Setup Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 with: java-version: "11" distribution: "adopt" - name: NDK Cache id: ndk-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: /usr/local/lib/android/sdk/ndk key: ndk-cache-23.1.7779620 @@ -58,7 +58,7 @@ jobs: with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: install gomobile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e533687b..16eae31fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,12 +166,12 @@ jobs: fi - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ~/go/pkg/mod @@ -374,12 +374,12 @@ jobs: fi - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ~/go/pkg/mod @@ -469,12 +469,12 @@ jobs: fetch-depth: 0 # It is required for GoReleaser to work properly persist-credentials: false - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ~/go/pkg/mod diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 1d7753177..0a4f2e371 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -73,12 +73,12 @@ jobs: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml index a5ae59720..35855918d 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -23,7 +23,7 @@ jobs: with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Install dependencies @@ -48,7 +48,7 @@ jobs: with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Build Wasm client From 5968cff242746a819ecfb69382e2995523ba0a3d Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:33:30 +0900 Subject: [PATCH 23/26] [client] Keep signal stream alive while receive loop is blocked on worker handoff (#6530) --- shared/signal/client/grpc.go | 24 +++++++++++++++++++++++- shared/signal/client/watchdog_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index 2086e0fe6..611ab0c45 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -78,6 +78,13 @@ type GrpcClient struct { // 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 + // receiveHandoffBlocked is set while the receive loop is parked handing a + // message to a busy decryption worker. The loop stops calling Recv (and + // markReceived) in that window, so the stream looks silent though it is + // healthy. The watchdog reads this to avoid misreading self-inflicted + // receive backpressure as a dead stream: reconnecting cannot help, since the + // new stream feeds the same worker, and only triggers a reconnect storm. + receiveHandoffBlocked atomic.Bool } // NewClient creates a new Signal client @@ -439,6 +446,16 @@ func (c *GrpcClient) idleSinceReceive() time.Duration { return time.Since(time.Unix(0, c.lastReceived.Load())) } +// receiveAlive reports whether the receive stream shows liveness: it delivered a +// frame within the inactivity threshold, or the receive loop is currently parked +// handing a message to a busy decryption worker. In the latter case the loop has +// stopped calling Recv, so the stream looks silent while being healthy, and +// reconnecting would not help, so the watchdog must treat it as alive. +func (c *GrpcClient) receiveAlive() bool { + return c.idleSinceReceive() < receiveInactivityThreshold || + c.receiveHandoffBlocked.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 @@ -455,7 +472,7 @@ func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream contex case <-ctx.Done(): return case <-ticker.C: - if c.idleSinceReceive() < receiveInactivityThreshold { + if c.receiveAlive() { probeSentAt = time.Time{} continue } @@ -517,9 +534,14 @@ func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) er continue } + // The handoff blocks while the worker is busy, which parks this loop and + // stops Recv. Flag it so the watchdog does not read the resulting silence + // as a dead stream. + c.receiveHandoffBlocked.Store(true) if err := c.decryptionWorker.AddMsg(c.ctx, msg); err != nil { log.Errorf("failed to add message to decryption worker: %v", err) } + c.receiveHandoffBlocked.Store(false) } } diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go index b780cb969..bc6b5520b 100644 --- a/shared/signal/client/watchdog_test.go +++ b/shared/signal/client/watchdog_test.go @@ -82,3 +82,27 @@ func TestReceiveProbeRoundTrips(t *testing.T) { t.Fatal("self-addressed heartbeat did not round-trip back through the signal server") } } + +// TestReceiveAliveTreatsHandoffBlockAsLiveness reproduces the false positive +// where a busy decryption worker parks the receive loop on the worker handoff, +// so Recv (and markReceived) stops firing even though the stream is healthy. +// With the receive stream silent past the inactivity threshold but the loop +// blocked on handoff, the watchdog must consider the stream alive rather than +// tear it down (reconnecting feeds the same worker and would not help). +func TestReceiveAliveTreatsHandoffBlockAsLiveness(t *testing.T) { + c := &GrpcClient{} + + // Receive stream silent and the loop not blocked on handoff: genuinely stalled. + c.lastReceived.Store(time.Now().Add(-2 * receiveInactivityThreshold).UnixNano()) + require.False(t, c.receiveAlive(), "silent stream with the receive loop idle must be treated as stalled") + + // Receive stream silent but the loop is parked handing a message to a busy + // worker: self-inflicted backpressure, not a dead stream, must not tear down. + c.receiveHandoffBlocked.Store(true) + require.True(t, c.receiveAlive(), "a receive loop blocked on worker handoff must keep the stream alive") + + // Handoff drained, loop back to reading, a frame just arrived: alive via the receive path. + c.receiveHandoffBlocked.Store(false) + c.markReceived() + require.True(t, c.receiveAlive(), "a freshly received frame must keep the stream alive") +} From 2d7b309004d79d6dc066bd7f7bf62422c7ed61b4 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Sun, 28 Jun 2026 16:15:54 +0200 Subject: [PATCH 24/26] [client] Categorize privileged tests behind a build tag and run them in Docker (#6425) * [client] categorize root/system-mutating tests behind a privileged build tag Tests that need root or mutate host state (nftables/iptables/DNS, TUN/WireGuard interfaces, routes, eBPF, SSH/service install) are now gated behind a //go:build privileged tag. The default `go test ./client/...` runs as a non-root user with no sudo and leaves host networking untouched; mixed files were split so pure-logic tests stay in the default suite. A self-hosting ory/dockertest/v4 harness (client/testutil/privileged) runs the privileged suite inside a --privileged --cap-add=NET_ADMIN container via `make test-privileged`; a DOCKER_CI=true guard skips the spawn when already inside the container. Added `make test-unit` for the host-safe run. * [client] add PRIV_RUN/PRIV_PKGS filters to the privileged test harness The dockertest harness now reads two optional env vars when building the in-container `go test` command: PRIV_RUN adds a -run test-name filter and PRIV_PKGS overrides the package list. Both empty reproduce the full privileged suite, so CI and `make test-privileged` behave as before. Lets a developer run a single privileged test in the container, e.g.: PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged * [client] fix unused-helper lint after the privileged test split Splitting privileged tests into *_privileged_test.go left their shared helpers in the untagged files, so in the default (no-tag) build they had no callers and golangci-lint flagged them as unused. Moved the privileged-only helpers into the privileged files next to their callers (generateDummyHandler; createEngine/startSignal/startManagement/getConnectedPeers/ getPeers + kaep/kasp; (*mockDaemon).setJWTToken). Annotated the shared routing-test fixtures that must stay untagged for cross-platform compilation with //nolint:unused (systemops_bsd expected* vars, ensureIPv6DefaultRoute on bsd/windows, loopbackIfaceWindows), matching the existing linux variant. * [client] fix privileged test CI failures and run the harness on macOS The host-safe unit run dropped sudo but two privileged test groups were never tagged, and the Docker privileged job silently never ran the suite: - Gate the ssh/server PrivilegeDropper command-construction tests behind the privileged tag (they require root to target a different UID); split them into executor_unix_privileged_test.go. - Tag sharedsock raw-socket tests privileged (need CAP_NET_RAW). - Fix the Docker job command: nested single quotes around the build tags closed the sh -c wrapper early, dropping the go list package set and the privileged tag, so go test ran on the empty repo root. Use double quotes. Make the self-hosting harness usable from a dev Mac: - Build it on darwin as well as linux; it only drives Docker. - Resolve the active docker context endpoint into DOCKER_HOST when the default /var/run/docker.sock is absent (Docker Desktop, Colima, OrbStack). - Rename the misspelled containerGoModache constant to containerGoModCache. * Update client/internal/engine_privileged_test.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update client/internal/routemanager/systemops/systemops_linux_test.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update client/internal/routemanager/systemops/systemops_windows_test.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update client/server/server_privileged_test.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * [ci] Run privileged-tagged tests on darwin, windows and freebsd The privileged build tag split moved root/system-mutating tests behind //go:build privileged, but only the linux docker job was given the tag. The native darwin (sudo), windows (PsExec64 -s) and freebsd VM runners already have the required privileges, so add the privileged tag there too to keep CI running the same set of tests as before the split. * [ci] Exclude dockertest harness from the darwin privileged run The privileged tag now compiles client/testutil/privileged on darwin, whose TestRunPrivilegedSuiteInDocker spawns a container the macOS runner has no Docker for. Exclude the harness package from the darwin list, matching the linux job, so the privileged tests run in place without a container spawn. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/golang-test-darwin.yml | 2 +- .github/workflows/golang-test-freebsd.yml | 20 +- .github/workflows/golang-test-linux.yml | 4 +- .github/workflows/golang-test-windows.yml | 2 +- Makefile | 14 +- client/cmd/service_privileged_test.go | 196 ++++++ client/cmd/service_test.go | 184 ------ .../firewall/iptables/manager_linux_test.go | 2 + client/firewall/iptables/router_linux_test.go | 2 +- .../firewall/nftables/manager_linux_test.go | 2 + client/firewall/nftables/router_linux_test.go | 2 +- client/iface/iface_test.go | 2 + client/iface/wgproxy/proxy_linux_test.go | 2 +- client/iface/wgproxy/proxy_seed_test.go | 2 +- client/iface/wgproxy/redirect_test.go | 118 ++-- client/internal/dns/server_privileged_test.go | 485 +++++++++++++++ client/internal/dns/server_test.go | 462 -------------- client/internal/engine_privileged_test.go | 565 ++++++++++++++++++ client/internal/engine_test.go | 539 ----------------- client/internal/routemanager/manager_test.go | 2 + .../systemops/rt_tables_linux_test.go | 69 +++ .../systemops_bsd_privileged_test.go | 191 ++++++ .../systemops/systemops_bsd_test.go | 190 +----- .../systemops/systemops_dialer_test.go | 17 + .../systemops/systemops_generic_test.go | 129 +--- .../systemops/systemops_isvpnroute_test.go | 132 ++++ .../systemops/systemops_linux_test.go | 65 +- .../systemops_routing_data_linux_test.go | 15 + .../systemops/systemops_routing_data_test.go | 83 +++ .../systemops/systemops_unix_test.go | 69 +-- .../systemops/systemops_windows_test.go | 2 + .../systemops/v6route_bsd_test.go | 2 + .../systemops/v6route_linux_test.go | 2 +- .../systemops/v6route_windows_test.go | 3 + client/server/server_privileged_test.go | 235 ++++++++ client/server/server_test.go | 220 +------ client/ssh/client/client_privileged_test.go | 118 ++++ client/ssh/client/client_test.go | 101 ---- client/ssh/proxy/proxy_privileged_test.go | 423 +++++++++++++ client/ssh/proxy/proxy_test.go | 406 ------------- .../server/executor_unix_privileged_test.go | 66 ++ client/ssh/server/executor_unix_test.go | 55 -- client/testutil/privileged/runner_test.go | 196 ++++++ docs/testing-privileged.md | 78 +++ go.mod | 9 +- go.sum | 24 +- sharedsock/sock_linux_test.go | 2 + 47 files changed, 3014 insertions(+), 2495 deletions(-) create mode 100644 client/cmd/service_privileged_test.go create mode 100644 client/internal/dns/server_privileged_test.go create mode 100644 client/internal/engine_privileged_test.go create mode 100644 client/internal/routemanager/systemops/rt_tables_linux_test.go create mode 100644 client/internal/routemanager/systemops/systemops_bsd_privileged_test.go create mode 100644 client/internal/routemanager/systemops/systemops_dialer_test.go create mode 100644 client/internal/routemanager/systemops/systemops_isvpnroute_test.go create mode 100644 client/internal/routemanager/systemops/systemops_routing_data_linux_test.go create mode 100644 client/internal/routemanager/systemops/systemops_routing_data_test.go create mode 100644 client/server/server_privileged_test.go create mode 100644 client/ssh/client/client_privileged_test.go create mode 100644 client/ssh/proxy/proxy_privileged_test.go create mode 100644 client/ssh/server/executor_unix_privileged_test.go create mode 100644 client/testutil/privileged/runner_test.go create mode 100644 docs/testing-privileged.md diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index b27ff24e4..748e3f996 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -45,7 +45,7 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged) - name: Upload coverage reports to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml index 4243613b1..9c795e783 100644 --- a/.github/workflows/golang-test-freebsd.yml +++ b/.github/workflows/golang-test-freebsd.yml @@ -48,14 +48,14 @@ jobs: export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin time go build -o netbird client/main.go # check all component except management, since we do not support management server on freebsd - time go test -timeout 1m -failfast ./base62/... + time go test -tags privileged -timeout 1m -failfast ./base62/... # NOTE: without -p1 `client/internal/dns` will fail because of `listen udp4 :33100: bind: address already in use` - time go test -timeout 8m -failfast -v -p 1 ./client/... - time go test -timeout 1m -failfast ./dns/... - time go test -timeout 1m -failfast ./encryption/... - time go test -timeout 1m -failfast ./formatter/... - time go test -timeout 1m -failfast ./client/iface/... - time go test -timeout 1m -failfast ./route/... - time go test -timeout 1m -failfast ./sharedsock/... - time go test -timeout 1m -failfast ./util/... - time go test -timeout 1m -failfast ./version/... + time go test -tags privileged -timeout 8m -failfast -v -p 1 ./client/... + time go test -tags privileged -timeout 1m -failfast ./dns/... + time go test -tags privileged -timeout 1m -failfast ./encryption/... + time go test -tags privileged -timeout 1m -failfast ./formatter/... + time go test -tags privileged -timeout 1m -failfast ./client/iface/... + time go test -tags privileged -timeout 1m -failfast ./route/... + time go test -tags privileged -timeout 1m -failfast ./sharedsock/... + time go test -tags privileged -timeout 1m -failfast ./util/... + time go test -tags privileged -timeout 1m -failfast ./version/... diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index da1603b79..34b215c60 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -158,7 +158,7 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' @@ -229,7 +229,7 @@ jobs: sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ - go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server) + go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged) ' test_relay: diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index d587e8b6e..b61c87cf6 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -68,7 +68,7 @@ jobs: run: | $packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" - $cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" + $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd - name: test diff --git a/Makefile b/Makefile index 5d52b94fa..0a4fad2f2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint lint-all lint-install setup-hooks +.PHONY: lint lint-all lint-install setup-hooks test-unit test-privileged GOLANGCI_LINT := $(shell pwd)/bin/golangci-lint # Install golangci-lint locally if needed @@ -25,3 +25,15 @@ setup-hooks: @git config core.hooksPath .githooks @chmod +x .githooks/pre-push @echo "✅ Git hooks configured! Pre-push will now run 'make lint'" + +# Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating). +# Runs as a normal user with no sudo and leaves host networking untouched. +test-unit: + @go test -tags devcert -timeout 10m ./... + +# Privileged suite: runs the `privileged`-tagged tests inside a --privileged +# --cap-add=NET_ADMIN container via the ory/dockertest harness. Requires Docker. +# Narrow the run with env vars, e.g.: +# PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged +test-privileged: + @go test -tags 'devcert privileged' -timeout 30m -run TestRunPrivilegedSuiteInDocker -v ./client/testutil/privileged/... diff --git a/client/cmd/service_privileged_test.go b/client/cmd/service_privileged_test.go new file mode 100644 index 000000000..075d7f378 --- /dev/null +++ b/client/cmd/service_privileged_test.go @@ -0,0 +1,196 @@ +//go:build privileged + +package cmd + +import ( + "context" + "fmt" + "os" + "runtime" + "testing" + "time" + + "github.com/kardianos/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + serviceStartTimeout = 10 * time.Second + serviceStopTimeout = 5 * time.Second + statusPollInterval = 500 * time.Millisecond +) + +// waitForServiceStatus waits for service to reach expected status with timeout +func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) { + cfg, err := newSVCConfig() + if err != nil { + return false, err + } + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + if err != nil { + return false, err + } + + ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout) + defer timeoutCancel() + + ticker := time.NewTicker(statusPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus) + case <-ticker.C: + status, err := s.Status() + if err != nil { + // Continue polling on transient errors + continue + } + if status == expectedStatus { + return true, nil + } + } + } +} + +// TestServiceLifecycle tests the complete service lifecycle +func TestServiceLifecycle(t *testing.T) { + // TODO: Add support for Windows and macOS + if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { + t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS) + } + + if os.Getenv("CONTAINER") == "true" { + t.Skip("Skipping service lifecycle test in container environment") + } + + originalServiceName := serviceName + serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix()) + defer func() { + serviceName = originalServiceName + }() + + tempDir := t.TempDir() + configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir) + logLevel = "info" + daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir) + + // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run. + t.Cleanup(func() { + cfg, err := newSVCConfig() + if err != nil { + t.Errorf("cleanup: create service config: %v", err) + return + } + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + if err != nil { + t.Errorf("cleanup: create service: %v", err) + return + } + + // If the subtests already cleaned up, there's nothing to do. + if _, err := s.Status(); err != nil { + return + } + + if err := s.Stop(); err != nil { + t.Errorf("cleanup: stop service: %v", err) + } + if err := s.Uninstall(); err != nil { + t.Errorf("cleanup: uninstall service: %v", err) + } + }) + + ctx := context.Background() + + t.Run("Install", func(t *testing.T) { + installCmd.SetContext(ctx) + err := installCmd.RunE(installCmd, []string{}) + require.NoError(t, err) + + cfg, err := newSVCConfig() + require.NoError(t, err) + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + require.NoError(t, err) + + status, err := s.Status() + assert.NoError(t, err) + assert.NotEqual(t, service.StatusUnknown, status) + }) + + t.Run("Start", func(t *testing.T) { + startCmd.SetContext(ctx) + err := startCmd.RunE(startCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Restart", func(t *testing.T) { + restartCmd.SetContext(ctx) + err := restartCmd.RunE(restartCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Reconfigure", func(t *testing.T) { + originalLogLevel := logLevel + logLevel = "debug" + defer func() { + logLevel = originalLogLevel + }() + + reconfigureCmd.SetContext(ctx) + err := reconfigureCmd.RunE(reconfigureCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Stop", func(t *testing.T) { + stopCmd.SetContext(ctx) + err := stopCmd.RunE(stopCmd, []string{}) + require.NoError(t, err) + + stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout) + require.NoError(t, err) + assert.True(t, stopped) + }) + + t.Run("Uninstall", func(t *testing.T) { + uninstallCmd.SetContext(ctx) + err := uninstallCmd.RunE(uninstallCmd, []string{}) + require.NoError(t, err) + + cfg, err := newSVCConfig() + require.NoError(t, err) + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + require.NoError(t, err) + + _, err = s.Status() + assert.Error(t, err) + }) +} diff --git a/client/cmd/service_test.go b/client/cmd/service_test.go index ce6f71550..22eba206d 100644 --- a/client/cmd/service_test.go +++ b/client/cmd/service_test.go @@ -1,16 +1,12 @@ package cmd import ( - "context" - "fmt" "os" "os/signal" "runtime" "syscall" "testing" - "time" - "github.com/kardianos/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,186 +27,6 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -const ( - serviceStartTimeout = 10 * time.Second - serviceStopTimeout = 5 * time.Second - statusPollInterval = 500 * time.Millisecond -) - -// waitForServiceStatus waits for service to reach expected status with timeout -func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) { - cfg, err := newSVCConfig() - if err != nil { - return false, err - } - - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - if err != nil { - return false, err - } - - ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout) - defer timeoutCancel() - - ticker := time.NewTicker(statusPollInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus) - case <-ticker.C: - status, err := s.Status() - if err != nil { - // Continue polling on transient errors - continue - } - if status == expectedStatus { - return true, nil - } - } - } -} - -// TestServiceLifecycle tests the complete service lifecycle -func TestServiceLifecycle(t *testing.T) { - // TODO: Add support for Windows and macOS - if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { - t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS) - } - - if os.Getenv("CONTAINER") == "true" { - t.Skip("Skipping service lifecycle test in container environment") - } - - originalServiceName := serviceName - serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix()) - defer func() { - serviceName = originalServiceName - }() - - tempDir := t.TempDir() - configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir) - logLevel = "info" - daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir) - - // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run. - t.Cleanup(func() { - cfg, err := newSVCConfig() - if err != nil { - t.Errorf("cleanup: create service config: %v", err) - return - } - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - if err != nil { - t.Errorf("cleanup: create service: %v", err) - return - } - - // If the subtests already cleaned up, there's nothing to do. - if _, err := s.Status(); err != nil { - return - } - - if err := s.Stop(); err != nil { - t.Errorf("cleanup: stop service: %v", err) - } - if err := s.Uninstall(); err != nil { - t.Errorf("cleanup: uninstall service: %v", err) - } - }) - - ctx := context.Background() - - t.Run("Install", func(t *testing.T) { - installCmd.SetContext(ctx) - err := installCmd.RunE(installCmd, []string{}) - require.NoError(t, err) - - cfg, err := newSVCConfig() - require.NoError(t, err) - - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - require.NoError(t, err) - - status, err := s.Status() - assert.NoError(t, err) - assert.NotEqual(t, service.StatusUnknown, status) - }) - - t.Run("Start", func(t *testing.T) { - startCmd.SetContext(ctx) - err := startCmd.RunE(startCmd, []string{}) - require.NoError(t, err) - - running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) - require.NoError(t, err) - assert.True(t, running) - }) - - t.Run("Restart", func(t *testing.T) { - restartCmd.SetContext(ctx) - err := restartCmd.RunE(restartCmd, []string{}) - require.NoError(t, err) - - running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) - require.NoError(t, err) - assert.True(t, running) - }) - - t.Run("Reconfigure", func(t *testing.T) { - originalLogLevel := logLevel - logLevel = "debug" - defer func() { - logLevel = originalLogLevel - }() - - reconfigureCmd.SetContext(ctx) - err := reconfigureCmd.RunE(reconfigureCmd, []string{}) - require.NoError(t, err) - - running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) - require.NoError(t, err) - assert.True(t, running) - }) - - t.Run("Stop", func(t *testing.T) { - stopCmd.SetContext(ctx) - err := stopCmd.RunE(stopCmd, []string{}) - require.NoError(t, err) - - stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout) - require.NoError(t, err) - assert.True(t, stopped) - }) - - t.Run("Uninstall", func(t *testing.T) { - uninstallCmd.SetContext(ctx) - err := uninstallCmd.RunE(uninstallCmd, []string{}) - require.NoError(t, err) - - cfg, err := newSVCConfig() - require.NoError(t, err) - - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - require.NoError(t, err) - - _, err = s.Status() - assert.Error(t, err) - }) -} - // TestServiceEnvVars tests environment variable parsing func TestServiceEnvVars(t *testing.T) { tests := []struct { diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go index cc4bda0e0..7b0989f6c 100644 --- a/client/firewall/iptables/manager_linux_test.go +++ b/client/firewall/iptables/manager_linux_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package iptables import ( diff --git a/client/firewall/iptables/router_linux_test.go b/client/firewall/iptables/router_linux_test.go index 6707573be..9ca6b9f7e 100644 --- a/client/firewall/iptables/router_linux_test.go +++ b/client/firewall/iptables/router_linux_test.go @@ -1,4 +1,4 @@ -//go:build !android +//go:build !android && privileged package iptables diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go index be4f65881..4eb466281 100644 --- a/client/firewall/nftables/manager_linux_test.go +++ b/client/firewall/nftables/manager_linux_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package nftables import ( diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go index c5d6729d9..2fc664d51 100644 --- a/client/firewall/nftables/router_linux_test.go +++ b/client/firewall/nftables/router_linux_test.go @@ -1,4 +1,4 @@ -//go:build !android +//go:build !android && privileged package nftables diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index dbeb69bc6..8ff2bbb54 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package iface import ( diff --git a/client/iface/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go index 7f7abcb4a..e34dd3b6b 100644 --- a/client/iface/wgproxy/proxy_linux_test.go +++ b/client/iface/wgproxy/proxy_linux_test.go @@ -1,4 +1,4 @@ -//go:build linux && !android +//go:build linux && !android && privileged package wgproxy diff --git a/client/iface/wgproxy/proxy_seed_test.go b/client/iface/wgproxy/proxy_seed_test.go index 9278029a5..4fb9ed77a 100644 --- a/client/iface/wgproxy/proxy_seed_test.go +++ b/client/iface/wgproxy/proxy_seed_test.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux || !privileged package wgproxy diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go index b52eead25..135970838 100644 --- a/client/iface/wgproxy/redirect_test.go +++ b/client/iface/wgproxy/redirect_test.go @@ -1,4 +1,4 @@ -//go:build linux && !android +//go:build linux && !android && privileged package wgproxy @@ -26,64 +26,6 @@ func compareUDPAddr(addr1, addr2 net.Addr) bool { return udpAddr1.IP.Equal(udpAddr2.IP) && udpAddr1.Port == udpAddr2.Port } -// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses -func TestRedirectAs_eBPF_IPv4(t *testing.T) { - wgPort := 51850 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) - } - defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) - } - }() - - proxy := ebpf.NewProxyWrapper(ebpfProxy) - - // NetBird UDP address of the remote peer - nbAddr := &net.UDPAddr{ - IP: net.ParseIP("100.108.111.177"), - Port: 38746, - } - - p2pEndpoint := &net.UDPAddr{ - IP: net.ParseIP("192.168.0.56"), - Port: 51820, - } - - testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) -} - -// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses -func TestRedirectAs_eBPF_IPv6(t *testing.T) { - wgPort := 51851 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) - } - defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) - } - }() - - proxy := ebpf.NewProxyWrapper(ebpfProxy) - - // NetBird UDP address of the remote peer - nbAddr := &net.UDPAddr{ - IP: net.ParseIP("100.108.111.177"), - Port: 38746, - } - - p2pEndpoint := &net.UDPAddr{ - IP: net.ParseIP("fe80::56"), - Port: 51820, - } - - testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) -} - // TestRedirectAs_UDP_IPv4 tests RedirectAs with UDP proxy using IPv4 addresses func TestRedirectAs_UDP_IPv4(t *testing.T) { wgPort := 51852 @@ -256,6 +198,64 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint * } } +// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses +func TestRedirectAs_eBPF_IPv4(t *testing.T) { + wgPort := 51850 + ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) + if err := ebpfProxy.Listen(); err != nil { + t.Fatalf("failed to initialize ebpf proxy: %v", err) + } + defer func() { + if err := ebpfProxy.Free(); err != nil { + t.Errorf("failed to free ebpf proxy: %v", err) + } + }() + + proxy := ebpf.NewProxyWrapper(ebpfProxy) + + // NetBird UDP address of the remote peer + nbAddr := &net.UDPAddr{ + IP: net.ParseIP("100.108.111.177"), + Port: 38746, + } + + p2pEndpoint := &net.UDPAddr{ + IP: net.ParseIP("192.168.0.56"), + Port: 51820, + } + + testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) +} + +// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses +func TestRedirectAs_eBPF_IPv6(t *testing.T) { + wgPort := 51851 + ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) + if err := ebpfProxy.Listen(); err != nil { + t.Fatalf("failed to initialize ebpf proxy: %v", err) + } + defer func() { + if err := ebpfProxy.Free(); err != nil { + t.Errorf("failed to free ebpf proxy: %v", err) + } + }() + + proxy := ebpf.NewProxyWrapper(ebpfProxy) + + // NetBird UDP address of the remote peer + nbAddr := &net.UDPAddr{ + IP: net.ParseIP("100.108.111.177"), + Port: 38746, + } + + p2pEndpoint := &net.UDPAddr{ + IP: net.ParseIP("fe80::56"), + Port: 51820, + } + + testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) +} + // TestRedirectAs_Multiple_Switches tests switching between multiple endpoints func TestRedirectAs_Multiple_Switches(t *testing.T) { wgPort := 51856 diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go new file mode 100644 index 000000000..a03aea169 --- /dev/null +++ b/client/internal/dns/server_privileged_test.go @@ -0,0 +1,485 @@ +//go:build privileged + +package dns + +import ( + "context" + "fmt" + "net/netip" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface" + pfmock "github.com/netbirdio/netbird/client/iface/mocks" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/dns/local" + "github.com/netbirdio/netbird/client/internal/dns/test" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/stdnet" + nbdns "github.com/netbirdio/netbird/dns" +) + +func TestUpdateDNSServer(t *testing.T) { + + nameServers := []nbdns.NameServer{ + { + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + { + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + } + + testCases := []struct { + name string + initUpstreamMap []handlerWrapper + initLocalZones []nbdns.CustomZone + initSerial uint64 + inputSerial uint64 + inputUpdate nbdns.Config + shouldFail bool + expectedUpstreamMap []handlerWrapper + expectedLocalQs []dns.Question + }{ + { + name: "Initial Config Should Succeed", + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + Domains: []string{"netbird.io"}, + NameServers: nameServers, + }, + { + NameServers: nameServers, + Primary: true, + }, + }, + }, + expectedUpstreamMap: []handlerWrapper{ + { + domain: "netbird.io", + priority: PriorityUpstream, + }, + { + domain: "netbird.cloud", + priority: PriorityLocal, + }, + { + domain: nbdns.RootZone, + priority: PriorityDefault, + }, + }, + expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + { + name: "New Config Should Succeed", + initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, + initUpstreamMap: []handlerWrapper{ + { + domain: "netbird.cloud", + handler: &mockHandler{}, + priority: PriorityUpstream, + }, + }, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + Domains: []string{"netbird.io"}, + NameServers: nameServers, + }, + }, + }, + expectedUpstreamMap: []handlerWrapper{ + { + domain: "netbird.io", + priority: PriorityUpstream, + }, + { + domain: "netbird.cloud", + priority: PriorityLocal, + }, + }, + expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}}, + }, + { + name: "Smaller Config Serial Should Be Skipped", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 2, + inputSerial: 1, + shouldFail: true, + }, + { + name: "Empty NS Group Domain Or Not Primary Element Should Fail", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + NameServers: nameServers, + }, + }, + }, + shouldFail: true, + }, + { + name: "Invalid NS Group Nameservers list Should Fail", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + NameServers: nameServers, + }, + }, + }, + shouldFail: true, + }, + { + name: "Invalid Custom Zone Records list Should Skip", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + NameServers: nameServers, + Primary: true, + }, + }, + }, + expectedUpstreamMap: []handlerWrapper{{ + domain: ".", + priority: PriorityDefault, + }}, + }, + { + name: "Empty Config Should Succeed and Clean Maps", + initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, + initUpstreamMap: []handlerWrapper{ + { + domain: zoneRecords[0].Name, + handler: &mockHandler{}, + priority: PriorityUpstream, + }, + }, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ServiceEnable: true}, + expectedUpstreamMap: nil, + expectedLocalQs: []dns.Question{}, + }, + { + name: "Disabled Service Should clean map", + initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, + initUpstreamMap: []handlerWrapper{ + { + domain: zoneRecords[0].Name, + handler: &mockHandler{}, + priority: PriorityUpstream, + }, + }, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ServiceEnable: false}, + expectedUpstreamMap: nil, + expectedLocalQs: []dns.Question{}, + }, + } + + for n, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + privKey, _ := wgtypes.GenerateKey() + newNet, err := stdnet.NewNet(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + + opts := iface.WGIFaceOpts{ + IFaceName: fmt.Sprintf("utun230%d", n), + Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)), + WGPort: 33100, + WGPrivKey: privKey.String(), + MTU: iface.DefaultMTU, + TransportNet: newNet, + } + + wgIface, err := iface.NewWGIFace(opts) + if err != nil { + t.Fatal(err) + } + err = wgIface.Create() + if err != nil { + t.Fatal(err) + } + defer func() { + err = wgIface.Close() + if err != nil { + t.Log(err) + } + }() + dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ + WgInterface: wgIface, + CustomAddress: "", + StatusRecorder: peer.NewRecorder("mgm"), + StateManager: nil, + DisableSys: false, + }) + if err != nil { + t.Fatal(err) + } + err = dnsServer.Initialize() + if err != nil { + t.Fatal(err) + } + defer func() { + err = dnsServer.hostManager.restoreHostDNS() + if err != nil { + t.Log(err) + } + }() + + dnsServer.dnsMuxHandlers = testCase.initUpstreamMap + dnsServer.localResolver.Update(testCase.initLocalZones) + dnsServer.updateSerial = testCase.initSerial + + err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate) + if err != nil { + if testCase.shouldFail { + return + } + t.Fatalf("update dns server should not fail, got error: %v", err) + } + + if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) { + t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers)) + } + + for _, expected := range testCase.expectedUpstreamMap { + found := false + for _, got := range dnsServer.dnsMuxHandlers { + if got.domain == expected.domain && got.priority == expected.priority { + found = true + break + } + } + if !found { + t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers) + } + } + + var responseMSG *dns.Msg + responseWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + responseMSG = m + return nil + }, + } + for _, q := range testCase.expectedLocalQs { + dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{ + Question: []dns.Question{q}, + }) + } + + if len(testCase.expectedLocalQs) > 0 { + assert.NotNil(t, responseMSG, "response message should not be nil") + assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success") + assert.NotEmpty(t, responseMSG.Answer, "response message should have answers") + } + }) + } +} + +func TestDNSFakeResolverHandleUpdates(t *testing.T) { + ov := os.Getenv("NB_WG_KERNEL_DISABLED") + defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) + + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) + if err != nil { + t.Errorf("create stdnet: %v", err) + return + } + + privKey, _ := wgtypes.GeneratePrivateKey() + opts := iface.WGIFaceOpts{ + IFaceName: "utun2301", + Address: wgaddr.MustParseWGAddress("100.66.100.1/32"), + WGPort: 33100, + WGPrivKey: privKey.String(), + MTU: iface.DefaultMTU, + TransportNet: newNet, + } + wgIface, err := iface.NewWGIFace(opts) + if err != nil { + t.Errorf("build interface wireguard: %v", err) + return + } + + err = wgIface.Create() + if err != nil { + t.Errorf("create and init wireguard interface: %v", err) + return + } + defer func() { + if err = wgIface.Close(); err != nil { + t.Logf("close wireguard interface: %v", err) + } + }() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + packetfilter := pfmock.NewMockPacketFilter(ctrl) + packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes() + packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + + if err := wgIface.SetFilter(packetfilter); err != nil { + t.Errorf("set packet filter: %v", err) + return + } + + dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ + WgInterface: wgIface, + CustomAddress: "", + StatusRecorder: peer.NewRecorder("mgm"), + StateManager: nil, + DisableSys: false, + }) + if err != nil { + t.Errorf("create DNS server: %v", err) + return + } + + err = dnsServer.Initialize() + if err != nil { + t.Errorf("run DNS server: %v", err) + return + } + defer func() { + if err = dnsServer.hostManager.restoreHostDNS(); err != nil { + t.Logf("restore DNS settings on the host: %v", err) + return + } + }() + + dnsServer.dnsMuxHandlers = []handlerWrapper{ + { + domain: zoneRecords[0].Name, + handler: &local.Resolver{}, + priority: PriorityUpstream, + }, + } + dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}) + dnsServer.updateSerial = 0 + + nameServers := []nbdns.NameServer{ + { + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + { + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + } + + update := nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + Domains: []string{"netbird.io"}, + NameServers: nameServers, + }, + { + NameServers: nameServers, + Primary: true, + }, + }, + } + + // Start the server with regular configuration + if err := dnsServer.UpdateDNSServer(1, update); err != nil { + t.Fatalf("update dns server should not fail, got error: %v", err) + return + } + + update2 := update + update2.ServiceEnable = false + // Disable the server, stop the listener + if err := dnsServer.UpdateDNSServer(2, update2); err != nil { + t.Fatalf("update dns server should not fail, got error: %v", err) + return + } + + update3 := update2 + update3.NameServerGroups = update3.NameServerGroups[:1] + // But service still get updates and we checking that we handle + // internal state in the right way + if err := dnsServer.UpdateDNSServer(3, update3); err != nil { + t.Fatalf("update dns server should not fail, got error: %v", err) + return + } +} diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 4ef790412..96e55a354 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -10,7 +10,6 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" "github.com/miekg/dns" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -23,7 +22,6 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/device" - pfmock "github.com/netbirdio/netbird/client/iface/mocks" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/dns/local" "github.com/netbirdio/netbird/client/internal/dns/test" @@ -104,466 +102,6 @@ func init() { formatter.SetTextFormatter(log.StandardLogger()) } -func TestUpdateDNSServer(t *testing.T) { - - nameServers := []nbdns.NameServer{ - { - IP: netip.MustParseAddr("8.8.8.8"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - { - IP: netip.MustParseAddr("8.8.4.4"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - } - - testCases := []struct { - name string - initUpstreamMap []handlerWrapper - initLocalZones []nbdns.CustomZone - initSerial uint64 - inputSerial uint64 - inputUpdate nbdns.Config - shouldFail bool - expectedUpstreamMap []handlerWrapper - expectedLocalQs []dns.Question - }{ - { - name: "Initial Config Should Succeed", - initUpstreamMap: nil, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - Domains: []string{"netbird.io"}, - NameServers: nameServers, - }, - { - NameServers: nameServers, - Primary: true, - }, - }, - }, - expectedUpstreamMap: []handlerWrapper{ - { - domain: "netbird.io", - priority: PriorityUpstream, - }, - { - domain: "netbird.cloud", - priority: PriorityLocal, - }, - { - domain: nbdns.RootZone, - priority: PriorityDefault, - }, - }, - expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, - }, - { - name: "New Config Should Succeed", - initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: []handlerWrapper{ - { - domain: "netbird.cloud", - handler: &mockHandler{}, - priority: PriorityUpstream, - }, - }, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - Domains: []string{"netbird.io"}, - NameServers: nameServers, - }, - }, - }, - expectedUpstreamMap: []handlerWrapper{ - { - domain: "netbird.io", - priority: PriorityUpstream, - }, - { - domain: "netbird.cloud", - priority: PriorityLocal, - }, - }, - expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}}, - }, - { - name: "Smaller Config Serial Should Be Skipped", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: nil, - initSerial: 2, - inputSerial: 1, - shouldFail: true, - }, - { - name: "Empty NS Group Domain Or Not Primary Element Should Fail", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: nil, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - NameServers: nameServers, - }, - }, - }, - shouldFail: true, - }, - { - name: "Invalid NS Group Nameservers list Should Fail", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: nil, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - NameServers: nameServers, - }, - }, - }, - shouldFail: true, - }, - { - name: "Invalid Custom Zone Records list Should Skip", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: nil, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - NameServers: nameServers, - Primary: true, - }, - }, - }, - expectedUpstreamMap: []handlerWrapper{{ - domain: ".", - priority: PriorityDefault, - }}, - }, - { - name: "Empty Config Should Succeed and Clean Maps", - initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: []handlerWrapper{ - { - domain: zoneRecords[0].Name, - handler: &mockHandler{}, - priority: PriorityUpstream, - }, - }, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ServiceEnable: true}, - expectedUpstreamMap: nil, - expectedLocalQs: []dns.Question{}, - }, - { - name: "Disabled Service Should clean map", - initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: []handlerWrapper{ - { - domain: zoneRecords[0].Name, - handler: &mockHandler{}, - priority: PriorityUpstream, - }, - }, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ServiceEnable: false}, - expectedUpstreamMap: nil, - expectedLocalQs: []dns.Question{}, - }, - } - - for n, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - privKey, _ := wgtypes.GenerateKey() - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } - - opts := iface.WGIFaceOpts{ - IFaceName: fmt.Sprintf("utun230%d", n), - Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)), - WGPort: 33100, - WGPrivKey: privKey.String(), - MTU: iface.DefaultMTU, - TransportNet: newNet, - } - - wgIface, err := iface.NewWGIFace(opts) - if err != nil { - t.Fatal(err) - } - err = wgIface.Create() - if err != nil { - t.Fatal(err) - } - defer func() { - err = wgIface.Close() - if err != nil { - t.Log(err) - } - }() - dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ - WgInterface: wgIface, - CustomAddress: "", - StatusRecorder: peer.NewRecorder("mgm"), - StateManager: nil, - DisableSys: false, - }) - if err != nil { - t.Fatal(err) - } - err = dnsServer.Initialize() - if err != nil { - t.Fatal(err) - } - defer func() { - err = dnsServer.hostManager.restoreHostDNS() - if err != nil { - t.Log(err) - } - }() - - dnsServer.dnsMuxHandlers = testCase.initUpstreamMap - dnsServer.localResolver.Update(testCase.initLocalZones) - dnsServer.updateSerial = testCase.initSerial - - err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate) - if err != nil { - if testCase.shouldFail { - return - } - t.Fatalf("update dns server should not fail, got error: %v", err) - } - - if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) { - t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers)) - } - - for _, expected := range testCase.expectedUpstreamMap { - found := false - for _, got := range dnsServer.dnsMuxHandlers { - if got.domain == expected.domain && got.priority == expected.priority { - found = true - break - } - } - if !found { - t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers) - } - } - - var responseMSG *dns.Msg - responseWriter := &test.MockResponseWriter{ - WriteMsgFunc: func(m *dns.Msg) error { - responseMSG = m - return nil - }, - } - for _, q := range testCase.expectedLocalQs { - dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{ - Question: []dns.Question{q}, - }) - } - - if len(testCase.expectedLocalQs) > 0 { - assert.NotNil(t, responseMSG, "response message should not be nil") - assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success") - assert.NotEmpty(t, responseMSG.Answer, "response message should have answers") - } - }) - } -} - -func TestDNSFakeResolverHandleUpdates(t *testing.T) { - ov := os.Getenv("NB_WG_KERNEL_DISABLED") - defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) - - t.Setenv("NB_WG_KERNEL_DISABLED", "true") - newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) - if err != nil { - t.Errorf("create stdnet: %v", err) - return - } - - privKey, _ := wgtypes.GeneratePrivateKey() - opts := iface.WGIFaceOpts{ - IFaceName: "utun2301", - Address: wgaddr.MustParseWGAddress("100.66.100.1/32"), - WGPort: 33100, - WGPrivKey: privKey.String(), - MTU: iface.DefaultMTU, - TransportNet: newNet, - } - wgIface, err := iface.NewWGIFace(opts) - if err != nil { - t.Errorf("build interface wireguard: %v", err) - return - } - - err = wgIface.Create() - if err != nil { - t.Errorf("create and init wireguard interface: %v", err) - return - } - defer func() { - if err = wgIface.Close(); err != nil { - t.Logf("close wireguard interface: %v", err) - } - }() - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - packetfilter := pfmock.NewMockPacketFilter(ctrl) - packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes() - packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - - if err := wgIface.SetFilter(packetfilter); err != nil { - t.Errorf("set packet filter: %v", err) - return - } - - dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ - WgInterface: wgIface, - CustomAddress: "", - StatusRecorder: peer.NewRecorder("mgm"), - StateManager: nil, - DisableSys: false, - }) - if err != nil { - t.Errorf("create DNS server: %v", err) - return - } - - err = dnsServer.Initialize() - if err != nil { - t.Errorf("run DNS server: %v", err) - return - } - defer func() { - if err = dnsServer.hostManager.restoreHostDNS(); err != nil { - t.Logf("restore DNS settings on the host: %v", err) - return - } - }() - - dnsServer.dnsMuxHandlers = []handlerWrapper{ - { - domain: zoneRecords[0].Name, - handler: &local.Resolver{}, - priority: PriorityUpstream, - }, - } - dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}) - dnsServer.updateSerial = 0 - - nameServers := []nbdns.NameServer{ - { - IP: netip.MustParseAddr("8.8.8.8"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - { - IP: netip.MustParseAddr("8.8.4.4"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - } - - update := nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - Domains: []string{"netbird.io"}, - NameServers: nameServers, - }, - { - NameServers: nameServers, - Primary: true, - }, - }, - } - - // Start the server with regular configuration - if err := dnsServer.UpdateDNSServer(1, update); err != nil { - t.Fatalf("update dns server should not fail, got error: %v", err) - return - } - - update2 := update - update2.ServiceEnable = false - // Disable the server, stop the listener - if err := dnsServer.UpdateDNSServer(2, update2); err != nil { - t.Fatalf("update dns server should not fail, got error: %v", err) - return - } - - update3 := update2 - update3.NameServerGroups = update3.NameServerGroups[:1] - // But service still get updates and we checking that we handle - // internal state in the right way - if err := dnsServer.UpdateDNSServer(3, update3); err != nil { - t.Fatalf("update dns server should not fail, got error: %v", err) - return - } -} - func TestDNSServerStartStop(t *testing.T) { testCases := []struct { name string diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go new file mode 100644 index 000000000..f787f741f --- /dev/null +++ b/client/internal/engine_privileged_test.go @@ -0,0 +1,565 @@ +//go:build privileged + +package internal + +import ( + "context" + "fmt" + "net" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" + "google.golang.org/grpc/keepalive" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/peer" + nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/system" + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/server/config" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/groups" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" + "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/management/server/types" + mgmt "github.com/netbirdio/netbird/shared/management/client" + mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + relayClient "github.com/netbirdio/netbird/shared/relay/client" + signal "github.com/netbirdio/netbird/shared/signal/client" + "github.com/netbirdio/netbird/shared/signal/proto" + signalServer "github.com/netbirdio/netbird/signal/server" + "github.com/netbirdio/netbird/util" +) + +func TestEngine_SSH(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + return + } + + sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + if err != nil { + t.Fatal(err) + return + } + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine( + ctx, cancel, + &EngineConfig{ + WgIfaceName: "utun101", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + ServerSSHAllowed: true, + MTU: iface.DefaultMTU, + SSHKey: sshKey, + }, + EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, + MobileDependency{}, + ) + + engine.dnsServer = &dns.MockServer{ + UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, + } + + err = engine.Start(nil, nil) + require.NoError(t, err) + + defer func() { + err := engine.Stop() + if err != nil { + return + } + }() + + peerWithSSH := &mgmtProto.RemotePeerConfig{ + WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + AllowedIps: []string{"100.64.0.21/24"}, + SshConfig: &mgmtProto.SSHConfig{ + SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"), + }, + } + + // SSH server is not enabled so SSH config of a remote peer should be ignored + networkMap := &mgmtProto.NetworkMap{ + Serial: 6, + PeerConfig: nil, + RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + assert.Nil(t, engine.sshServer) + + // SSH server is enabled, therefore SSH config should be applied + networkMap = &mgmtProto.NetworkMap{ + Serial: 7, + PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", + SshConfig: &mgmtProto.SSHConfig{ + SshEnabled: true, + JwtConfig: &mgmtProto.JWTConfig{ + Issuer: "test-issuer", + Audience: "test-audience", + KeysLocation: "test-keys", + MaxTokenAge: 3600, + }, + }}, + RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + time.Sleep(250 * time.Millisecond) + assert.NotNil(t, engine.sshServer) + + // now remove peer + networkMap = &mgmtProto.NetworkMap{ + Serial: 8, + RemotePeers: []*mgmtProto.RemotePeerConfig{}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + // time.Sleep(250 * time.Millisecond) + assert.NotNil(t, engine.sshServer) + + // now disable SSH server + networkMap = &mgmtProto.NetworkMap{ + Serial: 9, + PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", + SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}}, + RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + assert.Nil(t, engine.sshServer) +} + +func TestEngine_Sync(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + return + } + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + // feed updates to Engine via mocked Management client + updates := make(chan *mgmtProto.SyncResponse) + defer close(updates) + syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error { + for msg := range updates { + err := msgHandler(msg) + if err != nil { + t.Fatal(err) + } + } + return nil + } + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine(ctx, cancel, &EngineConfig{ + WgIfaceName: "utun103", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + MTU: iface.DefaultMTU, + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{SyncFunc: syncFunc}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) + engine.ctx = ctx + + engine.dnsServer = &dns.MockServer{ + UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, + } + + defer func() { + err := engine.Stop() + if err != nil { + return + } + }() + + err = engine.Start(nil, nil) + if err != nil { + t.Fatal(err) + return + } + + peer1 := &mgmtProto.RemotePeerConfig{ + WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + AllowedIps: []string{"100.64.0.10/24"}, + } + peer2 := &mgmtProto.RemotePeerConfig{ + WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", + AllowedIps: []string{"100.64.0.11/24"}, + } + peer3 := &mgmtProto.RemotePeerConfig{ + WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", + AllowedIps: []string{"100.64.0.12/24"}, + } + // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update + updates <- &mgmtProto.SyncResponse{ + NetworkMap: &mgmtProto.NetworkMap{ + Serial: 10, + PeerConfig: nil, + RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3}, + RemotePeersIsEmpty: false, + }, + } + + timeout := time.After(time.Second * 2) + for { + select { + case <-timeout: + t.Fatalf("timeout while waiting for test to finish") + return + default: + } + + if getPeers(engine) == 3 && engine.networkSerial == 10 { + break + } + } +} + +func TestEngine_MultiplePeers(t *testing.T) { + // log.SetLevel(log.DebugLevel) + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + sigServer, signalAddr, err := startSignal(t) + if err != nil { + t.Fatal(err) + return + } + defer sigServer.Stop() + mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql") + if err != nil { + t.Fatal(err) + return + } + defer mgmtServer.GracefulStop() + + setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" + + mu := sync.Mutex{} + engines := []*Engine{} + numPeers := 10 + wg := sync.WaitGroup{} + wg.Add(numPeers) + // create and start peers + for i := 0; i < numPeers; i++ { + j := i + go func() { + engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr) + if err != nil { + wg.Done() + t.Errorf("unable to create the engine for peer %d with error %v", j, err) + return + } + engine.dnsServer = &dns.MockServer{} + mu.Lock() + defer mu.Unlock() + guid := fmt.Sprintf("{%s}", uuid.New().String()) + device.CustomWindowsGUIDString = strings.ToLower(guid) + err = engine.Start(nil, nil) + if err != nil { + t.Errorf("unable to start engine for peer %d with error %v", j, err) + wg.Done() + return + } + engines = append(engines, engine) + wg.Done() + }() + } + + // wait until all have been created and started + wg.Wait() + if len(engines) != numPeers { + t.Fatal("not all peers were started") + } + // check whether all the peer have expected peers connected + + expectedConnected := numPeers * (numPeers - 1) + + // adjust according to timeouts + timeout := 50 * time.Second + timeoutChan := time.After(timeout) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() +loop: + for { + select { + case <-timeoutChan: + t.Fatalf("waiting for expected connections timeout after %s", timeout.String()) + break loop + case <-ticker.C: + totalConnected := 0 + for _, engine := range engines { + totalConnected += getConnectedPeers(engine) + } + if totalConnected == expectedConnected { + log.Infof("total connected=%d", totalConnected) + break loop + } + log.Infof("total connected=%d", totalConnected) + } + } + // cleanup test + for n, peerEngine := range engines { + t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n) + errStop := peerEngine.mgmClient.Close() + if errStop != nil { + log.Infoln("got error trying to close management clients from engine: ", errStop) + } + errStop = peerEngine.Stop() + if errStop != nil { + log.Infoln("got error trying to close testing peers engine: ", errStop) + } + } +} + +var ( + kaep = keepalive.EnforcementPolicy{ + MinTime: 15 * time.Second, + PermitWithoutStream: true, + } + + kasp = keepalive.ServerParameters{ + MaxConnectionIdle: 15 * time.Second, + MaxConnectionAgeGrace: 5 * time.Second, + Time: 5 * time.Second, + Timeout: 2 * time.Second, + } +) + +func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, err + } + mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false) + if err != nil { + return nil, err + } + signalClient, err := signal.NewClient(ctx, signalAddr, key, false) + if err != nil { + return nil, err + } + + info := system.GetInfo(ctx) + resp, err := mgmtClient.Register(setupKey, "", info, nil, nil) + if err != nil { + return nil, err + } + + var ifaceName string + if runtime.GOOS == "darwin" { + ifaceName = fmt.Sprintf("utun1%d", i) + } else { + ifaceName = fmt.Sprintf("wt%d", i) + } + + wgPort := 33100 + i + conf := &EngineConfig{ + WgIfaceName: ifaceName, + WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address), + WgPrivateKey: key, + WgPort: wgPort, + MTU: iface.DefaultMTU, + } + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + e, err := NewEngine(ctx, cancel, conf, EngineServices{ + SignalClient: signalClient, + MgmClient: mgmtClient, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}), nil + e.ctx = ctx + return e, err +} + +func startSignal(t *testing.T) (*grpc.Server, string, error) { + t.Helper() + + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + + srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + proto.RegisterSignalExchangeServer(s, srv) + + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} + +func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) { + t.Helper() + + config := &config.Config{ + Stuns: []*config.Host{}, + TURNConfig: &config.TURNConfig{}, + Relay: &config.Relay{ + Addresses: []string{"127.0.0.1:1234"}, + CredentialsTTL: util.Duration{Duration: time.Hour}, + Secret: "222222222222222222", + }, + Signal: &config.Host{ + Proto: "http", + URI: "localhost:10000", + }, + Datadir: dataDir, + HttpConfig: nil, + } + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, "", err + } + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + + store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir) + if err != nil { + return nil, "", err + } + t.Cleanup(cleanUp) + + eventStore := &activity.InMemoryEventStore{} + if err != nil { + return nil, "", err + } + + permissionsManager := permissions.NewManager(store) + peersManager := peers.NewManager(store, permissionsManager) + jobManager := job.NewJobManager(nil, store, peersManager) + + cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + return nil, "", err + } + + ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore) + + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + settingsMockManager := settings.NewMockManager(ctrl) + settingsMockManager.EXPECT(). + GetSettings(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.Settings{}, nil). + AnyTimes() + settingsMockManager.EXPECT(). + GetExtraSettings(gomock.Any(), gomock.Any()). + Return(&types.ExtraSettings{}, nil). + AnyTimes() + + groupsManager := groups.NewManagerMock() + + updateManager := update_channel.NewPeersUpdateManager(metrics) + requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) + networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + if err != nil { + return nil, "", err + } + + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + if err != nil { + return nil, "", err + } + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) + if err != nil { + return nil, "", err + } + mgmtProto.RegisterManagementServiceServer(s, mgmtServer) + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} + +// getConnectedPeers returns a connection Status or nil if peer connection wasn't found +func getConnectedPeers(e *Engine) int { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + i := 0 + for _, id := range e.peerStore.PeersPubKey() { + conn, _ := e.peerStore.PeerConn(id) + if conn.IsConnected() { + i++ + } + } + return i +} + +func getPeers(e *Engine) int { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + + return len(e.peerStore.PeersPubKey()) +} diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 8f29bf072..1ac9ceff7 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -6,37 +6,18 @@ import ( "net" "net/netip" "os" - "runtime" "strings" "sync" "testing" "time" - "github.com/golang/mock/gomock" - "github.com/google/uuid" - log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" wgdevice "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/grpc" - "google.golang.org/grpc/keepalive" "github.com/netbirdio/netbird/client/internal/stdnet" - "github.com/netbirdio/netbird/management/server/job" - - "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" - - "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" - "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" - "github.com/netbirdio/netbird/management/internals/modules/peers" - "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" - nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - - "github.com/netbirdio/netbird/management/internals/server/config" - "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/configurer" @@ -50,18 +31,7 @@ import ( icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/routemanager" - nbssh "github.com/netbirdio/netbird/client/ssh" - "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/management/server" - "github.com/netbirdio/netbird/management/server/activity" - nbcache "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" - "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/telemetry" - "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/monotime" "github.com/netbirdio/netbird/route" mgmt "github.com/netbirdio/netbird/shared/management/client" @@ -69,25 +39,9 @@ import ( "github.com/netbirdio/netbird/shared/netiputil" relayClient "github.com/netbirdio/netbird/shared/relay/client" signal "github.com/netbirdio/netbird/shared/signal/client" - "github.com/netbirdio/netbird/shared/signal/proto" - signalServer "github.com/netbirdio/netbird/signal/server" "github.com/netbirdio/netbird/util" ) -var ( - kaep = keepalive.EnforcementPolicy{ - MinTime: 15 * time.Second, - PermitWithoutStream: true, - } - - kasp = keepalive.ServerParameters{ - MaxConnectionIdle: 15 * time.Second, - MaxConnectionAgeGrace: 5 * time.Second, - Time: 5 * time.Second, - Timeout: 2 * time.Second, - } -) - type MockWGIface struct { CreateFunc func() error CreateOnAndroidFunc func(routeRange []string, ip string, domains []string) error @@ -234,129 +188,6 @@ func TestMain(m *testing.M) { os.Exit(code) } -func TestEngine_SSH(t *testing.T) { - key, err := wgtypes.GeneratePrivateKey() - if err != nil { - t.Fatal(err) - return - } - - sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) - if err != nil { - t.Fatal(err) - return - } - - ctx, cancel := context.WithCancel(CtxInitState(context.Background())) - defer cancel() - - relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine( - ctx, cancel, - &EngineConfig{ - WgIfaceName: "utun101", - WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), - WgPrivateKey: key, - WgPort: 33100, - ServerSSHAllowed: true, - MTU: iface.DefaultMTU, - SSHKey: sshKey, - }, - EngineServices{ - SignalClient: &signal.MockClient{}, - MgmClient: &mgmt.MockClient{}, - RelayManager: relayMgr, - StatusRecorder: peer.NewRecorder("https://mgm"), - }, - MobileDependency{}, - ) - - engine.dnsServer = &dns.MockServer{ - UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, - } - - err = engine.Start(nil, nil) - require.NoError(t, err) - - defer func() { - err := engine.Stop() - if err != nil { - return - } - }() - - peerWithSSH := &mgmtProto.RemotePeerConfig{ - WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", - AllowedIps: []string{"100.64.0.21/24"}, - SshConfig: &mgmtProto.SSHConfig{ - SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"), - }, - } - - // SSH server is not enabled so SSH config of a remote peer should be ignored - networkMap := &mgmtProto.NetworkMap{ - Serial: 6, - PeerConfig: nil, - RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - assert.Nil(t, engine.sshServer) - - // SSH server is enabled, therefore SSH config should be applied - networkMap = &mgmtProto.NetworkMap{ - Serial: 7, - PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", - SshConfig: &mgmtProto.SSHConfig{ - SshEnabled: true, - JwtConfig: &mgmtProto.JWTConfig{ - Issuer: "test-issuer", - Audience: "test-audience", - KeysLocation: "test-keys", - MaxTokenAge: 3600, - }, - }}, - RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - time.Sleep(250 * time.Millisecond) - assert.NotNil(t, engine.sshServer) - - // now remove peer - networkMap = &mgmtProto.NetworkMap{ - Serial: 8, - RemotePeers: []*mgmtProto.RemotePeerConfig{}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - // time.Sleep(250 * time.Millisecond) - assert.NotNil(t, engine.sshServer) - - // now disable SSH server - networkMap = &mgmtProto.NetworkMap{ - Serial: 9, - PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", - SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}}, - RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - assert.Nil(t, engine.sshServer) -} - func TestEngine_SSHUpdateLogic(t *testing.T) { // Test that SSH server start/stop logic works based on config engine := &Engine{ @@ -631,97 +462,6 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { } } -func TestEngine_Sync(t *testing.T) { - key, err := wgtypes.GeneratePrivateKey() - if err != nil { - t.Fatal(err) - return - } - - ctx, cancel := context.WithCancel(CtxInitState(context.Background())) - defer cancel() - - // feed updates to Engine via mocked Management client - updates := make(chan *mgmtProto.SyncResponse) - defer close(updates) - syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error { - for msg := range updates { - err := msgHandler(msg) - if err != nil { - t.Fatal(err) - } - } - return nil - } - relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine(ctx, cancel, &EngineConfig{ - WgIfaceName: "utun103", - WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), - WgPrivateKey: key, - WgPort: 33100, - MTU: iface.DefaultMTU, - }, EngineServices{ - SignalClient: &signal.MockClient{}, - MgmClient: &mgmt.MockClient{SyncFunc: syncFunc}, - RelayManager: relayMgr, - StatusRecorder: peer.NewRecorder("https://mgm"), - }, MobileDependency{}) - engine.ctx = ctx - - engine.dnsServer = &dns.MockServer{ - UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, - } - - defer func() { - err := engine.Stop() - if err != nil { - return - } - }() - - err = engine.Start(nil, nil) - if err != nil { - t.Fatal(err) - return - } - - peer1 := &mgmtProto.RemotePeerConfig{ - WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", - AllowedIps: []string{"100.64.0.10/24"}, - } - peer2 := &mgmtProto.RemotePeerConfig{ - WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", - AllowedIps: []string{"100.64.0.11/24"}, - } - peer3 := &mgmtProto.RemotePeerConfig{ - WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", - AllowedIps: []string{"100.64.0.12/24"}, - } - // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update - updates <- &mgmtProto.SyncResponse{ - NetworkMap: &mgmtProto.NetworkMap{ - Serial: 10, - PeerConfig: nil, - RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3}, - RemotePeersIsEmpty: false, - }, - } - - timeout := time.After(time.Second * 2) - for { - select { - case <-timeout: - t.Fatalf("timeout while waiting for test to finish") - return - default: - } - - if getPeers(engine) == 3 && engine.networkSerial == 10 { - break - } - } -} - func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { testCases := []struct { name string @@ -1105,104 +845,6 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { } } -func TestEngine_MultiplePeers(t *testing.T) { - // log.SetLevel(log.DebugLevel) - - ctx, cancel := context.WithCancel(CtxInitState(context.Background())) - defer cancel() - - sigServer, signalAddr, err := startSignal(t) - if err != nil { - t.Fatal(err) - return - } - defer sigServer.Stop() - mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql") - if err != nil { - t.Fatal(err) - return - } - defer mgmtServer.GracefulStop() - - setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" - - mu := sync.Mutex{} - engines := []*Engine{} - numPeers := 10 - wg := sync.WaitGroup{} - wg.Add(numPeers) - // create and start peers - for i := 0; i < numPeers; i++ { - j := i - go func() { - engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr) - if err != nil { - wg.Done() - t.Errorf("unable to create the engine for peer %d with error %v", j, err) - return - } - engine.dnsServer = &dns.MockServer{} - mu.Lock() - defer mu.Unlock() - guid := fmt.Sprintf("{%s}", uuid.New().String()) - device.CustomWindowsGUIDString = strings.ToLower(guid) - err = engine.Start(nil, nil) - if err != nil { - t.Errorf("unable to start engine for peer %d with error %v", j, err) - wg.Done() - return - } - engines = append(engines, engine) - wg.Done() - }() - } - - // wait until all have been created and started - wg.Wait() - if len(engines) != numPeers { - t.Fatal("not all peers was started") - } - // check whether all the peer have expected peers connected - - expectedConnected := numPeers * (numPeers - 1) - - // adjust according to timeouts - timeout := 50 * time.Second - timeoutChan := time.After(timeout) - ticker := time.NewTicker(time.Second) - defer ticker.Stop() -loop: - for { - select { - case <-timeoutChan: - t.Fatalf("waiting for expected connections timeout after %s", timeout.String()) - break loop - case <-ticker.C: - totalConnected := 0 - for _, engine := range engines { - totalConnected += getConnectedPeers(engine) - } - if totalConnected == expectedConnected { - log.Infof("total connected=%d", totalConnected) - break loop - } - log.Infof("total connected=%d", totalConnected) - } - } - // cleanup test - for n, peerEngine := range engines { - t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n) - errStop := peerEngine.mgmClient.Close() - if errStop != nil { - log.Infoln("got error trying to close management clients from engine: ", errStop) - } - errStop = peerEngine.Stop() - if errStop != nil { - log.Infoln("got error trying to close testing peers engine: ", errStop) - } - } -} - func Test_ParseNATExternalIPMappings(t *testing.T) { ifaceList, err := net.Interfaces() if err != nil { @@ -1526,187 +1168,6 @@ func TestCompareNetIPLists(t *testing.T) { } } -func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) { - key, err := wgtypes.GeneratePrivateKey() - if err != nil { - return nil, err - } - mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false) - if err != nil { - return nil, err - } - signalClient, err := signal.NewClient(ctx, signalAddr, key, false) - if err != nil { - return nil, err - } - - info := system.GetInfo(ctx) - resp, err := mgmtClient.Register(setupKey, "", info, nil, nil) - if err != nil { - return nil, err - } - - var ifaceName string - if runtime.GOOS == "darwin" { - ifaceName = fmt.Sprintf("utun1%d", i) - } else { - ifaceName = fmt.Sprintf("wt%d", i) - } - - wgPort := 33100 + i - conf := &EngineConfig{ - WgIfaceName: ifaceName, - WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address), - WgPrivateKey: key, - WgPort: wgPort, - MTU: iface.DefaultMTU, - } - - relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - e, err := NewEngine(ctx, cancel, conf, EngineServices{ - SignalClient: signalClient, - MgmClient: mgmtClient, - RelayManager: relayMgr, - StatusRecorder: peer.NewRecorder("https://mgm"), - }, MobileDependency{}), nil - e.ctx = ctx - return e, err -} - -func startSignal(t *testing.T) (*grpc.Server, string, error) { - t.Helper() - - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - log.Fatalf("failed to listen: %v", err) - } - - srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) - require.NoError(t, err) - proto.RegisterSignalExchangeServer(s, srv) - - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil -} - -func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) { - t.Helper() - - config := &config.Config{ - Stuns: []*config.Host{}, - TURNConfig: &config.TURNConfig{}, - Relay: &config.Relay{ - Addresses: []string{"127.0.0.1:1234"}, - CredentialsTTL: util.Duration{Duration: time.Hour}, - Secret: "222222222222222222", - }, - Signal: &config.Host{ - Proto: "http", - URI: "localhost:10000", - }, - Datadir: dataDir, - HttpConfig: nil, - } - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - return nil, "", err - } - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - - store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir) - if err != nil { - return nil, "", err - } - t.Cleanup(cleanUp) - - eventStore := &activity.InMemoryEventStore{} - if err != nil { - return nil, "", err - } - - permissionsManager := permissions.NewManager(store) - peersManager := peers.NewManager(store, permissionsManager) - jobManager := job.NewJobManager(nil, store, peersManager) - - cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) - if err != nil { - return nil, "", err - } - - ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore) - - metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) - require.NoError(t, err) - - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - settingsMockManager := settings.NewMockManager(ctrl) - settingsMockManager.EXPECT(). - GetSettings(gomock.Any(), gomock.Any(), gomock.Any()). - Return(&types.Settings{}, nil). - AnyTimes() - settingsMockManager.EXPECT(). - GetExtraSettings(gomock.Any(), gomock.Any()). - Return(&types.ExtraSettings{}, nil). - AnyTimes() - - groupsManager := groups.NewManagerMock() - - updateManager := update_channel.NewPeersUpdateManager(metrics) - requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) - if err != nil { - return nil, "", err - } - - secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) - if err != nil { - return nil, "", err - } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) - if err != nil { - return nil, "", err - } - mgmtProto.RegisterManagementServiceServer(s, mgmtServer) - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil -} - -// getConnectedPeers returns a connection Status or nil if peer connection wasn't found -func getConnectedPeers(e *Engine) int { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() - i := 0 - for _, id := range e.peerStore.PeersPubKey() { - conn, _ := e.peerStore.PeerConn(id) - if conn.IsConnected() { - i++ - } - } - return i -} - -func getPeers(e *Engine) int { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() - - return len(e.peerStore.PeersPubKey()) -} - func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte { t.Helper() b, err := netiputil.EncodePrefix(p) diff --git a/client/internal/routemanager/manager_test.go b/client/internal/routemanager/manager_test.go index 926f06bc9..18b44820a 100644 --- a/client/internal/routemanager/manager_test.go +++ b/client/internal/routemanager/manager_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package routemanager import ( diff --git a/client/internal/routemanager/systemops/rt_tables_linux_test.go b/client/internal/routemanager/systemops/rt_tables_linux_test.go new file mode 100644 index 000000000..bc9cca8b1 --- /dev/null +++ b/client/internal/routemanager/systemops/rt_tables_linux_test.go @@ -0,0 +1,69 @@ +//go:build linux && !android + +package systemops + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEntryExists(t *testing.T) { + tempDir := t.TempDir() + tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir) + + content := []string{ + "1000 reserved", + fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName), + "9999 other_table", + } + require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644)) + + file, err := os.Open(tempFilePath) + require.NoError(t, err) + defer func() { + assert.NoError(t, file.Close()) + }() + + tests := []struct { + name string + id int + shouldExist bool + err error + }{ + { + name: "ExistsWithNetbirdPrefix", + id: 7120, + shouldExist: true, + err: nil, + }, + { + name: "ExistsWithDifferentName", + id: 1000, + shouldExist: true, + err: ErrTableIDExists, + }, + { + name: "DoesNotExist", + id: 1234, + shouldExist: false, + err: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + exists, err := entryExists(file, tc.id) + if tc.err != nil { + assert.ErrorIs(t, err, tc.err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.shouldExist, exists) + }) + } +} diff --git a/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go new file mode 100644 index 000000000..d45028c19 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go @@ -0,0 +1,191 @@ +//go:build (darwin || dragonfly || freebsd || netbsd || openbsd) && privileged + +package systemops + +import ( + "fmt" + "net" + "net/netip" + "os/exec" + "regexp" + "runtime" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + testCases = append(testCases, []testCase{ + { + name: "To more specific route without custom dialer via vpn", + expectedInterface: expectedVPNint, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53), + }, + }...) +} + +func TestConcurrentRoutes(t *testing.T) { + baseIP := netip.MustParseAddr("192.0.2.0") + + var intf *net.Interface + var nexthop Nexthop + + _, intf = setupDummyInterface(t) + nexthop = Nexthop{netip.Addr{}, intf} + + r := New(nil, nil) + + var wg sync.WaitGroup + for i := 0; i < 1024; i++ { + wg.Add(1) + go func(ip netip.Addr) { + defer wg.Done() + prefix := netip.PrefixFrom(ip, 32) + if err := r.addToRouteTable(prefix, nexthop); err != nil { + t.Errorf("Failed to add route for %s: %v", prefix, err) + } + }(baseIP) + baseIP = baseIP.Next() + } + + wg.Wait() + + baseIP = netip.MustParseAddr("192.0.2.0") + + for i := 0; i < 1024; i++ { + wg.Add(1) + go func(ip netip.Addr) { + defer wg.Done() + prefix := netip.PrefixFrom(ip, 32) + if err := r.removeFromRouteTable(prefix, nexthop); err != nil { + t.Errorf("Failed to remove route for %s: %v", prefix, err) + } + }(baseIP) + baseIP = baseIP.Next() + } + + wg.Wait() +} + +func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string { + t.Helper() + + if runtime.GOOS == "darwin" { + err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run() + require.NoError(t, err, "Failed to create loopback alias") + + t.Cleanup(func() { + err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run() + assert.NoError(t, err, "Failed to remove loopback alias") + }) + + return intf + } + + prefix, err := netip.ParsePrefix(ipAddressCIDR) + require.NoError(t, err, "Failed to parse prefix") + + netIntf, err := net.InterfaceByName(intf) + require.NoError(t, err, "Failed to get interface by name") + + nexthop := Nexthop{netip.Addr{}, netIntf} + + r := New(nil, nil) + err = r.addToRouteTable(prefix, nexthop) + require.NoError(t, err, "Failed to add route to table") + + t.Cleanup(func() { + err := r.removeFromRouteTable(prefix, nexthop) + assert.NoError(t, err, "Failed to remove route from table") + }) + + return intf +} + +func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) { + t.Helper() + + var originalNexthop net.IP + if dstCIDR == "0.0.0.0/0" { + var err error + originalNexthop, err = fetchOriginalGateway() + if err != nil { + t.Logf("Failed to fetch original gateway: %v", err) + } + + if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil { + t.Logf("Failed to delete route: %v, output: %s", err, output) + } + } + + t.Cleanup(func() { + if originalNexthop != nil { + err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run() + assert.NoError(t, err, "Failed to restore original route") + } + }) + + err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run() + require.NoError(t, err, "Failed to add route") + + t.Cleanup(func() { + err := exec.Command("route", "delete", "-net", dstCIDR).Run() + assert.NoError(t, err, "Failed to remove route") + }) +} + +func fetchOriginalGateway() (net.IP, error) { + output, err := exec.Command("route", "-n", "get", "default").CombinedOutput() + if err != nil { + return nil, err + } + + matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output)) + if len(matches) == 0 { + return nil, fmt.Errorf("gateway not found") + } + + return net.ParseIP(matches[1]), nil +} + +// setupDummyInterface creates a dummy tun interface for FreeBSD route testing +func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) { + t.Helper() + + if runtime.GOOS == "darwin" { + return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"} + } + + output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput() + require.NoError(t, err, "Failed to create tun interface: %s", string(output)) + + tunName := strings.TrimSpace(string(output)) + + output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput() + require.NoError(t, err, "Failed to configure tun interface: %s", string(output)) + + intf, err := net.InterfaceByName(tunName) + require.NoError(t, err, "Failed to get interface by name") + + t.Cleanup(func() { + if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil { + t.Logf("Failed to destroy tun interface %s: %v", tunName, err) + } + }) + + return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf +} + +func setupDummyInterfacesAndRoutes(t *testing.T) { + t.Helper() + + defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24") + addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy) + + otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24") + addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy) +} diff --git a/client/internal/routemanager/systemops/systemops_bsd_test.go b/client/internal/routemanager/systemops/systemops_bsd_test.go index ec4fc406e..9650945b3 100644 --- a/client/internal/routemanager/systemops/systemops_bsd_test.go +++ b/client/internal/routemanager/systemops/systemops_bsd_test.go @@ -3,79 +3,24 @@ package systemops import ( - "fmt" - "net" - "net/netip" - "os/exec" - "regexp" - "runtime" - "strings" - "sync" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "golang.org/x/net/route" ) +// Interface names used by the shared routing test fixtures. Kept untagged (no +// privileged build tag) so the non-privileged test files in this package compile. +// +//nolint:unused // consumed by the privileged-tagged routing tests var expectedVPNint = "utun100" + +//nolint:unused // consumed by the privileged-tagged routing tests var expectedExternalInt = "lo0" + +//nolint:unused // consumed by the privileged-tagged routing tests var expectedInternalInt = "lo0" -func init() { - testCases = append(testCases, []testCase{ - { - name: "To more specific route without custom dialer via vpn", - expectedInterface: expectedVPNint, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53), - }, - }...) -} - -func TestConcurrentRoutes(t *testing.T) { - baseIP := netip.MustParseAddr("192.0.2.0") - - var intf *net.Interface - var nexthop Nexthop - - _, intf = setupDummyInterface(t) - nexthop = Nexthop{netip.Addr{}, intf} - - r := New(nil, nil) - - var wg sync.WaitGroup - for i := 0; i < 1024; i++ { - wg.Add(1) - go func(ip netip.Addr) { - defer wg.Done() - prefix := netip.PrefixFrom(ip, 32) - if err := r.addToRouteTable(prefix, nexthop); err != nil { - t.Errorf("Failed to add route for %s: %v", prefix, err) - } - }(baseIP) - baseIP = baseIP.Next() - } - - wg.Wait() - - baseIP = netip.MustParseAddr("192.0.2.0") - - for i := 0; i < 1024; i++ { - wg.Add(1) - go func(ip netip.Addr) { - defer wg.Done() - prefix := netip.PrefixFrom(ip, 32) - if err := r.removeFromRouteTable(prefix, nexthop); err != nil { - t.Errorf("Failed to remove route for %s: %v", prefix, err) - } - }(baseIP) - baseIP = baseIP.Next() - } - - wg.Wait() -} - func TestBits(t *testing.T) { tests := []struct { name string @@ -122,122 +67,3 @@ func TestBits(t *testing.T) { }) } } - -func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string { - t.Helper() - - if runtime.GOOS == "darwin" { - err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run() - require.NoError(t, err, "Failed to create loopback alias") - - t.Cleanup(func() { - err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run() - assert.NoError(t, err, "Failed to remove loopback alias") - }) - - return intf - } - - prefix, err := netip.ParsePrefix(ipAddressCIDR) - require.NoError(t, err, "Failed to parse prefix") - - netIntf, err := net.InterfaceByName(intf) - require.NoError(t, err, "Failed to get interface by name") - - nexthop := Nexthop{netip.Addr{}, netIntf} - - r := New(nil, nil) - err = r.addToRouteTable(prefix, nexthop) - require.NoError(t, err, "Failed to add route to table") - - t.Cleanup(func() { - err := r.removeFromRouteTable(prefix, nexthop) - assert.NoError(t, err, "Failed to remove route from table") - }) - - return intf -} - -func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) { - t.Helper() - - var originalNexthop net.IP - if dstCIDR == "0.0.0.0/0" { - var err error - originalNexthop, err = fetchOriginalGateway() - if err != nil { - t.Logf("Failed to fetch original gateway: %v", err) - } - - if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil { - t.Logf("Failed to delete route: %v, output: %s", err, output) - } - } - - t.Cleanup(func() { - if originalNexthop != nil { - err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run() - assert.NoError(t, err, "Failed to restore original route") - } - }) - - err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run() - require.NoError(t, err, "Failed to add route") - - t.Cleanup(func() { - err := exec.Command("route", "delete", "-net", dstCIDR).Run() - assert.NoError(t, err, "Failed to remove route") - }) -} - -func fetchOriginalGateway() (net.IP, error) { - output, err := exec.Command("route", "-n", "get", "default").CombinedOutput() - if err != nil { - return nil, err - } - - matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output)) - if len(matches) == 0 { - return nil, fmt.Errorf("gateway not found") - } - - return net.ParseIP(matches[1]), nil -} - -// setupDummyInterface creates a dummy tun interface for FreeBSD route testing -func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) { - t.Helper() - - if runtime.GOOS == "darwin" { - return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"} - } - - output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput() - require.NoError(t, err, "Failed to create tun interface: %s", string(output)) - - tunName := strings.TrimSpace(string(output)) - - output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput() - require.NoError(t, err, "Failed to configure tun interface: %s", string(output)) - - intf, err := net.InterfaceByName(tunName) - require.NoError(t, err, "Failed to get interface by name") - - t.Cleanup(func() { - if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil { - t.Logf("Failed to destroy tun interface %s: %v", tunName, err) - } - }) - - return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf -} - -func setupDummyInterfacesAndRoutes(t *testing.T) { - t.Helper() - - defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24") - addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy) - - otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24") - addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy) -} diff --git a/client/internal/routemanager/systemops/systemops_dialer_test.go b/client/internal/routemanager/systemops/systemops_dialer_test.go new file mode 100644 index 000000000..f00f9099c --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_dialer_test.go @@ -0,0 +1,17 @@ +//go:build !android && !ios + +package systemops + +import ( + "context" + "net" +) + +// dialer is shared by the per-platform routing test cases. Kept untagged (no +// privileged build tag) so the non-privileged test files compile on every platform. +// +//nolint:unused // consumed by the privileged-tagged routing tests +type dialer interface { + Dial(network, address string) (net.Conn, error) + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go index 5695c40c3..c4f739c30 100644 --- a/client/internal/routemanager/systemops/systemops_generic_test.go +++ b/client/internal/routemanager/systemops/systemops_generic_test.go @@ -1,4 +1,4 @@ -//go:build !android && !ios +//go:build !android && !ios && privileged package systemops @@ -26,11 +26,6 @@ import ( nbnet "github.com/netbirdio/netbird/client/net" ) -type dialer interface { - Dial(network, address string) (net.Conn, error) - DialContext(ctx context.Context, network, address string) (net.Conn, error) -} - func TestAddVPNRoute(t *testing.T) { testCases := []struct { name string @@ -515,125 +510,3 @@ func setupTestEnv(t *testing.T) { // unique route in vpn table setupRouteAndCleanup(t, r, netip.MustParsePrefix("172.16.0.0/12"), intf) } - -func TestIsVpnRoute(t *testing.T) { - tests := []struct { - name string - addr string - vpnRoutes []string - localRoutes []string - expectedVpn bool - expectedPrefix netip.Prefix - }{ - { - name: "Match in VPN routes", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Match in local routes", - addr: "10.1.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"), - }, - { - name: "No match", - addr: "172.16.0.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: false, - expectedPrefix: netip.Prefix{}, - }, - { - name: "Default route ignored", - addr: "192.168.1.1", - vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Default route matches but ignored", - addr: "172.16.1.1", - vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: false, - expectedPrefix: netip.Prefix{}, - }, - { - name: "Longest prefix match local", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.0.0/16"}, - localRoutes: []string{"192.168.1.0/24"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Longest prefix match local multiple", - addr: "192.168.0.1", - vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, - localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"), - }, - { - name: "Longest prefix match vpn", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"192.168.0.0/16"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Longest prefix match vpn multiple", - addr: "192.168.0.1", - vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, - localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"), - }, - { - name: "Duplicate prefix in both", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"192.168.1.0/24"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - addr, err := netip.ParseAddr(tt.addr) - if err != nil { - t.Fatalf("Failed to parse address %s: %v", tt.addr, err) - } - - var vpnRoutes, localRoutes []netip.Prefix - for _, route := range tt.vpnRoutes { - prefix, err := netip.ParsePrefix(route) - if err != nil { - t.Fatalf("Failed to parse VPN route %s: %v", route, err) - } - vpnRoutes = append(vpnRoutes, prefix) - } - - for _, route := range tt.localRoutes { - prefix, err := netip.ParsePrefix(route) - if err != nil { - t.Fatalf("Failed to parse local route %s: %v", route, err) - } - localRoutes = append(localRoutes, prefix) - } - - isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes) - assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value") - assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix") - }) - } -} diff --git a/client/internal/routemanager/systemops/systemops_isvpnroute_test.go b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go new file mode 100644 index 000000000..677fe1287 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go @@ -0,0 +1,132 @@ +//go:build !android && !ios + +package systemops + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsVpnRoute(t *testing.T) { + tests := []struct { + name string + addr string + vpnRoutes []string + localRoutes []string + expectedVpn bool + expectedPrefix netip.Prefix + }{ + { + name: "Match in VPN routes", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Match in local routes", + addr: "10.1.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"), + }, + { + name: "No match", + addr: "172.16.0.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: false, + expectedPrefix: netip.Prefix{}, + }, + { + name: "Default route ignored", + addr: "192.168.1.1", + vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Default route matches but ignored", + addr: "172.16.1.1", + vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: false, + expectedPrefix: netip.Prefix{}, + }, + { + name: "Longest prefix match local", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.0.0/16"}, + localRoutes: []string{"192.168.1.0/24"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Longest prefix match local multiple", + addr: "192.168.0.1", + vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, + localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"), + }, + { + name: "Longest prefix match vpn", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"192.168.0.0/16"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Longest prefix match vpn multiple", + addr: "192.168.0.1", + vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, + localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"), + }, + { + name: "Duplicate prefix in both", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"192.168.1.0/24"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, err := netip.ParseAddr(tt.addr) + if err != nil { + t.Fatalf("Failed to parse address %s: %v", tt.addr, err) + } + + var vpnRoutes, localRoutes []netip.Prefix + for _, route := range tt.vpnRoutes { + prefix, err := netip.ParsePrefix(route) + if err != nil { + t.Fatalf("Failed to parse VPN route %s: %v", route, err) + } + vpnRoutes = append(vpnRoutes, prefix) + } + + for _, route := range tt.localRoutes { + prefix, err := netip.ParsePrefix(route) + if err != nil { + t.Fatalf("Failed to parse local route %s: %v", route, err) + } + localRoutes = append(localRoutes, prefix) + } + + isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes) + assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value") + assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix") + }) + } +} diff --git a/client/internal/routemanager/systemops/systemops_linux_test.go b/client/internal/routemanager/systemops/systemops_linux_test.go index 880296d91..06c528ce5 100644 --- a/client/internal/routemanager/systemops/systemops_linux_test.go +++ b/client/internal/routemanager/systemops/systemops_linux_test.go @@ -1,13 +1,10 @@ -//go:build !android +//go:build linux && !android && privileged package systemops import ( "errors" - "fmt" "net" - "os" - "strings" "syscall" "testing" @@ -18,10 +15,6 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager/vars" ) -var expectedVPNint = "wgtest0" -var expectedExternalInt = "dummyext0" -var expectedInternalInt = "dummyint0" - func init() { testCases = append(testCases, []testCase{ { @@ -33,62 +26,6 @@ func init() { }...) } -func TestEntryExists(t *testing.T) { - tempDir := t.TempDir() - tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir) - - content := []string{ - "1000 reserved", - fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName), - "9999 other_table", - } - require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644)) - - file, err := os.Open(tempFilePath) - require.NoError(t, err) - defer func() { - assert.NoError(t, file.Close()) - }() - - tests := []struct { - name string - id int - shouldExist bool - err error - }{ - { - name: "ExistsWithNetbirdPrefix", - id: 7120, - shouldExist: true, - err: nil, - }, - { - name: "ExistsWithDifferentName", - id: 1000, - shouldExist: true, - err: ErrTableIDExists, - }, - { - name: "DoesNotExist", - id: 1234, - shouldExist: false, - err: nil, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - exists, err := entryExists(file, tc.id) - if tc.err != nil { - assert.ErrorIs(t, err, tc.err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tc.shouldExist, exists) - }) - } -} - func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) string { t.Helper() diff --git a/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go new file mode 100644 index 000000000..9be267980 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go @@ -0,0 +1,15 @@ +//go:build linux && !android + +package systemops + +// Interface names used by the shared routing test fixtures. Kept untagged (no +// privileged build tag) so the non-privileged test files in this package compile. +// +//nolint:unused // consumed by the privileged-tagged routing tests +var expectedVPNint = "wgtest0" + +//nolint:unused // consumed by the privileged-tagged routing tests +var expectedExternalInt = "dummyext0" + +//nolint:unused // consumed by the privileged-tagged routing tests +var expectedInternalInt = "dummyint0" diff --git a/client/internal/routemanager/systemops/systemops_routing_data_test.go b/client/internal/routemanager/systemops/systemops_routing_data_test.go new file mode 100644 index 000000000..16f17f5b9 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_routing_data_test.go @@ -0,0 +1,83 @@ +//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly + +package systemops + +import ( + "net" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// Shared, non-privileged routing test fixtures. The privileged TestRouting (and its +// per-platform init() appenders) consume these; they live here so the unprivileged +// BSD/darwin test files compile without the privileged build tag. + +type PacketExpectation struct { + SrcIP net.IP + DstIP net.IP + SrcPort int + DstPort int + UDP bool + TCP bool +} + +//nolint:unused // consumed by the privileged-tagged routing tests +type testCase struct { + name string + expectedInterface string + dialer dialer + expectedPacket PacketExpectation +} + +//nolint:unused // consumed by the privileged-tagged routing tests +var testCases = []testCase{ + { + name: "To external host without custom dialer via vpn", + expectedInterface: expectedVPNint, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53), + }, + { + name: "To external host with custom dialer via physical interface", + expectedInterface: expectedExternalInt, + dialer: nbnet.NewDialer(), + expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53), + }, + + { + name: "To duplicate internal route with custom dialer via physical interface", + expectedInterface: expectedInternalInt, + dialer: nbnet.NewDialer(), + expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), + }, + { + name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence + expectedInterface: expectedInternalInt, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), + }, + + { + name: "To unique vpn route with custom dialer via physical interface", + expectedInterface: expectedExternalInt, + dialer: nbnet.NewDialer(), + expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53), + }, + { + name: "To unique vpn route without custom dialer via vpn", + expectedInterface: expectedVPNint, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53), + }, +} + +//nolint:unused // consumed by the privileged-tagged routing tests +func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation { + return PacketExpectation{ + SrcIP: net.ParseIP(srcIP), + DstIP: net.ParseIP(dstIP), + SrcPort: srcPort, + DstPort: dstPort, + UDP: true, + } +} diff --git a/client/internal/routemanager/systemops/systemops_unix_test.go b/client/internal/routemanager/systemops/systemops_unix_test.go index 959c697e4..efb0ae4e4 100644 --- a/client/internal/routemanager/systemops/systemops_unix_test.go +++ b/client/internal/routemanager/systemops/systemops_unix_test.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly +//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly) && privileged package systemops @@ -20,63 +20,6 @@ import ( nbnet "github.com/netbirdio/netbird/client/net" ) -type PacketExpectation struct { - SrcIP net.IP - DstIP net.IP - SrcPort int - DstPort int - UDP bool - TCP bool -} - -type testCase struct { - name string - expectedInterface string - dialer dialer - expectedPacket PacketExpectation -} - -var testCases = []testCase{ - { - name: "To external host without custom dialer via vpn", - expectedInterface: expectedVPNint, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53), - }, - { - name: "To external host with custom dialer via physical interface", - expectedInterface: expectedExternalInt, - dialer: nbnet.NewDialer(), - expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53), - }, - - { - name: "To duplicate internal route with custom dialer via physical interface", - expectedInterface: expectedInternalInt, - dialer: nbnet.NewDialer(), - expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), - }, - { - name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence - expectedInterface: expectedInternalInt, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), - }, - - { - name: "To unique vpn route with custom dialer via physical interface", - expectedInterface: expectedExternalInt, - dialer: nbnet.NewDialer(), - expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53), - }, - { - name: "To unique vpn route without custom dialer via vpn", - expectedInterface: expectedVPNint, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53), - }, -} - func TestRouting(t *testing.T) { nbnet.Init() for _, tc := range testCases { @@ -102,16 +45,6 @@ func TestRouting(t *testing.T) { } } -func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation { - return PacketExpectation{ - SrcIP: net.ParseIP(srcIP), - DstIP: net.ParseIP(dstIP), - SrcPort: srcPort, - DstPort: dstPort, - UDP: true, - } -} - func startPacketCapture(t *testing.T, intf, filter string) *pcap.Handle { t.Helper() diff --git a/client/internal/routemanager/systemops/systemops_windows_test.go b/client/internal/routemanager/systemops/systemops_windows_test.go index 3561adec4..77e349bd6 100644 --- a/client/internal/routemanager/systemops/systemops_windows_test.go +++ b/client/internal/routemanager/systemops/systemops_windows_test.go @@ -1,3 +1,5 @@ +//go:build windows && privileged + package systemops import ( diff --git a/client/internal/routemanager/systemops/v6route_bsd_test.go b/client/internal/routemanager/systemops/v6route_bsd_test.go index 98ce29c6d..90e49f54e 100644 --- a/client/internal/routemanager/systemops/v6route_bsd_test.go +++ b/client/internal/routemanager/systemops/v6route_bsd_test.go @@ -11,6 +11,8 @@ import ( // ensureIPv6DefaultRoute installs an IPv6 default route via the loopback // interface so route lookups for global IPv6 prefixes resolve in environments // without v6 connectivity. If a default already exists it is left alone. +// +//nolint:unused // consumed by the privileged-tagged routing tests func ensureIPv6DefaultRoute(t *testing.T) { t.Helper() diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go index 0b17cefff..449d4cbd2 100644 --- a/client/internal/routemanager/systemops/v6route_linux_test.go +++ b/client/internal/routemanager/systemops/v6route_linux_test.go @@ -1,4 +1,4 @@ -//go:build linux && !android +//go:build linux && !android && privileged package systemops diff --git a/client/internal/routemanager/systemops/v6route_windows_test.go b/client/internal/routemanager/systemops/v6route_windows_test.go index f79277b87..2c813a790 100644 --- a/client/internal/routemanager/systemops/v6route_windows_test.go +++ b/client/internal/routemanager/systemops/v6route_windows_test.go @@ -8,11 +8,14 @@ import ( "testing" ) +//nolint:unused // consumed by the privileged-tagged routing tests const loopbackIfaceWindows = "Loopback Pseudo-Interface 1" // ensureIPv6DefaultRoute installs an IPv6 default route via the loopback // interface so route lookups for global IPv6 prefixes resolve in environments // without v6 connectivity. If a default already exists it is left alone. +// +//nolint:unused // consumed by the privileged-tagged routing tests func ensureIPv6DefaultRoute(t *testing.T) { t.Helper() diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go new file mode 100644 index 000000000..225cf6494 --- /dev/null +++ b/client/server/server_privileged_test.go @@ -0,0 +1,235 @@ +//go:build privileged + +package server + +import ( + "context" + "net" + "os/user" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/job" + + "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/groups" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/keepalive" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/telemetry" + mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/signal/proto" + signalServer "github.com/netbirdio/netbird/signal/server" +) + +var ( + kaep = keepalive.EnforcementPolicy{ + MinTime: 15 * time.Second, + PermitWithoutStream: true, + } + + kasp = keepalive.ServerParameters{ + MaxConnectionIdle: 15 * time.Second, + MaxConnectionAgeGrace: 5 * time.Second, + Time: 5 * time.Second, + Timeout: 2 * time.Second, + } +) + +// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables +// we will use a management server started via to simulate the server and capture the number of retries +func TestConnectWithRetryRuns(t *testing.T) { + // start the signal server + _, signalAddr, err := startSignal(t) + if err != nil { + t.Fatalf("failed to start signal server: %v", err) + } + + counter := 0 + // start the management server + _, mgmtAddr, err := startManagement(t, signalAddr, &counter) + if err != nil { + t.Fatalf("failed to start management server: %v", err) + } + + ctx := internal.CtxInitState(context.Background()) + + ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second)) + defer cancel() + // create new server + ic := profilemanager.ConfigInput{ + ManagementURL: "http://" + mgmtAddr, + ConfigPath: t.TempDir() + "/test-profile.json", + } + + config, err := profilemanager.UpdateOrCreateConfig(ic) + if err != nil { + t.Fatalf("failed to create config: %v", err) + } + + currUser, err := user.Current() + require.NoError(t, err) + + pm := profilemanager.ServiceManager{} + err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: "test-profile", + Username: currUser.Username, + }) + if err != nil { + t.Fatalf("failed to set active profile state: %v", err) + } + + s := New(ctx, "debug", "", false, false, false, false) + + s.config = config + + s.statusRecorder = peer.NewRecorder(config.ManagementURL.String()) + t.Setenv(retryInitialIntervalVar, "1s") + t.Setenv(maxRetryIntervalVar, "2s") + t.Setenv(maxRetryTimeVar, "5s") + t.Setenv(retryMultiplierVar, "1") + + s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil) + if counter < 3 { + t.Fatalf("expected counter > 2, got %d", counter) + } +} + +type mockServer struct { + mgmtProto.ManagementServiceServer + counter *int +} + +func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) { + *m.counter++ + return m.ManagementServiceServer.Login(ctx, req) +} + +func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) { + t.Helper() + dataDir := t.TempDir() + + config := &config.Config{ + Stuns: []*config.Host{}, + TURNConfig: &config.TURNConfig{}, + Signal: &config.Host{ + Proto: "http", + URI: signalAddr, + }, + Datadir: dataDir, + HttpConfig: nil, + } + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, "", err + } + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir) + if err != nil { + return nil, "", err + } + t.Cleanup(cleanUp) + + eventStore := &activity.InMemoryEventStore{} + if err != nil { + return nil, "", err + } + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + permissionsManagerMock := permissions.NewMockManager(ctrl) + peersManager := peers.NewManager(store, permissionsManagerMock) + settingsManagerMock := settings.NewMockManager(ctrl) + + jobManager := job.NewJobManager(nil, store, peersManager) + + cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + return nil, "", err + } + + ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore) + + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + require.NoError(t, err) + + settingsMockManager := settings.NewMockManager(ctrl) + groupsManager := groups.NewManagerMock() + + requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) + peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) + networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + if err != nil { + return nil, "", err + } + + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + if err != nil { + return nil, "", err + } + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) + if err != nil { + return nil, "", err + } + mock := &mockServer{ + ManagementServiceServer: mgmtServer, + counter: counter, + } + mgmtProto.RegisterManagementServiceServer(s, mock) + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} + +func startSignal(t *testing.T) (*grpc.Server, string, error) { + t.Helper() + + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, "", err + } + + srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + proto.RegisterSignalExchangeServer(s, srv) + + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} diff --git a/client/server/server_test.go b/client/server/server_test.go index fa9599818..7717cfcf8 100644 --- a/client/server/server_test.go +++ b/client/server/server_test.go @@ -2,124 +2,22 @@ package server import ( "context" - "net" "net/url" "os/user" "path/filepath" "testing" "time" - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" - - "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" - - "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" - "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" - "github.com/netbirdio/netbird/management/internals/modules/peers" - "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" - nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - "github.com/netbirdio/netbird/management/server/job" - - "github.com/netbirdio/netbird/management/internals/server/config" - "github.com/netbirdio/netbird/management/server/groups" - log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/grpc" - "google.golang.org/grpc/keepalive" "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" daemonProto "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/management/server" - "github.com/netbirdio/netbird/management/server/activity" - nbcache "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" - "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/telemetry" - mgmtProto "github.com/netbirdio/netbird/shared/management/proto" - "github.com/netbirdio/netbird/shared/signal/proto" - signalServer "github.com/netbirdio/netbird/signal/server" ) -var ( - kaep = keepalive.EnforcementPolicy{ - MinTime: 15 * time.Second, - PermitWithoutStream: true, - } - - kasp = keepalive.ServerParameters{ - MaxConnectionIdle: 15 * time.Second, - MaxConnectionAgeGrace: 5 * time.Second, - Time: 5 * time.Second, - Timeout: 2 * time.Second, - } -) - -// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables -// we will use a management server started via to simulate the server and capture the number of retries -func TestConnectWithRetryRuns(t *testing.T) { - // start the signal server - _, signalAddr, err := startSignal(t) - if err != nil { - t.Fatalf("failed to start signal server: %v", err) - } - - counter := 0 - // start the management server - _, mgmtAddr, err := startManagement(t, signalAddr, &counter) - if err != nil { - t.Fatalf("failed to start management server: %v", err) - } - - ctx := internal.CtxInitState(context.Background()) - - ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second)) - defer cancel() - // create new server - ic := profilemanager.ConfigInput{ - ManagementURL: "http://" + mgmtAddr, - ConfigPath: t.TempDir() + "/test-profile.json", - } - - config, err := profilemanager.UpdateOrCreateConfig(ic) - if err != nil { - t.Fatalf("failed to create config: %v", err) - } - - currUser, err := user.Current() - require.NoError(t, err) - - pm := profilemanager.ServiceManager{} - err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - ID: "test-profile", - Username: currUser.Username, - }) - if err != nil { - t.Fatalf("failed to set active profile state: %v", err) - } - - s := New(ctx, "debug", "", false, false, false, false) - - s.config = config - - s.statusRecorder = peer.NewRecorder(config.ManagementURL.String()) - t.Setenv(retryInitialIntervalVar, "1s") - t.Setenv(maxRetryIntervalVar, "2s") - t.Setenv(maxRetryTimeVar, "5s") - t.Setenv(retryMultiplierVar, "1") - - s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil) - if counter < 3 { - t.Fatalf("expected counter > 2, got %d", counter) - } -} - func TestServer_Up(t *testing.T) { tempDir := t.TempDir() origDefaultProfileDir := profilemanager.DefaultConfigPathDir @@ -259,119 +157,3 @@ func TestServer_SubcribeEvents(t *testing.T) { assert.NoError(t, err) } - -type mockServer struct { - mgmtProto.ManagementServiceServer - counter *int -} - -func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) { - *m.counter++ - return m.ManagementServiceServer.Login(ctx, req) -} - -func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) { - t.Helper() - dataDir := t.TempDir() - - config := &config.Config{ - Stuns: []*config.Host{}, - TURNConfig: &config.TURNConfig{}, - Signal: &config.Host{ - Proto: "http", - URI: signalAddr, - }, - Datadir: dataDir, - HttpConfig: nil, - } - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - return nil, "", err - } - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir) - if err != nil { - return nil, "", err - } - t.Cleanup(cleanUp) - - eventStore := &activity.InMemoryEventStore{} - if err != nil { - return nil, "", err - } - - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - - permissionsManagerMock := permissions.NewMockManager(ctrl) - peersManager := peers.NewManager(store, permissionsManagerMock) - settingsManagerMock := settings.NewMockManager(ctrl) - - jobManager := job.NewJobManager(nil, store, peersManager) - - cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) - if err != nil { - return nil, "", err - } - - ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore) - - metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) - require.NoError(t, err) - - settingsMockManager := settings.NewMockManager(ctrl) - groupsManager := groups.NewManagerMock() - - requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) - networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) - if err != nil { - return nil, "", err - } - - secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) - if err != nil { - return nil, "", err - } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) - if err != nil { - return nil, "", err - } - mock := &mockServer{ - ManagementServiceServer: mgmtServer, - counter: counter, - } - mgmtProto.RegisterManagementServiceServer(s, mock) - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil -} - -func startSignal(t *testing.T) (*grpc.Server, string, error) { - t.Helper() - - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - log.Fatalf("failed to listen: %v", err) - } - - srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) - require.NoError(t, err) - proto.RegisterSignalExchangeServer(s, srv) - - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil -} diff --git a/client/ssh/client/client_privileged_test.go b/client/ssh/client/client_privileged_test.go new file mode 100644 index 000000000..12edbbc06 --- /dev/null +++ b/client/ssh/client/client_privileged_test.go @@ -0,0 +1,118 @@ +//go:build privileged + +package client + +import ( + "context" + "errors" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" + + "github.com/netbirdio/netbird/client/ssh/testutil" +) + +func TestSSHClient_CommandExecution(t *testing.T) { + if runtime.GOOS == "windows" && testutil.IsCI() { + t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues") + } + + server, _, client := setupTestSSHServerAndClient(t) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + defer func() { + err := client.Close() + assert.NoError(t, err) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + t.Run("ExecuteCommand captures output", func(t *testing.T) { + output, err := client.ExecuteCommand(ctx, "echo hello") + assert.NoError(t, err) + assert.Contains(t, string(output), "hello") + }) + + t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) { + err := client.ExecuteCommandWithIO(ctx, "echo world") + assert.NoError(t, err) + }) + + t.Run("commands with flags work", func(t *testing.T) { + output, err := client.ExecuteCommand(ctx, "echo -n test_flag") + assert.NoError(t, err) + assert.Equal(t, "test_flag", strings.TrimSpace(string(output))) + }) + + t.Run("non-zero exit codes don't return errors", func(t *testing.T) { + var testCmd string + if runtime.GOOS == "windows" { + testCmd = "echo hello | Select-String notfound" + } else { + testCmd = "echo 'hello' | grep 'notfound'" + } + _, err := client.ExecuteCommand(ctx, testCmd) + assert.NoError(t, err) + }) +} + +func TestSSHClient_ContextCancellation(t *testing.T) { + server, serverAddr, _ := setupTestSSHServerAndClient(t) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + t.Run("connection with short timeout", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + currentUser := testutil.GetTestUsername(t) + _, err := Dial(ctx, serverAddr, currentUser, DialOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + // Check for actual timeout-related errors rather than string matching + assert.True(t, + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) || + strings.Contains(err.Error(), "timeout"), + "Expected timeout-related error, got: %v", err) + } + }) + + t.Run("command execution cancellation", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + currentUser := testutil.GetTestUsername(t) + client, err := Dial(ctx, serverAddr, currentUser, DialOptions{ + InsecureSkipVerify: true, + }) + require.NoError(t, err) + defer func() { + if err := client.Close(); err != nil { + t.Logf("client close error: %v", err) + } + }() + + cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cmdCancel() + + err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10") + if err != nil { + var exitMissingErr *cryptossh.ExitMissingError + isValidCancellation := errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) || + errors.As(err, &exitMissingErr) + assert.True(t, isValidCancellation, "Should handle command cancellation properly") + } + }) +} diff --git a/client/ssh/client/client_test.go b/client/ssh/client/client_test.go index e38e02a86..191362940 100644 --- a/client/ssh/client/client_test.go +++ b/client/ssh/client/client_test.go @@ -15,7 +15,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - cryptossh "golang.org/x/crypto/ssh" "github.com/netbirdio/netbird/client/ssh" sshserver "github.com/netbirdio/netbird/client/ssh/server" @@ -78,53 +77,6 @@ func TestSSHClient_DialWithKey(t *testing.T) { assert.NotNil(t, client.client) } -func TestSSHClient_CommandExecution(t *testing.T) { - if runtime.GOOS == "windows" && testutil.IsCI() { - t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues") - } - - server, _, client := setupTestSSHServerAndClient(t) - defer func() { - err := server.Stop() - require.NoError(t, err) - }() - defer func() { - err := client.Close() - assert.NoError(t, err) - }() - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - t.Run("ExecuteCommand captures output", func(t *testing.T) { - output, err := client.ExecuteCommand(ctx, "echo hello") - assert.NoError(t, err) - assert.Contains(t, string(output), "hello") - }) - - t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) { - err := client.ExecuteCommandWithIO(ctx, "echo world") - assert.NoError(t, err) - }) - - t.Run("commands with flags work", func(t *testing.T) { - output, err := client.ExecuteCommand(ctx, "echo -n test_flag") - assert.NoError(t, err) - assert.Equal(t, "test_flag", strings.TrimSpace(string(output))) - }) - - t.Run("non-zero exit codes don't return errors", func(t *testing.T) { - var testCmd string - if runtime.GOOS == "windows" { - testCmd = "echo hello | Select-String notfound" - } else { - testCmd = "echo 'hello' | grep 'notfound'" - } - _, err := client.ExecuteCommand(ctx, testCmd) - assert.NoError(t, err) - }) -} - func TestSSHClient_ConnectionHandling(t *testing.T) { server, serverAddr, _ := setupTestSSHServerAndClient(t) defer func() { @@ -154,59 +106,6 @@ func TestSSHClient_ConnectionHandling(t *testing.T) { } } -func TestSSHClient_ContextCancellation(t *testing.T) { - server, serverAddr, _ := setupTestSSHServerAndClient(t) - defer func() { - err := server.Stop() - require.NoError(t, err) - }() - - t.Run("connection with short timeout", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) - defer cancel() - - currentUser := testutil.GetTestUsername(t) - _, err := Dial(ctx, serverAddr, currentUser, DialOptions{ - InsecureSkipVerify: true, - }) - if err != nil { - // Check for actual timeout-related errors rather than string matching - assert.True(t, - errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, context.Canceled) || - strings.Contains(err.Error(), "timeout"), - "Expected timeout-related error, got: %v", err) - } - }) - - t.Run("command execution cancellation", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - currentUser := testutil.GetTestUsername(t) - client, err := Dial(ctx, serverAddr, currentUser, DialOptions{ - InsecureSkipVerify: true, - }) - require.NoError(t, err) - defer func() { - if err := client.Close(); err != nil { - t.Logf("client close error: %v", err) - } - }() - - cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cmdCancel() - - err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10") - if err != nil { - var exitMissingErr *cryptossh.ExitMissingError - isValidCancellation := errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, context.Canceled) || - errors.As(err, &exitMissingErr) - assert.True(t, isValidCancellation, "Should handle command cancellation properly") - } - }) -} - func TestSSHClient_NoAuthMode(t *testing.T) { hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) require.NoError(t, err) diff --git a/client/ssh/proxy/proxy_privileged_test.go b/client/ssh/proxy/proxy_privileged_test.go new file mode 100644 index 000000000..94495a3ae --- /dev/null +++ b/client/ssh/proxy/proxy_privileged_test.go @@ -0,0 +1,423 @@ +//go:build privileged + +package proxy + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "runtime" + "strconv" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" + + nbssh "github.com/netbirdio/netbird/client/ssh" + sshauth "github.com/netbirdio/netbird/client/ssh/auth" + "github.com/netbirdio/netbird/client/ssh/server" + "github.com/netbirdio/netbird/client/ssh/testutil" + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" + sshuserhash "github.com/netbirdio/netbird/shared/sshauth" +) + +func (m *mockDaemon) setJWTToken(token string) { + m.impl.jwtToken = token +} + +func TestSSHProxy_Connect(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // TODO: Windows test times out - user switching and command execution tested on Linux + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows - covered by Linux tests") + } + + const ( + issuer = "https://test-issuer.example.com" + audience = "test-audience" + ) + + jwksServer, privateKey, jwksURL := setupJWKSServer(t) + defer jwksServer.Close() + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + hostPubKey, err := nbssh.GeneratePublicKey(hostKey) + require.NoError(t, err) + + serverConfig := &server.Config{ + HostKeyPEM: hostKey, + JWT: &server.JWTConfig{ + Issuer: issuer, + Audiences: []string{audience}, + KeysLocation: jwksURL, + }, + } + sshServer := server.New(serverConfig) + sshServer.SetAllowRootLogin(true) + + // Configure SSH authorization for the test user + testUsername := testutil.GetTestUsername(t) + testJWTUser := "test-username" + testUserHash, err := sshuserhash.HashUserID(testJWTUser) + require.NoError(t, err) + + authConfig := &sshauth.Config{ + UserIDClaim: sshauth.DefaultUserIDClaim, + AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, + MachineUsers: map[string][]uint32{ + testUsername: {0}, // Index 0 in AuthorizedUsers + }, + } + sshServer.UpdateSSHAuth(authConfig) + + sshServerAddr := server.StartTestServer(t, sshServer) + defer func() { _ = sshServer.Stop() }() + + mockDaemon := startMockDaemon(t) + defer mockDaemon.stop() + + host, portStr, err := net.SplitHostPort(sshServerAddr) + require.NoError(t, err) + port, err := strconv.Atoi(portStr) + require.NoError(t, err) + + mockDaemon.setHostKey(host, hostPubKey) + + validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) + mockDaemon.setJWTToken(validToken) + + proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) + require.NoError(t, err) + + clientConn, proxyConn := net.Pipe() + defer func() { _ = clientConn.Close() }() + + origStdin := os.Stdin + origStdout := os.Stdout + defer func() { + os.Stdin = origStdin + os.Stdout = origStdout + }() + + stdinReader, stdinWriter, err := os.Pipe() + require.NoError(t, err) + stdoutReader, stdoutWriter, err := os.Pipe() + require.NoError(t, err) + + os.Stdin = stdinReader + os.Stdout = stdoutWriter + + go func() { + _, _ = io.Copy(stdinWriter, proxyConn) + }() + go func() { + _, _ = io.Copy(proxyConn, stdoutReader) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + connectErrCh := make(chan error, 1) + go func() { + connectErrCh <- proxyInstance.Connect(ctx) + }() + + sshConfig := &cryptossh.ClientConfig{ + User: testutil.GetTestUsername(t), + Auth: []cryptossh.AuthMethod{}, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 3 * time.Second, + } + + sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) + require.NoError(t, err, "Should connect to proxy server") + defer func() { _ = sshClientConn.Close() }() + + sshClient := cryptossh.NewClient(sshClientConn, chans, reqs) + + session, err := sshClient.NewSession() + require.NoError(t, err, "Should create session through full proxy to backend") + + outputCh := make(chan []byte, 1) + errCh := make(chan error, 1) + go func() { + output, err := session.Output("echo hello-from-proxy") + outputCh <- output + errCh <- err + }() + + select { + case output := <-outputCh: + err := <-errCh + require.NoError(t, err, "Command should execute successfully through proxy") + assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy") + case <-time.After(3 * time.Second): + t.Fatal("Command execution timed out") + } + + _ = session.Close() + _ = sshClient.Close() + _ = clientConn.Close() + cancel() +} + +// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting +// when forwarding commands to the backend. This is critical for tools like +// Ansible that send commands such as: +// +// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0' +// +// The single quotes must be preserved so the backend shell receives the +// subshell expression as a single argument to -c. +func TestSSHProxy_CommandQuoting(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + sshClient, cleanup := setupProxySSHClient(t) + defer cleanup() + + // These commands simulate what the SSH protocol delivers as exec payloads. + // When a user types: ssh host '/bin/sh -c "( echo hello )"' + // the local shell strips the outer single quotes, and the SSH exec request + // contains the raw string: /bin/sh -c "( echo hello )" + // + // The proxy must forward this string verbatim. Using session.Command() + // (shlex.Split + strings.Join) strips the inner double quotes, breaking + // the command on the backend. + tests := []struct { + name string + command string + expect string + }{ + { + name: "subshell_in_double_quotes", + command: `/bin/sh -c "( echo from-subshell ) && echo outer"`, + expect: "from-subshell\nouter\n", + }, + { + name: "printf_with_special_chars", + command: `/bin/sh -c "printf '%s\n' 'hello world'"`, + expect: "hello world\n", + }, + { + name: "nested_command_substitution", + command: `/bin/sh -c "echo $(echo nested)"`, + expect: "nested\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + session, err := sshClient.NewSession() + require.NoError(t, err) + defer func() { _ = session.Close() }() + + var stderrBuf bytes.Buffer + session.Stderr = &stderrBuf + + outputCh := make(chan []byte, 1) + errCh := make(chan error, 1) + go func() { + output, err := session.Output(tc.command) + outputCh <- output + errCh <- err + }() + + select { + case output := <-outputCh: + err := <-errCh + if stderrBuf.Len() > 0 { + t.Logf("stderr: %s", stderrBuf.String()) + } + require.NoError(t, err, "command should succeed: %s", tc.command) + assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command) + case <-time.After(5 * time.Second): + t.Fatalf("command timed out: %s", tc.command) + } + }) + } +} + +// setupProxySSHClient creates a full proxy test environment and returns +// an SSH client connected through the proxy to a backend NetBird SSH server. +func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) { + t.Helper() + + const ( + issuer = "https://test-issuer.example.com" + audience = "test-audience" + ) + + jwksServer, privateKey, jwksURL := setupJWKSServer(t) + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + hostPubKey, err := nbssh.GeneratePublicKey(hostKey) + require.NoError(t, err) + + serverConfig := &server.Config{ + HostKeyPEM: hostKey, + JWT: &server.JWTConfig{ + Issuer: issuer, + Audiences: []string{audience}, + KeysLocation: jwksURL, + }, + } + sshServer := server.New(serverConfig) + sshServer.SetAllowRootLogin(true) + + testUsername := testutil.GetTestUsername(t) + testJWTUser := "test-username" + testUserHash, err := sshuserhash.HashUserID(testJWTUser) + require.NoError(t, err) + + authConfig := &sshauth.Config{ + UserIDClaim: sshauth.DefaultUserIDClaim, + AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, + MachineUsers: map[string][]uint32{ + testUsername: {0}, + }, + } + sshServer.UpdateSSHAuth(authConfig) + + sshServerAddr := server.StartTestServer(t, sshServer) + + mockDaemon := startMockDaemon(t) + + host, portStr, err := net.SplitHostPort(sshServerAddr) + require.NoError(t, err) + port, err := strconv.Atoi(portStr) + require.NoError(t, err) + + mockDaemon.setHostKey(host, hostPubKey) + + validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) + mockDaemon.setJWTToken(validToken) + + proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) + require.NoError(t, err) + + origStdin := os.Stdin + origStdout := os.Stdout + + stdinReader, stdinWriter, err := os.Pipe() + require.NoError(t, err) + stdoutReader, stdoutWriter, err := os.Pipe() + require.NoError(t, err) + + os.Stdin = stdinReader + os.Stdout = stdoutWriter + + clientConn, proxyConn := net.Pipe() + + go func() { _, _ = io.Copy(stdinWriter, proxyConn) }() + go func() { _, _ = io.Copy(proxyConn, stdoutReader) }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + + go func() { + _ = proxyInstance.Connect(ctx) + }() + + sshConfig := &cryptossh.ClientConfig{ + User: testutil.GetTestUsername(t), + Auth: []cryptossh.AuthMethod{}, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) + require.NoError(t, err) + + client := cryptossh.NewClient(sshClientConn, chans, reqs) + + cleanupFn := func() { + _ = client.Close() + _ = clientConn.Close() + cancel() + os.Stdin = origStdin + os.Stdout = origStdout + _ = sshServer.Stop() + mockDaemon.stop() + jwksServer.Close() + } + + return client, cleanupFn +} + +func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) { + t.Helper() + privateKey, jwksJSON := generateTestJWKS(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(jwksJSON); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + + return server, privateKey, server.URL +} + +func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + publicKey := &privateKey.PublicKey + n := publicKey.N.Bytes() + e := publicKey.E + + jwk := nbjwt.JSONWebKey{ + Kty: "RSA", + Kid: "test-key-id", + Use: "sig", + N: base64.RawURLEncoding.EncodeToString(n), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()), + } + + jwks := nbjwt.Jwks{ + Keys: []nbjwt.JSONWebKey{jwk}, + } + + jwksJSON, err := json.Marshal(jwks) + require.NoError(t, err) + + return privateKey, jwksJSON +} + +func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string { + t.Helper() + claims := jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "sub": user, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = "test-key-id" + + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + + return tokenString +} diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go index b33d5f8f4..2795c786b 100644 --- a/client/ssh/proxy/proxy_test.go +++ b/client/ssh/proxy/proxy_test.go @@ -1,25 +1,12 @@ package proxy import ( - "bytes" "context" - "crypto/rand" - "crypto/rsa" - "encoding/base64" - "encoding/json" "fmt" - "io" - "math/big" "net" - "net/http" - "net/http/httptest" "os" - "runtime" - "strconv" "testing" - "time" - "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" cryptossh "golang.org/x/crypto/ssh" @@ -28,11 +15,7 @@ import ( "github.com/netbirdio/netbird/client/proto" nbssh "github.com/netbirdio/netbird/client/ssh" - sshauth "github.com/netbirdio/netbird/client/ssh/auth" - "github.com/netbirdio/netbird/client/ssh/server" "github.com/netbirdio/netbird/client/ssh/testutil" - nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" - sshuserhash "github.com/netbirdio/netbird/shared/sshauth" ) func TestMain(m *testing.M) { @@ -106,331 +89,6 @@ func TestSSHProxy_verifyHostKey(t *testing.T) { }) } -func TestSSHProxy_Connect(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - // TODO: Windows test times out - user switching and command execution tested on Linux - if runtime.GOOS == "windows" { - t.Skip("Skipping on Windows - covered by Linux tests") - } - - const ( - issuer = "https://test-issuer.example.com" - audience = "test-audience" - ) - - jwksServer, privateKey, jwksURL := setupJWKSServer(t) - defer jwksServer.Close() - - hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) - require.NoError(t, err) - hostPubKey, err := nbssh.GeneratePublicKey(hostKey) - require.NoError(t, err) - - serverConfig := &server.Config{ - HostKeyPEM: hostKey, - JWT: &server.JWTConfig{ - Issuer: issuer, - Audiences: []string{audience}, - KeysLocation: jwksURL, - }, - } - sshServer := server.New(serverConfig) - sshServer.SetAllowRootLogin(true) - - // Configure SSH authorization for the test user - testUsername := testutil.GetTestUsername(t) - testJWTUser := "test-username" - testUserHash, err := sshuserhash.HashUserID(testJWTUser) - require.NoError(t, err) - - authConfig := &sshauth.Config{ - UserIDClaim: sshauth.DefaultUserIDClaim, - AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, - MachineUsers: map[string][]uint32{ - testUsername: {0}, // Index 0 in AuthorizedUsers - }, - } - sshServer.UpdateSSHAuth(authConfig) - - sshServerAddr := server.StartTestServer(t, sshServer) - defer func() { _ = sshServer.Stop() }() - - mockDaemon := startMockDaemon(t) - defer mockDaemon.stop() - - host, portStr, err := net.SplitHostPort(sshServerAddr) - require.NoError(t, err) - port, err := strconv.Atoi(portStr) - require.NoError(t, err) - - mockDaemon.setHostKey(host, hostPubKey) - - validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) - mockDaemon.setJWTToken(validToken) - - proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) - require.NoError(t, err) - - clientConn, proxyConn := net.Pipe() - defer func() { _ = clientConn.Close() }() - - origStdin := os.Stdin - origStdout := os.Stdout - defer func() { - os.Stdin = origStdin - os.Stdout = origStdout - }() - - stdinReader, stdinWriter, err := os.Pipe() - require.NoError(t, err) - stdoutReader, stdoutWriter, err := os.Pipe() - require.NoError(t, err) - - os.Stdin = stdinReader - os.Stdout = stdoutWriter - - go func() { - _, _ = io.Copy(stdinWriter, proxyConn) - }() - go func() { - _, _ = io.Copy(proxyConn, stdoutReader) - }() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - connectErrCh := make(chan error, 1) - go func() { - connectErrCh <- proxyInstance.Connect(ctx) - }() - - sshConfig := &cryptossh.ClientConfig{ - User: testutil.GetTestUsername(t), - Auth: []cryptossh.AuthMethod{}, - HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), - Timeout: 3 * time.Second, - } - - sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) - require.NoError(t, err, "Should connect to proxy server") - defer func() { _ = sshClientConn.Close() }() - - sshClient := cryptossh.NewClient(sshClientConn, chans, reqs) - - session, err := sshClient.NewSession() - require.NoError(t, err, "Should create session through full proxy to backend") - - outputCh := make(chan []byte, 1) - errCh := make(chan error, 1) - go func() { - output, err := session.Output("echo hello-from-proxy") - outputCh <- output - errCh <- err - }() - - select { - case output := <-outputCh: - err := <-errCh - require.NoError(t, err, "Command should execute successfully through proxy") - assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy") - case <-time.After(3 * time.Second): - t.Fatal("Command execution timed out") - } - - _ = session.Close() - _ = sshClient.Close() - _ = clientConn.Close() - cancel() -} - -// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting -// when forwarding commands to the backend. This is critical for tools like -// Ansible that send commands such as: -// -// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0' -// -// The single quotes must be preserved so the backend shell receives the -// subshell expression as a single argument to -c. -func TestSSHProxy_CommandQuoting(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - sshClient, cleanup := setupProxySSHClient(t) - defer cleanup() - - // These commands simulate what the SSH protocol delivers as exec payloads. - // When a user types: ssh host '/bin/sh -c "( echo hello )"' - // the local shell strips the outer single quotes, and the SSH exec request - // contains the raw string: /bin/sh -c "( echo hello )" - // - // The proxy must forward this string verbatim. Using session.Command() - // (shlex.Split + strings.Join) strips the inner double quotes, breaking - // the command on the backend. - tests := []struct { - name string - command string - expect string - }{ - { - name: "subshell_in_double_quotes", - command: `/bin/sh -c "( echo from-subshell ) && echo outer"`, - expect: "from-subshell\nouter\n", - }, - { - name: "printf_with_special_chars", - command: `/bin/sh -c "printf '%s\n' 'hello world'"`, - expect: "hello world\n", - }, - { - name: "nested_command_substitution", - command: `/bin/sh -c "echo $(echo nested)"`, - expect: "nested\n", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - session, err := sshClient.NewSession() - require.NoError(t, err) - defer func() { _ = session.Close() }() - - var stderrBuf bytes.Buffer - session.Stderr = &stderrBuf - - outputCh := make(chan []byte, 1) - errCh := make(chan error, 1) - go func() { - output, err := session.Output(tc.command) - outputCh <- output - errCh <- err - }() - - select { - case output := <-outputCh: - err := <-errCh - if stderrBuf.Len() > 0 { - t.Logf("stderr: %s", stderrBuf.String()) - } - require.NoError(t, err, "command should succeed: %s", tc.command) - assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command) - case <-time.After(5 * time.Second): - t.Fatalf("command timed out: %s", tc.command) - } - }) - } -} - -// setupProxySSHClient creates a full proxy test environment and returns -// an SSH client connected through the proxy to a backend NetBird SSH server. -func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) { - t.Helper() - - const ( - issuer = "https://test-issuer.example.com" - audience = "test-audience" - ) - - jwksServer, privateKey, jwksURL := setupJWKSServer(t) - - hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) - require.NoError(t, err) - hostPubKey, err := nbssh.GeneratePublicKey(hostKey) - require.NoError(t, err) - - serverConfig := &server.Config{ - HostKeyPEM: hostKey, - JWT: &server.JWTConfig{ - Issuer: issuer, - Audiences: []string{audience}, - KeysLocation: jwksURL, - }, - } - sshServer := server.New(serverConfig) - sshServer.SetAllowRootLogin(true) - - testUsername := testutil.GetTestUsername(t) - testJWTUser := "test-username" - testUserHash, err := sshuserhash.HashUserID(testJWTUser) - require.NoError(t, err) - - authConfig := &sshauth.Config{ - UserIDClaim: sshauth.DefaultUserIDClaim, - AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, - MachineUsers: map[string][]uint32{ - testUsername: {0}, - }, - } - sshServer.UpdateSSHAuth(authConfig) - - sshServerAddr := server.StartTestServer(t, sshServer) - - mockDaemon := startMockDaemon(t) - - host, portStr, err := net.SplitHostPort(sshServerAddr) - require.NoError(t, err) - port, err := strconv.Atoi(portStr) - require.NoError(t, err) - - mockDaemon.setHostKey(host, hostPubKey) - - validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) - mockDaemon.setJWTToken(validToken) - - proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) - require.NoError(t, err) - - origStdin := os.Stdin - origStdout := os.Stdout - - stdinReader, stdinWriter, err := os.Pipe() - require.NoError(t, err) - stdoutReader, stdoutWriter, err := os.Pipe() - require.NoError(t, err) - - os.Stdin = stdinReader - os.Stdout = stdoutWriter - - clientConn, proxyConn := net.Pipe() - - go func() { _, _ = io.Copy(stdinWriter, proxyConn) }() - go func() { _, _ = io.Copy(proxyConn, stdoutReader) }() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - - go func() { - _ = proxyInstance.Connect(ctx) - }() - - sshConfig := &cryptossh.ClientConfig{ - User: testutil.GetTestUsername(t), - Auth: []cryptossh.AuthMethod{}, - HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), - Timeout: 5 * time.Second, - } - - sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) - require.NoError(t, err) - - client := cryptossh.NewClient(sshClientConn, chans, reqs) - - cleanupFn := func() { - _ = client.Close() - _ = clientConn.Close() - cancel() - os.Stdin = origStdin - os.Stdout = origStdout - _ = sshServer.Stop() - mockDaemon.stop() - jwksServer.Close() - } - - return client, cleanupFn -} - type mockDaemonServer struct { proto.UnimplementedDaemonServiceServer hostKeys map[string][]byte @@ -492,10 +150,6 @@ func (m *mockDaemon) setHostKey(addr string, pubKey []byte) { m.impl.hostKeys[addr] = pubKey } -func (m *mockDaemon) setJWTToken(token string) { - m.impl.jwtToken = token -} - func (m *mockDaemon) stop() { if m.server != nil { m.server.Stop() @@ -508,63 +162,3 @@ func mustParsePublicKey(t *testing.T, pubKeyBytes []byte) cryptossh.PublicKey { require.NoError(t, err) return pubKey } - -func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) { - t.Helper() - privateKey, jwksJSON := generateTestJWKS(t) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if _, err := w.Write(jwksJSON); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - })) - - return server, privateKey, server.URL -} - -func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) { - t.Helper() - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - publicKey := &privateKey.PublicKey - n := publicKey.N.Bytes() - e := publicKey.E - - jwk := nbjwt.JSONWebKey{ - Kty: "RSA", - Kid: "test-key-id", - Use: "sig", - N: base64.RawURLEncoding.EncodeToString(n), - E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()), - } - - jwks := nbjwt.Jwks{ - Keys: []nbjwt.JSONWebKey{jwk}, - } - - jwksJSON, err := json.Marshal(jwks) - require.NoError(t, err) - - return privateKey, jwksJSON -} - -func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string { - t.Helper() - claims := jwt.MapClaims{ - "iss": issuer, - "aud": audience, - "sub": user, - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - token.Header["kid"] = "test-key-id" - - tokenString, err := token.SignedString(privateKey) - require.NoError(t, err) - - return tokenString -} diff --git a/client/ssh/server/executor_unix_privileged_test.go b/client/ssh/server/executor_unix_privileged_test.go new file mode 100644 index 000000000..f1b0805d9 --- /dev/null +++ b/client/ssh/server/executor_unix_privileged_test.go @@ -0,0 +1,66 @@ +//go:build unix && privileged + +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) { + pd := NewPrivilegeDropper() + + config := ExecutorConfig{ + UID: 1000, + GID: 1000, + Groups: []uint32{1000, 1001}, + WorkingDir: "/home/testuser", + Shell: "/bin/bash", + Command: "ls -la", + } + + cmd, err := pd.CreateExecutorCommand(context.Background(), config) + require.NoError(t, err) + require.NotNil(t, cmd) + + // Verify the command is calling netbird ssh exec + assert.Contains(t, cmd.Args, "ssh") + assert.Contains(t, cmd.Args, "exec") + assert.Contains(t, cmd.Args, "--uid") + assert.Contains(t, cmd.Args, "1000") + assert.Contains(t, cmd.Args, "--gid") + assert.Contains(t, cmd.Args, "1000") + assert.Contains(t, cmd.Args, "--groups") + assert.Contains(t, cmd.Args, "1000") + assert.Contains(t, cmd.Args, "1001") + assert.Contains(t, cmd.Args, "--working-dir") + assert.Contains(t, cmd.Args, "/home/testuser") + assert.Contains(t, cmd.Args, "--shell") + assert.Contains(t, cmd.Args, "/bin/bash") + assert.Contains(t, cmd.Args, "--cmd") + assert.Contains(t, cmd.Args, "ls -la") +} + +func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) { + pd := NewPrivilegeDropper() + + config := ExecutorConfig{ + UID: 1000, + GID: 1000, + Groups: []uint32{1000}, + WorkingDir: "/home/testuser", + Shell: "/bin/bash", + Command: "", + } + + cmd, err := pd.CreateExecutorCommand(context.Background(), config) + require.NoError(t, err) + require.NotNil(t, cmd) + + // Verify no command mode (command is empty so no --cmd flag) + assert.NotContains(t, cmd.Args, "--cmd") + assert.NotContains(t, cmd.Args, "--interactive") +} diff --git a/client/ssh/server/executor_unix_test.go b/client/ssh/server/executor_unix_test.go index 0c5108f57..171e78b83 100644 --- a/client/ssh/server/executor_unix_test.go +++ b/client/ssh/server/executor_unix_test.go @@ -73,61 +73,6 @@ func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) { } } -func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) { - pd := NewPrivilegeDropper() - - config := ExecutorConfig{ - UID: 1000, - GID: 1000, - Groups: []uint32{1000, 1001}, - WorkingDir: "/home/testuser", - Shell: "/bin/bash", - Command: "ls -la", - } - - cmd, err := pd.CreateExecutorCommand(context.Background(), config) - require.NoError(t, err) - require.NotNil(t, cmd) - - // Verify the command is calling netbird ssh exec - assert.Contains(t, cmd.Args, "ssh") - assert.Contains(t, cmd.Args, "exec") - assert.Contains(t, cmd.Args, "--uid") - assert.Contains(t, cmd.Args, "1000") - assert.Contains(t, cmd.Args, "--gid") - assert.Contains(t, cmd.Args, "1000") - assert.Contains(t, cmd.Args, "--groups") - assert.Contains(t, cmd.Args, "1000") - assert.Contains(t, cmd.Args, "1001") - assert.Contains(t, cmd.Args, "--working-dir") - assert.Contains(t, cmd.Args, "/home/testuser") - assert.Contains(t, cmd.Args, "--shell") - assert.Contains(t, cmd.Args, "/bin/bash") - assert.Contains(t, cmd.Args, "--cmd") - assert.Contains(t, cmd.Args, "ls -la") -} - -func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) { - pd := NewPrivilegeDropper() - - config := ExecutorConfig{ - UID: 1000, - GID: 1000, - Groups: []uint32{1000}, - WorkingDir: "/home/testuser", - Shell: "/bin/bash", - Command: "", - } - - cmd, err := pd.CreateExecutorCommand(context.Background(), config) - require.NoError(t, err) - require.NotNil(t, cmd) - - // Verify no command mode (command is empty so no --cmd flag) - assert.NotContains(t, cmd.Args, "--cmd") - assert.NotContains(t, cmd.Args, "--interactive") -} - // TestPrivilegeDropper_ActualPrivilegeDrop tests actual privilege dropping // This test requires root privileges and will be skipped if not running as root func TestPrivilegeDropper_ActualPrivilegeDrop(t *testing.T) { diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go new file mode 100644 index 000000000..d1945894d --- /dev/null +++ b/client/testutil/privileged/runner_test.go @@ -0,0 +1,196 @@ +//go:build privileged && (linux || darwin) + +// Package privileged provides a self-hosting harness that runs the repo's +// privileged-tagged test suite inside a --privileged --cap-add=NET_ADMIN +// container, so developers can exercise the root/system-mutating tests on a +// non-root host with a single `go test` invocation. +package privileged + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/moby/moby/api/types/container" + "github.com/ory/dockertest/v4" +) + +// containerImage / containerTag match the image used by the CI privileged job +// (.github/workflows/golang-test-linux.yml, test_client_on_docker). +const ( + containerImage = "golang" + containerTag = "1.25-alpine" +) + +const ( + containerWorkdir = "/app" + containerGoCache = "/root/.cache/go-build" + containerGoModCache = "/go/pkg/mod" +) + +// alpinePackages are the build/runtime deps the privileged tests need, mirroring +// the CI container setup. +const alpinePackages = "ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base" + +// privilegedTestPackages is the package list the suite runs, excluding the +// server-side trees and UI/upload helpers, matching the CI Docker job's filter. +const privilegedTestPackages = `go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server` + +// testWriter forwards container output to the test log line by line. +type testWriter struct{ t *testing.T } + +func (w testWriter) Write(p []byte) (int, error) { + for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") { + w.t.Log(line) + } + return len(p), nil +} + +// TestRunPrivilegedSuiteInDocker spins up a privileged container, mounts the repo, +// and runs `go test -tags 'devcert privileged'` inside it. When already running +// inside that container (DOCKER_CI=true) it returns immediately so the real +// privileged tests in the suite execute in place instead of recursing. +func TestRunPrivilegedSuiteInDocker(t *testing.T) { + if os.Getenv("DOCKER_CI") == "true" { + t.Skip("inside privileged container, skipping container spawn; privileged tests run in place") + } + + repoRoot, err := findRepoRoot() + if err != nil { + t.Fatalf("locate repo root: %v", err) + } + goCache, goModCache := hostGoCaches(t) + + // dockertest reads DOCKER_HOST; point it at the active context's socket when + // the default one is absent (macOS Docker Desktop, Colima, OrbStack). + if host := dockerHost(); host != "" { + t.Setenv("DOCKER_HOST", host) + } + + // NewPoolT registers container cleanup via t.Cleanup automatically. + pool := dockertest.NewPoolT(t, "", dockertest.WithMaxWait(30*time.Minute)) + + // Keep the container alive so the suite runs via Exec, which yields a clean + // exit code (the v4 Resource API exposes no container wait/exit-code). + resource := pool.RunT(t, containerImage, + dockertest.WithTag(containerTag), + dockertest.WithWorkingDir(containerWorkdir), + dockertest.WithMounts([]string{ + repoRoot + ":" + containerWorkdir, + goCache + ":" + containerGoCache, + goModCache + ":" + containerGoModCache, + }), + dockertest.WithEnv([]string{ + "CGO_ENABLED=1", + "CI=true", + "DOCKER_CI=true", + "CONTAINER=true", + "GOCACHE=" + containerGoCache, + "GOMODCACHE=" + containerGoModCache, + }), + dockertest.WithCmd([]string{"sleep", "infinity"}), + dockertest.WithHostConfig(func(hc *container.HostConfig) { + hc.Privileged = true + hc.CapAdd = []string{"NET_ADMIN"} + }), + dockertest.WithoutReuse(), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + result, err := resource.Exec(ctx, []string{"sh", "-c", buildTestScript()}) + if err != nil { + t.Fatalf("run privileged suite in container: %v", err) + } + + w := testWriter{t} + _, _ = w.Write([]byte(result.StdOut)) + _, _ = w.Write([]byte(result.StdErr)) + + if result.ExitCode != 0 { + t.Fatalf("privileged test suite failed in container (exit code %d)", result.ExitCode) + } +} + +// findRepoRoot walks up from the test's working directory to the module root. +func findRepoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found above %s", dir) + } + dir = parent + } +} + +// dockerHost returns a DOCKER_HOST override when the default socket is missing. +// An empty result means the caller should leave DOCKER_HOST untouched (it is +// already set, or the default unix socket exists). When neither is present +// (common on macOS Docker Desktop, Colima and OrbStack, which use a per-user +// socket), it resolves the active docker context's endpoint. +func dockerHost() string { + if os.Getenv("DOCKER_HOST") != "" { + return "" + } + if _, err := os.Stat("/var/run/docker.sock"); err == nil { + return "" + } + + out, err := exec.Command("docker", "context", "inspect", "-f", "{{.Endpoints.docker.Host}}").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// hostGoCaches resolves the host GOCACHE/GOMODCACHE so the container reuses the +// existing build/module cache for speed. +func hostGoCaches(t *testing.T) (string, string) { + t.Helper() + return goEnv(t, "GOCACHE"), goEnv(t, "GOMODCACHE") +} + +func goEnv(t *testing.T, key string) string { + t.Helper() + var out bytes.Buffer + cmd := exec.Command("go", "env", key) + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + t.Fatalf("go env %s: %v", key, err) + } + return strings.TrimSpace(out.String()) +} + +// buildTestScript builds the in-container command. PRIV_PKGS overrides the package +// list (default: the full filtered set); PRIV_RUN adds a -run test-name filter. +// Both empty reproduces the full privileged suite. +func buildTestScript() string { + pkgs := privilegedTestPackages + " | xargs" + if p := os.Getenv("PRIV_PKGS"); p != "" { + pkgs = "echo " + p + " | xargs" + } + + runFilter := "" + if r := os.Getenv("PRIV_RUN"); r != "" { + runFilter = "-run '" + r + "' " + } + + return fmt.Sprintf( + "apk update >/dev/null && apk add --no-cache %s >/dev/null && %s go test -buildvcs=false -tags 'devcert privileged' %s-v -timeout 20m -p 1", + alpinePackages, pkgs, runFilter, + ) +} diff --git a/docs/testing-privileged.md b/docs/testing-privileged.md new file mode 100644 index 000000000..cf2f23171 --- /dev/null +++ b/docs/testing-privileged.md @@ -0,0 +1,78 @@ +# Privileged tests + +Some tests in this repo need `root` or mutate host network state: they create +TUN/WireGuard interfaces, open netlink/raw sockets, run eBPF programs, or shell +out to `ip`/`iptables`/`nft`/`ifconfig`/`route`. Running them on a developer +machine would require `sudo` and could leave stray interfaces or routes behind. + +These tests are gated behind the **`privileged` build tag** so the default test +run is host-safe. + +## Running tests + +```bash +# Host-safe: excludes privileged tests. Runs as a normal user, no sudo. +make test-unit +# equivalently: +go test -tags devcert ./... + +# Privileged suite: runs the privileged-tagged tests inside a +# --privileged --cap-add=NET_ADMIN container (requires Docker). +make test-privileged + +# Narrow the container run to a single test / package: +PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged +``` + +`PRIV_RUN` adds a `-run` test-name filter and `PRIV_PKGS` overrides the package +list; both are optional and default to the full privileged suite. + +`make test-privileged` invokes the `ory/dockertest` harness in +`client/testutil/privileged/`. The harness: + +1. Skips immediately when it detects it is already inside the container + (`DOCKER_CI=true`), so the privileged tests run in place instead of recursing. +2. Otherwise spins up a `golang:1.25-alpine` container (matching CI), + bind-mounts the repo and the host Go build/module caches, installs the + required packages, and runs `go test -tags 'devcert privileged'` over the + client packages. +3. Streams the container's output to the test log and fails if the suite fails. + +## Adding a privileged test + +A test is privileged if it does any of: + +- creates a real interface via `iface.NewWGIFace(...).Create()`, +- opens a netlink or raw socket that hard-fails without `CAP_NET_ADMIN`, +- runs an eBPF program (`ebpf.*.Listen()`), +- shells out to `ip`, `iptables`, `nft`, `ifconfig`, or `route` to change state. + +Add the tag to the **top** of the file, combined with any existing platform +constraint: + +```go +//go:build privileged && linux + +package foo +``` + +If a file mixes privileged and pure-logic tests, **split it**: keep the pure +tests (and any shared data — type/var declarations, table-driven `testCases`, +helper interfaces) in an untagged file, and move the privileged tests into a +`*_privileged_test.go` file with the tag. Shared declarations must stay untagged, +otherwise the unprivileged files in the package will not compile. + +Always verify both build modes compile on every target platform: + +```bash +go vet -tags devcert ./... +go vet -tags 'devcert privileged' ./... +``` + +## CI + +- The `Client / Unit` job runs `go test -tags devcert` with **no** `sudo` — only + host-safe tests. +- The `Client (Docker) / Unit` job runs `go test -tags 'devcert privileged'` + inside a `--privileged --cap-add=NET_ADMIN` container, which is where the + privileged tests actually execute. diff --git a/go.mod b/go.mod index fa5b431bf..9a57de1c9 100644 --- a/go.mod +++ b/go.mod @@ -78,10 +78,12 @@ require ( github.com/mdp/qrterminal/v3 v3.2.1 github.com/miekg/dns v1.1.72 github.com/mitchellh/hashstructure/v2 v2.0.2 + github.com/moby/moby/api v1.54.1 github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 github.com/oapi-codegen/runtime v1.1.2 github.com/okta/okta-sdk-golang/v2 v2.18.0 + github.com/ory/dockertest/v4 v4.0.0 github.com/oschwald/maxminddb-golang v1.12.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 @@ -145,7 +147,7 @@ require ( dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.1 // indirect github.com/AppsFlyer/go-sundheit v0.6.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect @@ -177,6 +179,8 @@ require ( github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect @@ -271,11 +275,12 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.5.0 // indirect github.com/moby/sys/user v0.3.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect - github.com/moby/term v0.5.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect diff --git a/go.sum b/go.sum index 3e1d7c97a..7b29b7604 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9 github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk= github.com/AppsFlyer/go-sundheit v0.6.0/go.mod h1:LDdBHD6tQBtmHsdW+i1GwdTt6Wqc0qazf5ZEJVTbTME= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= @@ -117,6 +117,10 @@ github.com/cilium/ebpf v0.19.0 h1:Ro/rE64RmFBeA9FGjcTc+KmCeY6jXmryu6FfnzPRIao= github.com/cilium/ebpf v0.19.0/go.mod h1:fLCgMo3l8tZmAdM3B2XqdFzXBpwkcSTroaVqN08OWVY= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -480,6 +484,10 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= @@ -488,8 +496,8 @@ github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= @@ -542,6 +550,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= +github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs= github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -973,11 +983,13 @@ gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDa gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/sharedsock/sock_linux_test.go b/sharedsock/sock_linux_test.go index a22af461a..0ed15e282 100644 --- a/sharedsock/sock_linux_test.go +++ b/sharedsock/sock_linux_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package sharedsock import ( From 4400372f37ac9cf1ecb70cd3d31f7aa0bc445ad2 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:50:17 +0900 Subject: [PATCH 25/26] [client] Forward non-address DNS record types through route forwarders (#6455) --- client/internal/dns/resutil/resolve.go | 230 ++++++++++++++- client/internal/dns/resutil/resolve_test.go | 159 +++++++++++ client/internal/dnsfwd/forwarder.go | 70 ++++- client/internal/dnsfwd/forwarder_test.go | 267 ++++++++++++++++-- .../routemanager/dnsinterceptor/handler.go | 24 +- 5 files changed, 703 insertions(+), 47 deletions(-) diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go index a2599aee7..931938755 100644 --- a/client/internal/dns/resutil/resolve.go +++ b/client/internal/dns/resutil/resolve.go @@ -8,6 +8,7 @@ import ( "errors" "net" "net/netip" + "slices" "strings" "github.com/miekg/dns" @@ -167,7 +168,10 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina case dns.TypeA: alternativeNetwork = "ip6" default: - return dns.RcodeNameError + // Non-address types reach LookupIP only unexpectedly; without an + // address pair to probe we cannot prove the name is absent, so answer + // NODATA rather than a poisoning NXDOMAIN. + return dns.RcodeSuccess } if _, err := r.LookupNetIP(ctx, alternativeNetwork, domain); err != nil { @@ -184,6 +188,230 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina return dns.RcodeSuccess } +// RecordResolver is the host resolver surface used to forward non-address +// record queries. net.DefaultResolver satisfies it. +type RecordResolver interface { + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + LookupTXT(ctx context.Context, name string) ([]string, error) + LookupNS(ctx context.Context, name string) ([]*net.NS, error) + LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) + LookupCNAME(ctx context.Context, host string) (string, error) + LookupAddr(ctx context.Context, addr string) ([]string, error) +} + +// LookupRecords resolves a non-address DNS record type through the host +// resolver and returns the resource records and the DNS rcode. Types the host +// resolver cannot answer (anything not covered by the net.Resolver Lookup* +// methods) yield NODATA so that a routed name is never poisoned with NXDOMAIN +// for an unsupported type. +func LookupRecords(ctx context.Context, r RecordResolver, name string, qtype uint16, ttl uint32) ([]dns.RR, int) { + fqdn := dns.Fqdn(name) + + switch qtype { + case dns.TypeMX: + return lookupMX(ctx, r, name, fqdn, ttl) + case dns.TypeTXT: + return lookupTXT(ctx, r, name, fqdn, ttl) + case dns.TypeNS: + return lookupNS(ctx, r, name, fqdn, ttl) + case dns.TypeSRV: + return lookupSRV(ctx, r, name, fqdn, ttl) + case dns.TypeCNAME: + return lookupCNAME(ctx, r, name, fqdn, ttl) + case dns.TypePTR: + return lookupPTR(ctx, r, name, fqdn, ttl) + default: + return nil, dns.RcodeSuccess + } +} + +func recordHeader(fqdn string, rrtype uint16, ttl uint32) dns.RR_Header { + return dns.RR_Header{Name: fqdn, Rrtype: rrtype, Class: dns.ClassINET, Ttl: ttl} +} + +func lookupMX(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupMX(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, mx := range recs { + rrs = append(rrs, &dns.MX{ + Hdr: recordHeader(fqdn, dns.TypeMX, ttl), + Preference: mx.Pref, + Mx: dns.Fqdn(mx.Host), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupTXT(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupTXT(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, txt := range recs { + rrs = append(rrs, &dns.TXT{ + Hdr: recordHeader(fqdn, dns.TypeTXT, ttl), + Txt: chunkTXT(txt), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupNS(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupNS(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, ns := range recs { + rrs = append(rrs, &dns.NS{ + Hdr: recordHeader(fqdn, dns.TypeNS, ttl), + Ns: dns.Fqdn(ns.Host), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupSRV(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + _, recs, err := r.LookupSRV(ctx, "", "", name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, srv := range recs { + rrs = append(rrs, &dns.SRV{ + Hdr: recordHeader(fqdn, dns.TypeSRV, ttl), + Priority: srv.Priority, + Weight: srv.Weight, + Port: srv.Port, + Target: dns.Fqdn(srv.Target), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupCNAME(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + cname, err := r.LookupCNAME(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + // LookupCNAME returns the queried name itself when the name resolves but + // has no CNAME record; that is a NODATA result, not a CNAME. + if strings.EqualFold(dns.Fqdn(cname), fqdn) { + return nil, dns.RcodeSuccess + } + return []dns.RR{&dns.CNAME{ + Hdr: recordHeader(fqdn, dns.TypeCNAME, ttl), + Target: dns.Fqdn(cname), + }}, dns.RcodeSuccess +} + +func lookupPTR(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + addr, ok := ptrQueryAddr(name) + if !ok { + return nil, dns.RcodeSuccess + } + names, err := r.LookupAddr(ctx, addr) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(names)) + for _, n := range names { + rrs = append(rrs, &dns.PTR{ + Hdr: recordHeader(fqdn, dns.TypePTR, ttl), + Ptr: dns.Fqdn(n), + }) + } + return rrs, dns.RcodeSuccess +} + +// ptrQueryAddr converts a reverse-DNS query name (in-addr.arpa or ip6.arpa) +// into the address string expected by net.Resolver.LookupAddr. It reports false +// when the name is not a well-formed reverse name. +func ptrQueryAddr(qname string) (string, bool) { + name := strings.TrimSuffix(strings.ToLower(dns.Fqdn(qname)), ".") + + switch { + case strings.HasSuffix(name, ".in-addr.arpa"): + return parseInAddrArpa(strings.TrimSuffix(name, ".in-addr.arpa")) + case strings.HasSuffix(name, ".ip6.arpa"): + return parseIP6Arpa(strings.TrimSuffix(name, ".ip6.arpa")) + default: + return "", false + } +} + +// parseInAddrArpa turns the label portion of an in-addr.arpa name into an IPv4 +// address string, reporting false when it is not a well-formed reverse name. +func parseInAddrArpa(labelPart string) (string, bool) { + labels := strings.Split(labelPart, ".") + if len(labels) != 4 { + return "", false + } + slices.Reverse(labels) + addr, err := netip.ParseAddr(strings.Join(labels, ".")) + if err != nil || !addr.Is4() { + return "", false + } + return addr.String(), true +} + +// parseIP6Arpa turns the nibble portion of an ip6.arpa name into an IPv6 +// address string, reporting false when it is not a well-formed reverse name. +func parseIP6Arpa(nibblePart string) (string, bool) { + nibbles := strings.Split(nibblePart, ".") + if len(nibbles) != 32 { + return "", false + } + slices.Reverse(nibbles) + var sb strings.Builder + for i, n := range nibbles { + if i > 0 && i%4 == 0 { + sb.WriteByte(':') + } + sb.WriteString(n) + } + addr, err := netip.ParseAddr(sb.String()) + if err != nil || !addr.Is6() { + return "", false + } + return addr.String(), true +} + +// rcodeForRecordError maps a non-address lookup error to a DNS rcode. A +// not-found result becomes NODATA rather than NXDOMAIN: net.DNSError.IsNotFound +// does not distinguish a missing name from a name that exists only with records +// of other types, so the name cannot be proven absent and must not be poisoned. +func rcodeForRecordError(err error) int { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return dns.RcodeSuccess + } + return dns.RcodeServerFailure +} + +// chunkTXT splits a TXT string into character-strings no longer than 255 bytes +// so the record can be packed. The chunks form one TXT resource record. +func chunkTXT(s string) []string { + const maxLen = 255 + if len(s) <= maxLen { + return []string{s} + } + + var chunks []string + for len(s) > maxLen { + chunks = append(chunks, s[:maxLen]) + s = s[maxLen:] + } + if len(s) > 0 { + chunks = append(chunks, s) + } + return chunks +} + // FormatAnswers formats DNS resource records for logging. func FormatAnswers(answers []dns.RR) string { if len(answers) == 0 { diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go index e6a8cc6a5..f51092a83 100644 --- a/client/internal/dns/resutil/resolve_test.go +++ b/client/internal/dns/resutil/resolve_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "net/netip" + "strings" "testing" "github.com/miekg/dns" @@ -121,6 +122,164 @@ func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) { assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL") } +func TestPtrQueryAddr(t *testing.T) { + tests := []struct { + name string + qname string + want string + wantOK bool + }{ + {name: "ipv4", qname: "4.3.2.1.in-addr.arpa.", want: "1.2.3.4", wantOK: true}, + {name: "ipv4 no trailing dot", qname: "1.0.0.127.in-addr.arpa", want: "127.0.0.1", wantOK: true}, + { + name: "ipv6", + qname: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", + want: "2001:db8::1", + wantOK: true, + }, + {name: "ipv4 wrong label count", qname: "2.1.in-addr.arpa.", wantOK: false}, + {name: "ipv6 wrong nibble count", qname: "1.0.ip6.arpa.", wantOK: false}, + {name: "not a reverse name", qname: "example.com.", wantOK: false}, + {name: "ipv4 bad octet", qname: "4.3.2.999.in-addr.arpa.", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ptrQueryAddr(tt.qname) + assert.Equal(t, tt.wantOK, ok, "parse success mismatch") + if tt.wantOK { + assert.Equal(t, tt.want, got, "parsed address mismatch") + } + }) + } +} + +type mockRecordResolver struct { + mx []*net.MX + txt []string + ns []*net.NS + srv []*net.SRV + cname string + ptr []string + err error +} + +func (m *mockRecordResolver) LookupMX(context.Context, string) ([]*net.MX, error) { + return m.mx, m.err +} +func (m *mockRecordResolver) LookupTXT(context.Context, string) ([]string, error) { + return m.txt, m.err +} +func (m *mockRecordResolver) LookupNS(context.Context, string) ([]*net.NS, error) { + return m.ns, m.err +} +func (m *mockRecordResolver) LookupSRV(context.Context, string, string, string) (string, []*net.SRV, error) { + return "", m.srv, m.err +} +func (m *mockRecordResolver) LookupCNAME(context.Context, string) (string, error) { + return m.cname, m.err +} +func (m *mockRecordResolver) LookupAddr(context.Context, string) ([]string, error) { + return m.ptr, m.err +} + +func TestLookupRecords(t *testing.T) { + notFound := &net.DNSError{IsNotFound: true, Name: "example.com."} + + t.Run("MX success", func(t *testing.T) { + r := &mockRecordResolver{mx: []*net.MX{{Host: "mail.example.com.", Pref: 10}}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "mail.example.com.", rrs[0].(*dns.MX).Mx) + }) + + t.Run("TXT short string is one character-string", func(t *testing.T) { + r := &mockRecordResolver{txt: []string{"v=spf1 -all"}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, []string{"v=spf1 -all"}, rrs[0].(*dns.TXT).Txt) + }) + + t.Run("TXT chunks long strings", func(t *testing.T) { + long := strings.Repeat("a", 300) + r := &mockRecordResolver{txt: []string{long}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + txt := rrs[0].(*dns.TXT).Txt + require.Len(t, txt, 2, "300-byte string should split into two character-strings") + assert.Equal(t, 255, len(txt[0])) + assert.Equal(t, 45, len(txt[1])) + }) + + t.Run("NS success", func(t *testing.T) { + r := &mockRecordResolver{ns: []*net.NS{{Host: "ns1.example.com."}}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeNS, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "ns1.example.com.", rrs[0].(*dns.NS).Ns) + }) + + t.Run("SRV success", func(t *testing.T) { + r := &mockRecordResolver{srv: []*net.SRV{{Target: "sip.example.com.", Port: 5060}}} + rrs, rcode := LookupRecords(context.Background(), r, "_sip._tcp.example.com.", dns.TypeSRV, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, uint16(5060), rrs[0].(*dns.SRV).Port) + }) + + t.Run("CNAME success", func(t *testing.T) { + r := &mockRecordResolver{cname: "target.example.com."} + rrs, rcode := LookupRecords(context.Background(), r, "www.example.com.", dns.TypeCNAME, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "target.example.com.", rrs[0].(*dns.CNAME).Target) + }) + + t.Run("CNAME equal to name is NODATA", func(t *testing.T) { + r := &mockRecordResolver{cname: "example.com."} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCNAME, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs, "self-referential CNAME is NODATA") + }) + + t.Run("PTR success", func(t *testing.T) { + r := &mockRecordResolver{ptr: []string{"host.example.com."}} + rrs, rcode := LookupRecords(context.Background(), r, "4.3.2.1.in-addr.arpa.", dns.TypePTR, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "host.example.com.", rrs[0].(*dns.PTR).Ptr) + }) + + t.Run("PTR malformed name is NODATA", func(t *testing.T) { + r := &mockRecordResolver{} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypePTR, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs) + }) + + t.Run("not found is NODATA never NXDOMAIN", func(t *testing.T) { + r := &mockRecordResolver{err: notFound} + _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeSuccess, rcode, "missing record must not poison the name") + }) + + t.Run("server failure maps to SERVFAIL", func(t *testing.T) { + r := &mockRecordResolver{err: &net.DNSError{Err: "server misbehaving", IsTemporary: true}} + _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeServerFailure, rcode) + }) + + t.Run("unsupported type is NODATA", func(t *testing.T) { + r := &mockRecordResolver{} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCAA, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs) + }) +} + func TestStripOPT(t *testing.T) { rm := &dns.Msg{ Extra: []dns.RR{ diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go index c15a8520f..b7e5a10e3 100644 --- a/client/internal/dnsfwd/forwarder.go +++ b/client/internal/dnsfwd/forwarder.go @@ -37,6 +37,12 @@ const ( type resolver interface { LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + LookupTXT(ctx context.Context, name string) ([]string, error) + LookupNS(ctx context.Context, name string) ([]*net.NS, error) + LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) + LookupCNAME(ctx context.Context, host string) (string, error) + LookupAddr(ctx context.Context, addr string) ([]string, error) } type firewaller interface { @@ -210,12 +216,6 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass]) resp := query.SetReply(query) - network := resutil.NetworkForQtype(question.Qtype) - if network == "" { - resp.Rcode = dns.RcodeNotImplemented - f.writeResponse(logger, w, resp, qname, startTime) - return - } mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, ".")) if mostSpecificResId == "" { @@ -227,9 +227,46 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout) defer cancel() + reqHasEdns := query.IsEdns0() != nil + + switch question.Qtype { + case dns.TypeA, dns.TypeAAAA: + f.handleAddressQuery(ctx, logger, w, resp, mostSpecificResId, matchingEntries, reqHasEdns, startTime) + case dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCNAME, dns.TypePTR: + f.handleRecordQuery(ctx, logger, w, resp, startTime) + default: + // The domain is routed here, so any other type is answered NODATA + // (NOERROR, empty answer) rather than falling back to a resolver that + // would poison the name with NXDOMAIN. The Extended DNS Error lets a + // client tell this capability-driven NODATA apart from an + // authoritative one. The OPT pseudo-record must not appear unless the + // query advertised EDNS0. + if reqHasEdns { + attachEDE(resp, dns.ExtendedErrorCodeNotSupported, "netbird forwarder: unsupported query type") + } + f.writeResponse(logger, w, resp, qname, startTime) + } +} + +// handleAddressQuery resolves A/AAAA queries, programs the firewall sets and +// resolved-IP state, and caches the answer for resilience on upstream failure. +func (f *DNSForwarder) handleAddressQuery( + ctx context.Context, + logger *log.Entry, + w dns.ResponseWriter, + resp *dns.Msg, + mostSpecificResId route.ResID, + matchingEntries []*ForwarderEntry, + reqHasEdns bool, + startTime time.Time, +) { + question := resp.Question[0] + qname := strings.ToLower(question.Name) + + network := resutil.NetworkForQtype(question.Qtype) result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype) if result.Err != nil { - f.handleDNSError(ctx, logger, w, question, resp, qname, result, query.IsEdns0() != nil, startTime) + f.handleDNSError(ctx, logger, w, question, resp, qname, result, reqHasEdns, startTime) return } @@ -240,6 +277,25 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q f.writeResponse(logger, w, resp, qname, startTime) } +// handleRecordQuery resolves non-address record types (MX, TXT, NS, SRV, +// CNAME, PTR) through the host resolver. Missing records are answered NODATA so +// the routed name is never poisoned with NXDOMAIN. +func (f *DNSForwarder) handleRecordQuery( + ctx context.Context, + logger *log.Entry, + w dns.ResponseWriter, + resp *dns.Msg, + startTime time.Time, +) { + question := resp.Question[0] + qname := strings.ToLower(question.Name) + + records, rcode := resutil.LookupRecords(ctx, f.resolver, qname, question.Qtype, f.ttl) + resp.Rcode = rcode + resp.Answer = append(resp.Answer, records...) + f.writeResponse(logger, w, resp, qname, startTime) +} + func (f *DNSForwarder) writeResponse(logger *log.Entry, w dns.ResponseWriter, resp *dns.Msg, qname string, startTime time.Time) { if err := w.WriteMsg(resp); err != nil { logger.Errorf("failed to write DNS response: %v", err) diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go index 046595473..c69a9166e 100644 --- a/client/internal/dnsfwd/forwarder_test.go +++ b/client/internal/dnsfwd/forwarder_test.go @@ -133,6 +133,41 @@ func (m *MockResolver) LookupNetIP(ctx context.Context, network, host string) ([ return args.Get(0).([]netip.Addr), args.Error(1) } +func (m *MockResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]*net.MX) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]string) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupNS(ctx context.Context, name string) ([]*net.NS, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]*net.NS) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) { + args := m.Called(ctx, service, proto, name) + recs, _ := args.Get(1).([]*net.SRV) + return args.String(0), recs, args.Error(2) +} + +func (m *MockResolver) LookupCNAME(ctx context.Context, host string) (string, error) { + args := m.Called(ctx, host) + return args.String(0), args.Error(1) +} + +func (m *MockResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { + args := m.Called(ctx, addr) + recs, _ := args.Get(0).([]string) + return recs, args.Error(1) +} + func TestDNSForwarder_SubdomainAccessLogic(t *testing.T) { tests := []struct { name string @@ -545,12 +580,15 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) { } func TestDNSForwarder_ResponseCodes(t *testing.T) { + // A type with no net.Resolver Lookup method (CAA) must answer NODATA + // (NOERROR, empty) rather than NXDOMAIN/NOTIMP to avoid poisoning the name. tests := []struct { name string queryType uint16 queryDomain string configured string expectedCode int + expectEDE bool description string }{ { @@ -562,28 +600,13 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { description: "RFC compliant REFUSED for unauthorized queries", }, { - name: "unsupported query type returns NOTIMP", - queryType: dns.TypeMX, + name: "unsupported query type returns NODATA", + queryType: dns.TypeCAA, queryDomain: "example.com", configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "RFC compliant NOTIMP for unsupported types", - }, - { - name: "CNAME query returns NOTIMP", - queryType: dns.TypeCNAME, - queryDomain: "example.com", - configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "CNAME queries not supported", - }, - { - name: "TXT query returns NOTIMP", - queryType: dns.TypeTXT, - queryDomain: "example.com", - configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "TXT queries not supported", + expectedCode: dns.RcodeSuccess, + expectEDE: true, + description: "Unsupported types answer NODATA, not NXDOMAIN/NOTIMP", }, } @@ -599,6 +622,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { query := &dns.Msg{} query.SetQuestion(dns.Fqdn(tt.queryDomain), tt.queryType) + query.SetEdns0(dns.DefaultMsgSize, false) // Capture the written response var writtenResp *dns.Msg @@ -614,10 +638,213 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { // Check the response written to the writer require.NotNil(t, writtenResp, "Expected response to be written") assert.Equal(t, tt.expectedCode, writtenResp.Rcode, tt.description) + assert.Empty(t, writtenResp.Answer, "Non-address response should carry no answers") + + if tt.expectEDE { + require.NotNil(t, writtenResp.IsEdns0(), "EDNS0 client should get an OPT in the reply") + assert.True(t, hasEDE(writtenResp, dns.ExtendedErrorCodeNotSupported), + "unsupported type NODATA should carry EDE Not Supported") + } }) } } +func hasEDE(m *dns.Msg, code uint16) bool { + opt := m.IsEdns0() + if opt == nil { + return false + } + for _, o := range opt.Option { + if ede, ok := o.(*dns.EDNS0_EDE); ok && ede.InfoCode == code { + return true + } + } + return false +} + +func TestDNSForwarder_RecordQueries(t *testing.T) { + notFound := &net.DNSError{IsNotFound: true, Name: "example.com"} + + t.Run("MX records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupMX", mock.Anything, "example.com."). + Return([]*net.MX{{Host: "mail.example.com.", Pref: 10}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + mx, ok := resp.Answer[0].(*dns.MX) + require.True(t, ok, "answer should be an MX record") + assert.Equal(t, uint16(10), mx.Preference) + assert.Equal(t, "mail.example.com.", mx.Mx) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing MX is NODATA not NXDOMAIN", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + // A not-found cannot prove the name is absent (it may exist with only + // other record types), so it must answer NODATA, never NXDOMAIN. + mockResolver.On("LookupMX", mock.Anything, "example.com."). + Return(nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "missing record must be NODATA") + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("NS records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupNS", mock.Anything, "example.com."). + Return([]*net.NS{{Host: "ns1.example.com."}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + ns, ok := resp.Answer[0].(*dns.NS) + require.True(t, ok, "answer should be an NS record") + assert.Equal(t, "ns1.example.com.", ns.Ns) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing NS is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupNS", mock.Anything, "example.com."). + Return(nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("SRV records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com") + + mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com."). + Return("", []*net.SRV{{Target: "sip.example.com.", Port: 5060, Priority: 10, Weight: 5}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + srv, ok := resp.Answer[0].(*dns.SRV) + require.True(t, ok, "answer should be an SRV record") + assert.Equal(t, "sip.example.com.", srv.Target) + assert.Equal(t, uint16(5060), srv.Port) + assert.Equal(t, uint16(10), srv.Priority) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing SRV is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com") + + mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com."). + Return("", nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("TXT records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupTXT", mock.Anything, "example.com."). + Return([]string{"v=spf1 -all"}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeTXT) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + txt, ok := resp.Answer[0].(*dns.TXT) + require.True(t, ok, "answer should be a TXT record") + assert.Equal(t, []string{"v=spf1 -all"}, txt.Txt) + mockResolver.AssertExpectations(t) + }) + + t.Run("CNAME record is forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "www.example.com") + + mockResolver.On("LookupCNAME", mock.Anything, "www.example.com."). + Return("target.example.com.", nil).Once() + + resp := runRecordQuery(t, forwarder, "www.example.com", dns.TypeCNAME) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok, "answer should be a CNAME record") + assert.Equal(t, "target.example.com.", cname.Target) + mockResolver.AssertExpectations(t) + }) + + t.Run("CNAME equal to the name is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + // No CNAME exists: LookupCNAME echoes the queried name back. + mockResolver.On("LookupCNAME", mock.Anything, "example.com."). + Return("example.com.", nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeCNAME) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer, "self-referential CNAME means no CNAME record") + mockResolver.AssertExpectations(t) + }) + + t.Run("PTR record is forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "*.in-addr.arpa") + + // The reverse name is parsed back to the address LookupAddr expects. + mockResolver.On("LookupAddr", mock.Anything, "1.2.3.4"). + Return([]string{"host.example.com."}, nil).Once() + + resp := runRecordQuery(t, forwarder, "4.3.2.1.in-addr.arpa", dns.TypePTR) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + ptr, ok := resp.Answer[0].(*dns.PTR) + require.True(t, ok, "answer should be a PTR record") + assert.Equal(t, "host.example.com.", ptr.Ptr) + mockResolver.AssertExpectations(t) + }) +} + +func newRecordTestForwarder(t *testing.T, r resolver, configured string) *DNSForwarder { + t.Helper() + forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil) + forwarder.resolver = r + + d, err := domain.FromString(configured) + require.NoError(t, err) + forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}}) + return forwarder +} + +func runRecordQuery(t *testing.T, forwarder *DNSForwarder, qname string, qtype uint16) *dns.Msg { + t.Helper() + query := &dns.Msg{} + query.SetQuestion(dns.Fqdn(qname), qtype) + + mockWriter := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + + resp := mockWriter.GetLastResponse() + require.NotNil(t, resp, "expected response to be written") + return resp +} + func TestDNSForwarder_UpstreamFailureEDE(t *testing.T) { tests := []struct { name string diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index 22f3355c8..b784cc274 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -226,12 +226,11 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } - // pass if non A/AAAA query - if r.Question[0].Qtype != dns.TypeA && r.Question[0].Qtype != dns.TypeAAAA { - d.continueToNextHandler(w, r, logger, "non A/AAAA query") - return - } - + // All query types for an intercepted domain are forwarded to the peer's + // DNS forwarder, which owns the name. Falling through to the system + // resolver would let it answer NXDOMAIN for a name it isn't authoritative + // for, poisoning the whole name (including the A/AAAA records the route + // does serve). The forwarder answers NODATA for types it cannot resolve. d.mu.RLock() peerKey := d.currentPeerKey d.mu.RUnlock() @@ -293,19 +292,6 @@ func (d *DnsInterceptor) writeDNSError(w dns.ResponseWriter, r *dns.Msg, logger } } -// continueToNextHandler signals the handler chain to try the next handler -func (d *DnsInterceptor) continueToNextHandler(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry, reason string) { - logger.Tracef("continuing to next handler for domain=%s reason=%s", r.Question[0].Name, reason) - - resp := new(dns.Msg) - resp.SetRcode(r, dns.RcodeNameError) - // Set Zero bit to signal handler chain to continue - resp.MsgHdr.Zero = true - if err := w.WriteMsg(resp); err != nil { - logger.Errorf("failed writing DNS continue response: %v", err) - } -} - func (d *DnsInterceptor) getUpstreamIP(peerKey string) (netip.Addr, error) { peerAllowedIP, exists := d.peerStore.AllowedIP(peerKey) if !exists { From 1409a1325a805d70f456244fde999bbb09bb06b6 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 29 Jun 2026 09:19:01 +0200 Subject: [PATCH 26/26] [misc] Update careers page link (#6538) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c9a51b6f1..40c6b9ed5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@

- 🚀 We are hiring! Join us at careers.netbird.io + 🚀 We are hiring! Join us at https://netbird.io/careers