From e4397d4d4614295343e9935b51b9bd10e0b794f1 Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Wed, 17 Jun 2026 16:37:24 +0200
Subject: [PATCH 01/54] [management] remove nmap calc from login (#6449)
---
.../network_map/controller/controller.go | 26 ++--
.../controllers/network_map/interface.go | 2 +-
.../controllers/network_map/interface_mock.go | 19 ++-
management/internals/modules/peers/manager.go | 2 +-
management/internals/shared/grpc/server.go | 8 +-
management/server/account/manager.go | 4 +-
management/server/account/manager_mock.go | 18 ++-
management/server/account_test.go | 28 ++--
management/server/affected_peers_test.go | 2 +-
management/server/dns_test.go | 4 +-
management/server/group_ipv6_test.go | 2 +-
.../http/handlers/peers/peers_handler.go | 2 +-
management/server/management_proto_test.go | 4 +-
management/server/mock_server/account_mock.go | 12 +-
management/server/nameserver_test.go | 4 +-
management/server/peer.go | 138 ++++++++++--------
management/server/peer_test.go | 64 ++++----
management/server/types/account.go | 41 ++++++
.../networkmap_components_correctness_test.go | 94 ++++++++++++
management/server/user_test.go | 2 +-
20 files changed, 318 insertions(+), 158 deletions(-)
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 9adf594cd..d271c499d 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -585,66 +585,66 @@ func (b *bufferAffectedUpdate) setTimer(d time.Duration, f func()) {
b.next.Reset(d)
}
-func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) {
if isRequiresApproval {
network, err := c.repo.GetAccountNetwork(ctx, accountID)
if err != nil {
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
emptyMap := &types.NetworkMap{
Network: network.Copy(),
}
- return peer, emptyMap, nil, 0, nil
+ return emptyMap, nil, 0, nil
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
account.InjectProxyPolicies(ctx)
approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
if err != nil {
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
startPosture := time.Now()
- postureChecks, err := c.getPeerPostureChecks(account, peer.ID)
+ postureChecks, err := c.getPeerPostureChecks(account, peerID)
if err != nil {
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
log.WithContext(ctx).Debugf("getPeerPostureChecks took %s", time.Since(startPosture))
accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
if err != nil {
log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
dnsDomain := c.GetDNSDomain(account.Settings)
peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
- proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers)
+ proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers)
if err != nil {
log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err)
- return nil, nil, nil, 0, err
+ return nil, nil, 0, err
}
resourcePolicies := account.GetResourcePoliciesMap()
routers := account.GetResourceRoutersMap()
groupIDToUserIDs := account.GetActiveGroupUsers()
- networkMap := account.GetPeerNetworkMapFromComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
+ networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
- proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
+ proxyNetworkMap, ok := proxyNetworkMaps[peerID]
if ok {
networkMap.Merge(proxyNetworkMap)
}
dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
- return peer, networkMap, postureChecks, dnsFwdPort, nil
+ return networkMap, postureChecks, dnsFwdPort, nil
}
// GetDNSDomain returns the configured dnsDomain
diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go
index dbdd87708..14b12aba6 100644
--- a/management/internals/controllers/network_map/interface.go
+++ b/management/internals/controllers/network_map/interface.go
@@ -23,7 +23,7 @@ type Controller interface {
BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error
UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error
BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error
- GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
+ GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error)
GetDNSDomain(settings *types.Settings) string
StartWarmup(context.Context)
GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go
index a67156719..bfff32e6f 100644
--- a/management/internals/controllers/network_map/interface_mock.go
+++ b/management/internals/controllers/network_map/interface_mock.go
@@ -127,21 +127,20 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal
}
// GetValidatedPeerWithMap mocks base method.
-func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
+func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, p)
- ret0, _ := ret[0].(*peer.Peer)
- ret1, _ := ret[1].(*types.NetworkMap)
- ret2, _ := ret[2].([]*posture.Checks)
- ret3, _ := ret[3].(int64)
- ret4, _ := ret[4].(error)
- return ret0, ret1, ret2, ret3, ret4
+ ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID)
+ ret0, _ := ret[0].(*types.NetworkMap)
+ ret1, _ := ret[1].([]*posture.Checks)
+ ret2, _ := ret[2].(int64)
+ ret3, _ := ret[3].(error)
+ return ret0, ret1, ret2, ret3
}
// GetValidatedPeerWithMap indicates an expected call of GetValidatedPeerWithMap.
-func (mr *MockControllerMockRecorder) GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, p any) *gomock.Call {
+func (mr *MockControllerMockRecorder) GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peerID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithMap", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithMap), ctx, isRequiresApproval, accountID, p)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithMap", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithMap), ctx, isRequiresApproval, accountID, peerID)
}
// OnPeerConnected mocks base method.
diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go
index 8f3253063..e22d1e6e0 100644
--- a/management/internals/modules/peers/manager.go
+++ b/management/internals/modules/peers/manager.go
@@ -242,7 +242,7 @@ func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, pee
},
}
- _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, true)
+ _, _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, true)
if err != nil {
return fmt.Errorf("failed to create proxy peer: %w", err)
}
diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go
index 2d19ca32b..7283cae6c 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -778,7 +778,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
sshKey = loginReq.GetPeerKeys().GetSshPubKey()
}
- peer, netMap, postureChecks, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{
+ peer, network, postureChecks, enableSSH, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{
WireGuardPubKey: peerKey.String(),
SSHKey: string(sshKey),
Meta: peerMeta,
@@ -792,7 +792,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
return nil, mapError(ctx, err)
}
- loginResp, err := s.prepareLoginResponse(ctx, peer, netMap, postureChecks)
+ loginResp, err := s.prepareLoginResponse(ctx, peer, network, postureChecks, enableSSH)
if err != nil {
log.WithContext(ctx).Warnf("failed preparing login response for peer %s: %s", peerKey, err)
return nil, status.Errorf(codes.Internal, "failed logging in peer")
@@ -895,7 +895,7 @@ func (s *Server) ExtendAuthSession(ctx context.Context, req *proto.EncryptedMess
}, nil
}
-func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, netMap *types.NetworkMap, postureChecks []*posture.Checks) (*proto.LoginResponse, error) {
+func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, network *types.Network, postureChecks []*posture.Checks, enableSSH bool) (*proto.LoginResponse, error) {
var relayToken *Token
var err error
if s.config.Relay != nil && len(s.config.Relay.Addresses) > 0 {
@@ -914,7 +914,7 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne
// if peer has reached this point then it has logged in
loginResp := &proto.LoginResponse{
NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil),
- PeerConfig: toPeerConfig(peer, netMap.Network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, netMap.EnableSSH),
+ PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH),
Checks: toProtocolChecks(ctx, postureChecks),
}
diff --git a/management/server/account/manager.go b/management/server/account/manager.go
index 2fdfdba5a..784e432f6 100644
--- a/management/server/account/manager.go
+++ b/management/server/account/manager.go
@@ -70,7 +70,7 @@ type Manager interface {
UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error
GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error)
- AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error)
+ AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
CreatePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error)
DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error
GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error)
@@ -109,7 +109,7 @@ type Manager interface {
GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error)
- LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) // used by peer gRPC API
+ LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) // used by peer gRPC API
ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession
SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API
GetExternalCacheManager() ExternalCacheManager
diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go
index 0e06ebf91..145e6e00f 100644
--- a/management/server/account/manager_mock.go
+++ b/management/server/account/manager_mock.go
@@ -80,14 +80,15 @@ func (mr *MockManagerMockRecorder) AccountExists(ctx, accountID interface{}) *go
}
// AddPeer mocks base method.
-func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AddPeer", ctx, accountID, setupKey, userID, p, temporary)
ret0, _ := ret[0].(*peer.Peer)
- ret1, _ := ret[1].(*types.NetworkMap)
+ ret1, _ := ret[1].(*types.Network)
ret2, _ := ret[2].([]*posture.Checks)
- ret3, _ := ret[3].(error)
- return ret0, ret1, ret2, ret3
+ ret3, _ := ret[3].(bool)
+ ret4, _ := ret[4].(error)
+ return ret0, ret1, ret2, ret3, ret4
}
// AddPeer indicates an expected call of AddPeer.
@@ -1289,14 +1290,15 @@ func (mr *MockManagerMockRecorder) ListUsers(ctx, accountID interface{}) *gomock
}
// LoginPeer mocks base method.
-func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LoginPeer", ctx, login)
ret0, _ := ret[0].(*peer.Peer)
- ret1, _ := ret[1].(*types.NetworkMap)
+ ret1, _ := ret[1].(*types.Network)
ret2, _ := ret[2].([]*posture.Checks)
- ret3, _ := ret[3].(error)
- return ret0, ret1, ret2, ret3
+ ret3, _ := ret[3].(bool)
+ ret4, _ := ret[4].(error)
+ return ret0, ret1, ret2, ret3, ret4
}
// LoginPeer indicates an expected call of LoginPeer.
diff --git a/management/server/account_test.go b/management/server/account_test.go
index 51f079a57..256b71f18 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -84,7 +84,7 @@ func verifyCanAddPeerToAccount(t *testing.T, manager nbAccount.Manager, account
setupKey = key.Key
}
- _, _, _, err := manager.AddPeer(context.Background(), "", setupKey, userID, peer, false)
+ _, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey, userID, peer, false)
if err != nil {
t.Error("expected to add new peer successfully after creating new account, but failed", err)
}
@@ -1092,7 +1092,7 @@ func TestAccountManager_AddPeer(t *testing.T) {
}
expectedPeerKey := key.PublicKey().String()
- peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: expectedPeerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
}, false)
@@ -1156,7 +1156,7 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) {
expectedPeerKey := key.PublicKey().String()
expectedUserID := userID
- peer, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: expectedPeerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
}, false)
@@ -1504,7 +1504,7 @@ func TestAccountManager_DeletePeer(t *testing.T) {
peerKey := key.PublicKey().String()
- peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: peerKey},
}, false)
@@ -1826,7 +1826,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) {
key, err := wgtypes.GenerateKey()
require.NoError(t, err, "unable to generate WireGuard key")
- peer, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
LoginExpirationEnabled: true,
@@ -1882,7 +1882,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
key, err := wgtypes.GenerateKey()
require.NoError(t, err, "unable to generate WireGuard key")
- _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
LoginExpirationEnabled: true,
@@ -1927,7 +1927,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
require.NoError(t, err, "unable to generate WireGuard key")
peerPubKey := key.PublicKey().String()
- _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: peerPubKey,
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
}, false)
@@ -2017,7 +2017,7 @@ func TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace(t *testing.T) {
require.NoError(t, err, "unable to generate WireGuard key")
peerPubKey := key.PublicKey().String()
- _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: peerPubKey,
Meta: nbpeer.PeerSystemMeta{Hostname: "race-peer"},
}, false)
@@ -2080,7 +2080,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test
key, err := wgtypes.GenerateKey()
require.NoError(t, err, "unable to generate WireGuard key")
- _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
LoginExpirationEnabled: true,
@@ -3276,7 +3276,7 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel.
}
expectedPeerKey := key.PublicKey().String()
- peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: expectedPeerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
Status: &nbpeer.PeerStatus{
@@ -3444,7 +3444,7 @@ func BenchmarkLoginPeer_ExistingPeer(b *testing.B) {
b.ResetTimer()
start := time.Now()
for i := 0; i < b.N; i++ {
- _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{
+ _, _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{
WireGuardPubKey: account.Peers["peer-1"].Key,
SSHKey: "someKey",
Meta: nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)},
@@ -3513,7 +3513,7 @@ func BenchmarkLoginPeer_NewPeer(b *testing.B) {
b.ResetTimer()
start := time.Now()
for i := 0; i < b.N; i++ {
- _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{
+ _, _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{
WireGuardPubKey: "some-new-key" + strconv.Itoa(i),
SSHKey: "someKey",
Meta: nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)},
@@ -3908,13 +3908,13 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) {
key2, err := wgtypes.GenerateKey()
require.NoError(t, err, "unable to generate WireGuard key")
- peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"},
}, false)
require.NoError(t, err, "unable to add peer1")
- peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go
index b66eeb3b5..e2dcd830b 100644
--- a/management/server/affected_peers_test.go
+++ b/management/server/affected_peers_test.go
@@ -1663,7 +1663,7 @@ func addPeerToAccount(t *testing.T, manager *DefaultAccountManager, _, setupKeyK
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
- peer, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{
+ peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: key.PublicKey().String()},
}, false)
diff --git a/management/server/dns_test.go b/management/server/dns_test.go
index c443223c6..8917902d9 100644
--- a/management/server/dns_test.go
+++ b/management/server/dns_test.go
@@ -298,11 +298,11 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account
return nil, err
}
- savedPeer1, _, _, err := am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer1, false)
+ savedPeer1, _, _, _, err := am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer1, false)
if err != nil {
return nil, err
}
- _, _, _, err = am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer2, false)
+ _, _, _, _, err = am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer2, false)
if err != nil {
return nil, err
}
diff --git a/management/server/group_ipv6_test.go b/management/server/group_ipv6_test.go
index e4603c879..dfb436060 100644
--- a/management/server/group_ipv6_test.go
+++ b/management/server/group_ipv6_test.go
@@ -55,7 +55,7 @@ func TestGroupIPv6Assignment(t *testing.T) {
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
- peer, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{
+ peer, _, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{
Key: key.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "ipv6-test-host"},
}, false)
diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go
index 1d4af95e9..310f90653 100644
--- a/management/server/http/handlers/peers/peers_handler.go
+++ b/management/server/http/handlers/peers/peers_handler.go
@@ -479,7 +479,7 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
return
}
- peer, _, _, err := h.accountManager.AddPeer(r.Context(), userAuth.AccountId, "", userAuth.UserId, newPeer, true)
+ peer, _, _, _, err := h.accountManager.AddPeer(r.Context(), userAuth.AccountId, "", userAuth.UserId, newPeer, true)
if err != nil {
util.WriteError(r.Context(), err, w)
return
diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go
index 1b77ea335..45d4ab8c9 100644
--- a/management/server/management_proto_test.go
+++ b/management/server/management_proto_test.go
@@ -728,7 +728,7 @@ func Test_LoginPerformance(t *testing.T) {
}
login := func() error {
- _, _, _, err = am.LoginPeer(context.Background(), peerLogin)
+ _, _, _, _, err = am.LoginPeer(context.Background(), peerLogin)
if err != nil {
t.Logf("failed to login peer: %v", err)
return err
@@ -746,7 +746,7 @@ func Test_LoginPerformance(t *testing.T) {
go func(peerLogin types.PeerLogin, counterStart *int32) {
defer wgPeer.Done()
- _, _, _, err = am.LoginPeer(context.Background(), peerLogin)
+ _, _, _, _, err = am.LoginPeer(context.Background(), peerLogin)
if err != nil {
t.Logf("failed to login peer: %v", err)
return
diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go
index 15eb9b190..f81139f24 100644
--- a/management/server/mock_server/account_mock.go
+++ b/management/server/mock_server/account_mock.go
@@ -45,7 +45,7 @@ type MockAccountManager struct {
DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error
GetNetworkMapFunc func(ctx context.Context, peerKey string) (*types.NetworkMap, error)
GetPeerNetworkFunc func(ctx context.Context, peerKey string) (*types.Network, error)
- AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error)
+ AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
GetGroupFunc func(ctx context.Context, accountID, groupID, userID string) (*types.Group, error)
GetAllGroupsFunc func(ctx context.Context, accountID, userID string) ([]*types.Group, error)
GetGroupByNameFunc func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error)
@@ -98,7 +98,7 @@ type MockAccountManager struct {
SaveDNSSettingsFunc func(ctx context.Context, accountID, userID string, dnsSettingsToSave *types.DNSSettings) error
GetPeerFunc func(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error)
UpdateAccountSettingsFunc func(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
- LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error)
+ LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error)
ExtendPeerSessionFunc func(ctx context.Context, peerPubKey, userID string) (time.Time, error)
SyncPeerFunc func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
InviteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserEmail string) error
@@ -424,11 +424,11 @@ func (am *MockAccountManager) AddPeer(
userId string,
peer *nbpeer.Peer,
temporary bool,
-) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
if am.AddPeerFunc != nil {
return am.AddPeerFunc(ctx, accountID, setupKey, userId, peer, temporary)
}
- return nil, nil, nil, status.Errorf(codes.Unimplemented, "method AddPeer is not implemented")
+ return nil, nil, nil, false, status.Errorf(codes.Unimplemented, "method AddPeer is not implemented")
}
// GetGroupByName mock implementation of GetGroupByName from server.AccountManager interface
@@ -862,11 +862,11 @@ func (am *MockAccountManager) UpdateAccountSettings(ctx context.Context, account
}
// LoginPeer mocks LoginPeer of the AccountManager interface
-func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
if am.LoginPeerFunc != nil {
return am.LoginPeerFunc(ctx, login)
}
- return nil, nil, nil, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented")
+ return nil, nil, nil, false, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented")
}
// ExtendPeerSession mocks ExtendPeerSession of the AccountManager interface
diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go
index b2c8300d6..e13b0bb19 100644
--- a/management/server/nameserver_test.go
+++ b/management/server/nameserver_test.go
@@ -896,11 +896,11 @@ func initTestNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account,
return nil, err
}
- _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer1, false)
+ _, _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer1, false)
if err != nil {
return nil, err
}
- _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer2, false)
+ _, _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer2, false)
if err != nil {
return nil, err
}
diff --git a/management/server/peer.go b/management/server/peer.go
index baf62a7eb..9d78f597b 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -718,10 +718,10 @@ func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, en
// to it. We also add the User ID to the peer metadata to identify registrant. If no userID provided, then fail with status.PermissionDenied
// Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused).
// The peer property is just a placeholder for the Peer properties to pass further
-func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
if setupKey == "" && userID == "" && !peer.ProxyMeta.Embedded {
// no auth method provided => reject access
- return nil, nil, nil, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login")
+ return nil, nil, nil, false, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login")
}
upperKey := strings.ToUpper(setupKey)
@@ -737,7 +737,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
// The connecting peer should be able to recover with a retry.
_, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peer.Key)
if err == nil {
- return nil, nil, nil, status.Errorf(status.PreconditionFailed, "peer has been already registered")
+ return nil, nil, nil, false, status.Errorf(status.PreconditionFailed, "peer has been already registered")
}
opEvent := &activity.Event{
@@ -748,7 +748,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
peerAddConfig, err := am.processPeerAddAuth(ctx, accountID, userID, encodedHashedKey, peer, temporary, addedByUser, addedBySetupKey, opEvent)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
accountID = peerAddConfig.AccountID
ephemeral := peerAddConfig.Ephemeral
@@ -763,7 +763,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
}
if err := domain.ValidateDomainsList(peer.ExtraDNSLabels); err != nil {
- return nil, nil, nil, status.Errorf(status.InvalidArgument, "invalid extra DNS labels: %v", err)
+ return nil, nil, nil, false, status.Errorf(status.InvalidArgument, "invalid extra DNS labels: %v", err)
}
registrationTime := time.Now().UTC()
@@ -789,7 +789,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
}
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to get account settings: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed to get account settings: %w", err)
}
if am.geo != nil && newPeer.Location.ConnectionIP != nil {
@@ -807,30 +807,30 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
network, err := am.Store.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed getting network: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed getting network: %w", err)
}
maxAttempts := 10
for attempt := 1; attempt <= maxAttempts; attempt++ {
netPrefix, err := netip.ParsePrefix(network.Net.String())
if err != nil {
- return nil, nil, nil, fmt.Errorf("parse network prefix: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("parse network prefix: %w", err)
}
freeIP, err := types.AllocateRandomPeerIP(netPrefix)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to get free IP: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed to get free IP: %w", err)
}
var freeLabel string
if ephemeral || attempt > 1 {
freeLabel, err = getPeerIPDNSLabel(freeIP, peer.Meta.Hostname)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to get free DNS label: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err)
}
} else {
freeLabel, err = nbdns.GetParsedDomainLabel(peer.Meta.Hostname)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to get free DNS label: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err)
}
}
newPeer.DNSLabel = freeLabel
@@ -852,11 +852,11 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
if allocate {
v6Prefix, err := netip.ParsePrefix(network.NetV6.String())
if err != nil {
- return nil, nil, nil, fmt.Errorf("parse IPv6 prefix: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("parse IPv6 prefix: %w", err)
}
freeIPv6, err := types.AllocateRandomPeerIPv6(v6Prefix)
if err != nil {
- return nil, nil, nil, fmt.Errorf("allocate peer IPv6: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("allocate peer IPv6: %w", err)
}
newPeer.IPv6 = freeIPv6
}
@@ -929,10 +929,10 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
continue
}
- return nil, nil, nil, fmt.Errorf("failed to add peer to database: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", err)
}
if newPeer == nil {
- return nil, nil, nil, fmt.Errorf("new peer is nil")
+ return nil, nil, nil, false, fmt.Errorf("new peer is nil")
}
opEvent.TargetID = newPeer.ID
@@ -940,7 +940,8 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
if !addedByUser {
opEvent.Meta["setup_key_name"] = peerAddConfig.SetupKeyName
}
- if newPeer.Status != nil && newPeer.Status.RequiresApproval {
+ requiresApproval := newPeer.Status != nil && newPeer.Status.RequiresApproval
+ if requiresApproval {
opEvent.Meta["pending_approval"] = true
}
@@ -948,18 +949,18 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta)
}
- p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, false, accountID, newPeer)
+ network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, newPeer, !requiresApproval)
if err != nil {
- return p, nmap, pc, err
+ return nil, nil, nil, false, err
}
changedPeerIDs := []string{newPeer.ID}
- affectedPeerIDs := affectedPeerIDsFromNetworkMap(nmap, newPeer.ID)
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
if err := am.networkMapController.OnPeersAdded(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
log.WithContext(ctx).Errorf("failed to update network map cache for peer %s: %v", newPeer.ID, err)
}
- return p, nmap, pc, nil
+ return newPeer, network, postureChecks, enableSSH, nil
}
func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) {
@@ -1041,7 +1042,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return nil, nil, nil, 0, err
}
- resPeer, nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer)
+ nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer.ID)
if err != nil {
return nil, nil, nil, 0, err
}
@@ -1054,7 +1055,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
}
}
- return resPeer, nmap, resPostureChecks, dnsFwdPort, nil
+ return peer, nmap, resPostureChecks, dnsFwdPort, nil
}
// syncPeerAffectedPeers resolves the peers affected by a SyncPeer change. The
@@ -1085,7 +1086,7 @@ func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context,
return affectedPeerIDsFromNetworkMap(nmap, peerID)
}
-func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
if errStatus, ok := status.FromError(err); ok && errStatus.Type() == status.NotFound {
// we couldn't find this peer by its public key which can mean that peer hasn't been registered yet.
// Try registering it.
@@ -1101,12 +1102,12 @@ func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, lo
}
log.WithContext(ctx).Errorf("failed while logging in peer %s: %v", login.WireGuardPubKey, err)
- return nil, nil, nil, status.Errorf(status.Internal, "failed while logging in peer")
+ return nil, nil, nil, false, status.Errorf(status.Internal, "failed while logging in peer")
}
// LoginPeer logs in or registers a peer.
// If peer doesn't exist the function checks whether a setup key or a user is present and registers a new peer if so.
-func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
+func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) {
accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, login.WireGuardPubKey)
if err != nil {
return am.handlePeerLoginNotFound(ctx, login, err)
@@ -1118,20 +1119,17 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
if login.UserID == "" {
err = am.checkIFPeerNeedsLoginWithoutLock(ctx, accountID, login)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
}
var peer *nbpeer.Peer
- var updateRemotePeers bool
- var isPeerUpdated bool
- var ipv6CapabilityChanged bool
- var postureChecks []*posture.Checks
+ var shouldStorePeer bool
var peerGroupIDs []string
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
@@ -1140,9 +1138,6 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return err
}
- // this flag prevents unnecessary calls to the persistent store.
- shouldStorePeer := false
-
if login.UserID != "" {
if peer.UserID != login.UserID {
log.Warnf("user mismatch when logging in peer %s: peer user %s, login user %s ", peer.ID, peer.UserID, login.UserID)
@@ -1156,7 +1151,6 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
if changed {
shouldStorePeer = true
- updateRemotePeers = true
}
}
@@ -1165,23 +1159,9 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return err
}
- oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
- isPeerUpdated, _ = peer.UpdateMetaIfNew(login.Meta)
- ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
- if isPeerUpdated {
- am.metrics.AccountManagerMetrics().CountPeerMetUpdate()
- shouldStorePeer = true
-
- postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peer.ID)
- if err != nil {
- return err
- }
- }
-
if peer.SSHKey != login.SSHKey {
peer.SSHKey = login.SSHKey
shouldStorePeer = true
- updateRemotePeers = true
}
if !peer.AllowExtraDNSLabels && len(login.ExtraDNSLabels) > 0 {
@@ -1197,28 +1177,28 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return nil
})
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
isRequiresApproval, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
- p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer)
+ network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, peer, !isRequiresApproval)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, false, err
}
- if updateRemotePeers || isStatusChanged || ipv6CapabilityChanged || (isPeerUpdated && len(postureChecks) > 0) {
+ if isStatusChanged || shouldStorePeer {
changedPeerIDs := []string{peer.ID}
- affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, isRequiresApproval, isPeerUpdated, len(postureChecks) > 0)
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
- return nil, nil, nil, fmt.Errorf("notify network map controller of peer update: %w", err)
+ return nil, nil, nil, false, fmt.Errorf("notify network map controller of peer update: %w", err)
}
}
- return p, nmap, pc, nil
+ return peer, network, postureChecks, enableSSH, nil
}
// ExtendPeerSession refreshes the peer's SSO session deadline by updating
@@ -1294,6 +1274,50 @@ func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubK
return refreshed.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), nil
}
+// getPeerLoginInfo computes the login/register response data (network, posture
+// checks, SSH) from the store without building the peer's full network map.
+func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*posture.Checks, bool, error) {
+ network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return nil, nil, false, fmt.Errorf("get account network: %w", err)
+ }
+
+ if !isValid {
+ return network, nil, false, nil
+ }
+
+ postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peer.ID)
+ if err != nil {
+ return nil, nil, false, err
+ }
+
+ enableSSH, err := isPeerSSHEnabled(ctx, transaction, accountID, peer)
+ if err != nil {
+ return nil, nil, false, err
+ }
+
+ return network, postureChecks, enableSSH, nil
+}
+
+func isPeerSSHEnabled(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer) (bool, error) {
+ policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return false, err
+ }
+
+ peerGroups, err := transaction.GetPeerGroups(ctx, store.LockingStrengthNone, accountID, peer.ID)
+ if err != nil {
+ return false, err
+ }
+
+ peerGroupIDs := make(map[string]struct{}, len(peerGroups))
+ for _, g := range peerGroups {
+ peerGroupIDs[g.ID] = struct{}{}
+ }
+
+ return types.PeerSSHEnabledFromPolicies(policies, peer.ID, peerGroupIDs, peer.SSHEnabled), nil
+}
+
// getPeerPostureChecks returns the posture checks for the peer.
func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string) ([]*posture.Checks, error) {
policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
diff --git a/management/server/peer_test.go b/management/server/peer_test.go
index ee1b33da2..98cf10acf 100644
--- a/management/server/peer_test.go
+++ b/management/server/peer_test.go
@@ -205,7 +205,7 @@ func testGetNetworkMapGeneral(t *testing.T) {
return
}
- peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"},
}, false)
@@ -219,7 +219,7 @@ func testGetNetworkMapGeneral(t *testing.T) {
t.Fatal(err)
return
}
- _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -278,7 +278,7 @@ func TestAccountManager_GetNetworkMapWithPolicy(t *testing.T) {
return
}
- peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"},
}, false)
@@ -292,7 +292,7 @@ func TestAccountManager_GetNetworkMapWithPolicy(t *testing.T) {
t.Fatal(err)
return
}
- peer2, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer2, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -454,7 +454,7 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) {
return
}
- peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"},
}, false)
@@ -468,7 +468,7 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) {
t.Fatal(err)
return
}
- _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -526,7 +526,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) {
return
}
- peer1, _, _, err := manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{
Key: peerKey1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -542,7 +542,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) {
}
// the second peer added with a setup key
- peer2, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
+ peer2, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{
Key: peerKey2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -698,7 +698,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) {
return
}
- _, _, _, err = manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{
Key: peerKey1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"},
}, false)
@@ -707,7 +707,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) {
return
}
- _, _, _, err = manager.AddPeer(context.Background(), "", "", adminUser, &nbpeer.Peer{
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", adminUser, &nbpeer.Peer{
Key: peerKey2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"},
}, false)
@@ -1332,7 +1332,7 @@ func Test_RegisterPeerByUser(t *testing.T) {
},
}
- addedPeer, _, _, err := am.AddPeer(context.Background(), "", "", existingUserID, newPeer, false)
+ addedPeer, _, _, _, err := am.AddPeer(context.Background(), "", "", existingUserID, newPeer, false)
require.NoError(t, err)
assert.Equal(t, newPeer.ExtraDNSLabels, addedPeer.ExtraDNSLabels)
@@ -1465,7 +1465,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) {
ExtraDNSLabels: newPeerTemplate.ExtraDNSLabels,
}
- addedPeer, _, _, err := am.AddPeer(context.Background(), "", tc.existingSetupKeyID, "", currentPeer, false)
+ addedPeer, _, _, _, err := am.AddPeer(context.Background(), "", tc.existingSetupKeyID, "", currentPeer, false)
if tc.expectAddPeerError {
require.Error(t, err, "Expected an error when adding peer with setup key: %s", tc.existingSetupKeyID)
@@ -1577,7 +1577,7 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) {
SSHEnabled: false,
}
- _, _, _, err = am.AddPeer(context.Background(), "", faultyKey, "", newPeer, false)
+ _, _, _, _, err = am.AddPeer(context.Background(), "", faultyKey, "", newPeer, false)
require.Error(t, err)
_, err = s.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, newPeer.Key)
@@ -1723,7 +1723,7 @@ func Test_LoginPeer(t *testing.T) {
if sk.AllowExtraDNSLabels {
currentPeer.ExtraDNSLabels = newPeerTemplate.ExtraDNSLabels
}
- _, _, _, err = am.AddPeer(context.Background(), "", tc.setupKey, "", currentPeer, false)
+ _, _, _, _, err = am.AddPeer(context.Background(), "", tc.setupKey, "", currentPeer, false)
require.NoError(t, err, "Expected no error when adding peer with setup key: %s", tc.setupKey)
loginInput := types.PeerLogin{
@@ -1739,12 +1739,12 @@ func Test_LoginPeer(t *testing.T) {
loginInput.ExtraDNSLabels = tc.extraDNSLabels
}
- loggedinPeer, networkMap, postureChecks, loginErr := am.LoginPeer(context.Background(), loginInput)
+ loggedinPeer, network, postureChecks, _, loginErr := am.LoginPeer(context.Background(), loginInput)
if tc.expectLoginError {
require.Error(t, loginErr, "Expected an error during LoginPeer with setup key: %s", tc.setupKey)
assert.Contains(t, loginErr.Error(), tc.expectedErrorMsgSubstring, "Error message mismatch")
assert.Nil(t, loggedinPeer, "LoggedinPeer should be nil on error")
- assert.Nil(t, networkMap, "NetworkMap should be nil on error")
+ assert.Nil(t, network, "Network should be nil on error")
assert.Nil(t, postureChecks, "PostureChecks should be empty or nil on error")
return
}
@@ -1757,7 +1757,7 @@ func Test_LoginPeer(t *testing.T) {
} else {
assert.Equal(t, currentPeer.ExtraDNSLabels, loggedinPeer.ExtraDNSLabels, "ExtraDNSLabels mismatch on loggedinPeer")
}
- assert.NotNil(t, networkMap, "networkMap should not be nil on success")
+ assert.NotNil(t, network, "network should not be nil on success")
assert.Equal(t, existingAccountID, loggedinPeer.AccountID, "AccountID mismatch for logged peer")
@@ -1863,7 +1863,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
require.NoError(t, err)
expectedPeerKey := key.PublicKey().String()
- peer4, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{
+ peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{
Key: expectedPeerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
}, false)
@@ -1986,7 +1986,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
require.NoError(t, err)
expectedPeerKey := key.PublicKey().String()
- peer4, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{
+ peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{
Key: expectedPeerKey,
LoginExpirationEnabled: true,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
@@ -2053,7 +2053,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
require.NoError(t, err)
expectedPeerKey := key.PublicKey().String()
- peer5, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{
+ peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{
Key: expectedPeerKey,
LoginExpirationEnabled: true,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
@@ -2108,7 +2108,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
require.NoError(t, err)
expectedPeerKey := key.PublicKey().String()
- peer6, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{
+ peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{
Key: expectedPeerKey,
LoginExpirationEnabled: true,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
@@ -2286,7 +2286,7 @@ func Test_AddPeer(t *testing.T) {
<-start
- _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", newPeer, false)
+ _, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", newPeer, false)
if err != nil {
errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err)
return
@@ -2366,7 +2366,7 @@ func TestAddPeer_UserPendingApprovalBlocked(t *testing.T) {
},
}
- _, _, _, err = manager.AddPeer(context.Background(), "", "", pendingUser.Id, peer, false)
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", pendingUser.Id, peer, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "user pending approval cannot add peers")
}
@@ -2401,7 +2401,7 @@ func TestAddPeer_ApprovedUserCanAddPeers(t *testing.T) {
},
}
- _, _, _, err = manager.AddPeer(context.Background(), "", "", regularUser.Id, peer, false)
+ _, _, _, _, err = manager.AddPeer(context.Background(), "", "", regularUser.Id, peer, false)
require.NoError(t, err, "Regular user should be able to add peers")
}
@@ -2444,7 +2444,7 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) {
WtVersion: "0.28.0",
},
}
- existingPeer, _, _, err := manager.AddPeer(context.Background(), "", "", pendingUser.Id, newPeer, false)
+ existingPeer, _, _, _, err := manager.AddPeer(context.Background(), "", "", pendingUser.Id, newPeer, false)
require.NoError(t, err)
// Now set the user back to pending approval after peer was created
@@ -2463,7 +2463,7 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) {
},
}
- _, _, _, err = manager.LoginPeer(context.Background(), login)
+ _, _, _, _, err = manager.LoginPeer(context.Background(), login)
require.Error(t, err)
e, ok := status.FromError(err)
require.True(t, ok, "error is not a gRPC status error")
@@ -2500,7 +2500,7 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) {
WtVersion: "0.28.0",
},
}
- existingPeer, _, _, err := manager.AddPeer(context.Background(), "", "", regularUser.Id, newPeer, false)
+ existingPeer, _, _, _, err := manager.AddPeer(context.Background(), "", "", regularUser.Id, newPeer, false)
require.NoError(t, err)
// Try to login with regular user
@@ -2513,7 +2513,7 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) {
},
}
- _, _, _, err = manager.LoginPeer(context.Background(), login)
+ _, _, _, _, err = manager.LoginPeer(context.Background(), login)
require.NoError(t, err, "Regular user should be able to login peers")
}
@@ -2837,7 +2837,7 @@ func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) {
// Add first peer with hostname that produces DNS label "netbird1"
key1, err := wgtypes.GenerateKey()
require.NoError(t, err)
- peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "netbird1.netbird.cloud"},
}, false)
@@ -2847,7 +2847,7 @@ func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) {
// Add second peer with a different hostname
key2, err := wgtypes.GenerateKey()
require.NoError(t, err)
- peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "ip-10-29-5-130"},
}, false)
@@ -2871,7 +2871,7 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) {
key1, err := wgtypes.GenerateKey()
require.NoError(t, err)
- peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key1.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "web-server"},
}, false)
@@ -2881,7 +2881,7 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) {
// Add second peer and rename it to a unique FQDN whose first label doesn't collide
key2, err := wgtypes.GenerateKey()
require.NoError(t, err)
- peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
+ peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
Key: key2.PublicKey().String(),
Meta: nbpeer.PeerSystemMeta{Hostname: "old-name"},
}, false)
diff --git a/management/server/types/account.go b/management/server/types/account.go
index d658f605d..7a0a0054f 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -1156,6 +1156,47 @@ func policyRuleImpliesLegacySSH(rule *PolicyRule) bool {
return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges)))
}
+// PeerSSHEnabledFromPolicies is the network-map-free equivalent of the sshEnabled
+// determination in GetPeerConnectionResources / CalculateNetworkMapFromComponents.
+func PeerSSHEnabledFromPolicies(policies []*Policy, peerID string, peerGroupIDs map[string]struct{}, peerSSHEnabled bool) bool {
+ for _, policy := range policies {
+ if !policy.Enabled {
+ continue
+ }
+
+ for _, rule := range policy.Rules {
+ if !rule.Enabled {
+ continue
+ }
+
+ isSSHRule := rule.Protocol == PolicyRuleProtocolNetbirdSSH ||
+ (policyRuleImpliesLegacySSH(rule) && peerSSHEnabled)
+ if !isSSHRule {
+ continue
+ }
+
+ if ruleHasDestination(rule, peerID, peerGroupIDs) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+func ruleHasDestination(rule *PolicyRule, peerID string, peerGroupIDs map[string]struct{}) bool {
+ if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
+ return rule.DestinationResource.ID == peerID
+ }
+
+ for _, groupID := range rule.Destinations {
+ if _, ok := peerGroupIDs[groupID]; ok {
+ return true
+ }
+ }
+ return false
+}
+
func portRangeIncludesSSH(portRanges []RulePortRange) bool {
for _, pr := range portRanges {
if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) {
diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go
index 3785a7399..1e3035300 100644
--- a/management/server/types/networkmap_components_correctness_test.go
+++ b/management/server/types/networkmap_components_correctness_test.go
@@ -1233,3 +1233,97 @@ func TestComponents_DisabledRuleInEnabledPolicy(t *testing.T) {
assert.True(t, has3000, "enabled rule should generate firewall rule for port 3000")
assert.False(t, has3001, "disabled rule should NOT generate firewall rule for port 3001")
}
+
+func peerGroupIDSet(account *types.Account, peerID string) map[string]struct{} {
+ return account.GetPeerGroups(peerID)
+}
+
+func assertSSHEquivalence(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) {
+ t.Helper()
+ nm := componentsNetworkMap(account, peerID, validatedPeers)
+ require.NotNil(t, nm)
+
+ got := types.PeerSSHEnabledFromPolicies(account.Policies, peerID, peerGroupIDSet(account, peerID), account.Peers[peerID].SSHEnabled)
+ assert.Equalf(t, nm.EnableSSH, got, "PeerSSHEnabledFromPolicies mismatch for %s", peerID)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_NetbirdSSHProtocol(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(20, 2)
+ account.Groups["ssh-users"] = &types.Group{ID: "ssh-users", Name: "SSH Users", Peers: []string{}}
+ account.Policies = append(account.Policies, &types.Policy{
+ ID: "policy-ssh", Name: "SSH Access", Enabled: true, AccountID: "test-account",
+ Rules: []*types.PolicyRule{{
+ ID: "rule-ssh", Name: "Allow SSH", Enabled: true,
+ Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+ Bidirectional: false,
+ Sources: []string{"group-0"}, Destinations: []string{"group-1"},
+ AuthorizedGroups: map[string][]string{"ssh-users": {"root"}},
+ }},
+ })
+
+ assertSSHEquivalence(t, account, "peer-10", validatedPeers)
+ assertSSHEquivalence(t, account, "peer-0", validatedPeers)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_NoSSHPolicy(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(20, 2)
+ assertSSHEquivalence(t, account, "peer-0", validatedPeers)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_LegacyImpliedSSH(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(20, 2)
+ account.Peers["peer-10"].SSHEnabled = true
+ assertSSHEquivalence(t, account, "peer-10", validatedPeers)
+ assertSSHEquivalence(t, account, "peer-11", validatedPeers)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_PeerAsDestinationResource(t *testing.T) {
+ account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2)
+ account.Policies = append(account.Policies, &types.Policy{
+ ID: "policy-ssh-res", Name: "SSH to peer", Enabled: true, AccountID: "test-account",
+ Rules: []*types.PolicyRule{{
+ ID: "rule-ssh-res", Name: "SSH to peer-5", Enabled: true,
+ Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+ Sources: []string{"group-0"},
+ DestinationResource: types.Resource{ID: "peer-5", Type: types.ResourceTypePeer},
+ }},
+ })
+
+ assertSSHEquivalence(t, account, "peer-5", validatedPeers)
+ assertSSHEquivalence(t, account, "peer-6", validatedPeers)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_DisabledSSHPolicy(t *testing.T) {
+ account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2)
+ account.Policies = append(account.Policies, &types.Policy{
+ ID: "policy-ssh-off", Name: "SSH disabled", Enabled: false, AccountID: "test-account",
+ Rules: []*types.PolicyRule{{
+ ID: "rule-ssh-off", Name: "Allow SSH", Enabled: true,
+ Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+ Sources: []string{"group-0"}, Destinations: []string{"group-1"},
+ }},
+ })
+ assertSSHEquivalence(t, account, "peer-10", validatedPeers)
+}
+
+func TestPeerSSHEnabledFromPolicies_MatchesMap_Sweep(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(60, 6)
+ account.Policies = append(account.Policies, &types.Policy{
+ ID: "policy-ssh-sweep", Name: "SSH sweep", Enabled: true, AccountID: "test-account",
+ Rules: []*types.PolicyRule{{
+ ID: "rule-ssh-sweep", Name: "Allow SSH", Enabled: true,
+ Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH,
+ Sources: []string{"group-0"}, Destinations: []string{"group-2"},
+ }},
+ })
+ for peerID := range account.Peers {
+ account.Peers[peerID].SSHEnabled = len(peerID)%2 == 0
+ }
+
+ for peerID := range account.Peers {
+ if _, ok := validatedPeers[peerID]; !ok {
+ continue
+ }
+ assertSSHEquivalence(t, account, peerID, validatedPeers)
+ }
+}
diff --git a/management/server/user_test.go b/management/server/user_test.go
index d46519396..f32a6b3a1 100644
--- a/management/server/user_test.go
+++ b/management/server/user_test.go
@@ -1565,7 +1565,7 @@ func TestUserAccountPeersUpdate(t *testing.T) {
require.NoError(t, err)
expectedPeerKey := key.PublicKey().String()
- peer4, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{
+ peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{
Key: expectedPeerKey,
Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey},
}, false)
From 8ae2cd0a08af0a5311cc3d3d52656448141afbb3 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Wed, 17 Jun 2026 18:29:33 +0200
Subject: [PATCH 02/54] [client] Fix ios route notify ordering (#6454)
* [client] fix iOS route-update reordering that black-holed IPv6 on exit-node disable
On iOS the route notifier delivered each prefix update from its own
fire-and-forget goroutine (notify -> `go func`), so Go provided no ordering
guarantee between consecutive updates. It also read currentPrefixes inside
that goroutine without holding the lock, racing the next OnNewPrefixes write.
On exit-node disable the core removes the default routes as two separate
prefix updates (0.0.0.0/0, then the synthesized ::/0). When the two
goroutines were reordered, the stale snapshot still containing ::/0 was
delivered last and clobbered the correct default-free one. iOS then kept the
::/0 default route on the tunnel with no exit node to carry it, black-holing
all IPv6 traffic while IPv4 recovered correctly.
Fix: deliver updates through a single worker goroutine fed by a buffered
channel, preserving production order, and snapshot the joined prefix string
under the mutex so it can't race a concurrent update. Buffered so producers
(which run under the route manager lock) don't block on the listener callback.
* [client] close iOS notifier delivery goroutine on Stop, unbounded queue
The delivery goroutine was never stopped, leaking on every engine
restart. Add Notifier.Close, called from the route manager Stop after
routing cleanup.
Replace the buffered update channel with a cond-driven linked-list
queue so route-update producers (running under the route manager lock)
never block when the listener callback is slow.
---
client/internal/routemanager/manager.go | 2 +
.../routemanager/notifier/notifier_android.go | 6 +-
.../routemanager/notifier/notifier_ios.go | 64 +++++++++++++------
.../routemanager/notifier/notifier_other.go | 4 ++
4 files changed, 57 insertions(+), 19 deletions(-)
diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go
index 0edf4607f..22458d575 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -333,6 +333,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) {
}
}
+ m.notifier.Close()
+
m.mux.Lock()
defer m.mux.Unlock()
m.clientRoutes = nil
diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go
index 140a583f7..49300dbb2 100644
--- a/client/internal/routemanager/notifier/notifier_android.go
+++ b/client/internal/routemanager/notifier/notifier_android.go
@@ -16,7 +16,7 @@ import (
type Notifier struct {
initialRoutes []*route.Route
currentRoutes []*route.Route
- fakeIPRoutes []*route.Route
+ fakeIPRoutes []*route.Route
listener listener.NetworkChangeListener
listenerMux sync.Mutex
@@ -119,3 +119,7 @@ func (n *Notifier) GetInitialRouteRanges() []string {
sort.Strings(initialStrings)
return initialStrings
}
+
+func (n *Notifier) Close() {
+ // unused
+}
diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go
index 27a2a722d..d0888f3a1 100644
--- a/client/internal/routemanager/notifier/notifier_ios.go
+++ b/client/internal/routemanager/notifier/notifier_ios.go
@@ -3,6 +3,7 @@
package notifier
import (
+ "container/list"
"net/netip"
"slices"
"sort"
@@ -14,19 +15,26 @@ import (
)
type Notifier struct {
+ mu sync.Mutex
+ cond *sync.Cond
currentPrefixes []string
-
- listener listener.NetworkChangeListener
- listenerMux sync.Mutex
+ listener listener.NetworkChangeListener
+ queue *list.List
+ closed bool
}
func NewNotifier() *Notifier {
- return &Notifier{}
+ n := &Notifier{
+ queue: list.New(),
+ }
+ n.cond = sync.NewCond(&n.mu)
+ go n.deliverLoop()
+ return n
}
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
- n.listenerMux.Lock()
- defer n.listenerMux.Unlock()
+ n.mu.Lock()
+ defer n.mu.Unlock()
n.listener = listener
}
@@ -43,32 +51,52 @@ func (n *Notifier) OnNewRoutes(route.HAMap) {
}
func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
- newNets := make([]string, 0)
+ newNets := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
newNets = append(newNets, prefix.String())
}
sort.Strings(newNets)
+ n.mu.Lock()
if slices.Equal(n.currentPrefixes, newNets) {
+ n.mu.Unlock()
return
}
-
n.currentPrefixes = newNets
- n.notify()
+ routes := strings.Join(n.currentPrefixes, ",")
+ n.queue.PushBack(routes)
+ n.cond.Signal()
+ n.mu.Unlock()
}
-func (n *Notifier) notify() {
- n.listenerMux.Lock()
- defer n.listenerMux.Unlock()
- if n.listener == nil {
- return
- }
- go func(l listener.NetworkChangeListener) {
- l.OnNetworkChanged(strings.Join(n.currentPrefixes, ","))
- }(n.listener)
+func (n *Notifier) Close() {
+ n.mu.Lock()
+ n.closed = true
+ n.cond.Signal()
+ n.mu.Unlock()
}
func (n *Notifier) GetInitialRouteRanges() []string {
return nil
}
+
+func (n *Notifier) deliverLoop() {
+ for {
+ n.mu.Lock()
+ for n.queue.Len() == 0 && !n.closed {
+ n.cond.Wait()
+ }
+ if n.closed && n.queue.Len() == 0 {
+ n.mu.Unlock()
+ return
+ }
+ routes := n.queue.Remove(n.queue.Front()).(string)
+ l := n.listener
+ n.mu.Unlock()
+
+ if l != nil {
+ l.OnNetworkChanged(routes)
+ }
+ }
+}
diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go
index f57cadb0b..71b1096c2 100644
--- a/client/internal/routemanager/notifier/notifier_other.go
+++ b/client/internal/routemanager/notifier/notifier_other.go
@@ -38,3 +38,7 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
func (n *Notifier) GetInitialRouteRanges() []string {
return []string{}
}
+
+func (n *Notifier) Close() {
+ // unused
+}
From 5bd7c6c7ea0c8cebe78fdf8ecff9b80511660ec0 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Thu, 18 Jun 2026 01:48:09 +0900
Subject: [PATCH 03/54] [client] Detect and recover from a stalled signal
receive stream (#6459)
---
client/internal/engine.go | 7 ++
shared/signal/client/grpc.go | 121 ++++++++++++++++++++---
shared/signal/client/watchdog_test.go | 84 ++++++++++++++++
shared/signal/proto/signalexchange.pb.go | 64 ++++++------
shared/signal/proto/signalexchange.proto | 1 +
5 files changed, 233 insertions(+), 44 deletions(-)
create mode 100644 shared/signal/client/watchdog_test.go
diff --git a/client/internal/engine.go b/client/internal/engine.go
index cf40d8983..42712da92 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -1714,6 +1714,13 @@ func (e *Engine) receiveSignalEvents() {
return e.ctx.Err()
}
+ // Self-addressed heartbeat: the signal client's receive watchdog
+ // round-trips this through the server to confirm the receive stream
+ // is delivering. Liveness is already recorded before this handler.
+ if msg.GetBody().GetType() == sProto.Body_HEARTBEAT {
+ return nil
+ }
+
conn, ok := e.peerStore.PeerConn(msg.Key)
if !ok {
return fmt.Errorf("wrongly addressed message %s", msg.Key)
diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go
index b245b2296..eb18cea05 100644
--- a/shared/signal/client/grpc.go
+++ b/shared/signal/client/grpc.go
@@ -2,9 +2,11 @@ package client
import (
"context"
+ "errors"
"fmt"
"io"
"sync"
+ "sync/atomic"
"time"
"github.com/cenkalti/backoff/v4"
@@ -23,7 +25,23 @@ import (
"github.com/netbirdio/netbird/util/wsproxy"
)
-const healthCheckTimeout = 5 * time.Second
+const (
+ // receiveInactivityThreshold is how long the receive stream may be silent
+ // before the watchdog actively probes it. The gRPC transport can stay
+ // healthy (keepalive satisfied) while the server stops delivering messages,
+ // which the transport layer cannot detect.
+ receiveInactivityThreshold = 30 * time.Second
+ // receiveProbeTimeout is how long the watchdog waits for its self-addressed
+ // probe to round-trip back on the stream before declaring the receive
+ // direction dead.
+ receiveProbeTimeout = 10 * time.Second
+ // receiveWatchdogInterval is how often the watchdog evaluates the stream.
+ receiveWatchdogInterval = 10 * time.Second
+)
+
+// errReceiveStreamStalled is reported when the receive stream is transport-alive
+// but no longer delivering messages, so the stream is torn down to reconnect.
+var errReceiveStreamStalled = errors.New("signal receive stream stalled")
// ConnStateNotifier is a wrapper interface of the status recorder
type ConnStateNotifier interface {
@@ -52,6 +70,14 @@ type GrpcClient struct {
decryptionWorker *Worker
decryptionWorkerCancel context.CancelFunc
decryptionWg sync.WaitGroup
+
+ // lastReceived holds the Unix-nano timestamp of the last message read from
+ // the receive stream, used by the receive watchdog.
+ lastReceived atomic.Int64
+ // receiveStalled is set by the receive watchdog when the stream is
+ // transport-alive but no longer delivering messages. It is the source of
+ // truth IsHealthy reads, and is cleared once any frame is received again.
+ receiveStalled atomic.Bool
}
// NewClient creates a new Signal client
@@ -148,9 +174,9 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
// connect to Signal stream identifying ourselves with a public WireGuard key
// todo once the key rotation logic has been implemented, consider changing to some other identifier (received from management)
- ctx, cancelStream := context.WithCancel(ctx)
+ streamCtx, cancelStream := context.WithCancel(ctx)
defer cancelStream()
- stream, err := c.connect(ctx, c.key.PublicKey().String())
+ stream, err := c.connect(streamCtx, c.key.PublicKey().String())
if err != nil {
log.Warnf("disconnected from the Signal Exchange due to an error: %v", err)
return err
@@ -164,9 +190,16 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
// Start worker pool if not already started
c.startEncryptionWorker(msgHandler)
+ // Guard the receive direction: the transport can stay healthy while the
+ // server stops delivering messages. The watchdog reconnects via cancelStream.
+ c.markReceived()
+ go c.watchReceiveStream(streamCtx, cancelStream)
+
// start receiving messages from the Signal stream (from other peers through signal)
err = c.receive(stream)
if err != nil {
+ // Check the parent context, not streamCtx: a watchdog-triggered
+ // cancelStream must reconnect, only a parent cancel is shutdown.
if ctx.Err() != nil {
log.Debugf("signal connection context has been canceled, this usually indicates shutdown")
return nil
@@ -252,7 +285,10 @@ func (c *GrpcClient) Ready() bool {
return c.signalConn.GetState() == connectivity.Ready || c.signalConn.GetState() == connectivity.Idle
}
-// IsHealthy probes the gRPC connection and returns false on errors
+// IsHealthy reports whether the Signal connection is usable, based on the
+// transport state plus the receive watchdog's verdict, and updates the status
+// recorder accordingly. It does not actively probe: the watchdog
+// (watchReceiveStream) owns probing the receive path and reconnecting.
func (c *GrpcClient) IsHealthy() bool {
switch c.signalConn.GetState() {
case connectivity.TransientFailure:
@@ -265,16 +301,8 @@ func (c *GrpcClient) IsHealthy() bool {
case connectivity.Ready:
}
- ctx, cancel := context.WithTimeout(c.ctx, healthCheckTimeout)
- defer cancel()
- _, err := c.realClient.Send(ctx, &proto.EncryptedMessage{
- Key: c.key.PublicKey().String(),
- RemoteKey: "dummy",
- Body: nil,
- })
- if err != nil {
- c.notifyDisconnected(err)
- log.Warnf("health check returned: %s", err)
+ if c.receiveStalled.Load() {
+ c.notifyDisconnected(errReceiveStreamStalled)
return false
}
c.notifyConnected()
@@ -398,6 +426,68 @@ func (c *GrpcClient) Send(msg *proto.Message) error {
return err
}
+// markReceived records that a frame was just read from the receive stream and
+// clears the stalled flag.
+func (c *GrpcClient) markReceived() {
+ c.lastReceived.Store(time.Now().UnixNano())
+ c.receiveStalled.Store(false)
+}
+
+// idleSinceReceive returns how long the receive stream has been silent.
+func (c *GrpcClient) idleSinceReceive() time.Duration {
+ return time.Since(time.Unix(0, c.lastReceived.Load()))
+}
+
+// watchReceiveStream guards against a receive stream that is transport-alive but
+// no longer delivering messages. While the stream is idle past
+// receiveInactivityThreshold it sends a self-addressed probe that the Signal
+// server routes back to this client. If the probe does not round-trip within
+// receiveProbeTimeout the receive direction is considered dead and cancelStream
+// is called so the retry loop reconnects.
+func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream context.CancelFunc) {
+ ticker := time.NewTicker(receiveWatchdogInterval)
+ defer ticker.Stop()
+
+ var probeSentAt time.Time
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if c.idleSinceReceive() < receiveInactivityThreshold {
+ probeSentAt = time.Time{}
+ continue
+ }
+
+ if !probeSentAt.IsZero() && time.Since(probeSentAt) >= receiveProbeTimeout {
+ log.Warnf("signal receive stream stalled: no messages for %s and probe did not return, reconnecting", c.idleSinceReceive().Round(time.Second))
+ c.receiveStalled.Store(true)
+ c.notifyDisconnected(errReceiveStreamStalled)
+ cancelStream()
+ return
+ }
+
+ if probeSentAt.IsZero() {
+ if err := c.sendReceiveProbe(); err != nil {
+ log.Debugf("failed to send signal receive probe: %v", err)
+ }
+ probeSentAt = time.Now()
+ }
+ }
+ }
+}
+
+// sendReceiveProbe sends a self-addressed heartbeat. The Signal server routes it
+// back to this client, exercising the exact receive path the watchdog guards.
+func (c *GrpcClient) sendReceiveProbe() error {
+ self := c.key.PublicKey().String()
+ return c.Send(&proto.Message{
+ Key: self,
+ RemoteKey: self,
+ Body: &proto.Body{Type: proto.Body_HEARTBEAT},
+ })
+}
+
// receive receives messages from other peers coming through the Signal Exchange
// and distributes them to worker threads for processing
func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) error {
@@ -419,6 +509,9 @@ func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) er
return err
}
+ // Any frame from the server proves the receive direction is alive.
+ c.markReceived()
+
if msg == nil {
continue
}
diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go
new file mode 100644
index 000000000..1905e7562
--- /dev/null
+++ b/shared/signal/client/watchdog_test.go
@@ -0,0 +1,84 @@
+package client
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+ "google.golang.org/grpc"
+
+ sigProto "github.com/netbirdio/netbird/shared/signal/proto"
+ "github.com/netbirdio/netbird/signal/server"
+)
+
+func startTestSignalServer(t *testing.T) string {
+ t.Helper()
+
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+
+ s := grpc.NewServer()
+ srv, err := server.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ sigProto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ _ = s.Serve(lis)
+ }()
+ t.Cleanup(s.Stop)
+
+ return lis.Addr().String()
+}
+
+// TestReceiveProbeRoundTrips verifies that the watchdog's self-addressed heartbeat
+// is routed back to the same client through the signal server. This round-trip is
+// what lets the watchdog confirm the receive direction is still delivering.
+func TestReceiveProbeRoundTrips(t *testing.T) {
+ addr := startTestSignalServer(t)
+
+ key, err := wgtypes.GenerateKey()
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ client, err := NewClient(ctx, addr, key, false)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = client.Close() })
+
+ received := make(chan struct{}, 1)
+ go func() {
+ _ = client.Receive(ctx, func(msg *sigProto.Message) error {
+ if msg.GetBody().GetType() == sigProto.Body_HEARTBEAT && msg.GetKey() == key.PublicKey().String() {
+ select {
+ case received <- struct{}{}:
+ default:
+ }
+ }
+ return nil
+ })
+ }()
+
+ streamReady := make(chan struct{})
+ go func() {
+ client.WaitStreamConnected()
+ close(streamReady)
+ }()
+ select {
+ case <-streamReady:
+ case <-time.After(5 * time.Second):
+ t.Fatal("signal stream did not connect within timeout")
+ }
+
+ require.NoError(t, client.sendReceiveProbe())
+
+ select {
+ case <-received:
+ case <-time.After(3 * time.Second):
+ t.Fatal("self-addressed heartbeat did not round-trip back through the signal server")
+ }
+}
diff --git a/shared/signal/proto/signalexchange.pb.go b/shared/signal/proto/signalexchange.pb.go
index 0c80fb489..8e07977f0 100644
--- a/shared/signal/proto/signalexchange.pb.go
+++ b/shared/signal/proto/signalexchange.pb.go
@@ -30,6 +30,7 @@ const (
Body_CANDIDATE Body_Type = 2
Body_MODE Body_Type = 4
Body_GO_IDLE Body_Type = 5
+ Body_HEARTBEAT Body_Type = 6
)
// Enum value maps for Body_Type.
@@ -40,6 +41,7 @@ var (
2: "CANDIDATE",
4: "MODE",
5: "GO_IDLE",
+ 6: "HEARTBEAT",
}
Body_Type_value = map[string]int32{
"OFFER": 0,
@@ -47,6 +49,7 @@ var (
"CANDIDATE": 2,
"MODE": 4,
"GO_IDLE": 5,
+ "HEARTBEAT": 6,
}
)
@@ -463,7 +466,7 @@ var file_signalexchange_proto_rawDesc = []byte{
0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62,
0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e,
0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52,
- 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xc3, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
+ 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xd2, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d,
0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73,
0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f,
0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a,
@@ -491,38 +494,39 @@ var file_signalexchange_proto_rawDesc = []byte{
0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29,
0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18,
0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65,
- 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x43, 0x0a, 0x04, 0x54, 0x79, 0x70,
+ 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70,
0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06,
0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44,
0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10,
- 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x42, 0x15,
- 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64,
- 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f,
- 0x6e, 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72,
- 0x76, 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d,
- 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01,
- 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52,
- 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28,
- 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65,
- 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61,
- 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65,
- 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18,
- 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73,
- 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53,
- 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a,
- 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78,
- 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64,
- 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c,
- 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
- 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43,
- 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73,
- 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e,
- 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20,
- 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e,
- 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
- 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
- 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+ 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d,
+ 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a,
+ 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64,
+ 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
+ 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76,
+ 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f,
+ 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42,
+ 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f,
+ 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a,
+ 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
+ 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
+ 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53,
+ 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69,
+ 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04,
+ 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63,
+ 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
+ 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65,
+ 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
+ 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f,
+ 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69,
+ 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63,
+ 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e,
+ 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45,
+ 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22,
+ 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+ 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
diff --git a/shared/signal/proto/signalexchange.proto b/shared/signal/proto/signalexchange.proto
index 96a4001e3..8c304e37c 100644
--- a/shared/signal/proto/signalexchange.proto
+++ b/shared/signal/proto/signalexchange.proto
@@ -48,6 +48,7 @@ message Body {
CANDIDATE = 2;
MODE = 4;
GO_IDLE = 5;
+ HEARTBEAT = 6;
}
Type type = 1;
string payload = 2;
From 8d9580e49112857c99e44f3c877ececec2d20e4c Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Wed, 17 Jun 2026 20:13:13 +0200
Subject: [PATCH 04/54] [misc] improve goreleaser with RC handling and update
docker builds (#6438)
- introduce variables to avoid publishing latest docker tags and installers
- Refactor .goreleaser.yaml to simplify docker configurations and add environment-driven flags
- removed management debug containers (it was doing only log var)
- Stopped building arm v6 32bits in favor of v7 32 bits for services (not client)
- Add target argument to docker files
---
.github/workflows/release.yml | 53 ++-
.goreleaser.yaml | 862 ++++++++--------------------------
.goreleaser_ui.yaml | 5 +-
client/Dockerfile | 6 +-
client/Dockerfile-rootless | 6 +-
combined/Dockerfile | 3 +-
management/Dockerfile | 3 +-
management/Dockerfile.debug | 5 -
proxy/Dockerfile | 3 +-
relay/Dockerfile | 3 +-
signal/Dockerfile | 3 +-
upload-server/Dockerfile | 3 +-
12 files changed, 268 insertions(+), 687 deletions(-)
delete mode 100644 management/Dockerfile.debug
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b335aad72..bd3514d27 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,10 +9,13 @@ on:
pull_request:
env:
- SIGN_PIPE_VER: "v0.1.5"
- GORELEASER_VER: "v2.14.3"
+ SIGN_PIPE_VER: "v0.1.6"
+ GORELEASER_VER: "v2.16.0"
PRODUCT_NAME: "NetBird"
COPYRIGHT: "NetBird GmbH"
+ flags: ""
+ SKIP_PUBLISH: "true"
+ SKIP_DOCKER_PUSH: "false"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -130,8 +133,6 @@ jobs:
windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }}
macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }}
ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }}
- env:
- flags: ""
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -143,8 +144,27 @@ jobs:
id: semver_parser
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- - if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
- run: echo "flags=--snapshot" >> $GITHUB_ENV
+ - name: Set snapshot flag
+ if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
+ run: |
+ echo "flags=--snapshot" >> $GITHUB_ENV
+
+ - name: Set build vars
+ if: ${{ startsWith(github.ref, 'refs/tags/v') }}
+ run: |
+ if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
+ echo "x-${{ github.repository }}"
+ echo "x-${{ steps.semver_parser.outputs.prerelease }}"
+ echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
+ else
+ echo "x-${{ github.repository }}"
+ echo "x-${{ steps.semver_parser.outputs.prerelease }}"
+ fi
+
+ if [[ "x-${{ github.repository }}" != "x-netbirdio/netbird" ]]; then
+ echo "SKIP_DOCKER_PUSH=true" >> $GITHUB_ENV
+ fi
+
- name: Set up Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
@@ -212,6 +232,8 @@ jobs:
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
+ SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
+ SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }}
- name: Verify RPM signatures
run: |
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
@@ -334,8 +356,22 @@ jobs:
id: semver_parser
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- - if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
- run: echo "flags=--snapshot" >> $GITHUB_ENV
+ - name: Set snapshot flag
+ if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
+ run: |
+ echo "flags=--snapshot" >> $GITHUB_ENV
+
+ - name: Set build vars
+ if: ${{ startsWith(github.ref, 'refs/tags/v') }}
+ run: |
+ if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then
+ echo "x-${{ github.repository }}"
+ echo "x-${{ steps.semver_parser.outputs.prerelease }}"
+ echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
+ else
+ echo "x-${{ github.repository }}"
+ echo "x-${{ steps.semver_parser.outputs.prerelease }}"
+ fi
- name: Set up Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
@@ -395,6 +431,7 @@ jobs:
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
+ SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
- name: Verify RPM signatures
run: |
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 5ea479148..5031ef446 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -1,5 +1,7 @@
version: 2
-
+env:
+ - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
+ - SKIP_DOCKER_PUSH={{ if index .Env "SKIP_DOCKER_PUSH" }}{{ .Env.SKIP_DOCKER_PUSH }}{{ else }}false{{ end }}
project_name: netbird
builds:
- id: netbird-wasm
@@ -74,6 +76,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -88,6 +92,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -102,6 +108,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -122,6 +130,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -136,6 +146,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -150,6 +162,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -170,6 +184,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -222,670 +238,192 @@ nfpms:
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
-dockers:
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-amd64
- - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
- ids:
- - netbird
- goarch: amd64
- use: buildx
- dockerfile: client/Dockerfile
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
- ids:
- - netbird
- goarch: arm64
- use: buildx
- dockerfile: client/Dockerfile
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm
- ids:
- - netbird
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: client/Dockerfile
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-amd64
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
- ids:
- - netbird
- goarch: amd64
- use: buildx
- dockerfile: client/Dockerfile-rootless
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- ids:
- - netbird
- goarch: arm64
- use: buildx
- dockerfile: client/Dockerfile-rootless
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
- ids:
- - netbird
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: client/Dockerfile-rootless
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-
- - image_templates:
- - netbirdio/relay:{{ .Version }}-amd64
- - ghcr.io/netbirdio/relay:{{ .Version }}-amd64
- ids:
- - netbird-relay
- goarch: amd64
- use: buildx
- dockerfile: relay/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/relay:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
- ids:
- - netbird-relay
- goarch: arm64
- use: buildx
- dockerfile: relay/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/relay:{{ .Version }}-arm
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm
- ids:
- - netbird-relay
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: relay/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/signal:{{ .Version }}-amd64
- - ghcr.io/netbirdio/signal:{{ .Version }}-amd64
- ids:
- - netbird-signal
- goarch: amd64
- use: buildx
- dockerfile: signal/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/signal:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
- ids:
- - netbird-signal
- goarch: arm64
- use: buildx
- dockerfile: signal/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/signal:{{ .Version }}-arm
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm
- ids:
- - netbird-signal
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: signal/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-amd64
- - ghcr.io/netbirdio/management:{{ .Version }}-amd64
- ids:
- - netbird-mgmt
- goarch: amd64
- use: buildx
- dockerfile: management/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
- ids:
- - netbird-mgmt
- goarch: arm64
- use: buildx
- dockerfile: management/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-arm
- ids:
- - netbird-mgmt
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: management/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-debug-amd64
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64
- ids:
- - netbird-mgmt
- goarch: amd64
- use: buildx
- dockerfile: management/Dockerfile.debug
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-debug-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8
- ids:
- - netbird-mgmt
- goarch: arm64
- use: buildx
- dockerfile: management/Dockerfile.debug
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-
- - image_templates:
- - netbirdio/management:{{ .Version }}-debug-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm
- ids:
- - netbird-mgmt
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: management/Dockerfile.debug
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/upload:{{ .Version }}-amd64
- - ghcr.io/netbirdio/upload:{{ .Version }}-amd64
- ids:
- - netbird-upload
- goarch: amd64
- use: buildx
- dockerfile: upload-server/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/upload:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
- ids:
- - netbird-upload
- goarch: arm64
- use: buildx
- dockerfile: upload-server/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/upload:{{ .Version }}-arm
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm
- ids:
- - netbird-upload
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: upload-server/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird-server:{{ .Version }}-amd64
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
- ids:
- - netbird-server
- goarch: amd64
- use: buildx
- dockerfile: combined/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
- ids:
- - netbird-server
- goarch: arm64
- use: buildx
- dockerfile: combined/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
- ids:
- - netbird-server
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: combined/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-amd64
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
- ids:
- - netbird-proxy
- goarch: amd64
- use: buildx
- dockerfile: proxy/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- ids:
- - netbird-proxy
- goarch: arm64
- use: buildx
- dockerfile: proxy/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
- ids:
- - netbird-proxy
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: proxy/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-docker_manifests:
- - name_template: netbirdio/netbird:{{ .Version }}
- image_templates:
- - netbirdio/netbird:{{ .Version }}-arm64v8
- - netbirdio/netbird:{{ .Version }}-arm
- - netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird:latest
- image_templates:
- - netbirdio/netbird:{{ .Version }}-arm64v8
- - netbirdio/netbird:{{ .Version }}-arm
- - netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird:{{ .Version }}-rootless
- image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - netbirdio/netbird:{{ .Version }}-rootless-arm
- - netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: netbirdio/netbird:rootless-latest
- image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - netbirdio/netbird:{{ .Version }}-rootless-arm
- - netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: netbirdio/relay:{{ .Version }}
- image_templates:
- - netbirdio/relay:{{ .Version }}-arm64v8
- - netbirdio/relay:{{ .Version }}-arm
- - netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: netbirdio/relay:latest
- image_templates:
- - netbirdio/relay:{{ .Version }}-arm64v8
- - netbirdio/relay:{{ .Version }}-arm
- - netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: netbirdio/signal:{{ .Version }}
- image_templates:
- - netbirdio/signal:{{ .Version }}-arm64v8
- - netbirdio/signal:{{ .Version }}-arm
- - netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: netbirdio/signal:latest
- image_templates:
- - netbirdio/signal:{{ .Version }}-arm64v8
- - netbirdio/signal:{{ .Version }}-arm
- - netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: netbirdio/management:{{ .Version }}
- image_templates:
- - netbirdio/management:{{ .Version }}-arm64v8
- - netbirdio/management:{{ .Version }}-arm
- - netbirdio/management:{{ .Version }}-amd64
-
- - name_template: netbirdio/management:latest
- image_templates:
- - netbirdio/management:{{ .Version }}-arm64v8
- - netbirdio/management:{{ .Version }}-arm
- - netbirdio/management:{{ .Version }}-amd64
-
- - name_template: netbirdio/management:debug-latest
- image_templates:
- - netbirdio/management:{{ .Version }}-debug-arm64v8
- - netbirdio/management:{{ .Version }}-debug-arm
- - netbirdio/management:{{ .Version }}-debug-amd64
- - name_template: netbirdio/upload:{{ .Version }}
- image_templates:
- - netbirdio/upload:{{ .Version }}-arm64v8
- - netbirdio/upload:{{ .Version }}-arm
- - netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: netbirdio/upload:latest
- image_templates:
- - netbirdio/upload:{{ .Version }}-arm64v8
- - netbirdio/upload:{{ .Version }}-arm
- - netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird-server:{{ .Version }}
- image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm64v8
- - netbirdio/netbird-server:{{ .Version }}-arm
- - netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird-server:latest
- image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm64v8
- - netbirdio/netbird-server:{{ .Version }}-arm
- - netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:latest
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:{{ .Version }}-rootless
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:rootless-latest
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: ghcr.io/netbirdio/relay:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm
- - ghcr.io/netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/relay:latest
- image_templates:
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm
- - ghcr.io/netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/signal:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm
- - ghcr.io/netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/signal:latest
- image_templates:
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm
- - ghcr.io/netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/management:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/management:latest
- image_templates:
- - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/management:debug-latest
- image_templates:
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64
-
- - name_template: ghcr.io/netbirdio/upload:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm
- - ghcr.io/netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/upload:latest
- image_templates:
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm
- - ghcr.io/netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird-server:latest
- image_templates:
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: netbirdio/reverse-proxy:{{ .Version }}
- image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - netbirdio/reverse-proxy:{{ .Version }}-arm
- - netbirdio/reverse-proxy:{{ .Version }}-amd64
-
- - name_template: netbirdio/reverse-proxy:latest
- image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - netbirdio/reverse-proxy:{{ .Version }}-arm
- - netbirdio/reverse-proxy:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/reverse-proxy:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/reverse-proxy:latest
- image_templates:
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
+dockers_v2:
+ - id: netbird
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird
+ images:
+ - netbirdio/netbird
+ - ghcr.io/netbirdio/netbird
+ tags:
+ - "v{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: client/Dockerfile
+ extra_files:
+ - client/netbird-entrypoint.sh
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm/6
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: netbird-rootless
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird
+ images:
+ - netbirdio/netbird
+ - ghcr.io/netbirdio/netbird
+ tags:
+ - "v{{ .Version }}-rootless"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: client/Dockerfile-rootless
+ extra_files:
+ - client/netbird-entrypoint.sh
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm/6
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: relay
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-relay
+ images:
+ - netbirdio/relay
+ - ghcr.io/netbirdio/relay
+ tags:
+ - "v{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: relay/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: signal
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-signal
+ images:
+ - netbirdio/signal
+ - ghcr.io/netbirdio/signal
+ tags:
+ - "v{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: signal/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: management
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-mgmt
+ images:
+ - netbirdio/management
+ - ghcr.io/netbirdio/management
+ tags:
+ - "v{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: management/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: upload
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-upload
+ images:
+ - netbirdio/upload
+ - ghcr.io/netbirdio/upload
+ tags:
+ - "v{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: upload-server/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: netbird-server
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-server
+ images:
+ - netbirdio/netbird-server
+ - ghcr.io/netbirdio/netbird-server
+ tags:
+ - "v{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: combined/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: netbird-proxy
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-proxy
+ images:
+ - netbirdio/reverse-proxy
+ - ghcr.io/netbirdio/reverse-proxy
+ tags:
+ - "v{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: proxy/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
brews:
- ids:
- default
+ skip_upload: "{{ .Env.SKIP_PUBLISH }}"
repository:
owner: netbirdio
name: homebrew-tap
@@ -902,6 +440,7 @@ brews:
uploads:
- name: debian
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_deb
mode: archive
@@ -910,6 +449,7 @@ uploads:
method: PUT
- name: yum
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_rpm
mode: archive
diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml
index 470f1deaa..6f9b7c059 100644
--- a/.goreleaser_ui.yaml
+++ b/.goreleaser_ui.yaml
@@ -1,5 +1,6 @@
version: 2
-
+env:
+ - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
project_name: netbird-ui
builds:
- id: netbird-ui
@@ -101,6 +102,7 @@ nfpms:
uploads:
- name: debian
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_deb
mode: archive
@@ -109,6 +111,7 @@ uploads:
method: PUT
- name: yum
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_rpm
mode: archive
diff --git a/client/Dockerfile b/client/Dockerfile
index 53e4555ef..478b2d0e2 100644
--- a/client/Dockerfile
+++ b/client/Dockerfile
@@ -4,7 +4,7 @@
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
-FROM alpine:3.23.3
+FROM alpine:3.24
# iproute2: busybox doesn't display ip rules properly
RUN apk add --no-cache \
bash \
@@ -21,7 +21,7 @@ ENV \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
-
-ARG NETBIRD_BINARY=netbird
+ARG TARGETPLATFORM
+ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless
index 706bf40de..8141af6ed 100644
--- a/client/Dockerfile-rootless
+++ b/client/Dockerfile-rootless
@@ -4,7 +4,7 @@
# podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
# podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
-FROM alpine:3.22.0
+FROM alpine:3.24
RUN apk add --no-cache \
bash \
@@ -27,7 +27,7 @@ ENV \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
-
-ARG NETBIRD_BINARY=netbird
+ARG TARGETPLATFORM
+ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
diff --git a/combined/Dockerfile b/combined/Dockerfile
index 357e10cf8..ac88b8509 100644
--- a/combined/Dockerfile
+++ b/combined/Dockerfile
@@ -2,4 +2,5 @@ FROM ubuntu:24.04
RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt
ENTRYPOINT [ "/go/bin/netbird-server" ]
CMD ["--config", "/etc/netbird/config.yaml"]
-COPY netbird-server /go/bin/netbird-server
\ No newline at end of file
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-server /go/bin/netbird-server
diff --git a/management/Dockerfile b/management/Dockerfile
index 3b2df2623..fe414158c 100644
--- a/management/Dockerfile
+++ b/management/Dockerfile
@@ -2,4 +2,5 @@ FROM ubuntu:24.04
RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt
ENTRYPOINT [ "/go/bin/netbird-mgmt","management"]
CMD ["--log-file", "console"]
-COPY netbird-mgmt /go/bin/netbird-mgmt
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-mgmt /go/bin/netbird-mgmt
diff --git a/management/Dockerfile.debug b/management/Dockerfile.debug
deleted file mode 100644
index 4d9730bd7..000000000
--- a/management/Dockerfile.debug
+++ /dev/null
@@ -1,5 +0,0 @@
-FROM ubuntu:24.04
-RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt
-ENTRYPOINT [ "/go/bin/netbird-mgmt","management","--log-level","debug"]
-CMD ["--log-file", "console"]
-COPY netbird-mgmt /go/bin/netbird-mgmt
diff --git a/proxy/Dockerfile b/proxy/Dockerfile
index e64680fd6..22c4cbfaa 100644
--- a/proxy/Dockerfile
+++ b/proxy/Dockerfile
@@ -7,7 +7,8 @@ RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/pas
mkdir -p /tmp/certs
FROM gcr.io/distroless/base:debug
-COPY netbird-proxy /go/bin/netbird-proxy
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-proxy /go/bin/netbird-proxy
COPY --from=builder /tmp/passwd /etc/passwd
COPY --from=builder /tmp/group /etc/group
COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird
diff --git a/relay/Dockerfile b/relay/Dockerfile
index f750027c3..757ee7b59 100644
--- a/relay/Dockerfile
+++ b/relay/Dockerfile
@@ -1,4 +1,5 @@
FROM gcr.io/distroless/base:debug
ENTRYPOINT [ "/go/bin/netbird-relay" ]
ENV NB_LOG_FILE=console
-COPY netbird-relay /go/bin/netbird-relay
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-relay /go/bin/netbird-relay
diff --git a/signal/Dockerfile b/signal/Dockerfile
index 4fd5fe4a3..f6504dc74 100644
--- a/signal/Dockerfile
+++ b/signal/Dockerfile
@@ -1,4 +1,5 @@
FROM gcr.io/distroless/base:debug
ENTRYPOINT [ "/go/bin/netbird-signal","run" ]
CMD ["--log-file", "console"]
-COPY netbird-signal /go/bin/netbird-signal
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-signal /go/bin/netbird-signal
diff --git a/upload-server/Dockerfile b/upload-server/Dockerfile
index a38c6fbb8..3713d6f2a 100644
--- a/upload-server/Dockerfile
+++ b/upload-server/Dockerfile
@@ -1,3 +1,4 @@
FROM gcr.io/distroless/base:debug
ENTRYPOINT [ "/go/bin/netbird-upload" ]
-COPY netbird-upload /go/bin/netbird-upload
+ARG TARGETPLATFORM
+COPY ${TARGETPLATFORM}/netbird-upload /go/bin/netbird-upload
From ee360963f96f5feec295102f4f8a1cabc71f1410 Mon Sep 17 00:00:00 2001
From: Theodor Midtlien
Date: Thu, 18 Jun 2026 08:49:19 +0200
Subject: [PATCH 05/54] [client] Migrate profile identity from display name to
ID and allow renaming of profiles (#6367)
* Migrate to profile ids
* Migrate android profile manager
* Clean up
* Fix review
* Add ID type
* Fix test and runes in ShortID()
* Fix profile switch on up and android comments
* Revert android profile to string id
* Fix feedback
* Fix UI feedback
* Fix id assignment
* Add renaming of profiles
* Fix review
* Remove ui binary
* Fix getProfileConfigPath not validating id
* Change resolve handle order and fix server merge problems
* Fix mdm test
---
client/android/profile_manager.go | 102 +--
client/cmd/login.go | 39 +-
client/cmd/login_test.go | 2 +-
client/cmd/profile.go | 202 ++++--
client/cmd/root.go | 1 +
client/cmd/up.go | 18 +-
client/cmd/up_daemon_test.go | 4 +-
client/internal/debug/debug_test.go | 1 +
client/internal/profilemanager/config.go | 14 +
client/internal/profilemanager/id.go | 118 ++++
.../internal/profilemanager/profilemanager.go | 61 +-
.../profilemanager/profilemanager_test.go | 8 +-
client/internal/profilemanager/service.go | 425 ++++++++---
.../internal/profilemanager/service_test.go | 230 ++++++
client/internal/profilemanager/state.go | 18 +-
client/proto/daemon.pb.go | 666 +++++++++++-------
client/proto/daemon.proto | 42 +-
client/proto/daemon_grpc.pb.go | 38 +
client/server/login_overrides_test.go | 2 +-
client/server/server.go | 247 ++++---
client/server/server_test.go | 6 +-
client/server/setconfig_mdm_test.go | 8 +-
client/server/setconfig_test.go | 6 +-
client/ui/client_ui.go | 14 +-
client/ui/profile.go | 64 +-
25 files changed, 1712 insertions(+), 624 deletions(-)
create mode 100644 client/internal/profilemanager/id.go
create mode 100644 client/internal/profilemanager/service_test.go
diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go
index 60e4d5c32..87c001396 100644
--- a/client/android/profile_manager.go
+++ b/client/android/profile_manager.go
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
- "strings"
log "github.com/sirupsen/logrus"
@@ -24,6 +23,7 @@ const (
// Profile represents a profile for gomobile
type Profile struct {
+ ID string
Name string
IsActive bool
}
@@ -53,10 +53,10 @@ func (p *ProfileArray) Get(i int) *Profile {
├── state.json ← Default profile state
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
└── profiles/ ← Subdirectory for non-default profiles
- ├── work.json ← Work profile config
- ├── work.state.json ← Work profile state
- ├── personal.json ← Personal profile config
- └── personal.state.json ← Personal profile state
+ ├── work.json ← Legacy work profile config
+ ├── work.state.json ← Legacy work profile state
+ ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
+ ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
*/
// ProfileManager manages profiles for Android
@@ -99,6 +99,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
var profiles []*Profile
for _, p := range internalProfiles {
profiles = append(profiles, &Profile{
+ ID: p.ID.String(),
Name: p.Name,
IsActive: p.IsActive,
})
@@ -108,55 +109,65 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
}
// GetActiveProfile returns the currently active profile name
-func (pm *ProfileManager) GetActiveProfile() (string, error) {
+func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
// Use ServiceManager to stay consistent with ListProfiles
// ServiceManager uses active_profile.json
activeState, err := pm.serviceMgr.GetActiveProfileState()
if err != nil {
- return "", fmt.Errorf("failed to get active profile: %w", err)
+ return nil, fmt.Errorf("failed to get active profile: %w", err)
}
- return activeState.Name, nil
+
+ // ActiveProfileState only stores the ID (and username), not the display
+ // name. Resolve the ID to the full profile so callers get the real Name.
+ prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
+ }
+ return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil
}
// SwitchProfile switches to a different profile
-func (pm *ProfileManager) SwitchProfile(profileName string) error {
+func (pm *ProfileManager) SwitchProfile(id string) error {
// Use ServiceManager to stay consistent with ListProfiles
// ServiceManager uses active_profile.json
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profileName,
+ ID: profilemanager.ID(id),
Username: androidUsername,
})
if err != nil {
return fmt.Errorf("failed to switch profile: %w", err)
}
- log.Infof("switched to profile: %s", profileName)
+ log.Infof("switched to profile: %s", id)
return nil
}
// AddProfile creates a new profile
func (pm *ProfileManager) AddProfile(profileName string) error {
// Use ServiceManager (creates profile in profiles/ directory)
- if err := pm.serviceMgr.AddProfile(profileName, androidUsername); err != nil {
+ profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
+ if err != nil {
return fmt.Errorf("failed to add profile: %w", err)
}
- log.Infof("created new profile: %s", profileName)
+ log.Infof("created new profile: %s", profile.ID)
return nil
}
// LogoutProfile logs out from a profile (clears authentication)
-func (pm *ProfileManager) LogoutProfile(profileName string) error {
- profileName = sanitizeProfileName(profileName)
-
- configPath, err := pm.getProfileConfigPath(profileName)
+func (pm *ProfileManager) LogoutProfile(id string) error {
+ configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
}
+ if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
+ return fmt.Errorf("id '%s' is not valid", id)
+ }
+
// Check if profile exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
- return fmt.Errorf("profile '%s' does not exist", profileName)
+ return fmt.Errorf("profile '%s' does not exist", id)
}
// Read current config using internal profilemanager
@@ -174,53 +185,57 @@ func (pm *ProfileManager) LogoutProfile(profileName string) error {
return fmt.Errorf("failed to save config: %w", err)
}
- log.Infof("logged out from profile: %s", profileName)
+ log.Infof("logged out from profile: %s", id)
return nil
}
// RemoveProfile deletes a profile
-func (pm *ProfileManager) RemoveProfile(profileName string) error {
+func (pm *ProfileManager) RemoveProfile(id string) error {
// Use ServiceManager (removes profile from profiles/ directory)
- if err := pm.serviceMgr.RemoveProfile(profileName, androidUsername); err != nil {
+ if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
return fmt.Errorf("failed to remove profile: %w", err)
}
- log.Infof("removed profile: %s", profileName)
+ log.Infof("removed profile: %s", id)
return nil
}
// getProfileConfigPath returns the config file path for a profile
// This is needed for Android-specific path handling (netbird.cfg for default profile)
-func (pm *ProfileManager) getProfileConfigPath(profileName string) (string, error) {
- if profileName == "" || profileName == profilemanager.DefaultProfileName {
+func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
+ if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
+ return "", fmt.Errorf("id %q is not valid", id)
+ }
+
+ if id == profilemanager.DefaultProfileName {
// Android uses netbird.cfg for default profile instead of default.json
// Default profile is stored in root configDir, not in profiles/
return filepath.Join(pm.configDir, defaultConfigFilename), nil
}
- // Non-default profiles are stored in profiles subdirectory
- // This matches the Java Preferences.java expectation
- profileName = sanitizeProfileName(profileName)
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
- return filepath.Join(profilesDir, profileName+".json"), nil
+ return filepath.Join(profilesDir, id+".json"), nil
}
-// GetConfigPath returns the config file path for a given profile
+// GetConfigPath returns the config file path for a given profile id
// Java should call this instead of constructing paths with Preferences.configFile()
-func (pm *ProfileManager) GetConfigPath(profileName string) (string, error) {
- return pm.getProfileConfigPath(profileName)
+func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
+ return pm.getProfileConfigPath(id)
}
// GetStateFilePath returns the state file path for a given profile
// Java should call this instead of constructing paths with Preferences.stateFile()
-func (pm *ProfileManager) GetStateFilePath(profileName string) (string, error) {
- if profileName == "" || profileName == profilemanager.DefaultProfileName {
+func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
+ if id == "" || id == profilemanager.DefaultProfileName {
return filepath.Join(pm.configDir, "state.json"), nil
}
- profileName = sanitizeProfileName(profileName)
+ if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
+ return "", fmt.Errorf("id %q is not valid", id)
+ }
+
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
- return filepath.Join(profilesDir, profileName+".state.json"), nil
+ return filepath.Join(profilesDir, id+".state.json"), nil
}
// GetActiveConfigPath returns the config file path for the currently active profile
@@ -230,7 +245,7 @@ func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
if err != nil {
return "", fmt.Errorf("failed to get active profile: %w", err)
}
- return pm.GetConfigPath(activeProfile)
+ return pm.GetConfigPath(activeProfile.ID)
}
// GetActiveStateFilePath returns the state file path for the currently active profile
@@ -240,18 +255,5 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
if err != nil {
return "", fmt.Errorf("failed to get active profile: %w", err)
}
- return pm.GetStateFilePath(activeProfile)
-}
-
-// sanitizeProfileName removes invalid characters from profile name
-func sanitizeProfileName(name string) string {
- // Keep only alphanumeric, underscore, and hyphen
- var result strings.Builder
- for _, r := range name {
- if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
- (r >= '0' && r <= '9') || r == '_' || r == '-' {
- result.WriteRune(r)
- }
- }
- return result.String()
+ return pm.GetStateFilePath(activeProfile.ID)
}
diff --git a/client/cmd/login.go b/client/cmd/login.go
index bd37e30f1..2f7677901 100644
--- a/client/cmd/login.go
+++ b/client/cmd/login.go
@@ -96,17 +96,19 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
dnsLabelsReq = dnsLabelsValidated.ToSafeStringList()
}
+ handle := activeProf.ID.String()
+
loginRequest := proto.LoginRequest{
SetupKey: providedSetupKey,
ManagementUrl: managementURL,
IsUnixDesktopClient: isUnixRunningDesktop(),
Hostname: hostName,
DnsLabels: dnsLabelsReq,
- ProfileName: &activeProf.Name,
+ ProfileName: &handle,
Username: &username,
}
- profileState, err := pm.GetProfileState(activeProf.Name)
+ profileState, err := pm.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
@@ -170,14 +172,13 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
return activeProf, nil
}
-func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) error {
- err := switchProfile(context.Background(), profileName, username)
+func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error {
+ resolvedID, err := switchProfile(ctx, handle, username)
if err != nil {
return fmt.Errorf("switch profile on daemon: %v", err)
}
- err = pm.SwitchProfile(profileName)
- if err != nil {
+ if err := pm.SwitchProfile(resolvedID); err != nil {
return fmt.Errorf("switch profile: %v", err)
}
@@ -205,11 +206,15 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage
return nil
}
-func switchProfile(ctx context.Context, profileName string, username string) error {
+// switchProfile asks the daemon to switch to the profile identified by
+// handle (a name, ID, or unique ID prefix). Returns the resolved profile
+// ID so the caller can update the local active-profile state without
+// re-resolving the handle.
+func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) {
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
//nolint
- return fmt.Errorf("failed to connect to daemon error: %v\n"+
+ return "", fmt.Errorf("failed to connect to daemon error: %v\n"+
"If the daemon is not running please run: "+
"\nnetbird service install \nnetbird service start\n", err)
}
@@ -217,15 +222,15 @@ func switchProfile(ctx context.Context, profileName string, username string) err
client := proto.NewDaemonServiceClient(conn)
- _, err = client.SwitchProfile(ctx, &proto.SwitchProfileRequest{
- ProfileName: &profileName,
+ resp, err := client.SwitchProfile(ctx, &proto.SwitchProfileRequest{
+ ProfileName: &handle,
Username: &username,
})
if err != nil {
- return fmt.Errorf("switch profile failed: %v", err)
+ return "", fmt.Errorf("switch profile failed: %v", err)
}
- return nil
+ return profilemanager.ID(resp.Id), nil
}
func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, activeProf *profilemanager.Profile) error {
@@ -249,7 +254,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
return fmt.Errorf("read config file %s: %v", configFilePath, err)
}
- err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name)
+ err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID)
if err != nil {
return fmt.Errorf("foreground login failed: %v", err)
}
@@ -277,7 +282,7 @@ func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.Lo
return nil
}
-func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey, profileName string) error {
+func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey string, profileID profilemanager.ID) error {
authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
if err != nil {
return fmt.Errorf("failed to create auth client: %v", err)
@@ -291,7 +296,7 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman
jwtToken := ""
if setupKey == "" && needsLogin {
- tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileName)
+ tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileID)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
@@ -306,10 +311,10 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman
return nil
}
-func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileName string) (*auth.TokenInfo, error) {
+func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileID profilemanager.ID) (*auth.TokenInfo, error) {
hint := ""
pm := profilemanager.NewProfileManager()
- profileState, err := pm.GetProfileState(profileName)
+ profileState, err := pm.GetProfileState(profileID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
diff --git a/client/cmd/login_test.go b/client/cmd/login_test.go
index 47522e189..0aa1856b1 100644
--- a/client/cmd/login_test.go
+++ b/client/cmd/login_test.go
@@ -27,7 +27,7 @@ func TestLogin(t *testing.T) {
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
sm := profilemanager.ServiceManager{}
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "default",
+ ID: "default",
Username: currUser.Username,
})
if err != nil {
diff --git a/client/cmd/profile.go b/client/cmd/profile.go
index d6e81760f..4de2d754e 100644
--- a/client/cmd/profile.go
+++ b/client/cmd/profile.go
@@ -2,11 +2,16 @@ package cmd
import (
"context"
+ "errors"
"fmt"
"os/user"
+ "strings"
+ "text/tabwriter"
"time"
"github.com/spf13/cobra"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -14,6 +19,8 @@ import (
"github.com/netbirdio/netbird/util"
)
+var profileListShowID bool
+
var profileCmd = &cobra.Command{
Use: "profile",
Short: "Manage NetBird client profiles",
@@ -31,27 +38,40 @@ var profileListCmd = &cobra.Command{
var profileAddCmd = &cobra.Command{
Use: "add ",
Short: "Add a new profile",
- Long: `Add a new profile to the NetBird client. The profile name must be unique.`,
+ Long: `Add a new profile. Profile name is free-form, a unique ID is generated for the on-disk config file.`,
Args: cobra.ExactArgs(1),
RunE: addProfileFunc,
}
+var profileRenameCmd = &cobra.Command{
+ Use: "rename ",
+ Short: "Renames an existing profile",
+ Long: `Renames an existing profile (by a name, ID, or unique ID prefix). Profile name is free-form.`,
+ Args: cobra.ExactArgs(2),
+ RunE: renameProfileFunc,
+}
+
var profileRemoveCmd = &cobra.Command{
- Use: "remove ",
- Short: "Remove a profile",
- Long: `Remove a profile from the NetBird client. The profile must not be inactive.`,
- Args: cobra.ExactArgs(1),
- RunE: removeProfileFunc,
+ Use: "remove ",
+ Short: "Remove a profile",
+ Long: `Remove a profile by name, ID, or unique ID prefix.`,
+ Aliases: []string{"rm"},
+ Args: cobra.ExactArgs(1),
+ RunE: removeProfileFunc,
}
var profileSelectCmd = &cobra.Command{
- Use: "select ",
+ Use: "select ",
Short: "Select a profile",
- Long: `Make the specified profile active. This will switch the client to use the selected profile's configuration.`,
+ Long: `Make the specified profile active. Accepts a name, ID, or unique ID prefix.`,
Args: cobra.ExactArgs(1),
RunE: selectProfileFunc,
}
+func init() {
+ profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column")
+}
+
func setupCmd(cmd *cobra.Command) error {
SetFlagsFromEnvVars(rootCmd)
SetFlagsFromEnvVars(cmd)
@@ -65,6 +85,7 @@ func setupCmd(cmd *cobra.Command) error {
return nil
}
+
func listProfilesFunc(cmd *cobra.Command, _ []string) error {
if err := setupCmd(cmd); err != nil {
return err
@@ -83,25 +104,33 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
daemonClient := proto.NewDaemonServiceClient(conn)
- profiles, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
+ resp, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
Username: currUser.Username,
})
if err != nil {
return err
}
- // list profiles, add a tick if the profile is active
- cmd.Println("Found", len(profiles.Profiles), "profiles:")
- for _, profile := range profiles.Profiles {
- // use a cross to indicate the passive profiles
- activeMarker := "✗"
- if profile.IsActive {
- activeMarker = "✓"
- }
- cmd.Println(activeMarker, profile.Name)
+ tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
+ if profileListShowID {
+ fmt.Fprintln(tw, "ID\tNAME\tACTIVE")
+ } else {
+ fmt.Fprintln(tw, "NAME\tACTIVE")
}
-
- return nil
+ for _, profile := range resp.Profiles {
+ marker := ""
+ if profile.IsActive {
+ marker = "✓"
+ }
+ name := profilemanager.StripCtrlChars(profile.Name)
+ id := profilemanager.ID(profile.Id)
+ if profileListShowID {
+ fmt.Fprintf(tw, "%s\t%s\t%s\n", id.ShortID(), name, marker)
+ } else {
+ fmt.Fprintf(tw, "%s\t%s\n", name, marker)
+ }
+ }
+ return tw.Flush()
}
func addProfileFunc(cmd *cobra.Command, args []string) error {
@@ -121,21 +150,82 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
}
daemonClient := proto.NewDaemonServiceClient(conn)
-
profileName := args[0]
- _, err = daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{
+ resp, err := daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{
ProfileName: profileName,
Username: currUser.Username,
})
if err != nil {
+ return fmt.Errorf("add profile request: %w", err)
+ }
+
+ dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName)
+ if dupCount > 1 {
+ cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, profileName)
+ cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
+ }
+
+ id := profilemanager.ID(resp.Id)
+ cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName))
+ return nil
+
+}
+
+func renameProfileFunc(cmd *cobra.Command, args []string) error {
+ if err := setupCmd(cmd); err != nil {
return err
}
- cmd.Println("Profile added successfully:", profileName)
+ conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
+ if err != nil {
+ return fmt.Errorf("connect to service CLI interface: %w", err)
+ }
+ defer conn.Close()
+
+ currUser, err := user.Current()
+ if err != nil {
+ return fmt.Errorf("get current user: %w", err)
+ }
+
+ daemonClient := proto.NewDaemonServiceClient(conn)
+ handle := args[0]
+ newProfilename := args[1]
+
+ resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{
+ Handle: handle,
+ Username: currUser.Username,
+ NewProfileName: newProfilename,
+ })
+ if err != nil {
+ return wrapAmbiguityError(err, handle)
+ }
+
+ dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename)
+ if dupCount > 1 {
+ cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, newProfilename)
+ cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
+ }
+
+ cmd.Printf("Profile renamed from %s to %s\n", profilemanager.StripCtrlChars(resp.OldProfileName), profilemanager.StripCtrlChars(newProfilename))
+
return nil
}
+func countProfilesWithName(ctx context.Context, c proto.DaemonServiceClient, username, name string) (int, error) {
+ resp, err := c.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
+ if err != nil {
+ return 0, err
+ }
+ n := 0
+ for _, p := range resp.Profiles {
+ if p.Name == name {
+ n++
+ }
+ }
+ return n, nil
+}
+
func removeProfileFunc(cmd *cobra.Command, args []string) error {
if err := setupCmd(cmd); err != nil {
return err
@@ -153,18 +243,17 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error {
}
daemonClient := proto.NewDaemonServiceClient(conn)
+ handle := args[0]
- profileName := args[0]
-
- _, err = daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
- ProfileName: profileName,
+ resp, err := daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
+ ProfileName: handle,
Username: currUser.Username,
})
if err != nil {
- return err
+ return wrapAmbiguityError(err, handle)
}
- cmd.Println("Profile removed successfully:", profileName)
+ cmd.Printf("Profile removed: %s\n", resp.Id)
return nil
}
@@ -174,7 +263,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
}
profileManager := profilemanager.NewProfileManager()
- profileName := args[0]
+ handle := args[0]
currUser, err := user.Current()
if err != nil {
@@ -191,32 +280,15 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
daemonClient := proto.NewDaemonServiceClient(conn)
- profiles, err := daemonClient.ListProfiles(ctx, &proto.ListProfilesRequest{
- Username: currUser.Username,
+ switchResp, err := daemonClient.SwitchProfile(ctx, &proto.SwitchProfileRequest{
+ ProfileName: &handle,
+ Username: &currUser.Username,
})
if err != nil {
- return fmt.Errorf("list profiles: %w", err)
+ return wrapAmbiguityError(err, handle)
}
- var profileExists bool
-
- for _, profile := range profiles.Profiles {
- if profile.Name == profileName {
- profileExists = true
- break
- }
- }
-
- if !profileExists {
- return fmt.Errorf("profile %s does not exist", profileName)
- }
-
- if err := switchProfile(cmd.Context(), profileName, currUser.Username); err != nil {
- return err
- }
-
- err = profileManager.SwitchProfile(profileName)
- if err != nil {
+ if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil {
return err
}
@@ -231,6 +303,30 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
}
}
- cmd.Println("Profile switched successfully to:", profileName)
+ id := profilemanager.ID(switchResp.Id)
+ cmd.Printf("Profile switched to: %s\n", id.ShortID())
return nil
}
+
+// wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors
+// (which carry the resolver's message verbatim) into CLI-friendly text
+// that points the user at --show-id.
+func wrapAmbiguityError(err error, handle string) error {
+ if err == nil {
+ return nil
+ }
+ st, ok := gstatus.FromError(err)
+ if !ok {
+ return err
+ }
+ switch st.Code() {
+ case codes.InvalidArgument:
+ msg := st.Message()
+ if strings.Contains(msg, "ambiguous") {
+ return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n netbird profile select|remove ")
+ }
+ case codes.NotFound:
+ return fmt.Errorf("profile %q not found", handle)
+ }
+ return err
+}
diff --git a/client/cmd/root.go b/client/cmd/root.go
index b1d960bec..f3fde2f1c 100644
--- a/client/cmd/root.go
+++ b/client/cmd/root.go
@@ -190,6 +190,7 @@ func init() {
// profile commands
profileCmd.AddCommand(profileListCmd)
profileCmd.AddCommand(profileAddCmd)
+ profileCmd.AddCommand(profileRenameCmd)
profileCmd.AddCommand(profileRemoveCmd)
profileCmd.AddCommand(profileSelectCmd)
diff --git a/client/cmd/up.go b/client/cmd/up.go
index cabd0aacf..2761cf74a 100644
--- a/client/cmd/up.go
+++ b/client/cmd/up.go
@@ -128,13 +128,12 @@ func upFunc(cmd *cobra.Command, args []string) error {
var profileSwitched bool
// switch profile if provided
if profileName != "" {
- err = switchProfile(cmd.Context(), profileName, username.Username)
+ resolvedID, err := switchProfile(cmd.Context(), profileName, username.Username)
if err != nil {
return fmt.Errorf("switch profile: %v", err)
}
- err = pm.SwitchProfile(profileName)
- if err != nil {
+ if err := pm.SwitchProfile(resolvedID); err != nil {
return fmt.Errorf("switch profile: %v", err)
}
@@ -190,7 +189,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
- err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.Name)
+ err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID)
if err != nil {
return fmt.Errorf("foreground login failed: %v", err)
}
@@ -261,10 +260,10 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
}
// set the new config
- req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.Name, username.Username)
+ req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
if _, err := client.SetConfig(ctx, req); err != nil {
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
- log.Warnf("setConfig method is not available in the daemon")
+ log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
} else {
return fmt.Errorf("call service setConfig method: %v", err)
}
@@ -289,10 +288,11 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
return fmt.Errorf("setup login request: %v", err)
}
- loginRequest.ProfileName = &activeProf.Name
+ profileID := activeProf.ID.String()
+ loginRequest.ProfileName = &profileID
loginRequest.Username = &username
- profileState, err := pm.GetProfileState(activeProf.Name)
+ profileState, err := pm.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
@@ -329,7 +329,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
}
if _, err := client.Up(ctx, &proto.UpRequest{
- ProfileName: &activeProf.Name,
+ ProfileName: &profileID,
Username: &username,
}); err != nil {
return fmt.Errorf("call service up method: %v", err)
diff --git a/client/cmd/up_daemon_test.go b/client/cmd/up_daemon_test.go
index 682a45365..ea4cdf162 100644
--- a/client/cmd/up_daemon_test.go
+++ b/client/cmd/up_daemon_test.go
@@ -29,14 +29,14 @@ func TestUpDaemon(t *testing.T) {
}
sm := profilemanager.ServiceManager{}
- err = sm.AddProfile("test1", currUser.Username)
+ created, err := sm.AddProfile("test1", currUser.Username)
if err != nil {
t.Fatalf("failed to add profile: %v", err)
return
}
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "test1",
+ ID: created.ID,
Username: currUser.Username,
})
if err != nil {
diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go
index 76df588a5..ca7785d35 100644
--- a/client/internal/debug/debug_test.go
+++ b/client/internal/debug/debug_test.go
@@ -843,6 +843,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
"PreSharedKey": "sensitive: WireGuard pre-shared key",
"SSHKey": "sensitive: SSH private key",
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
+ "Name": "non-config: profile name is not needed for debug purposes",
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
}
diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go
index b0c7fd470..a77f0ff32 100644
--- a/client/internal/profilemanager/config.go
+++ b/client/internal/profilemanager/config.go
@@ -108,6 +108,10 @@ type ConfigInput struct {
// Config Configuration type
type Config struct {
+ // Name is the human-readable profile name shown in CLI/UI listings.
+ // It is independent of the profile's on-disk filename (which is the ID).
+ Name string
+
// Wireguard private key of local peer
PrivateKey string
PreSharedKey string
@@ -270,6 +274,16 @@ func createNewConfig(input ConfigInput) (*Config, error) {
}
func (config *Config) apply(input ConfigInput) (updated bool, err error) {
+ if config.Name != "" {
+ sanitized, err := sanitizeDisplayName(config.Name)
+ if err != nil {
+ return false, fmt.Errorf("invalid profile name: %w", err)
+ }
+ if sanitized != config.Name {
+ config.Name = sanitized
+ updated = true
+ }
+ }
if config.ManagementURL == nil {
log.Infof("using default Management URL %s", DefaultManagementURL)
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
diff --git a/client/internal/profilemanager/id.go b/client/internal/profilemanager/id.go
new file mode 100644
index 000000000..3b82c8779
--- /dev/null
+++ b/client/internal/profilemanager/id.go
@@ -0,0 +1,118 @@
+package profilemanager
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+const (
+ // profileIDByteLen is the number of random bytes generated for a new
+ // profile ID. The resulting hex string is twice this length.
+ profileIDByteLen = 16
+
+ // shortIDLen is the number of leading characters of an ID we render in
+ // list output. Profiles per device are few, so 8 chars is collision-safe
+ // in practice and easy to type as a prefix.
+ shortIDLen = 8
+
+ // maxProfileNameLen caps the human-readable profile name to keep table
+ // output legible and prevent denial-of-service via huge JSON fields.
+ maxProfileNameLen = 128
+
+ // maxProfileIDLen bounds the on-disk filename we'll accept. New
+ // IDs are 32 hex chars, legacy stems are sanitized profile names. The
+ // cap is generous enough to cover both without permitting absurdly
+ // long filenames.
+ maxProfileIDLen = 64
+)
+
+type ID string
+
+// generateProfileID returns a new random hex ID for a profile file.
+func generateProfileID() (ID, error) {
+ buf := make([]byte, profileIDByteLen)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("read random bytes: %w", err)
+ }
+ return ID(hex.EncodeToString(buf)), nil
+}
+
+// IsValidProfileFilenameStem reports whether id is safe to use as the stem
+// of a profile JSON filename.
+func IsValidProfileFilenameStem(id ID) bool {
+ s := id.String()
+ if s == "" || len(s) > maxProfileIDLen {
+ return false
+ }
+ if s == defaultProfileName {
+ return true
+ }
+ if strings.ContainsAny(s, `/\`) || strings.Contains(s, "..") {
+ return false
+ }
+ // filepath.Base catches any leftover separators on platforms with
+ // exotic path conventions.
+ if filepath.Base(s) != s {
+ return false
+ }
+ for _, r := range s {
+ if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-') {
+ return false
+ }
+ }
+ return true
+}
+
+// sanitizeDisplayName normalizes a user-supplied profile display name for
+// storage. It strips ASCII control characters, rejects invalid UTF-8, and
+// caps the length. Emojis, spaces, punctuation, and non-ASCII letters are
+// preserved. Returns an error if nothing usable remains.
+func sanitizeDisplayName(name string) (string, error) {
+ if !utf8.ValidString(name) {
+ return "", fmt.Errorf("name is not valid UTF-8")
+ }
+ name = StripCtrlChars(name)
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return "", fmt.Errorf("name is empty after sanitization")
+ }
+ if utf8.RuneCountInString(name) > maxProfileNameLen {
+ return "", fmt.Errorf("name exceeds %d characters", maxProfileNameLen)
+ }
+ return name, nil
+}
+
+// StripCtrlChars control characters from a name before printing it.
+func StripCtrlChars(name string) string {
+ var b strings.Builder
+ b.Grow(len(name))
+ for _, r := range name {
+ // Skip C0 controls and DEL, plus C1 controls (0x80–0x9F).
+ if r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F) {
+ continue
+ }
+ b.WriteRune(r)
+ }
+ return b.String()
+}
+
+// ShortID truncates an ID for display.
+func (id ID) ShortID() string {
+ if id == DefaultProfileName {
+ return DefaultProfileName
+ }
+ runes := []rune(id)
+ if len(runes) <= shortIDLen {
+ return id.String()
+ }
+ return string(runes[:shortIDLen])
+}
+
+func (id ID) String() string {
+ return string(id)
+}
diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go
index c87f521cb..e25d493d5 100644
--- a/client/internal/profilemanager/profilemanager.go
+++ b/client/internal/profilemanager/profilemanager.go
@@ -19,19 +19,41 @@ const (
)
type Profile struct {
- Name string
+ // ID is the on-disk filename stem (without .json). For new profiles
+ // it is a 32-char hex string; legacy profiles created before the
+ // ID-keyed layout keep their original name as their ID. The reserved
+ // value "default" identifies the special default profile.
+ ID ID
+ // Name is the human-readable display name. Falls back to ID when the
+ // underlying JSON has no "name" field set.
+ Name string
+ // Path is the absolute path to the profile JSON. Populated by the
+ // loader so callers do not have to reconstruct it from ID + dir.
+ Path string
IsActive bool
}
func (p *Profile) FilePath() (string, error) {
- if p.Name == "" {
- return "", fmt.Errorf("active profile name is empty")
+ if p.Path != "" {
+ return p.Path, nil
}
- if p.Name == defaultProfileName {
+ id := p.ID
+ if id == "" {
+ id = ID(p.Name)
+ }
+ if id == "" {
+ return "", fmt.Errorf("profile ID is empty")
+ }
+
+ if id == defaultProfileName {
return DefaultConfigPath, nil
}
+ if !IsValidProfileFilenameStem(id) {
+ return "", fmt.Errorf("invalid profile ID: %q", id)
+ }
+
username, err := user.Current()
if err != nil {
return "", fmt.Errorf("failed to get current user: %w", err)
@@ -42,10 +64,13 @@ func (p *Profile) FilePath() (string, error) {
return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err)
}
- return filepath.Join(configDir, p.Name+".json"), nil
+ return filepath.Join(configDir, id.String()+".json"), nil
}
func (p *Profile) IsDefault() bool {
+ if p.ID != "" {
+ return p.ID == defaultProfileName
+ }
return p.Name == defaultProfileName
}
@@ -57,18 +82,24 @@ func NewProfileManager() *ProfileManager {
return &ProfileManager{}
}
+// GetActiveProfile returns the active profile as recorded in the local
+// user state file. Only ID is populated.
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
pm.mu.Lock()
defer pm.mu.Unlock()
- prof := pm.getActiveProfileState()
- return &Profile{Name: prof}, nil
+ id := pm.getActiveProfileState()
+ return &Profile{ID: id}, nil
}
-func (pm *ProfileManager) SwitchProfile(profileName string) error {
- profileName = sanitizeProfileName(profileName)
+// SwitchProfile records the given profile ID as active in the local user
+// state file.
+func (pm *ProfileManager) SwitchProfile(id ID) error {
+ if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid profile ID: %q", id)
+ }
- if err := pm.setActiveProfileState(profileName); err != nil {
+ if err := pm.setActiveProfileState(id); err != nil {
return fmt.Errorf("failed to switch profile: %w", err)
}
return nil
@@ -85,7 +116,7 @@ func sanitizeProfileName(name string) string {
}, name)
}
-func (pm *ProfileManager) getActiveProfileState() string {
+func (pm *ProfileManager) getActiveProfileState() ID {
configDir, err := getConfigDir()
if err != nil {
@@ -113,10 +144,10 @@ func (pm *ProfileManager) getActiveProfileState() string {
return defaultProfileName
}
- return profileName
+ return ID(profileName)
}
-func (pm *ProfileManager) setActiveProfileState(profileName string) error {
+func (pm *ProfileManager) setActiveProfileState(id ID) error {
configDir, err := getConfigDir()
if err != nil {
@@ -125,7 +156,7 @@ func (pm *ProfileManager) setActiveProfileState(profileName string) error {
statePath := filepath.Join(configDir, activeProfileStateFilename)
- err = os.WriteFile(statePath, []byte(profileName), 0600)
+ err = os.WriteFile(statePath, []byte(id), 0600)
if err != nil {
return fmt.Errorf("failed to write active profile state: %w", err)
}
@@ -142,7 +173,7 @@ func GetLoginHint() string {
return ""
}
- profileState, err := pm.GetProfileState(activeProf.Name)
+ profileState, err := pm.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
return ""
diff --git a/client/internal/profilemanager/profilemanager_test.go b/client/internal/profilemanager/profilemanager_test.go
index 79a7ae650..882a71d0a 100644
--- a/client/internal/profilemanager/profilemanager_test.go
+++ b/client/internal/profilemanager/profilemanager_test.go
@@ -50,14 +50,14 @@ func TestServiceManager_CreateAndGetDefaultProfile(t *testing.T) {
state, err := sm.GetActiveProfileState()
assert.NoError(t, err)
- assert.Equal(t, state.Name, defaultProfileName) // No active profile state yet
+ assert.Equal(t, defaultProfileName, state.ID.String()) // No active profile state yet
err = sm.SetActiveProfileStateToDefault()
assert.NoError(t, err)
active, err := sm.GetActiveProfileState()
assert.NoError(t, err)
- assert.Equal(t, "default", active.Name)
+ assert.Equal(t, "default", active.ID.String())
})
})
}
@@ -92,14 +92,14 @@ func TestServiceManager_SetActiveProfileState(t *testing.T) {
currUser, err := user.Current()
assert.NoError(t, err)
sm := &ServiceManager{}
- state := &ActiveProfileState{Name: "foo", Username: currUser.Username}
+ state := &ActiveProfileState{ID: "foo", Username: currUser.Username}
err = sm.SetActiveProfileState(state)
assert.NoError(t, err)
// Should error on nil or incomplete state
err = sm.SetActiveProfileState(nil)
assert.Error(t, err)
- err = sm.SetActiveProfileState(&ActiveProfileState{Name: "", Username: ""})
+ err = sm.SetActiveProfileState(&ActiveProfileState{ID: "", Username: ""})
assert.Error(t, err)
})
})
diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go
index ef3eb1114..5ddd11b04 100644
--- a/client/internal/profilemanager/service.go
+++ b/client/internal/profilemanager/service.go
@@ -2,6 +2,7 @@ package profilemanager
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"io"
@@ -23,12 +24,43 @@ var (
DefaultConfigPathDir = ""
DefaultConfigPath = ""
ActiveProfileStatePath = ""
-)
-var (
ErrorOldDefaultConfigNotFound = errors.New("old default config not found")
)
+// ErrAmbiguousHandle is returned when a profile handle (ID prefix or name)
+// matches more than one profile. Callers can render Candidates to help the
+// user disambiguate.
+type ErrAmbiguousHandle struct {
+ Handle string
+ Candidates []Profile
+ Kind AmbiguityKind
+}
+
+// AmbiguityKind describes which matcher produced the ambiguity, so callers
+// can tailor the error message.
+type AmbiguityKind int
+
+const (
+ AmbiguityKindIDPrefix AmbiguityKind = iota
+ AmbiguityKindName
+)
+
+// profileMeta is the minimal slice of a profile JSON we need, so we avoid
+// reading all fields
+type profileMeta struct {
+ Name string
+}
+
+func (e *ErrAmbiguousHandle) Error() string {
+ switch e.Kind {
+ case AmbiguityKindIDPrefix:
+ return fmt.Sprintf("ID prefix %q is ambiguous (matches %d profiles)", e.Handle, len(e.Candidates))
+ default:
+ return fmt.Sprintf("name %q is ambiguous (%d profiles share this name)", e.Handle, len(e.Candidates))
+ }
+}
+
func init() {
DefaultConfigPathDir = "/var/lib/netbird/"
@@ -54,25 +86,34 @@ func init() {
}
type ActiveProfileState struct {
- Name string `json:"name"`
+ // ID is the on-disk filename stem of the active profile. The JSON tag stays
+ // as "name" for backwards compatibility with active state files written
+ // before the ID-based config files. Legacy values were profile names, which
+ // were also the legacy filename stems, so they still resolve to the correct
+ // file on disk.
+ ID ID `json:"name"`
Username string `json:"username"`
}
func (a *ActiveProfileState) FilePath() (string, error) {
- if a.Name == "" {
- return "", fmt.Errorf("active profile name is empty")
+ if a.ID == "" {
+ return "", fmt.Errorf("active profile ID is empty")
}
- if a.Name == defaultProfileName {
+ if a.ID == defaultProfileName {
return DefaultConfigPath, nil
}
+ if !IsValidProfileFilenameStem(a.ID) {
+ return "", fmt.Errorf("invalid profile ID: %q", a.ID)
+ }
+
configDir, err := getConfigDirForUser(a.Username)
if err != nil {
return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err)
}
- return filepath.Join(configDir, a.Name+".json"), nil
+ return filepath.Join(configDir, a.ID.String()+".json"), nil
}
type ServiceManager struct {
@@ -178,7 +219,7 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
}
return &ActiveProfileState{
- Name: "default",
+ ID: defaultProfileName,
Username: "",
}, nil
} else {
@@ -186,12 +227,12 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
}
}
- if activeProfile.Name == "" {
+ if activeProfile.ID == "" {
if err := s.SetActiveProfileStateToDefault(); err != nil {
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
}
return &ActiveProfileState{
- Name: "default",
+ ID: defaultProfileName,
Username: "",
}, nil
}
@@ -216,25 +257,29 @@ func (s *ServiceManager) setDefaultActiveState() error {
}
func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error {
- if a == nil || a.Name == "" {
+ if a == nil || a.ID == "" {
return errors.New("invalid active profile state")
}
- if a.Name != defaultProfileName && a.Username == "" {
- return fmt.Errorf("username must be set for non-default profiles, got: %s", a.Name)
+ if a.ID != defaultProfileName && a.Username == "" {
+ return fmt.Errorf("username must be set for non-default profiles, got: %s", a.ID)
+ }
+
+ if a.ID != defaultProfileName && !IsValidProfileFilenameStem(a.ID) {
+ return fmt.Errorf("invalid profile ID: %q", a.ID)
}
if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil {
return fmt.Errorf("failed to write active profile state: %w", err)
}
- log.Infof("active profile set to %s for %s", a.Name, a.Username)
+ log.Infof("active profile set to %s for %s", a.ID, a.Username)
return nil
}
func (s *ServiceManager) SetActiveProfileStateToDefault() error {
return s.SetActiveProfileState(&ActiveProfileState{
- Name: "default",
+ ID: defaultProfileName,
Username: "",
})
}
@@ -243,57 +288,117 @@ func (s *ServiceManager) DefaultProfilePath() string {
return DefaultConfigPath
}
-func (s *ServiceManager) AddProfile(profileName, username string) error {
+// AddProfile creates a new profile with a generated ID. The user-supplied
+// displayName is stored inside the JSON's name field, the on-disk filename
+// uses the generated ID.
+//
+// The returned Profile carries the freshly-generated ID so callers can
+// show it to the user (and so the gRPC AddProfileResponse can include
+// it).
+func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) {
configDir, err := s.getConfigDir(username)
if err != nil {
- return fmt.Errorf("failed to get config directory: %w", err)
+ return nil, fmt.Errorf("failed to get config directory: %w", err)
}
- profileName = sanitizeProfileName(profileName)
-
- if profileName == defaultProfileName {
- return fmt.Errorf("cannot create profile with reserved name: %s", defaultProfileName)
- }
-
- profPath := filepath.Join(configDir, profileName+".json")
- profileExists, err := fileExists(profPath)
+ displayName, err = sanitizeDisplayName(displayName)
if err != nil {
- return fmt.Errorf("failed to check if profile exists: %w", err)
- }
- if profileExists {
- return ErrProfileAlreadyExists
+ return nil, fmt.Errorf("invalid profile name: %w", err)
}
+ id, err := generateProfileID()
+ if err != nil {
+ return nil, fmt.Errorf("generate profile id: %w", err)
+ }
+
+ profPath := filepath.Join(configDir, id.String()+".json")
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath})
if err != nil {
- return fmt.Errorf("failed to create new config: %w", err)
+ return nil, fmt.Errorf("failed to create new config: %w", err)
+ }
+ cfg.Name = displayName
+
+ if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
+ return nil, fmt.Errorf("failed to write profile config: %w", err)
}
- err = util.WriteJson(context.Background(), profPath, cfg)
+ return &Profile{
+ ID: id,
+ Name: displayName,
+ Path: profPath,
+ }, nil
+}
+
+func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error {
+ displayName, err := sanitizeDisplayName(newName)
if err != nil {
- return fmt.Errorf("failed to write profile config: %w", err)
+ return fmt.Errorf("invalid profile name: %w", err)
}
+ if !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid profile ID: %q", id)
+ }
+
+ profiles, err := s.loadAllProfiles(username)
+ if err != nil {
+ return fmt.Errorf("load profiles: %w", err)
+ }
+
+ var target *Profile
+ for i := range profiles {
+ if profiles[i].ID == id {
+ target = &profiles[i]
+ break
+ }
+ }
+ if target == nil {
+ return ErrProfileNotFound
+ }
+
+ data, err := os.ReadFile(target.Path)
+ if err != nil {
+ return err
+ }
+ var cfg Config
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return err
+ }
+ cfg.Name = displayName
+
+ if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil {
+ return fmt.Errorf("failed to write profile name: %w", err)
+ }
return nil
}
-func (s *ServiceManager) RemoveProfile(profileName, username string) error {
- configDir, err := s.getConfigDir(username)
- if err != nil {
- return fmt.Errorf("failed to get config directory: %w", err)
+// RemoveProfile deletes the profile identified by id. Callers must have
+// already resolved any user-supplied handle to a concrete ID via
+// ResolveProfile.
+func (s *ServiceManager) RemoveProfile(id ID, username string) error {
+ if id == defaultProfileName {
+ defaultName := readProfileName(DefaultConfigPath)
+ if defaultName == "" {
+ defaultName = defaultProfileName
+ }
+ return fmt.Errorf("cannot remove default profile with name: %s", defaultName)
+ }
+ if !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid profile ID: %q", id)
}
- profileName = sanitizeProfileName(profileName)
-
- if profileName == defaultProfileName {
- return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName)
- }
- profPath := filepath.Join(configDir, profileName+".json")
- profileExists, err := fileExists(profPath)
+ profiles, err := s.loadAllProfiles(username)
if err != nil {
- return fmt.Errorf("failed to check if profile exists: %w", err)
+ return fmt.Errorf("load profiles: %w", err)
}
- if !profileExists {
+
+ var target *Profile
+ for i := range profiles {
+ if profiles[i].ID == id {
+ target = &profiles[i]
+ break
+ }
+ }
+ if target == nil {
return ErrProfileNotFound
}
@@ -301,57 +406,26 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error {
if err != nil && !errors.Is(err, ErrNoActiveProfile) {
return fmt.Errorf("failed to get active profile: %w", err)
}
-
- if activeProf != nil && activeProf.Name == profileName {
- return fmt.Errorf("cannot remove active profile: %s", profileName)
+ if activeProf != nil && activeProf.ID == id {
+ return fmt.Errorf("cannot remove active profile: %s", id)
}
- err = util.RemoveJson(profPath)
- if err != nil {
+ if err := util.RemoveJson(target.Path); err != nil {
return fmt.Errorf("failed to remove profile config: %w", err)
}
+
+ stateFile := filepath.Join(filepath.Dir(target.Path), id.String()+".state.json")
+ if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
+ log.Warnf("failed to remove profile state file %s: %v", stateFile, err)
+ }
+
return nil
}
+// ListProfiles returns every profile for the given user, including the
+// default profile, with IsActive flags set.
func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) {
- configDir, err := s.getConfigDir(username)
- if err != nil {
- return nil, fmt.Errorf("failed to get config directory: %w", err)
- }
-
- files, err := util.ListFiles(configDir, "*.json")
- if err != nil {
- return nil, fmt.Errorf("failed to list profile files: %w", err)
- }
-
- var filtered []string
- for _, file := range files {
- if strings.HasSuffix(file, "state.json") {
- continue // skip state files
- }
- filtered = append(filtered, file)
- }
- sort.Strings(filtered)
-
- var activeProfName string
- activeProf, err := s.GetActiveProfileState()
- if err == nil {
- activeProfName = activeProf.Name
- }
-
- var profiles []Profile
- // add default profile always
- profiles = append(profiles, Profile{Name: defaultProfileName, IsActive: activeProfName == "" || activeProfName == defaultProfileName})
- for _, file := range filtered {
- profileName := strings.TrimSuffix(filepath.Base(file), ".json")
- var isActive bool
- if activeProfName != "" && activeProfName == profileName {
- isActive = true
- }
- profiles = append(profiles, Profile{Name: profileName, IsActive: isActive})
- }
-
- return profiles, nil
+ return s.loadAllProfiles(username)
}
// GetStatePath returns the path to the state file based on the operating system
@@ -369,7 +443,12 @@ func (s *ServiceManager) GetStatePath() string {
return defaultStatePath
}
- if activeProf.Name == defaultProfileName {
+ if activeProf.ID == defaultProfileName {
+ return defaultStatePath
+ }
+
+ if !IsValidProfileFilenameStem(activeProf.ID) {
+ log.Warnf("invalid active profile ID %q, using default state path", activeProf.ID)
return defaultStatePath
}
@@ -379,7 +458,7 @@ func (s *ServiceManager) GetStatePath() string {
return defaultStatePath
}
- return filepath.Join(configDir, activeProf.Name+".state.json")
+ return filepath.Join(configDir, activeProf.ID.String()+".state.json")
}
// getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser
@@ -390,3 +469,169 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) {
return getConfigDirForUser(username)
}
+
+// loadAllProfiles returns every profile visible to the daemon for the
+// given user, including the default profile. The returned slice is sorted
+// by ID for a stable display order.
+//
+// Each Profile is fully populated: ID is the filename stem, Name comes
+// from the JSON's "name" field (falling back to the filename stem when absent)
+// and Path is built from a basename read off disk.
+func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) {
+ activeID, activeIsDefault := s.activeProfileID()
+ defaultName := readProfileName(DefaultConfigPath)
+ if defaultName == "" {
+ defaultName = defaultProfileName
+ }
+
+ profiles := []Profile{{
+ ID: defaultProfileName,
+ Name: defaultName,
+ Path: DefaultConfigPath,
+ IsActive: activeIsDefault,
+ }}
+
+ configDir, err := s.getConfigDir(username)
+ if err != nil {
+ return nil, fmt.Errorf("get config directory: %w", err)
+ }
+
+ entries, err := os.ReadDir(configDir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return profiles, nil
+ }
+ return nil, fmt.Errorf("read profile directory: %w", err)
+ }
+
+ var fileProfiles []Profile
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ base := entry.Name()
+ if !strings.HasSuffix(base, ".json") {
+ continue
+ }
+ if strings.HasSuffix(base, ".state.json") {
+ continue
+ }
+ stem := ID(strings.TrimSuffix(base, ".json"))
+ if stem == defaultProfileName {
+ // default lives at the top-level config dir, not under /
+ continue
+ }
+ if !IsValidProfileFilenameStem(ID(stem)) {
+ continue
+ }
+ path := filepath.Join(configDir, base)
+ name := readProfileName(path)
+ if name == "" {
+ name = stem.String()
+ }
+ fileProfiles = append(fileProfiles, Profile{
+ ID: stem,
+ Name: name,
+ Path: path,
+ IsActive: stem == ID(activeID),
+ })
+ }
+
+ sort.Slice(fileProfiles, func(i, j int) bool {
+ if fileProfiles[i].Name != fileProfiles[j].Name {
+ return fileProfiles[i].Name < fileProfiles[j].Name
+ }
+ // Sort tie-break on ID so duplicate names always render in the same order.
+ return fileProfiles[i].ID < fileProfiles[j].ID
+ })
+ profiles = append(profiles, fileProfiles...)
+ return profiles, nil
+}
+
+// readProfileName parses just the "name" field from the profile Json.
+func readProfileName(path string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ var meta profileMeta
+ if err := json.Unmarshal(data, &meta); err != nil {
+ return ""
+ }
+ return meta.Name
+}
+
+// activeProfileID returns the currently-active profile's ID. The second
+// return value is true when the active profile is the default one.
+func (s *ServiceManager) activeProfileID() (ID, bool) {
+ state, err := s.GetActiveProfileState()
+ if err != nil || state == nil {
+ return defaultProfileName, true
+ }
+ if state.ID == "" || state.ID == defaultProfileName {
+ return defaultProfileName, true
+ }
+ return state.ID, false
+}
+
+// ResolveProfile turns a user-supplied handle into a Profile. Resolution
+// precedence is: exact ID match, then unique exact name, then unique ID
+// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can
+// surface the candidates.
+func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, error) {
+ if handle == "" {
+ return nil, fmt.Errorf("profile handle is empty")
+ }
+
+ profiles, err := s.loadAllProfiles(username)
+ if err != nil {
+ return nil, err
+ }
+
+ for i := range profiles {
+ if profiles[i].ID == ID(handle) {
+ return &profiles[i], nil
+ }
+ }
+
+ var nameMatches []Profile
+ for i := range profiles {
+ if profiles[i].Name == handle {
+ nameMatches = append(nameMatches, profiles[i])
+ }
+ }
+ if len(nameMatches) == 1 {
+ return &nameMatches[0], nil
+ }
+ if len(nameMatches) > 1 {
+ return nil, &ErrAmbiguousHandle{
+ Handle: handle,
+ Candidates: nameMatches,
+ Kind: AmbiguityKindName,
+ }
+ }
+
+ // ID prefix match. Skip the default profile so `select d` does not
+ // accidentally pick it via prefix.
+ var prefixMatches []Profile
+ for i := range profiles {
+ if profiles[i].ID == defaultProfileName {
+ continue
+ }
+ if strings.HasPrefix(profiles[i].ID.String(), handle) {
+ prefixMatches = append(prefixMatches, profiles[i])
+ }
+ }
+ if len(prefixMatches) == 1 {
+ return &prefixMatches[0], nil
+ }
+ if len(prefixMatches) > 1 {
+ return nil, &ErrAmbiguousHandle{
+ Handle: handle,
+ Candidates: prefixMatches,
+ Kind: AmbiguityKindIDPrefix,
+ }
+ }
+
+ return nil, ErrProfileNotFound
+}
diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go
new file mode 100644
index 000000000..5e051b15d
--- /dev/null
+++ b/client/internal/profilemanager/service_test.go
@@ -0,0 +1,230 @@
+package profilemanager
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/util"
+)
+
+// withTestSM wires up patched globals + a clean config dir and returns a
+// fully initialized ServiceManager plus the username we are scoped to.
+func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
+ t.Helper()
+ withTempConfigDir(t, func(configDir string) {
+ withPatchedGlobals(t, configDir, func() {
+ u, err := user.Current()
+ require.NoError(t, err)
+ sm := &ServiceManager{}
+ require.NoError(t, sm.CreateDefaultProfile())
+ fn(sm, u.Username)
+ })
+ })
+}
+
+func TestServiceProfile_ExactID(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ created, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ got, err := sm.ResolveProfile(created.ID.String(), username)
+ require.NoError(t, err)
+ assert.Equal(t, created.ID, got.ID)
+ assert.Equal(t, "work", got.Name)
+ })
+}
+
+func TestServiceProfile_IDPrefix(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ created, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ prefix := created.ID[:4]
+ got, err := sm.ResolveProfile(prefix.String(), username)
+ require.NoError(t, err)
+ assert.Equal(t, created.ID, got.ID)
+ })
+}
+
+func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ // Plant two profiles whose IDs share a known prefix by writing
+ // the files directly, since generated IDs are random.
+ configDir, err := sm.getConfigDir(username)
+ require.NoError(t, err)
+ for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} {
+ path := filepath.Join(configDir, id+".json")
+ require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id}))
+ }
+
+ _, err = sm.ResolveProfile("abcd", username)
+ var amb *ErrAmbiguousHandle
+ require.ErrorAs(t, err, &amb)
+ assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind)
+ assert.Len(t, amb.Candidates, 2)
+ })
+}
+
+func TestServiceProfile_ExactNameUnique(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ _, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ got, err := sm.ResolveProfile("work", username)
+ require.NoError(t, err)
+ assert.Equal(t, "work", got.Name)
+ })
+}
+
+func TestServiceProfile_AmbiguousName(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ _, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+ _, err = sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ _, err = sm.ResolveProfile("work", username)
+ var amb *ErrAmbiguousHandle
+ require.ErrorAs(t, err, &amb)
+ assert.Equal(t, AmbiguityKindName, amb.Kind)
+ assert.Len(t, amb.Candidates, 2)
+ })
+}
+
+func TestServiceProfile_NotFound(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ _, err := sm.ResolveProfile("nope", username)
+ assert.ErrorIs(t, err, ErrProfileNotFound)
+ })
+}
+
+func TestServiceProfile_DefaultByExactID(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ got, err := sm.ResolveProfile(defaultProfileName, username)
+ require.NoError(t, err)
+ assert.Equal(t, defaultProfileName, got.ID.String())
+ })
+}
+
+func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
+ // Legacy profiles stored as .json with no "name" JSON field
+ // should still be discoverable by name and removable by name.
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ configDir, err := sm.getConfigDir(username)
+ require.NoError(t, err)
+ path := filepath.Join(configDir, "legacy.json")
+ require.NoError(t, util.WriteJson(context.Background(), path, &Config{}))
+
+ got, err := sm.ResolveProfile("legacy", username)
+ require.NoError(t, err)
+ assert.Equal(t, "legacy", got.ID.String())
+ // Name falls back to the filename stem when JSON omits it.
+ assert.Equal(t, "legacy", got.Name)
+ })
+}
+
+func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ first, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ second, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+ assert.NotEqual(t, first.ID, second.ID)
+ assert.Equal(t, "work", second.Name)
+ })
+}
+
+func TestAddProfile_RejectsInvalidNames(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ cases := []string{
+ "", // empty
+ "\x00\x01", // only control chars (becomes empty)
+ strings.Repeat("a", maxProfileNameLen+1), // too long
+ }
+ for _, name := range cases {
+ _, err := sm.AddProfile(name, username)
+ assert.Error(t, err, "expected error for %q", name)
+ }
+ })
+}
+
+func TestRemoveProfile_RejectsInvalidID(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ err := sm.RemoveProfile("../escape", username)
+ assert.Error(t, err)
+ })
+}
+
+func TestSanitizeDisplayName(t *testing.T) {
+ cases := []struct {
+ in string
+ want string
+ wantErr bool
+ }{
+ {"work", "work", false},
+ {"My Work Account", "My Work Account", false},
+ {"emoji 🚀 ok", "emoji 🚀 ok", false},
+ {"漢字テスト", "漢字テスト", false},
+ {"with\x00null", "withnull", false},
+ {"\x01\x02\x03", "", true},
+ {"", "", true},
+ }
+ for _, tc := range cases {
+ got, err := sanitizeDisplayName(tc.in)
+ if tc.wantErr {
+ assert.Error(t, err, "case %q", tc.in)
+ continue
+ }
+ assert.NoError(t, err, "case %q", tc.in)
+ assert.Equal(t, tc.want, got, "case %q", tc.in)
+ }
+}
+
+func TestIsValidProfileFilenameStem(t *testing.T) {
+ cases := []struct {
+ in string
+ want bool
+ }{
+ {"default", true},
+ {"abc123def456", true},
+ {"legacy-name", true},
+ {"legacy_name", true},
+ {"", false},
+ {"..", false},
+ {"../etc", false},
+ {"foo/bar", false},
+ {`foo\bar`, false},
+ {"with space", false},
+ {"with.dot", false},
+ {strings.Repeat("a", maxProfileIDLen+1), false},
+ }
+ for _, tc := range cases {
+ got := IsValidProfileFilenameStem(ID(tc.in))
+ assert.Equal(t, tc.want, got, "case %q", tc.in)
+ }
+}
+
+func TestRemoveProfile_DeletesStateFile(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ created, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ configDir, err := sm.getConfigDir(username)
+ require.NoError(t, err)
+ statePath := filepath.Join(configDir, created.ID.String()+".state.json")
+ require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600))
+
+ require.NoError(t, sm.RemoveProfile(created.ID, username))
+ _, err = os.Stat(statePath)
+ assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed")
+ })
+}
diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go
index f09391ede..1bf3318af 100644
--- a/client/internal/profilemanager/state.go
+++ b/client/internal/profilemanager/state.go
@@ -13,13 +13,20 @@ type ProfileState struct {
Email string `json:"email"`
}
-func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, error) {
+// GetProfileState reads the per-profile state file keyed by profile ID.
+// The state file lives in the user's config directory. Legacy state files
+// keyed by the old profile name remain readable.
+func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
configDir, err := getConfigDir()
if err != nil {
return nil, fmt.Errorf("get config directory: %w", err)
}
- stateFile := filepath.Join(configDir, profileName+".state.json")
+ if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
+ return nil, fmt.Errorf("invalid profile ID: %q", id)
+ }
+
+ stateFile := filepath.Join(configDir, id.String()+".state.json")
stateFileExists, err := fileExists(stateFile)
if err != nil {
return nil, fmt.Errorf("failed to check if profile state file exists: %w", err)
@@ -51,7 +58,12 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return fmt.Errorf("get active profile: %w", err)
}
- stateFile := filepath.Join(configDir, activeProf.Name+".state.json")
+ id := activeProf.ID
+ if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid active profile ID: %q", id)
+ }
+
+ stateFile := filepath.Join(configDir, id.String()+".state.json")
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
if err != nil {
return fmt.Errorf("write profile state: %w", err)
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 6b5a37658..488b0186c 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -3954,9 +3954,11 @@ func (x *GetEventsResponse) GetEvents() []*SystemEvent {
}
type SwitchProfileRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
- Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // profileName is treated as a handle: exact ID, unique ID prefix, or
+ // unique display name. The daemon resolves it server-side.
+ ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
+ Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4006,7 +4008,11 @@ func (x *SwitchProfileRequest) GetUsername() string {
}
type SwitchProfileResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // id is the resolved on-disk ID of the profile that became active.
+ // Lets CLI clients update their local active-profile state without
+ // duplicating the resolution logic.
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4041,6 +4047,13 @@ func (*SwitchProfileResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{55}
}
+func (x *SwitchProfileResponse) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
type SetConfigRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
@@ -4397,9 +4410,11 @@ func (*SetConfigResponse) Descriptor() ([]byte, []int) {
}
type AddProfileRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
- ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
+ // profileName carries the human-readable display name for the new
+ // profile. The on-disk filename is a separately-generated ID.
+ ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4449,7 +4464,10 @@ func (x *AddProfileRequest) GetProfileName() string {
}
type AddProfileResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // id is the generated on-disk ID of the new profile. CLI clients
+ // display a truncated form, UI clients can ignore it.
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4484,17 +4502,133 @@ func (*AddProfileResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{59}
}
+func (x *AddProfileResponse) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+type RenameProfileRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
+ // handle: an exact ID, a unique ID prefix, or a unique display name.
+ Handle string `protobuf:"bytes,2,opt,name=handle,proto3" json:"handle,omitempty"`
+ // newProfileName is the new human-readable display name for the profile.
+ NewProfileName string `protobuf:"bytes,3,opt,name=newProfileName,proto3" json:"newProfileName,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RenameProfileRequest) Reset() {
+ *x = RenameProfileRequest{}
+ mi := &file_daemon_proto_msgTypes[60]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RenameProfileRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RenameProfileRequest) ProtoMessage() {}
+
+func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[60]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RenameProfileRequest.ProtoReflect.Descriptor instead.
+func (*RenameProfileRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{60}
+}
+
+func (x *RenameProfileRequest) GetUsername() string {
+ if x != nil {
+ return x.Username
+ }
+ return ""
+}
+
+func (x *RenameProfileRequest) GetHandle() string {
+ if x != nil {
+ return x.Handle
+ }
+ return ""
+}
+
+func (x *RenameProfileRequest) GetNewProfileName() string {
+ if x != nil {
+ return x.NewProfileName
+ }
+ return ""
+}
+
+type RenameProfileResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // confirm the old profile name after resolving handle.
+ OldProfileName string `protobuf:"bytes,1,opt,name=oldProfileName,proto3" json:"oldProfileName,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RenameProfileResponse) Reset() {
+ *x = RenameProfileResponse{}
+ mi := &file_daemon_proto_msgTypes[61]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RenameProfileResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RenameProfileResponse) ProtoMessage() {}
+
+func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[61]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RenameProfileResponse.ProtoReflect.Descriptor instead.
+func (*RenameProfileResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{61}
+}
+
+func (x *RenameProfileResponse) GetOldProfileName() string {
+ if x != nil {
+ return x.OldProfileName
+ }
+ return ""
+}
+
type RemoveProfileRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
- ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
+ // profileName is treated as a handle: an exact ID, a unique ID
+ // prefix, or a unique display name. Resolution happens server-side.
+ ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveProfileRequest) Reset() {
*x = RemoveProfileRequest{}
- mi := &file_daemon_proto_msgTypes[60]
+ mi := &file_daemon_proto_msgTypes[62]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4506,7 +4640,7 @@ func (x *RemoveProfileRequest) String() string {
func (*RemoveProfileRequest) ProtoMessage() {}
func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[60]
+ mi := &file_daemon_proto_msgTypes[62]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4519,7 +4653,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead.
func (*RemoveProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{60}
+ return file_daemon_proto_rawDescGZIP(), []int{62}
}
func (x *RemoveProfileRequest) GetUsername() string {
@@ -4537,14 +4671,17 @@ func (x *RemoveProfileRequest) GetProfileName() string {
}
type RemoveProfileResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // id is the full resolved ID of the removed profile, so callers can
+ // confirm exactly which profile a name/prefix handle resolved to.
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveProfileResponse) Reset() {
*x = RemoveProfileResponse{}
- mi := &file_daemon_proto_msgTypes[61]
+ mi := &file_daemon_proto_msgTypes[63]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4556,7 +4693,7 @@ func (x *RemoveProfileResponse) String() string {
func (*RemoveProfileResponse) ProtoMessage() {}
func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[61]
+ mi := &file_daemon_proto_msgTypes[63]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4569,7 +4706,14 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead.
func (*RemoveProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{61}
+ return file_daemon_proto_rawDescGZIP(), []int{63}
+}
+
+func (x *RemoveProfileResponse) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
}
type ListProfilesRequest struct {
@@ -4581,7 +4725,7 @@ type ListProfilesRequest struct {
func (x *ListProfilesRequest) Reset() {
*x = ListProfilesRequest{}
- mi := &file_daemon_proto_msgTypes[62]
+ mi := &file_daemon_proto_msgTypes[64]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4593,7 +4737,7 @@ func (x *ListProfilesRequest) String() string {
func (*ListProfilesRequest) ProtoMessage() {}
func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[62]
+ mi := &file_daemon_proto_msgTypes[64]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4606,7 +4750,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead.
func (*ListProfilesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{62}
+ return file_daemon_proto_rawDescGZIP(), []int{64}
}
func (x *ListProfilesRequest) GetUsername() string {
@@ -4625,7 +4769,7 @@ type ListProfilesResponse struct {
func (x *ListProfilesResponse) Reset() {
*x = ListProfilesResponse{}
- mi := &file_daemon_proto_msgTypes[63]
+ mi := &file_daemon_proto_msgTypes[65]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4637,7 +4781,7 @@ func (x *ListProfilesResponse) String() string {
func (*ListProfilesResponse) ProtoMessage() {}
func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[63]
+ mi := &file_daemon_proto_msgTypes[65]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4650,7 +4794,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead.
func (*ListProfilesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{63}
+ return file_daemon_proto_rawDescGZIP(), []int{65}
}
func (x *ListProfilesResponse) GetProfiles() []*Profile {
@@ -4664,13 +4808,14 @@ type Profile struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
IsActive bool `protobuf:"varint,2,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"`
+ Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Profile) Reset() {
*x = Profile{}
- mi := &file_daemon_proto_msgTypes[64]
+ mi := &file_daemon_proto_msgTypes[66]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4682,7 +4827,7 @@ func (x *Profile) String() string {
func (*Profile) ProtoMessage() {}
func (x *Profile) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[64]
+ mi := &file_daemon_proto_msgTypes[66]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4695,7 +4840,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message {
// Deprecated: Use Profile.ProtoReflect.Descriptor instead.
func (*Profile) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{64}
+ return file_daemon_proto_rawDescGZIP(), []int{66}
}
func (x *Profile) GetName() string {
@@ -4712,6 +4857,13 @@ func (x *Profile) GetIsActive() bool {
return false
}
+func (x *Profile) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
type GetActiveProfileRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -4720,7 +4872,7 @@ type GetActiveProfileRequest struct {
func (x *GetActiveProfileRequest) Reset() {
*x = GetActiveProfileRequest{}
- mi := &file_daemon_proto_msgTypes[65]
+ mi := &file_daemon_proto_msgTypes[67]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4732,7 +4884,7 @@ func (x *GetActiveProfileRequest) String() string {
func (*GetActiveProfileRequest) ProtoMessage() {}
func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[65]
+ mi := &file_daemon_proto_msgTypes[67]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4745,20 +4897,21 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead.
func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{65}
+ return file_daemon_proto_rawDescGZIP(), []int{67}
}
type GetActiveProfileResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ProfileName string `protobuf:"bytes,1,opt,name=profileName,proto3" json:"profileName,omitempty"`
Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
+ Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetActiveProfileResponse) Reset() {
*x = GetActiveProfileResponse{}
- mi := &file_daemon_proto_msgTypes[66]
+ mi := &file_daemon_proto_msgTypes[68]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4770,7 +4923,7 @@ func (x *GetActiveProfileResponse) String() string {
func (*GetActiveProfileResponse) ProtoMessage() {}
func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[66]
+ mi := &file_daemon_proto_msgTypes[68]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4783,7 +4936,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead.
func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{66}
+ return file_daemon_proto_rawDescGZIP(), []int{68}
}
func (x *GetActiveProfileResponse) GetProfileName() string {
@@ -4800,6 +4953,13 @@ func (x *GetActiveProfileResponse) GetUsername() string {
return ""
}
+func (x *GetActiveProfileResponse) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
type LogoutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
@@ -4810,7 +4970,7 @@ type LogoutRequest struct {
func (x *LogoutRequest) Reset() {
*x = LogoutRequest{}
- mi := &file_daemon_proto_msgTypes[67]
+ mi := &file_daemon_proto_msgTypes[69]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4822,7 +4982,7 @@ func (x *LogoutRequest) String() string {
func (*LogoutRequest) ProtoMessage() {}
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[67]
+ mi := &file_daemon_proto_msgTypes[69]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4835,7 +4995,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead.
func (*LogoutRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{67}
+ return file_daemon_proto_rawDescGZIP(), []int{69}
}
func (x *LogoutRequest) GetProfileName() string {
@@ -4860,7 +5020,7 @@ type LogoutResponse struct {
func (x *LogoutResponse) Reset() {
*x = LogoutResponse{}
- mi := &file_daemon_proto_msgTypes[68]
+ mi := &file_daemon_proto_msgTypes[70]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4872,7 +5032,7 @@ func (x *LogoutResponse) String() string {
func (*LogoutResponse) ProtoMessage() {}
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[68]
+ mi := &file_daemon_proto_msgTypes[70]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4885,7 +5045,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead.
func (*LogoutResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{68}
+ return file_daemon_proto_rawDescGZIP(), []int{70}
}
type GetFeaturesRequest struct {
@@ -4896,7 +5056,7 @@ type GetFeaturesRequest struct {
func (x *GetFeaturesRequest) Reset() {
*x = GetFeaturesRequest{}
- mi := &file_daemon_proto_msgTypes[69]
+ mi := &file_daemon_proto_msgTypes[71]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4908,7 +5068,7 @@ func (x *GetFeaturesRequest) String() string {
func (*GetFeaturesRequest) ProtoMessage() {}
func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[69]
+ mi := &file_daemon_proto_msgTypes[71]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4921,7 +5081,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead.
func (*GetFeaturesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{69}
+ return file_daemon_proto_rawDescGZIP(), []int{71}
}
type GetFeaturesResponse struct {
@@ -4935,7 +5095,7 @@ type GetFeaturesResponse struct {
func (x *GetFeaturesResponse) Reset() {
*x = GetFeaturesResponse{}
- mi := &file_daemon_proto_msgTypes[70]
+ mi := &file_daemon_proto_msgTypes[72]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4947,7 +5107,7 @@ func (x *GetFeaturesResponse) String() string {
func (*GetFeaturesResponse) ProtoMessage() {}
func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[70]
+ mi := &file_daemon_proto_msgTypes[72]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4960,7 +5120,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead.
func (*GetFeaturesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{70}
+ return file_daemon_proto_rawDescGZIP(), []int{72}
}
func (x *GetFeaturesResponse) GetDisableProfiles() bool {
@@ -4998,7 +5158,7 @@ type MDMManagedFieldsViolation struct {
func (x *MDMManagedFieldsViolation) Reset() {
*x = MDMManagedFieldsViolation{}
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[73]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5010,7 +5170,7 @@ func (x *MDMManagedFieldsViolation) String() string {
func (*MDMManagedFieldsViolation) ProtoMessage() {}
func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[73]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5023,7 +5183,7 @@ func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message {
// Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead.
func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{71}
+ return file_daemon_proto_rawDescGZIP(), []int{73}
}
func (x *MDMManagedFieldsViolation) GetFields() []string {
@@ -5041,7 +5201,7 @@ type TriggerUpdateRequest struct {
func (x *TriggerUpdateRequest) Reset() {
*x = TriggerUpdateRequest{}
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[74]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5053,7 +5213,7 @@ func (x *TriggerUpdateRequest) String() string {
func (*TriggerUpdateRequest) ProtoMessage() {}
func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[74]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5066,7 +5226,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead.
func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{72}
+ return file_daemon_proto_rawDescGZIP(), []int{74}
}
type TriggerUpdateResponse struct {
@@ -5079,7 +5239,7 @@ type TriggerUpdateResponse struct {
func (x *TriggerUpdateResponse) Reset() {
*x = TriggerUpdateResponse{}
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[75]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5091,7 +5251,7 @@ func (x *TriggerUpdateResponse) String() string {
func (*TriggerUpdateResponse) ProtoMessage() {}
func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[75]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5104,7 +5264,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead.
func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{73}
+ return file_daemon_proto_rawDescGZIP(), []int{75}
}
func (x *TriggerUpdateResponse) GetSuccess() bool {
@@ -5132,7 +5292,7 @@ type GetPeerSSHHostKeyRequest struct {
func (x *GetPeerSSHHostKeyRequest) Reset() {
*x = GetPeerSSHHostKeyRequest{}
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5144,7 +5304,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string {
func (*GetPeerSSHHostKeyRequest) ProtoMessage() {}
func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[76]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5157,7 +5317,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{74}
+ return file_daemon_proto_rawDescGZIP(), []int{76}
}
func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string {
@@ -5184,7 +5344,7 @@ type GetPeerSSHHostKeyResponse struct {
func (x *GetPeerSSHHostKeyResponse) Reset() {
*x = GetPeerSSHHostKeyResponse{}
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[77]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5196,7 +5356,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string {
func (*GetPeerSSHHostKeyResponse) ProtoMessage() {}
func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[77]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5209,7 +5369,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{75}
+ return file_daemon_proto_rawDescGZIP(), []int{77}
}
func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte {
@@ -5251,7 +5411,7 @@ type RequestJWTAuthRequest struct {
func (x *RequestJWTAuthRequest) Reset() {
*x = RequestJWTAuthRequest{}
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[78]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5263,7 +5423,7 @@ func (x *RequestJWTAuthRequest) String() string {
func (*RequestJWTAuthRequest) ProtoMessage() {}
func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[78]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5276,7 +5436,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{76}
+ return file_daemon_proto_rawDescGZIP(), []int{78}
}
func (x *RequestJWTAuthRequest) GetHint() string {
@@ -5309,7 +5469,7 @@ type RequestJWTAuthResponse struct {
func (x *RequestJWTAuthResponse) Reset() {
*x = RequestJWTAuthResponse{}
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[79]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5321,7 +5481,7 @@ func (x *RequestJWTAuthResponse) String() string {
func (*RequestJWTAuthResponse) ProtoMessage() {}
func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[79]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5334,7 +5494,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{77}
+ return file_daemon_proto_rawDescGZIP(), []int{79}
}
func (x *RequestJWTAuthResponse) GetVerificationURI() string {
@@ -5399,7 +5559,7 @@ type WaitJWTTokenRequest struct {
func (x *WaitJWTTokenRequest) Reset() {
*x = WaitJWTTokenRequest{}
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[80]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5411,7 +5571,7 @@ func (x *WaitJWTTokenRequest) String() string {
func (*WaitJWTTokenRequest) ProtoMessage() {}
func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[80]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5424,7 +5584,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{78}
+ return file_daemon_proto_rawDescGZIP(), []int{80}
}
func (x *WaitJWTTokenRequest) GetDeviceCode() string {
@@ -5456,7 +5616,7 @@ type WaitJWTTokenResponse struct {
func (x *WaitJWTTokenResponse) Reset() {
*x = WaitJWTTokenResponse{}
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[81]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5468,7 +5628,7 @@ func (x *WaitJWTTokenResponse) String() string {
func (*WaitJWTTokenResponse) ProtoMessage() {}
func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[81]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5481,7 +5641,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{79}
+ return file_daemon_proto_rawDescGZIP(), []int{81}
}
func (x *WaitJWTTokenResponse) GetToken() string {
@@ -5514,7 +5674,7 @@ type StartCPUProfileRequest struct {
func (x *StartCPUProfileRequest) Reset() {
*x = StartCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[82]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5526,7 +5686,7 @@ func (x *StartCPUProfileRequest) String() string {
func (*StartCPUProfileRequest) ProtoMessage() {}
func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[82]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5539,7 +5699,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{80}
+ return file_daemon_proto_rawDescGZIP(), []int{82}
}
// StartCPUProfileResponse confirms CPU profiling has started
@@ -5551,7 +5711,7 @@ type StartCPUProfileResponse struct {
func (x *StartCPUProfileResponse) Reset() {
*x = StartCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[83]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5563,7 +5723,7 @@ func (x *StartCPUProfileResponse) String() string {
func (*StartCPUProfileResponse) ProtoMessage() {}
func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[83]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5576,7 +5736,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{81}
+ return file_daemon_proto_rawDescGZIP(), []int{83}
}
// StopCPUProfileRequest for stopping CPU profiling
@@ -5588,7 +5748,7 @@ type StopCPUProfileRequest struct {
func (x *StopCPUProfileRequest) Reset() {
*x = StopCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[84]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5600,7 +5760,7 @@ func (x *StopCPUProfileRequest) String() string {
func (*StopCPUProfileRequest) ProtoMessage() {}
func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[84]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5613,7 +5773,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{82}
+ return file_daemon_proto_rawDescGZIP(), []int{84}
}
// StopCPUProfileResponse confirms CPU profiling has stopped
@@ -5625,7 +5785,7 @@ type StopCPUProfileResponse struct {
func (x *StopCPUProfileResponse) Reset() {
*x = StopCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[85]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5637,7 +5797,7 @@ func (x *StopCPUProfileResponse) String() string {
func (*StopCPUProfileResponse) ProtoMessage() {}
func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[85]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5650,7 +5810,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{83}
+ return file_daemon_proto_rawDescGZIP(), []int{85}
}
type InstallerResultRequest struct {
@@ -5661,7 +5821,7 @@ type InstallerResultRequest struct {
func (x *InstallerResultRequest) Reset() {
*x = InstallerResultRequest{}
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[86]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5673,7 +5833,7 @@ func (x *InstallerResultRequest) String() string {
func (*InstallerResultRequest) ProtoMessage() {}
func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[86]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5686,7 +5846,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead.
func (*InstallerResultRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{84}
+ return file_daemon_proto_rawDescGZIP(), []int{86}
}
type InstallerResultResponse struct {
@@ -5699,7 +5859,7 @@ type InstallerResultResponse struct {
func (x *InstallerResultResponse) Reset() {
*x = InstallerResultResponse{}
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[87]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5711,7 +5871,7 @@ func (x *InstallerResultResponse) String() string {
func (*InstallerResultResponse) ProtoMessage() {}
func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[87]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5724,7 +5884,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead.
func (*InstallerResultResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{85}
+ return file_daemon_proto_rawDescGZIP(), []int{87}
}
func (x *InstallerResultResponse) GetSuccess() bool {
@@ -5757,7 +5917,7 @@ type ExposeServiceRequest struct {
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[88]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5769,7 +5929,7 @@ func (x *ExposeServiceRequest) String() string {
func (*ExposeServiceRequest) ProtoMessage() {}
func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[88]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5782,7 +5942,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead.
func (*ExposeServiceRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{86}
+ return file_daemon_proto_rawDescGZIP(), []int{88}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -5853,7 +6013,7 @@ type ExposeServiceEvent struct {
func (x *ExposeServiceEvent) Reset() {
*x = ExposeServiceEvent{}
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[89]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5865,7 +6025,7 @@ func (x *ExposeServiceEvent) String() string {
func (*ExposeServiceEvent) ProtoMessage() {}
func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[89]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5878,7 +6038,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead.
func (*ExposeServiceEvent) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{87}
+ return file_daemon_proto_rawDescGZIP(), []int{89}
}
func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event {
@@ -5919,7 +6079,7 @@ type ExposeServiceReady struct {
func (x *ExposeServiceReady) Reset() {
*x = ExposeServiceReady{}
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[90]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5931,7 +6091,7 @@ func (x *ExposeServiceReady) String() string {
func (*ExposeServiceReady) ProtoMessage() {}
func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[90]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5944,7 +6104,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead.
func (*ExposeServiceReady) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{88}
+ return file_daemon_proto_rawDescGZIP(), []int{90}
}
func (x *ExposeServiceReady) GetServiceName() string {
@@ -5989,7 +6149,7 @@ type StartCaptureRequest struct {
func (x *StartCaptureRequest) Reset() {
*x = StartCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[91]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6001,7 +6161,7 @@ func (x *StartCaptureRequest) String() string {
func (*StartCaptureRequest) ProtoMessage() {}
func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[91]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6014,7 +6174,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{89}
+ return file_daemon_proto_rawDescGZIP(), []int{91}
}
func (x *StartCaptureRequest) GetTextOutput() bool {
@@ -6068,7 +6228,7 @@ type CapturePacket struct {
func (x *CapturePacket) Reset() {
*x = CapturePacket{}
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[92]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6080,7 +6240,7 @@ func (x *CapturePacket) String() string {
func (*CapturePacket) ProtoMessage() {}
func (x *CapturePacket) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[92]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6093,7 +6253,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message {
// Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead.
func (*CapturePacket) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{90}
+ return file_daemon_proto_rawDescGZIP(), []int{92}
}
func (x *CapturePacket) GetData() []byte {
@@ -6114,7 +6274,7 @@ type StartBundleCaptureRequest struct {
func (x *StartBundleCaptureRequest) Reset() {
*x = StartBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[93]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6126,7 +6286,7 @@ func (x *StartBundleCaptureRequest) String() string {
func (*StartBundleCaptureRequest) ProtoMessage() {}
func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[93]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6139,7 +6299,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{91}
+ return file_daemon_proto_rawDescGZIP(), []int{93}
}
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
@@ -6157,7 +6317,7 @@ type StartBundleCaptureResponse struct {
func (x *StartBundleCaptureResponse) Reset() {
*x = StartBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[94]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6169,7 +6329,7 @@ func (x *StartBundleCaptureResponse) String() string {
func (*StartBundleCaptureResponse) ProtoMessage() {}
func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[94]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6182,7 +6342,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{92}
+ return file_daemon_proto_rawDescGZIP(), []int{94}
}
type StopBundleCaptureRequest struct {
@@ -6193,7 +6353,7 @@ type StopBundleCaptureRequest struct {
func (x *StopBundleCaptureRequest) Reset() {
*x = StopBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[95]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6205,7 +6365,7 @@ func (x *StopBundleCaptureRequest) String() string {
func (*StopBundleCaptureRequest) ProtoMessage() {}
func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[95]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6218,7 +6378,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{93}
+ return file_daemon_proto_rawDescGZIP(), []int{95}
}
type StopBundleCaptureResponse struct {
@@ -6229,7 +6389,7 @@ type StopBundleCaptureResponse struct {
func (x *StopBundleCaptureResponse) Reset() {
*x = StopBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[94]
+ mi := &file_daemon_proto_msgTypes[96]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6241,7 +6401,7 @@ func (x *StopBundleCaptureResponse) String() string {
func (*StopBundleCaptureResponse) ProtoMessage() {}
func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[94]
+ mi := &file_daemon_proto_msgTypes[96]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6254,7 +6414,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{94}
+ return file_daemon_proto_rawDescGZIP(), []int{96}
}
type PortInfo_Range struct {
@@ -6267,7 +6427,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
- mi := &file_daemon_proto_msgTypes[96]
+ mi := &file_daemon_proto_msgTypes[98]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6279,7 +6439,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[96]
+ mi := &file_daemon_proto_msgTypes[98]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6672,8 +6832,9 @@ const file_daemon_proto_rawDesc = "" +
"\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" +
"\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" +
"\f_profileNameB\v\n" +
- "\t_username\"\x17\n" +
- "\x15SwitchProfileResponse\"\x98\x11\n" +
+ "\t_username\"'\n" +
+ "\x15SwitchProfileResponse\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" +
"\x10SetConfigRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
"\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" +
@@ -6742,23 +6903,33 @@ const file_daemon_proto_rawDesc = "" +
"\x11SetConfigResponse\"Q\n" +
"\x11AddProfileRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
- "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x14\n" +
- "\x12AddProfileResponse\"T\n" +
+ "\vprofileName\x18\x02 \x01(\tR\vprofileName\"$\n" +
+ "\x12AddProfileResponse\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"r\n" +
+ "\x14RenameProfileRequest\x12\x1a\n" +
+ "\busername\x18\x01 \x01(\tR\busername\x12\x16\n" +
+ "\x06handle\x18\x02 \x01(\tR\x06handle\x12&\n" +
+ "\x0enewProfileName\x18\x03 \x01(\tR\x0enewProfileName\"?\n" +
+ "\x15RenameProfileResponse\x12&\n" +
+ "\x0eoldProfileName\x18\x01 \x01(\tR\x0eoldProfileName\"T\n" +
"\x14RemoveProfileRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
- "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x17\n" +
- "\x15RemoveProfileResponse\"1\n" +
+ "\vprofileName\x18\x02 \x01(\tR\vprofileName\"'\n" +
+ "\x15RemoveProfileResponse\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"1\n" +
"\x13ListProfilesRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\"C\n" +
"\x14ListProfilesResponse\x12+\n" +
- "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\":\n" +
+ "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\"J\n" +
"\aProfile\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1b\n" +
- "\tis_active\x18\x02 \x01(\bR\bisActive\"\x19\n" +
- "\x17GetActiveProfileRequest\"X\n" +
+ "\tis_active\x18\x02 \x01(\bR\bisActive\x12\x0e\n" +
+ "\x02id\x18\x03 \x01(\tR\x02id\"\x19\n" +
+ "\x17GetActiveProfileRequest\"h\n" +
"\x18GetActiveProfileResponse\x12 \n" +
"\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" +
- "\busername\x18\x02 \x01(\tR\busername\"t\n" +
+ "\busername\x18\x02 \x01(\tR\busername\x12\x0e\n" +
+ "\x02id\x18\x03 \x01(\tR\x02id\"t\n" +
"\rLogoutRequest\x12%\n" +
"\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" +
"\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" +
@@ -6869,7 +7040,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"EXPOSE_UDP\x10\x03\x12\x0e\n" +
"\n" +
- "EXPOSE_TLS\x10\x042\xaf\x17\n" +
+ "EXPOSE_TLS\x10\x042\xff\x17\n" +
"\rDaemonService\x126\n" +
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
@@ -6900,6 +7071,7 @@ const file_daemon_proto_rawDesc = "" +
"\tSetConfig\x12\x18.daemon.SetConfigRequest\x1a\x19.daemon.SetConfigResponse\"\x00\x12E\n" +
"\n" +
"AddProfile\x12\x19.daemon.AddProfileRequest\x1a\x1a.daemon.AddProfileResponse\"\x00\x12N\n" +
+ "\rRenameProfile\x12\x1c.daemon.RenameProfileRequest\x1a\x1d.daemon.RenameProfileResponse\"\x00\x12N\n" +
"\rRemoveProfile\x12\x1c.daemon.RemoveProfileRequest\x1a\x1d.daemon.RemoveProfileResponse\"\x00\x12K\n" +
"\fListProfiles\x12\x1b.daemon.ListProfilesRequest\x1a\x1c.daemon.ListProfilesResponse\"\x00\x12W\n" +
"\x10GetActiveProfile\x12\x1f.daemon.GetActiveProfileRequest\x1a .daemon.GetActiveProfileResponse\"\x00\x129\n" +
@@ -6927,7 +7099,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 98)
+var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 100)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
@@ -6993,53 +7165,55 @@ var file_daemon_proto_goTypes = []any{
(*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse
(*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest
(*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse
- (*RemoveProfileRequest)(nil), // 64: daemon.RemoveProfileRequest
- (*RemoveProfileResponse)(nil), // 65: daemon.RemoveProfileResponse
- (*ListProfilesRequest)(nil), // 66: daemon.ListProfilesRequest
- (*ListProfilesResponse)(nil), // 67: daemon.ListProfilesResponse
- (*Profile)(nil), // 68: daemon.Profile
- (*GetActiveProfileRequest)(nil), // 69: daemon.GetActiveProfileRequest
- (*GetActiveProfileResponse)(nil), // 70: daemon.GetActiveProfileResponse
- (*LogoutRequest)(nil), // 71: daemon.LogoutRequest
- (*LogoutResponse)(nil), // 72: daemon.LogoutResponse
- (*GetFeaturesRequest)(nil), // 73: daemon.GetFeaturesRequest
- (*GetFeaturesResponse)(nil), // 74: daemon.GetFeaturesResponse
- (*MDMManagedFieldsViolation)(nil), // 75: daemon.MDMManagedFieldsViolation
- (*TriggerUpdateRequest)(nil), // 76: daemon.TriggerUpdateRequest
- (*TriggerUpdateResponse)(nil), // 77: daemon.TriggerUpdateResponse
- (*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest
- (*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse
- (*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest
- (*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse
- (*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest
- (*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse
- (*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest
- (*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse
- (*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest
- (*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse
- (*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest
- (*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse
- (*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest
- (*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent
- (*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady
- (*StartCaptureRequest)(nil), // 93: daemon.StartCaptureRequest
- (*CapturePacket)(nil), // 94: daemon.CapturePacket
- (*StartBundleCaptureRequest)(nil), // 95: daemon.StartBundleCaptureRequest
- (*StartBundleCaptureResponse)(nil), // 96: daemon.StartBundleCaptureResponse
- (*StopBundleCaptureRequest)(nil), // 97: daemon.StopBundleCaptureRequest
- (*StopBundleCaptureResponse)(nil), // 98: daemon.StopBundleCaptureResponse
- nil, // 99: daemon.Network.ResolvedIPsEntry
- (*PortInfo_Range)(nil), // 100: daemon.PortInfo.Range
- nil, // 101: daemon.SystemEvent.MetadataEntry
- (*durationpb.Duration)(nil), // 102: google.protobuf.Duration
- (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp
+ (*RenameProfileRequest)(nil), // 64: daemon.RenameProfileRequest
+ (*RenameProfileResponse)(nil), // 65: daemon.RenameProfileResponse
+ (*RemoveProfileRequest)(nil), // 66: daemon.RemoveProfileRequest
+ (*RemoveProfileResponse)(nil), // 67: daemon.RemoveProfileResponse
+ (*ListProfilesRequest)(nil), // 68: daemon.ListProfilesRequest
+ (*ListProfilesResponse)(nil), // 69: daemon.ListProfilesResponse
+ (*Profile)(nil), // 70: daemon.Profile
+ (*GetActiveProfileRequest)(nil), // 71: daemon.GetActiveProfileRequest
+ (*GetActiveProfileResponse)(nil), // 72: daemon.GetActiveProfileResponse
+ (*LogoutRequest)(nil), // 73: daemon.LogoutRequest
+ (*LogoutResponse)(nil), // 74: daemon.LogoutResponse
+ (*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest
+ (*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse
+ (*MDMManagedFieldsViolation)(nil), // 77: daemon.MDMManagedFieldsViolation
+ (*TriggerUpdateRequest)(nil), // 78: daemon.TriggerUpdateRequest
+ (*TriggerUpdateResponse)(nil), // 79: daemon.TriggerUpdateResponse
+ (*GetPeerSSHHostKeyRequest)(nil), // 80: daemon.GetPeerSSHHostKeyRequest
+ (*GetPeerSSHHostKeyResponse)(nil), // 81: daemon.GetPeerSSHHostKeyResponse
+ (*RequestJWTAuthRequest)(nil), // 82: daemon.RequestJWTAuthRequest
+ (*RequestJWTAuthResponse)(nil), // 83: daemon.RequestJWTAuthResponse
+ (*WaitJWTTokenRequest)(nil), // 84: daemon.WaitJWTTokenRequest
+ (*WaitJWTTokenResponse)(nil), // 85: daemon.WaitJWTTokenResponse
+ (*StartCPUProfileRequest)(nil), // 86: daemon.StartCPUProfileRequest
+ (*StartCPUProfileResponse)(nil), // 87: daemon.StartCPUProfileResponse
+ (*StopCPUProfileRequest)(nil), // 88: daemon.StopCPUProfileRequest
+ (*StopCPUProfileResponse)(nil), // 89: daemon.StopCPUProfileResponse
+ (*InstallerResultRequest)(nil), // 90: daemon.InstallerResultRequest
+ (*InstallerResultResponse)(nil), // 91: daemon.InstallerResultResponse
+ (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest
+ (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent
+ (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady
+ (*StartCaptureRequest)(nil), // 95: daemon.StartCaptureRequest
+ (*CapturePacket)(nil), // 96: daemon.CapturePacket
+ (*StartBundleCaptureRequest)(nil), // 97: daemon.StartBundleCaptureRequest
+ (*StartBundleCaptureResponse)(nil), // 98: daemon.StartBundleCaptureResponse
+ (*StopBundleCaptureRequest)(nil), // 99: daemon.StopBundleCaptureRequest
+ (*StopBundleCaptureResponse)(nil), // 100: daemon.StopBundleCaptureResponse
+ nil, // 101: daemon.Network.ResolvedIPsEntry
+ (*PortInfo_Range)(nil), // 102: daemon.PortInfo.Range
+ nil, // 103: daemon.SystemEvent.MetadataEntry
+ (*durationpb.Duration)(nil), // 104: google.protobuf.Duration
+ (*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
- 102, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 104, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
- 103, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
- 103, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
- 102, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
+ 105, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
+ 105, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
+ 104, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState
@@ -7050,8 +7224,8 @@ var file_daemon_proto_depIdxs = []int32{
55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent
24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
- 99, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
- 100, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
+ 101, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
+ 102, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
@@ -7062,15 +7236,15 @@ var file_daemon_proto_depIdxs = []int32{
52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
- 103, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
- 101, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
+ 105, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
+ 103, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
- 102, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
- 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
+ 104, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 70, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
- 92, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
- 102, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
- 102, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
+ 94, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
+ 104, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
+ 104, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
@@ -7090,68 +7264,70 @@ var file_daemon_proto_depIdxs = []int32{
46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
- 93, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
- 95, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
- 97, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
+ 95, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
+ 97, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
+ 99, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
- 64, // 63: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
- 66, // 64: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
- 69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
- 71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
- 73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
- 76, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
- 78, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
- 80, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
- 82, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
- 84, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
- 86, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
- 88, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
- 90, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
- 6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
- 8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
- 10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse
- 12, // 79: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
- 14, // 80: daemon.DaemonService.Down:output_type -> daemon.DownResponse
- 16, // 81: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
- 27, // 82: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
- 29, // 83: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
- 29, // 84: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
- 34, // 85: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
- 36, // 86: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
- 38, // 87: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
- 40, // 88: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
- 43, // 89: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
- 45, // 90: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
- 47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
- 49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
- 53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
- 94, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
- 96, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
- 98, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
- 55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
- 57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
- 59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
- 61, // 100: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
- 63, // 101: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
- 65, // 102: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
- 67, // 103: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
- 70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
- 72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
- 74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
- 77, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
- 79, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
- 81, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
- 83, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
- 85, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
- 87, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
- 89, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
- 91, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
- 76, // [76:115] is the sub-list for method output_type
- 37, // [37:76] is the sub-list for method input_type
+ 64, // 63: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest
+ 66, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
+ 68, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
+ 71, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
+ 73, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
+ 75, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
+ 78, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
+ 80, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
+ 82, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
+ 84, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
+ 86, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
+ 88, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
+ 90, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
+ 92, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
+ 6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
+ 8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
+ 10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse
+ 12, // 80: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
+ 14, // 81: daemon.DaemonService.Down:output_type -> daemon.DownResponse
+ 16, // 82: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
+ 27, // 83: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
+ 29, // 84: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
+ 29, // 85: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
+ 34, // 86: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
+ 36, // 87: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
+ 38, // 88: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
+ 40, // 89: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
+ 43, // 90: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
+ 45, // 91: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
+ 47, // 92: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
+ 49, // 93: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
+ 53, // 94: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
+ 96, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
+ 98, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
+ 100, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
+ 55, // 98: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
+ 57, // 99: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
+ 59, // 100: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
+ 61, // 101: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
+ 63, // 102: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
+ 65, // 103: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse
+ 67, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
+ 69, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
+ 72, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
+ 74, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
+ 76, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
+ 79, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
+ 81, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
+ 83, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
+ 85, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
+ 87, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
+ 89, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
+ 91, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
+ 93, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
+ 77, // [77:117] is the sub-list for method output_type
+ 37, // [37:77] is the sub-list for method input_type
37, // [37:37] is the sub-list for extension type_name
37, // [37:37] is the sub-list for extension extendee
0, // [0:37] is the sub-list for field type_name
@@ -7173,9 +7349,9 @@ func file_daemon_proto_init() {
file_daemon_proto_msgTypes[48].OneofWrappers = []any{}
file_daemon_proto_msgTypes[54].OneofWrappers = []any{}
file_daemon_proto_msgTypes[56].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[67].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[76].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[87].OneofWrappers = []any{
+ file_daemon_proto_msgTypes[69].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[78].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[89].OneofWrappers = []any{
(*ExposeServiceEvent_Ready)(nil),
}
type x struct{}
@@ -7184,7 +7360,7 @@ func file_daemon_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 4,
- NumMessages: 98,
+ NumMessages: 100,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index ea668f629..c1e3fe513 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -85,6 +85,8 @@ service DaemonService {
rpc AddProfile(AddProfileRequest) returns (AddProfileResponse) {}
+ rpc RenameProfile(RenameProfileRequest) returns (RenameProfileResponse) {}
+
rpc RemoveProfile(RemoveProfileRequest) returns (RemoveProfileResponse) {}
rpc ListProfiles(ListProfilesRequest) returns (ListProfilesResponse) {}
@@ -625,11 +627,18 @@ message GetEventsResponse {
}
message SwitchProfileRequest {
+ // profileName is treated as a handle: exact ID, unique ID prefix, or
+ // unique display name. The daemon resolves it server-side.
optional string profileName = 1;
optional string username = 2;
}
-message SwitchProfileResponse {}
+message SwitchProfileResponse {
+ // id is the resolved on-disk ID of the profile that became active.
+ // Lets CLI clients update their local active-profile state without
+ // duplicating the resolution logic.
+ string id = 1;
+}
message SetConfigRequest {
string username = 1;
@@ -696,17 +705,42 @@ message SetConfigResponse{}
message AddProfileRequest {
string username = 1;
+ // profileName carries the human-readable display name for the new
+ // profile. The on-disk filename is a separately-generated ID.
string profileName = 2;
}
-message AddProfileResponse {}
+message AddProfileResponse {
+ // id is the generated on-disk ID of the new profile. CLI clients
+ // display a truncated form, UI clients can ignore it.
+ string id = 1;
+}
+
+message RenameProfileRequest {
+ string username = 1;
+ // handle: an exact ID, a unique ID prefix, or a unique display name.
+ string handle = 2;
+ // newProfileName is the new human-readable display name for the profile.
+ string newProfileName = 3;
+}
+
+message RenameProfileResponse {
+ // confirm the old profile name after resolving handle.
+ string oldProfileName = 1;
+}
message RemoveProfileRequest {
string username = 1;
+ // profileName is treated as a handle: an exact ID, a unique ID
+ // prefix, or a unique display name. Resolution happens server-side.
string profileName = 2;
}
-message RemoveProfileResponse {}
+message RemoveProfileResponse {
+ // id is the full resolved ID of the removed profile, so callers can
+ // confirm exactly which profile a name/prefix handle resolved to.
+ string id = 1;
+}
message ListProfilesRequest {
string username = 1;
@@ -719,6 +753,7 @@ message ListProfilesResponse {
message Profile {
string name = 1;
bool is_active = 2;
+ string id = 3;
}
message GetActiveProfileRequest {}
@@ -726,6 +761,7 @@ message GetActiveProfileRequest {}
message GetActiveProfileResponse {
string profileName = 1;
string username = 2;
+ string id = 3;
}
message LogoutRequest {
diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go
index 66a8efcc3..5f585aafc 100644
--- a/client/proto/daemon_grpc.pb.go
+++ b/client/proto/daemon_grpc.pb.go
@@ -45,6 +45,7 @@ const (
DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile"
DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig"
DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile"
+ DaemonService_RenameProfile_FullMethodName = "/daemon.DaemonService/RenameProfile"
DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile"
DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles"
DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile"
@@ -112,6 +113,7 @@ type DaemonServiceClient interface {
SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error)
SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error)
AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error)
+ RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error)
RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error)
ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error)
GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error)
@@ -422,6 +424,16 @@ func (c *daemonServiceClient) AddProfile(ctx context.Context, in *AddProfileRequ
return out, nil
}
+func (c *daemonServiceClient) RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(RenameProfileResponse)
+ err := c.cc.Invoke(ctx, DaemonService_RenameProfile_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveProfileResponse)
@@ -613,6 +625,7 @@ type DaemonServiceServer interface {
SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error)
SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error)
AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error)
+ RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error)
RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error)
ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error)
GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error)
@@ -723,6 +736,9 @@ func (UnimplementedDaemonServiceServer) SetConfig(context.Context, *SetConfigReq
func (UnimplementedDaemonServiceServer) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AddProfile not implemented")
}
+func (UnimplementedDaemonServiceServer) RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method RenameProfile not implemented")
+}
func (UnimplementedDaemonServiceServer) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveProfile not implemented")
}
@@ -1237,6 +1253,24 @@ func _DaemonService_AddProfile_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
+func _DaemonService_RenameProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(RenameProfileRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).RenameProfile(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_RenameProfile_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).RenameProfile(ctx, req.(*RenameProfileRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
func _DaemonService_RemoveProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveProfileRequest)
if err := dec(in); err != nil {
@@ -1567,6 +1601,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AddProfile",
Handler: _DaemonService_AddProfile_Handler,
},
+ {
+ MethodName: "RenameProfile",
+ Handler: _DaemonService_RenameProfile_Handler,
+ },
{
MethodName: "RemoveProfile",
Handler: _DaemonService_RemoveProfile_Handler,
diff --git a/client/server/login_overrides_test.go b/client/server/login_overrides_test.go
index c45557c59..5a2298764 100644
--- a/client/server/login_overrides_test.go
+++ b/client/server/login_overrides_test.go
@@ -79,7 +79,7 @@ func TestPersistLoginOverrides(t *testing.T) {
_, err := profilemanager.UpdateOrCreateConfig(seed)
require.NoError(t, err, "seed config")
- activeProf := &profilemanager.ActiveProfileState{Name: "default"}
+ activeProf := &profilemanager.ActiveProfileState{ID: "default"}
err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK)
require.NoError(t, err, "persistLoginOverrides")
diff --git a/client/server/server.go b/client/server/server.go
index 32daf7718..a4d53a823 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -78,7 +78,7 @@ type Server struct {
// changed by connectWithRetryRuns goroutine exit — for that
// (goroutine-still-alive) check, see connectionGoroutineRunning() which
// derives from clientGiveUpChan close state. Protected by s.mutex.
- clientRunning bool
+ clientRunning bool
clientRunningChan chan struct{}
clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
@@ -375,7 +375,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
return nil, err
}
- config, err := setConfigInputFromRequest(msg)
+ config, err := s.setConfigInputFromRequest(msg)
if err != nil {
return nil, err
}
@@ -398,17 +398,17 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
// field is its own optional case. Returns the resolved ConfigInput
// and a non-nil error only when the active profile file path cannot
// be determined.
-func setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
+func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
var config profilemanager.ConfigInput
- profState := profilemanager.ActiveProfileState{
- Name: msg.ProfileName,
- Username: msg.Username,
- }
- profPath, err := profState.FilePath()
+ resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
if err != nil {
- log.Errorf("failed to get active profile file path: %v", err)
- return config, fmt.Errorf("failed to get active profile file path: %w", err)
+ log.Errorf("failed to resolve profile %q: %v", msg.ProfileName, err)
+ return config, err
+ }
+ profPath := resolved.Path
+ if profPath == "" {
+ profPath = profilemanager.DefaultConfigPath
}
config.ConfigPath = profPath
@@ -535,30 +535,9 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
if msg.ProfileName != nil {
- if *msg.ProfileName != "default" && (msg.Username == nil || *msg.Username == "") {
- log.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName)
- return nil, fmt.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName)
- }
-
- var username string
- if *msg.ProfileName != "default" {
- username = *msg.Username
- }
-
- if *msg.ProfileName != activeProf.Name && username != activeProf.Username {
- if s.checkProfilesDisabled() {
- log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
- return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
- }
-
- log.Infof("switching to profile %s for user '%s'", *msg.ProfileName, username)
- if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: *msg.ProfileName,
- Username: username,
- }); err != nil {
- log.Errorf("failed to set active profile state: %v", err)
- return nil, fmt.Errorf("failed to set active profile state: %w", err)
- }
+ if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
+ log.Errorf("failed to switch profile: %v", err)
+ return nil, err
}
}
@@ -568,7 +547,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
- log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username)
+ log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
s.mutex.Lock()
@@ -806,10 +785,10 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
}
if msg != nil && msg.ProfileName != nil {
- if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
+ if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
s.mutex.Unlock()
log.Errorf("failed to switch profile: %v", err)
- return nil, fmt.Errorf("failed to switch profile: %w", err)
+ return nil, err
}
}
@@ -820,7 +799,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
- log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username)
+ log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
config, _, err := s.getConfig(activeProf)
if err != nil {
@@ -864,34 +843,60 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error)
}
}
-func (s *Server) switchProfileIfNeeded(profileName string, userName *string, activeProf *profilemanager.ActiveProfileState) error {
- if profileName != "default" && (userName == nil || *userName == "") {
- log.Errorf("profile name is set to %s, but username is not provided", profileName)
- return fmt.Errorf("profile name is set to %s, but username is not provided", profileName)
+// resolveProfileHandle resolves a wire-level profile handle (display
+// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC
+// status errors so handlers can return them directly.
+func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.Profile, error) {
+ p, err := s.profileManager.ResolveProfile(handle, username)
+ if err == nil {
+ return p, nil
+ }
+ var amb *profilemanager.ErrAmbiguousHandle
+ if errors.As(err, &amb) {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "%v", amb)
+ }
+ if errors.Is(err, profilemanager.ErrProfileNotFound) {
+ return nil, gstatus.Errorf(codes.NotFound, "profile %q not found", handle)
+ }
+ return nil, fmt.Errorf("resolve profile: %w", err)
+}
+
+// switchProfileIfNeeded resolves the user-supplied handle, updates the
+// active profile state if it differs from the current one, and returns
+// the resolved profile so callers can include its ID in RPC responses.
+func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) {
+ if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") {
+ log.Errorf("profile name is set to %s, but username is not provided", handle)
+ return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle)
}
var username string
- if profileName != "default" {
+ if handle != profilemanager.DefaultProfileName {
username = *userName
}
- if profileName != activeProf.Name || username != activeProf.Username {
+ resolved, err := s.resolveProfileHandle(handle, username)
+ if err != nil {
+ return nil, err
+ }
+
+ if resolved.ID != activeProf.ID || username != activeProf.Username {
if s.checkProfilesDisabled() {
log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
- return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
+ return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
- log.Infof("switching to profile %s for user %s", profileName, username)
+ log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username)
if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profileName,
+ ID: resolved.ID,
Username: username,
}); err != nil {
log.Errorf("failed to set active profile state: %v", err)
- return fmt.Errorf("failed to set active profile state: %w", err)
+ return nil, fmt.Errorf("failed to set active profile state: %w", err)
}
}
- return nil
+ return resolved, nil
}
// SwitchProfile switches the active profile in the daemon.
@@ -906,9 +911,9 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
}
if msg != nil && msg.ProfileName != nil {
- if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
+ if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
log.Errorf("failed to switch profile: %v", err)
- return nil, fmt.Errorf("failed to switch profile: %w", err)
+ return nil, err
}
}
activeProf, err = s.profileManager.GetActiveProfileState()
@@ -924,7 +929,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
s.config = config
- return &proto.SwitchProfileResponse{}, nil
+ return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil
}
// Down engine work in the daemon.
@@ -1014,22 +1019,27 @@ func (s *Server) Logout(ctx context.Context, msg *proto.LogoutRequest) (*proto.L
}
func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutRequest) (*proto.LogoutResponse, error) {
- if err := s.validateProfileOperation(*msg.ProfileName, true); err != nil {
- return nil, err
- }
-
if msg.Username == nil || *msg.Username == "" {
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided when profile name is specified")
}
username := *msg.Username
- if err := s.logoutFromProfile(ctx, *msg.ProfileName, username); err != nil {
- log.Errorf("failed to logout from profile %s: %v", *msg.ProfileName, err)
+ resolved, err := s.resolveProfileHandle(*msg.ProfileName, username)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := s.validateProfileOperation(resolved.ID, true); err != nil {
+ return nil, err
+ }
+
+ if err := s.logoutFromProfile(ctx, resolved); err != nil {
+ log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
return nil, gstatus.Errorf(codes.Internal, "logout: %v", err)
}
activeProf, _ := s.profileManager.GetActiveProfileState()
- if activeProf != nil && activeProf.Name == *msg.ProfileName {
+ if activeProf != nil && activeProf.ID == resolved.ID {
if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
log.Errorf("failed to cleanup connection: %v", err)
}
@@ -1091,30 +1101,30 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof
return config, configExisted, nil
}
-func (s *Server) canRemoveProfile(profileName string) error {
- if profileName == profilemanager.DefaultProfileName {
+func (s *Server) canRemoveProfile(id profilemanager.ID) error {
+ if id == profilemanager.DefaultProfileName {
return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName)
}
activeProf, err := s.profileManager.GetActiveProfileState()
- if err == nil && activeProf.Name == profileName {
- return fmt.Errorf("remove active profile: %s", profileName)
+ if err == nil && activeProf.ID == id {
+ return fmt.Errorf("remove active profile: %s", id)
}
return nil
}
-func (s *Server) validateProfileOperation(profileName string, allowActiveProfile bool) error {
+func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error {
if s.checkProfilesDisabled() {
return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
- if profileName == "" {
+ if id == "" {
return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
}
if !allowActiveProfile {
- if err := s.canRemoveProfile(profileName); err != nil {
+ if err := s.canRemoveProfile(id); err != nil {
return gstatus.Errorf(codes.InvalidArgument, "%v", err)
}
}
@@ -1122,25 +1132,20 @@ func (s *Server) validateProfileOperation(profileName string, allowActiveProfile
return nil
}
-// logoutFromProfile logs out from a specific profile by loading its config and sending logout request
-func (s *Server) logoutFromProfile(ctx context.Context, profileName, username string) error {
+func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error {
activeProf, err := s.profileManager.GetActiveProfileState()
- if err == nil && activeProf.Name == profileName && s.connectClient != nil {
+ if err == nil && activeProf.ID == profile.ID && s.connectClient != nil {
return s.sendLogoutRequest(ctx)
}
- profileState := &profilemanager.ActiveProfileState{
- Name: profileName,
- Username: username,
- }
- profilePath, err := profileState.FilePath()
- if err != nil {
- return fmt.Errorf("get profile path: %w", err)
+ cfgPath := profile.Path
+ if cfgPath == "" {
+ cfgPath = profilemanager.DefaultConfigPath
}
- config, err := profilemanager.GetConfig(profilePath)
+ config, err := profilemanager.GetConfig(cfgPath)
if err != nil {
- return fmt.Errorf("profile '%s' not found", profileName)
+ return fmt.Errorf("profile '%s' not found", profile.ID)
}
return s.sendLogoutRequestWithConfig(ctx, config)
@@ -1558,15 +1563,14 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
return nil, ctx.Err()
}
- prof := profilemanager.ActiveProfileState{
- Name: req.ProfileName,
- Username: req.Username,
- }
-
- cfgPath, err := prof.FilePath()
+ resolved, err := s.resolveProfileHandle(req.ProfileName, req.Username)
if err != nil {
- log.Errorf("failed to get active profile file path: %v", err)
- return nil, fmt.Errorf("failed to get active profile file path: %w", err)
+ log.Errorf("failed to resolve profile %q: %v", req.ProfileName, err)
+ return nil, err
+ }
+ cfgPath := resolved.Path
+ if cfgPath == "" {
+ cfgPath = profilemanager.DefaultConfigPath
}
cfg, err := profilemanager.GetConfig(cfgPath)
@@ -1671,12 +1675,39 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided")
}
- if err := s.profileManager.AddProfile(msg.ProfileName, msg.Username); err != nil {
+ created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username)
+ if err != nil {
log.Errorf("failed to create profile: %v", err)
return nil, fmt.Errorf("failed to create profile: %w", err)
}
- return &proto.AddProfileResponse{}, nil
+ return &proto.AddProfileResponse{Id: created.ID.String()}, nil
+}
+
+func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequest) (*proto.RenameProfileResponse, error) {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ if s.checkProfilesDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
+ }
+
+ if msg.Handle == "" || msg.Username == "" || msg.NewProfileName == "" {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided")
+ }
+
+ resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username)
+ if err != nil {
+ return nil, err
+ }
+
+ err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName)
+ if err != nil {
+ log.Errorf("failed to rename profile: %v", err)
+ return nil, fmt.Errorf("failed to rename profile: %w", err)
+ }
+
+ return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil
}
// RemoveProfile removes a profile from the daemon.
@@ -1684,20 +1715,29 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
s.mutex.Lock()
defer s.mutex.Unlock()
- if err := s.validateProfileOperation(msg.ProfileName, false); err != nil {
+ if s.checkProfilesDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
+ }
+
+ if msg.ProfileName == "" {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
+ }
+
+ resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
+ if err != nil {
return nil, err
}
- if err := s.logoutFromProfile(ctx, msg.ProfileName, msg.Username); err != nil {
- log.Warnf("failed to logout from profile %s before removal: %v", msg.ProfileName, err)
+ if err := s.logoutFromProfile(ctx, resolved); err != nil {
+ log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err)
}
- if err := s.profileManager.RemoveProfile(msg.ProfileName, msg.Username); err != nil {
+ if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil {
log.Errorf("failed to remove profile: %v", err)
return nil, fmt.Errorf("failed to remove profile: %w", err)
}
- return &proto.RemoveProfileResponse{}, nil
+ return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil
}
// ListProfiles lists all profiles in the daemon.
@@ -1720,6 +1760,7 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
}
for i, profile := range profiles {
response.Profiles[i] = &proto.Profile{
+ Id: profile.ID.String(),
Name: profile.Name,
IsActive: profile.IsActive,
}
@@ -1728,7 +1769,9 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
return response, nil
}
-// GetActiveProfile returns the active profile in the daemon.
+// GetActiveProfile returns the active profile in the daemon. The ProfileName
+// field carries the display name for backwards compatibility with UI clients,
+// new callers should prefer Id.
func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfileRequest) (*proto.GetActiveProfileResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -1739,9 +1782,23 @@ func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfi
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
+ // Fallback to legacy name == ID
+ displayName := activeProfile.ID.String()
+ if activeProfile.ID != profilemanager.DefaultProfileName {
+ if profiles, lerr := s.profileManager.ListProfiles(activeProfile.Username); lerr == nil {
+ for _, p := range profiles {
+ if p.ID == activeProfile.ID {
+ displayName = p.Name
+ break
+ }
+ }
+ }
+ }
+
return &proto.GetActiveProfileResponse{
- ProfileName: activeProfile.Name,
+ ProfileName: displayName,
Username: activeProfile.Username,
+ Id: activeProfile.ID.String(),
}, nil
}
diff --git a/client/server/server_test.go b/client/server/server_test.go
index 66e0fcc4c..fa9599818 100644
--- a/client/server/server_test.go
+++ b/client/server/server_test.go
@@ -97,7 +97,7 @@ func TestConnectWithRetryRuns(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "test-profile",
+ ID: "test-profile",
Username: currUser.Username,
})
if err != nil {
@@ -158,7 +158,7 @@ func TestServer_Up(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
})
if err != nil {
@@ -228,7 +228,7 @@ func TestServer_SubcribeEvents(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "default",
+ ID: "default",
Username: currUser.Username,
})
if err != nil {
diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go
index 53232c70d..9818f9fdf 100644
--- a/client/server/setconfig_mdm_test.go
+++ b/client/server/setconfig_mdm_test.go
@@ -62,7 +62,7 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN
pm := profilemanager.ServiceManager{}
require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
}))
@@ -107,9 +107,9 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) {
func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
- mdm.KeyManagementURL: "https://mdm.example.com:443",
- mdm.KeyBlockInbound: true,
- mdm.KeyRosenpassEnabled: true,
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ mdm.KeyBlockInbound: true,
+ mdm.KeyRosenpassEnabled: true,
}))
s, ctx, profName, username, _ := setupServerWithProfile(t)
diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go
index 553d4ad71..7c85d16ce 100644
--- a/client/server/setconfig_test.go
+++ b/client/server/setconfig_test.go
@@ -47,7 +47,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
})
require.NoError(t, err)
@@ -96,7 +96,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
DisableNotifications: &disableNotifications,
LazyConnectionEnabled: &lazyConnectionEnabled,
BlockInbound: &blockInbound,
- DisableIpv6: &disableIPv6,
+ DisableIpv6: &disableIPv6,
NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"},
CleanNATExternalIPs: false,
CustomDNSAddress: []byte("1.1.1.1:53"),
@@ -112,7 +112,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.NoError(t, err)
profState := profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
}
cfgPath, err := profState.FilePath()
diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go
index 5814ad9b4..d2f38cfd7 100644
--- a/client/ui/client_ui.go
+++ b/client/ui/client_ui.go
@@ -645,7 +645,7 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (
}
req := &proto.SetConfigRequest{
- ProfileName: activeProf.Name,
+ ProfileName: activeProf.ID.String(),
Username: currUser.Username,
}
@@ -818,13 +818,15 @@ func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginRe
return nil, fmt.Errorf("get current user: %w", err)
}
+ handle := activeProf.ID.String()
+
loginReq := &proto.LoginRequest{
IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
- ProfileName: &activeProf.Name,
+ ProfileName: &handle,
Username: &currUser.Username,
}
- profileState, err := s.profileManager.GetProfileState(activeProf.Name)
+ profileState, err := s.profileManager.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
@@ -1367,7 +1369,7 @@ func (s *serviceClient) getSrvConfig() {
}
srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
- ProfileName: activeProf.Name,
+ ProfileName: activeProf.ID.String(),
Username: currUser.Username,
})
if err != nil {
@@ -1613,7 +1615,7 @@ func (s *serviceClient) loadSettings() {
}
cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{
- ProfileName: activeProf.Name,
+ ProfileName: activeProf.ID.String(),
Username: currUser.Username,
})
if err != nil {
@@ -1813,7 +1815,7 @@ func (s *serviceClient) updateConfig() error {
}
req := proto.SetConfigRequest{
- ProfileName: activeProf.Name,
+ ProfileName: activeProf.ID.String(),
Username: currUser.Username,
DisableAutoConnect: &disableAutoStart,
ServerSSHAllowed: &sshAllowed,
diff --git a/client/ui/profile.go b/client/ui/profile.go
index d3db17855..83b0ec18b 100644
--- a/client/ui/profile.go
+++ b/client/ui/profile.go
@@ -66,7 +66,7 @@ func (s *serviceClient) showProfilesUI() {
} else {
indicator.SetText("")
}
- nameLabel.SetText(profile.Name)
+ nameLabel.SetText(formatProfileLabel(profile, profiles))
// Configure Select/Active button
selectBtn.SetText(func() string {
@@ -88,7 +88,7 @@ func (s *serviceClient) showProfilesUI() {
return
}
// switch
- err = s.switchProfile(profile.Name)
+ err = s.switchProfile(profile.ID)
if err != nil {
log.Errorf("failed to switch profile: %v", err)
dialog.ShowError(errors.New("failed to select profile"), s.wProfiles)
@@ -130,7 +130,7 @@ func (s *serviceClient) showProfilesUI() {
logoutBtn.Show()
logoutBtn.SetText("Deregister")
logoutBtn.OnTapped = func() {
- s.handleProfileLogout(profile.Name, refresh)
+ s.handleProfileLogout(profile, refresh)
}
// Remove profile
@@ -144,7 +144,7 @@ func (s *serviceClient) showProfilesUI() {
return
}
- err = s.removeProfile(profile.Name)
+ err = s.removeProfile(profile.ID)
if err != nil {
log.Errorf("failed to remove profile: %v", err)
dialog.ShowError(fmt.Errorf("failed to remove profile"), s.wProfiles)
@@ -250,7 +250,7 @@ func (s *serviceClient) addProfile(profileName string) error {
return nil
}
-func (s *serviceClient) switchProfile(profileName string) error {
+func (s *serviceClient) switchProfile(handle string) error {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
return fmt.Errorf(getClientFMT, err)
@@ -261,15 +261,15 @@ func (s *serviceClient) switchProfile(profileName string) error {
return fmt.Errorf("get current user: %w", err)
}
- if _, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{
- ProfileName: &profileName,
+ resp, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{
+ ProfileName: &handle,
Username: &currUser.Username,
- }); err != nil {
+ })
+ if err != nil {
return fmt.Errorf("switch profile failed: %w", err)
}
- err = s.profileManager.SwitchProfile(profileName)
- if err != nil {
+ if err := s.profileManager.SwitchProfile(profilemanager.ID(resp.Id)); err != nil {
return fmt.Errorf("switch profile: %w", err)
}
@@ -299,10 +299,27 @@ func (s *serviceClient) removeProfile(profileName string) error {
}
type Profile struct {
+ ID string
Name string
IsActive bool
}
+// formatProfileLabel returns the display label for a profile. Profiles can
+// share the same Name, so when more than one profile in profiles carries this
+// Name, a short form of the ID is appended to disambiguate the entries.
+func formatProfileLabel(profile Profile, profiles []Profile) string {
+ count := 0
+ for _, p := range profiles {
+ if p.Name == profile.Name {
+ count++
+ }
+ }
+ if count <= 1 {
+ return profile.Name
+ }
+ return fmt.Sprintf("%s (%s)", profile.Name, profilemanager.ID(profile.ID).ShortID())
+}
+
func (s *serviceClient) getProfiles() ([]Profile, error) {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
@@ -324,6 +341,7 @@ func (s *serviceClient) getProfiles() ([]Profile, error) {
for _, profile := range profilesResp.Profiles {
profiles = append(profiles, Profile{
+ ID: profile.Id,
Name: profile.Name,
IsActive: profile.IsActive,
})
@@ -332,10 +350,10 @@ func (s *serviceClient) getProfiles() ([]Profile, error) {
return profiles, nil
}
-func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback func()) {
+func (s *serviceClient) handleProfileLogout(profile Profile, refreshCallback func()) {
dialog.ShowConfirm(
"Deregister",
- fmt.Sprintf("Are you sure you want to deregister from '%s'?", profileName),
+ fmt.Sprintf("Are you sure you want to deregister from '%s'?", profile.Name),
func(confirm bool) {
if !confirm {
return
@@ -356,8 +374,10 @@ func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback
}
username := currUser.Username
+ // ProfileName is treated as a handle; send the ID so the
+ // daemon resolves to exactly this profile.
_, err = conn.Logout(s.ctx, &proto.LogoutRequest{
- ProfileName: &profileName,
+ ProfileName: &profile.ID,
Username: &username,
})
if err != nil {
@@ -368,7 +388,7 @@ func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback
dialog.ShowInformation(
"Deregistered",
- fmt.Sprintf("Successfully deregistered from '%s'", profileName),
+ fmt.Sprintf("Successfully deregistered from '%s'", profile.Name),
s.wProfiles,
)
@@ -461,6 +481,7 @@ func (p *profileMenu) getProfiles() ([]Profile, error) {
for _, profile := range profilesResp.Profiles {
profiles = append(profiles, Profile{
+ ID: profile.Id,
Name: profile.Name,
IsActive: profile.IsActive,
})
@@ -501,7 +522,7 @@ func (p *profileMenu) refresh() {
}
if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username {
- activeProfState, err := p.profileManager.GetProfileState(activeProf.ProfileName)
+ activeProfState, err := p.profileManager.GetProfileState(profilemanager.ID(activeProf.Id))
if err != nil {
log.Warnf("failed to get active profile state: %v", err)
p.emailMenuItem.Hide()
@@ -512,7 +533,7 @@ func (p *profileMenu) refresh() {
}
for _, profile := range profiles {
- item := p.profileMenuItem.AddSubMenuItem(profile.Name, "")
+ item := p.profileMenuItem.AddSubMenuItem(formatProfileLabel(profile, profiles), "")
if profile.IsActive {
item.Check()
}
@@ -541,8 +562,8 @@ func (p *profileMenu) refresh() {
return
}
- _, err = conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{
- ProfileName: &profile.Name,
+ switchResp, err := conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{
+ ProfileName: &profile.ID,
Username: &currUser.Username,
})
if err != nil {
@@ -552,7 +573,7 @@ func (p *profileMenu) refresh() {
return
}
- err = p.profileManager.SwitchProfile(profile.Name)
+ err = p.profileManager.SwitchProfile(profilemanager.ID(switchResp.Id))
if err != nil {
log.Errorf("failed to switch profile '%s': %v", profile.Name, err)
return
@@ -727,7 +748,10 @@ func (p *profileMenu) updateMenu() {
}
sort.Slice(profiles, func(i, j int) bool {
- return profiles[i].Name < profiles[j].Name
+ if profiles[i].Name != profiles[j].Name {
+ return profiles[i].Name < profiles[j].Name
+ }
+ return profiles[i].ID < profiles[j].ID
})
p.mu.Lock()
From d3710d4bb2cfd7dc17aa0c004304a8bb96f27f39 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Thu, 18 Jun 2026 22:00:19 +0900
Subject: [PATCH 06/54] [signal] Serialize concurrent sends to a peer signal
stream (#6463)
---
signal/peer/peer.go | 11 +++++
signal/server/concurrent_send_test.go | 67 +++++++++++++++++++++++++++
signal/server/signal.go | 2 +-
3 files changed, 79 insertions(+), 1 deletion(-)
create mode 100644 signal/server/concurrent_send_test.go
diff --git a/signal/peer/peer.go b/signal/peer/peer.go
index c9dd60fc0..c04654b8b 100644
--- a/signal/peer/peer.go
+++ b/signal/peer/peer.go
@@ -26,6 +26,10 @@ type Peer struct {
// a gRpc connection stream to the Peer
Stream proto.SignalExchange_ConnectStreamServer
+ // sendMu serializes writes to Stream. gRPC forbids concurrent SendMsg on
+ // the same ServerStream, and a peer can be the target of many senders at
+ // once.
+ sendMu sync.Mutex
// registration time
RegisteredAt time.Time
@@ -33,6 +37,13 @@ type Peer struct {
Cancel context.CancelFunc
}
+// Send writes a message to the peer's stream, serializing concurrent senders.
+func (p *Peer) Send(msg *proto.EncryptedMessage) error {
+ p.sendMu.Lock()
+ defer p.sendMu.Unlock()
+ return p.Stream.Send(msg)
+}
+
// NewPeer creates a new instance of a connected Peer
func NewPeer(id string, stream proto.SignalExchange_ConnectStreamServer, cancel context.CancelFunc) *Peer {
return &Peer{
diff --git a/signal/server/concurrent_send_test.go b/signal/server/concurrent_send_test.go
new file mode 100644
index 000000000..b3830482d
--- /dev/null
+++ b/signal/server/concurrent_send_test.go
@@ -0,0 +1,67 @@
+package server
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ "github.com/netbirdio/netbird/signal/peer"
+)
+
+// concurrencyCheckStream records the maximum number of Send calls in flight at
+// once. gRPC forbids concurrent SendMsg on the same ServerStream, so a correct
+// server must never have more than one in flight per peer.
+type concurrencyCheckStream struct {
+ proto.SignalExchange_ConnectStreamServer
+ ctx context.Context
+ inflight atomic.Int32
+ maxSeen atomic.Int32
+}
+
+func (s *concurrencyCheckStream) Send(*proto.EncryptedMessage) error {
+ n := s.inflight.Add(1)
+ for {
+ old := s.maxSeen.Load()
+ if n <= old || s.maxSeen.CompareAndSwap(old, n) {
+ break
+ }
+ }
+ // Widen the window so overlapping callers are reliably observed.
+ time.Sleep(time.Millisecond)
+ s.inflight.Add(-1)
+ return nil
+}
+
+func (s *concurrencyCheckStream) Context() context.Context { return s.ctx }
+
+// TestForwardMessageToPeerSerializesSend verifies that concurrent forwards to the
+// same peer never call Stream.Send concurrently, which would violate the gRPC
+// ServerStream contract.
+func TestForwardMessageToPeerSerializesSend(t *testing.T) {
+ s, err := NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+
+ const peerID = "peerX"
+ stream := &concurrencyCheckStream{ctx: context.Background()}
+ _, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ require.NoError(t, s.registry.Register(peer.NewPeer(peerID, stream, cancel)))
+
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ s.forwardMessageToPeer(context.Background(), &proto.EncryptedMessage{Key: "sender", RemoteKey: peerID})
+ }()
+ }
+ wg.Wait()
+
+ require.Equal(t, int32(1), stream.maxSeen.Load(), "Stream.Send must never run concurrently on the same peer stream")
+}
diff --git a/signal/server/signal.go b/signal/server/signal.go
index c46df56d2..7edbb4d34 100644
--- a/signal/server/signal.go
+++ b/signal/server/signal.go
@@ -179,7 +179,7 @@ func (s *Server) forwardMessageToPeer(ctx context.Context, msg *proto.EncryptedM
sendResultChan := make(chan error, 1)
go func() {
select {
- case sendResultChan <- dstPeer.Stream.Send(msg):
+ case sendResultChan <- dstPeer.Send(msg):
return
case <-dstPeer.Stream.Context().Done():
return
From 60a95446565fe02611a1202e30d48449c8a017c6 Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Thu, 18 Jun 2026 17:22:42 +0200
Subject: [PATCH 07/54] [management] pass meta update for browser clients
(#6465)
---
management/server/peer.go | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/management/server/peer.go b/management/server/peer.go
index 9d78f597b..58ea53d8c 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -1124,7 +1124,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
}
var peer *nbpeer.Peer
- var shouldStorePeer bool
+ var shouldStorePeer, shouldUpdatePeers bool
var peerGroupIDs []string
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
@@ -1151,6 +1151,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
if changed {
shouldStorePeer = true
+ shouldUpdatePeers = true
}
}
@@ -1174,13 +1175,16 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
}
}
+ // This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop
+ peer.UpdateMetaIfNew(login.Meta)
+
return nil
})
if err != nil {
return nil, nil, nil, false, err
}
- isRequiresApproval, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
+ isRequiresApproval, _, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
if err != nil {
return nil, nil, nil, false, err
}
@@ -1190,7 +1194,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return nil, nil, nil, false, err
}
- if isStatusChanged || shouldStorePeer {
+ if shouldUpdatePeers {
changedPeerIDs := []string{peer.ID}
affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
From 8c031ea6f0798de65a414290a3f3cf5297745ecb Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Thu, 18 Jun 2026 22:12:59 +0200
Subject: [PATCH 08/54] [management] remove db calls in nested loops (#6470)
---
management/server/peer.go | 73 ++++++++++++++++-----------------------
1 file changed, 29 insertions(+), 44 deletions(-)
diff --git a/management/server/peer.go b/management/server/peer.go
index 58ea53d8c..bd6b2b6c5 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -1026,7 +1026,12 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return err
}
- postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peer.ID)
+ policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return err
+ }
+
+ postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies)
if err != nil {
return err
}
@@ -1290,12 +1295,22 @@ func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID st
return network, nil, false, nil
}
- postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peer.ID)
+ policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, nil, false, err
}
- enableSSH, err := isPeerSSHEnabled(ctx, transaction, accountID, peer)
+ peerGroupIDs, err := transaction.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peer.ID)
+ if err != nil {
+ return nil, nil, false, err
+ }
+
+ postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies)
+ if err != nil {
+ return nil, nil, false, err
+ }
+
+ enableSSH, err := isPeerSSHEnabled(ctx, peer, policies, peerGroupIDs)
if err != nil {
return nil, nil, false, err
}
@@ -1303,32 +1318,16 @@ func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID st
return network, postureChecks, enableSSH, nil
}
-func isPeerSSHEnabled(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer) (bool, error) {
- policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
- if err != nil {
- return false, err
+func isPeerSSHEnabled(ctx context.Context, peer *nbpeer.Peer, policies []*types.Policy, peerGroupIDs []string) (bool, error) {
+ groupIDsMap := make(map[string]struct{}, len(peerGroupIDs))
+ for _, peerID := range peerGroupIDs {
+ groupIDsMap[peerID] = struct{}{}
}
-
- peerGroups, err := transaction.GetPeerGroups(ctx, store.LockingStrengthNone, accountID, peer.ID)
- if err != nil {
- return false, err
- }
-
- peerGroupIDs := make(map[string]struct{}, len(peerGroups))
- for _, g := range peerGroups {
- peerGroupIDs[g.ID] = struct{}{}
- }
-
- return types.PeerSSHEnabledFromPolicies(policies, peer.ID, peerGroupIDs, peer.SSHEnabled), nil
+ return types.PeerSSHEnabledFromPolicies(policies, peer.ID, groupIDsMap, peer.SSHEnabled), nil
}
// getPeerPostureChecks returns the posture checks for the peer.
-func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string) ([]*posture.Checks, error) {
- policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
- if err != nil {
- return nil, err
- }
-
+func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID string, peerGroupIDs []string, policies []*types.Policy) ([]*posture.Checks, error) {
if len(policies) == 0 {
return nil, nil
}
@@ -1340,11 +1339,7 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
continue
}
- postureChecksIDs, err := processPeerPostureChecks(ctx, transaction, policy, accountID, peerID)
- if err != nil {
- return nil, err
- }
-
+ postureChecksIDs := processPeerPostureChecks(policy, peerGroupIDs)
peerPostureChecksIDs = append(peerPostureChecksIDs, postureChecksIDs...)
}
@@ -1357,29 +1352,19 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI
}
// processPeerPostureChecks checks if the peer is in the source group of the policy and returns the posture checks.
-func processPeerPostureChecks(ctx context.Context, transaction store.Store, policy *types.Policy, accountID, peerID string) ([]string, error) {
+func processPeerPostureChecks(policy *types.Policy, peerGroupIDs []string) []string {
for _, rule := range policy.Rules {
if !rule.Enabled {
continue
}
- sourceGroups, err := transaction.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, rule.Sources)
- if err != nil {
- return nil, err
- }
-
for _, sourceGroup := range rule.Sources {
- group, ok := sourceGroups[sourceGroup]
- if !ok {
- return nil, fmt.Errorf("failed to check peer in policy source group")
- }
-
- if slices.Contains(group.Peers, peerID) {
- return policy.SourcePostureChecks, nil
+ if slices.Contains(peerGroupIDs, sourceGroup) {
+ return policy.SourcePostureChecks
}
}
}
- return nil, nil
+ return nil
}
// checkIFPeerNeedsLoginWithoutLock checks if the peer needs login without acquiring the account lock. The check validate if the peer was not added via SSO
From 679c7182a4a112aa86466df596aafd06d1578fae Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Thu, 18 Jun 2026 22:34:24 +0200
Subject: [PATCH 09/54] [misc] Remove version prefix `v` docker tags (#6471)
---
.goreleaser.yaml | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 5031ef446..c068f51d1 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -247,7 +247,7 @@ dockers_v2:
- netbirdio/netbird
- ghcr.io/netbirdio/netbird
tags:
- - "v{{ .Version }}"
+ - "{{ .Version }}"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
dockerfile: client/Dockerfile
extra_files:
@@ -295,7 +295,7 @@ dockers_v2:
- netbirdio/relay
- ghcr.io/netbirdio/relay
tags:
- - "v{{ .Version }}"
+ - "{{ .Version }}"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
dockerfile: relay/Dockerfile
platforms:
@@ -317,7 +317,7 @@ dockers_v2:
- netbirdio/signal
- ghcr.io/netbirdio/signal
tags:
- - "v{{ .Version }}"
+ - "{{ .Version }}"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
dockerfile: signal/Dockerfile
platforms:
@@ -339,7 +339,7 @@ dockers_v2:
- netbirdio/management
- ghcr.io/netbirdio/management
tags:
- - "v{{ .Version }}"
+ - "{{ .Version }}"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
dockerfile: management/Dockerfile
platforms:
@@ -361,7 +361,7 @@ dockers_v2:
- netbirdio/upload
- ghcr.io/netbirdio/upload
tags:
- - "v{{ .Version }}"
+ - "{{ .Version }}"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
dockerfile: upload-server/Dockerfile
platforms:
@@ -383,7 +383,7 @@ dockers_v2:
- netbirdio/netbird-server
- ghcr.io/netbirdio/netbird-server
tags:
- - "v{{ .Version }}"
+ - "{{ .Version }}"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
dockerfile: combined/Dockerfile
platforms:
@@ -405,7 +405,7 @@ dockers_v2:
- netbirdio/reverse-proxy
- ghcr.io/netbirdio/reverse-proxy
tags:
- - "v{{ .Version }}"
+ - "{{ .Version }}"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
dockerfile: proxy/Dockerfile
platforms:
From fb87f751a5f2da6d463a333e6ccb6e470269e12c Mon Sep 17 00:00:00 2001
From: Brad Ison
Date: Fri, 19 Jun 2026 11:39:21 +0200
Subject: [PATCH 10/54] [management] Fetch complete user data in
ValidateTunnelPeer (#6457)
* [management] Fetch complete user data in ValidateTunnelPeer
Previously the `ValidateTunnelPeer` method used by the ProxyService
would fetch user information from the database if the connected peer
was associated with a user ID, but it would not consult the IdP data
for cached info from JWT claims like email. This caused the value of
the injected `X-Netbird-User` header to always display the peer ID and
never the user email associated with the peer as expected.
This change adds an optional IdP manager to the ProxyService and
fetches the complete user data from it if present.
* [management] Refactor ValidateTunnelPeer principal info gathering
This refactors the gathering of info on proxy tunnel peer principals
into its own method to keep the complexity down and make Sonar happy.
---
.../service/manager/manager_test.go | 6 +-
management/internals/server/boot.go | 2 +-
management/internals/shared/grpc/proxy.go | 64 ++++--
.../shared/grpc/proxy_group_access_test.go | 208 ++++++++++++++++++
.../shared/grpc/validate_session_test.go | 2 +-
management/server/account_test.go | 2 +-
.../proxy/auth_callback_integration_test.go | 1 +
.../testing/testing_tools/channel/channel.go | 4 +-
proxy/management_byop_integration_test.go | 1 +
proxy/management_integration_test.go | 1 +
10 files changed, 266 insertions(+), 25 deletions(-)
diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go
index ace105b31..29a117921 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go
@@ -434,7 +434,7 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) {
t.Helper()
tokenStore := nbgrpc.NewOneTimeTokenStore(context.Background(), testCacheStore(t))
pkceStore := nbgrpc.NewPKCEVerifierStore(context.Background(), testCacheStore(t))
- srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil)
+ srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
return srv
}
@@ -723,7 +723,7 @@ func setupIntegrationTest(t *testing.T) (*Manager, store.Store) {
tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t))
pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t))
- proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil)
+ proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter(""))
require.NoError(t, err)
@@ -1147,7 +1147,7 @@ func TestDeleteService_DeletesTargets(t *testing.T) {
tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t))
pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t))
- proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil)
+ proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter(""))
require.NoError(t, err)
diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go
index 46e475143..ae82b60fe 100644
--- a/management/internals/server/boot.go
+++ b/management/internals/server/boot.go
@@ -219,7 +219,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer {
return Create(s, func() *nbgrpc.ProxyServiceServer {
- proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.ProxyManager(), s.Store())
+ proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store())
s.AfterInit(func(s *BaseServer) {
proxyService.SetServiceManager(s.ServiceManager())
proxyService.SetProxyController(s.ServiceProxyController())
diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go
index 0feb807f6..76663f898 100644
--- a/management/internals/shared/grpc/proxy.go
+++ b/management/internals/shared/grpc/proxy.go
@@ -33,6 +33,8 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
+ "github.com/netbirdio/netbird/management/server/idp"
+ "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/users"
proxyauth "github.com/netbirdio/netbird/proxy/auth"
@@ -82,6 +84,9 @@ type ProxyServiceServer struct {
// Manager for users
usersManager users.Manager
+ // Manager for IdP-enriched user data (may be nil when no IdP is configured)
+ idpManager idp.Manager
+
// Store for one-time authentication tokens
tokenStore *OneTimeTokenStore
@@ -157,7 +162,7 @@ func enforceAccountScope(ctx context.Context, requestAccountID string) error {
}
// NewProxyServiceServer creates a new proxy service server.
-func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer {
+func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer {
ctx, cancel := context.WithCancel(context.Background())
s := &ProxyServiceServer{
accessLogManager: accessLogMgr,
@@ -166,6 +171,7 @@ func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeT
pkceVerifierStore: pkceStore,
peersManager: peersManager,
usersManager: usersManager,
+ idpManager: idpManager,
proxyManager: proxyMgr,
tokenChecker: tokenChecker,
snapshotBatchSize: snapshotBatchSizeFromEnv(),
@@ -1702,22 +1708,7 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
}
groupIDs, groupNames := pairGroupIDsAndNames(peerGroups)
-
- // Resolve the principal: when the peer is linked to a user, the human
- // is the principal so multiple peers owned by the same user share a
- // single identity. Unlinked peers (machine agents) are their own
- // principal keyed on peer.ID. displayIdentity is what upstream gateways
- // tag spend with — user.Email when linked, peer.Name when not.
- principalID := peer.ID
- displayIdentity := peer.Name
- if peer.UserID != "" {
- if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil {
- principalID = user.Id
- if user.Email != "" {
- displayIdentity = user.Email
- }
- }
- }
+ principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer)
if err := checkPeerGroupAccess(service, groupIDs); err != nil {
log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied")
@@ -1754,6 +1745,45 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.
}, nil
}
+// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a
+// user or peer ID, and peer name or user email.
+func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer) (string, string) {
+ // Resolve the principal: when the peer is linked to a user, the human is the
+ // principal so multiple peers owned by the same user share a single
+ // identity. Unlinked peers (machine agents) are their own principal keyed on
+ // peer.ID. displayIdentity is what upstream gateways tag spend with —
+ // user.Email when linked, peer.Name when not.
+
+ // If the peer isn't associated with a user, return the peer info directly.
+ if peer.UserID == "" {
+ return peer.ID, peer.Name
+ }
+
+ // Otherwise, if the peer is linked to a user, the user is the principal and
+ // if an IdP is available, we gather details on the user from it.
+ principalID := peer.UserID
+ displayIdentity := peer.Name
+ // Stored column first (cheap, but often empty for OIDC-provisioned users).
+ if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil {
+ principalID = user.Id
+ if user.Email != "" {
+ displayIdentity = user.Email
+ }
+ }
+ // IdP enrichment wins when available — the stored email column is a
+ // best-effort cache and is frequently empty for OIDC users. Enrichment
+ // failures must never fail the RPC; we simply keep the stored/peer identity.
+ if s.idpManager != nil {
+ if ud, uerr := s.idpManager.GetUserDataByID(ctx, peer.UserID, idp.AppMetadata{WTAccountID: service.AccountID}); uerr == nil && ud != nil && ud.Email != "" {
+ displayIdentity = ud.Email
+ } else if uerr != nil {
+ log.WithFields(log.Fields{"domain": domain, "user_id": peer.UserID, "error": uerr.Error()}).Debug("ValidateTunnelPeer: IdP user enrichment failed; using stored/peer identity")
+ }
+ }
+
+ return principalID, displayIdentity
+}
+
// checkPeerGroupAccess gates ValidateTunnelPeer by the service's required
// groups. Private services authorise against AccessGroups (empty list fails
// closed — Validate() rejects that at save time but the RPC is the security
diff --git a/management/internals/shared/grpc/proxy_group_access_test.go b/management/internals/shared/grpc/proxy_group_access_test.go
index 76da7ddbc..532cb7cc3 100644
--- a/management/internals/shared/grpc/proxy_group_access_test.go
+++ b/management/internals/shared/grpc/proxy_group_access_test.go
@@ -3,14 +3,19 @@ package grpc
import (
"context"
"errors"
+ "net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ "github.com/netbirdio/netbird/management/server/idp"
+ "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
)
type mockReverseProxyManager struct {
@@ -137,6 +142,52 @@ func (m *mockUsersManager) GetUserWithGroups(ctx context.Context, userID string)
return user, nil, nil
}
+// mockTunnelPeersManager implements only the two peers.Manager methods that
+// ValidateTunnelPeer calls; the embedded interface satisfies the rest (and
+// panics if any unexpected method is invoked).
+type mockTunnelPeersManager struct {
+ peers.Manager
+ peer *peer.Peer
+ peerErr error
+ groups []*types.Group
+ groupsErr error
+}
+
+func (m *mockTunnelPeersManager) GetPeerByTunnelIP(_ context.Context, _ string, _ net.IP) (*peer.Peer, error) {
+ return m.peer, m.peerErr
+}
+
+func (m *mockTunnelPeersManager) GetPeerWithGroups(_ context.Context, _, _ string) (*peer.Peer, []*types.Group, error) {
+ return m.peer, m.groups, m.groupsErr
+}
+
+// mockTunnelIdpManager implements only GetUserDataByID; the embedded interface
+// satisfies the rest of idp.Manager. hasData==false returns (nil, nil) to model
+// an IdP that knows nothing about the user.
+type mockTunnelIdpManager struct {
+ idp.Manager
+ email string
+ hasData bool
+ err error
+ gotCalls int
+ gotMeta []idp.AppMetadata
+}
+
+func (m *mockTunnelIdpManager) GetUserDataByID(_ context.Context, userID string, meta idp.AppMetadata) (*idp.UserData, error) {
+ m.gotCalls++
+ m.gotMeta = append(m.gotMeta, meta)
+ if m.err != nil {
+ return nil, m.err
+ }
+ if !m.hasData {
+ // This might not be a thing any of the actual IDP implementations do,
+ // i.e. return a nil value with no error, but it seems valuable to test
+ // that behavior here.
+ return nil, nil //nolint:nilnil
+ }
+ return &idp.UserData{ID: userID, Email: m.email}, nil
+}
+
func TestValidateUserGroupAccess(t *testing.T) {
tests := []struct {
name string
@@ -354,6 +405,163 @@ func TestValidateUserGroupAccess(t *testing.T) {
}
}
+// TestValidateTunnelPeerUserEmailEnrichment verifies the UserEmail/UserId
+// resolution in ValidateTunnelPeer, including the IdP-enrichment fallback order
+// (IdP email -> stored User.Email -> peer.Name).
+func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) {
+ const (
+ domain = "app.example.com"
+ accountID = "account1"
+ peerID = "peer1"
+ peerName = "peer-display-name"
+ userID = "user1"
+ )
+
+ storedUser := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: "stored@example.com"}}
+ storedUserNoEmail := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: ""}}
+
+ tests := []struct {
+ name string
+ peerUserID string
+ storedUsers map[string]*types.User
+ storedErr error
+ noIdP bool
+ idpEmail string
+ idpHasData bool
+ idpErr error
+ expectEmail string
+ expectUserID string
+ expectIdPHit bool
+ }{
+ {
+ name: "idp email wins over stored email",
+ peerUserID: userID,
+ storedUsers: storedUser,
+ idpEmail: "idp@example.com",
+ idpHasData: true,
+ expectEmail: "idp@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "stored email when idp returns empty email",
+ peerUserID: userID,
+ storedUsers: storedUser,
+ idpEmail: "",
+ idpHasData: true,
+ expectEmail: "stored@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "stored email when idp has no data",
+ peerUserID: userID,
+ storedUsers: storedUser,
+ idpHasData: false,
+ expectEmail: "stored@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "stored email when idp errors",
+ peerUserID: userID,
+ storedUsers: storedUser,
+ idpErr: errors.New("idp unreachable"),
+ expectEmail: "stored@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "stored email when no idp manager",
+ peerUserID: userID,
+ storedUsers: storedUser,
+ noIdP: true,
+ expectEmail: "stored@example.com",
+ expectUserID: userID,
+ },
+ {
+ name: "idp email when stored email is empty",
+ peerUserID: userID,
+ storedUsers: storedUserNoEmail,
+ idpEmail: "idp@example.com",
+ idpHasData: true,
+ expectEmail: "idp@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "idp email when stored user missing keeps peer.UserID as principal",
+ peerUserID: userID,
+ storedUsers: map[string]*types.User{},
+ idpEmail: "idp@example.com",
+ idpHasData: true,
+ expectEmail: "idp@example.com",
+ expectUserID: userID,
+ expectIdPHit: true,
+ },
+ {
+ name: "unlinked peer uses peer name and never consults idp",
+ peerUserID: "",
+ storedUsers: storedUser,
+ idpEmail: "idp@example.com",
+ idpHasData: true,
+ expectEmail: peerName,
+ expectUserID: peerID,
+ expectIdPHit: false,
+ },
+ {
+ name: "linked peer with empty stored email and no idp falls back to peer name",
+ peerUserID: userID,
+ storedUsers: storedUserNoEmail,
+ noIdP: true,
+ expectEmail: peerName,
+ expectUserID: userID,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ svc := &service.Service{Domain: domain, AccountID: accountID}
+ server := &ProxyServiceServer{
+ serviceManager: &mockReverseProxyManager{
+ proxiesByAccount: map[string][]*service.Service{accountID: {svc}},
+ },
+ peersManager: &mockTunnelPeersManager{
+ peer: &peer.Peer{ID: peerID, Name: peerName, UserID: tt.peerUserID},
+ },
+ usersManager: &mockUsersManager{users: tt.storedUsers, err: tt.storedErr},
+ }
+
+ var idpMock *mockTunnelIdpManager
+ if !tt.noIdP {
+ idpMock = &mockTunnelIdpManager{email: tt.idpEmail, hasData: tt.idpHasData, err: tt.idpErr}
+ server.idpManager = idpMock
+ }
+
+ resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{
+ Domain: domain,
+ TunnelIp: "100.64.0.1",
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.True(t, resp.GetValid(), "expected access granted")
+ assert.Equal(t, tt.expectEmail, resp.GetUserEmail())
+ assert.Equal(t, tt.expectUserID, resp.GetUserId())
+
+ if idpMock != nil {
+ if tt.expectIdPHit {
+ assert.Equal(t, 1, idpMock.gotCalls, "expected IdP to be consulted")
+ require.Len(t, idpMock.gotMeta, 1)
+ assert.Equal(t, accountID, idpMock.gotMeta[0].WTAccountID)
+ } else {
+ assert.Equal(t, 0, idpMock.gotCalls, "expected IdP to not be consulted")
+ }
+ }
+ })
+ }
+}
+
func TestGetAccountProxyByDomain(t *testing.T) {
tests := []struct {
name string
diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go
index 27d9a65e7..d649102a1 100644
--- a/management/internals/shared/grpc/validate_session_test.go
+++ b/management/internals/shared/grpc/validate_session_test.go
@@ -42,7 +42,7 @@ func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup {
tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t))
pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t))
- proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, proxyManager, nil)
+ proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil)
proxyService.SetServiceManager(serviceManager)
createTestProxies(t, ctx, testStore)
diff --git a/management/server/account_test.go b/management/server/account_test.go
index 256b71f18..2e26ac222 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -3215,7 +3215,7 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU
return nil, nil, err
}
- proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, proxyManager, nil)
+ proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, nil, proxyManager, nil)
proxyController, err := proxymanager.NewGRPCController(proxyGrpcServer, noop.Meter{})
if err != nil {
return nil, nil, err
diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go
index f08d5daf1..a24857066 100644
--- a/management/server/http/handlers/proxy/auth_callback_integration_test.go
+++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go
@@ -217,6 +217,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup {
usersManager,
nil,
nil,
+ nil,
)
proxyService.SetServiceManager(&testServiceManager{store: testStore})
diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go
index 8da9c7ad4..61584a615 100644
--- a/management/server/http/testing/testing_tools/channel/channel.go
+++ b/management/server/http/testing/testing_tools/channel/channel.go
@@ -110,7 +110,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
if err != nil {
t.Fatalf("Failed to create proxy manager: %v", err)
}
- proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr, nil)
+ proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
if err != nil {
@@ -240,7 +240,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
if err != nil {
t.Fatalf("Failed to create proxy manager: %v", err)
}
- proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr, nil)
+ proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
if err != nil {
diff --git a/proxy/management_byop_integration_test.go b/proxy/management_byop_integration_test.go
index c0fbe682a..d075e47ec 100644
--- a/proxy/management_byop_integration_test.go
+++ b/proxy/management_byop_integration_test.go
@@ -125,6 +125,7 @@ func setupBYOPIntegrationTest(t *testing.T) *byopTestSetup {
oidcConfig,
nil,
usersManager,
+ nil,
realProxyManager,
nil,
)
diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go
index bf5067b85..cb82813b0 100644
--- a/proxy/management_integration_test.go
+++ b/proxy/management_integration_test.go
@@ -140,6 +140,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup {
oidcConfig,
nil,
usersManager,
+ nil,
proxyManager,
nil,
)
From 35b465fa4a1667b99877d78c554bb1d1231aa616 Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Fri, 19 Jun 2026 11:43:01 +0200
Subject: [PATCH 11/54] [management] reduce sync and login transaction (#6472)
---
management/server/peer.go | 42 ++++++++++++++-------------------------
1 file changed, 15 insertions(+), 27 deletions(-)
diff --git a/management/server/peer.go b/management/server/peer.go
index bd6b2b6c5..83236d961 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -982,8 +982,6 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
var peer *nbpeer.Peer
var updated, versionChanged, ipv6CapabilityChanged bool
var err error
- var postureChecks []*posture.Checks
- var peerGroupIDs []string
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
@@ -1011,11 +1009,6 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return status.NewPeerLoginExpiredError()
}
- peerGroupIDs, err = getPeerGroupIDs(ctx, transaction, accountID, peer.ID)
- if err != nil {
- return err
- }
-
oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
updated, versionChanged = peer.UpdateMetaIfNew(sync.Meta)
ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
@@ -1025,16 +1018,6 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
if err = transaction.SavePeer(ctx, accountID, peer); err != nil {
return err
}
-
- policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
- if err != nil {
- return err
- }
-
- postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies)
- if err != nil {
- return err
- }
}
return nil
})
@@ -1042,6 +1025,11 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return nil, nil, nil, 0, err
}
+ peerGroupIDs, err := getPeerGroupIDs(ctx, am.Store, accountID, peer.ID)
+ if err != nil {
+ return nil, nil, nil, 0, err
+ }
+
peerNotValid, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
if err != nil {
return nil, nil, nil, 0, err
@@ -1052,9 +1040,9 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return nil, nil, nil, 0, err
}
- if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(postureChecks) > 0 || versionChanged)) {
+ if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(resPostureChecks) > 0 || versionChanged)) {
changedPeerIDs := []string{peer.ID}
- affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, updated, len(postureChecks) > 0)
+ affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, updated, len(resPostureChecks) > 0)
if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
return nil, nil, nil, 0, fmt.Errorf("notify network map controller of peer update: %w", err)
}
@@ -1160,11 +1148,6 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
}
}
- peerGroupIDs, err = getPeerGroupIDs(ctx, transaction, accountID, peer.ID)
- if err != nil {
- return err
- }
-
if peer.SSHKey != login.SSHKey {
peer.SSHKey = login.SSHKey
shouldStorePeer = true
@@ -1180,15 +1163,20 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
}
}
- // This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop
- peer.UpdateMetaIfNew(login.Meta)
-
return nil
})
if err != nil {
return nil, nil, nil, false, err
}
+ // This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop
+ peer.UpdateMetaIfNew(login.Meta)
+
+ peerGroupIDs, err = getPeerGroupIDs(ctx, am.Store, accountID, peer.ID)
+ if err != nil {
+ return nil, nil, nil, false, err
+ }
+
isRequiresApproval, _, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra)
if err != nil {
return nil, nil, nil, false, err
From 85116872706057cd8063e4ec22deb19da32799f1 Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Fri, 19 Jun 2026 13:30:52 +0200
Subject: [PATCH 12/54] [management] log peer meta diff (#6468)
---
management/server/peer.go | 4 +-
management/server/peer/peer.go | 167 +++++++++++++------
management/server/peer/peer_metadiff_test.go | 113 +++++++++++++
3 files changed, 233 insertions(+), 51 deletions(-)
create mode 100644 management/server/peer/peer_metadiff_test.go
diff --git a/management/server/peer.go b/management/server/peer.go
index 83236d961..c54c1dc7b 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -1010,7 +1010,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
}
oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
- updated, versionChanged = peer.UpdateMetaIfNew(sync.Meta)
+ updated, versionChanged = peer.UpdateMetaIfNew(ctx, sync.Meta)
ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay)
if updated {
am.metrics.AccountManagerMetrics().CountPeerMetUpdate()
@@ -1170,7 +1170,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
}
// This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop
- peer.UpdateMetaIfNew(login.Meta)
+ peer.UpdateMetaIfNew(ctx, login.Meta)
peerGroupIDs, err = getPeerGroupIDs(ctx, am.Store, accountID, peer.ID)
if err != nil {
diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go
index e5475c07d..591ac074e 100644
--- a/management/server/peer/peer.go
+++ b/management/server/peer/peer.go
@@ -1,12 +1,16 @@
package peer
import (
+ "context"
+ "fmt"
"net"
"net/netip"
"slices"
- "sort"
+ "strings"
"time"
+ log "github.com/sirupsen/logrus"
+
"github.com/netbirdio/netbird/management/server/util"
"github.com/netbirdio/netbird/shared/management/http/api"
)
@@ -162,49 +166,7 @@ type PeerSystemMeta struct { //nolint:revive
}
func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool {
- sort.Slice(p.NetworkAddresses, func(i, j int) bool {
- return p.NetworkAddresses[i].Mac < p.NetworkAddresses[j].Mac
- })
- sort.Slice(other.NetworkAddresses, func(i, j int) bool {
- return other.NetworkAddresses[i].Mac < other.NetworkAddresses[j].Mac
- })
- equalNetworkAddresses := slices.EqualFunc(p.NetworkAddresses, other.NetworkAddresses, func(addr NetworkAddress, oAddr NetworkAddress) bool {
- return addr.Mac == oAddr.Mac && addr.NetIP == oAddr.NetIP
- })
- if !equalNetworkAddresses {
- return false
- }
-
- sort.Slice(p.Files, func(i, j int) bool {
- return p.Files[i].Path < p.Files[j].Path
- })
- sort.Slice(other.Files, func(i, j int) bool {
- return other.Files[i].Path < other.Files[j].Path
- })
- equalFiles := slices.EqualFunc(p.Files, other.Files, func(file File, oFile File) bool {
- return file.Path == oFile.Path && file.Exist == oFile.Exist && file.ProcessIsRunning == oFile.ProcessIsRunning
- })
- if !equalFiles {
- return false
- }
-
- return p.Hostname == other.Hostname &&
- p.GoOS == other.GoOS &&
- p.Kernel == other.Kernel &&
- p.KernelVersion == other.KernelVersion &&
- p.Core == other.Core &&
- p.Platform == other.Platform &&
- p.OS == other.OS &&
- p.OSVersion == other.OSVersion &&
- p.WtVersion == other.WtVersion &&
- p.UIVersion == other.UIVersion &&
- p.SystemSerialNumber == other.SystemSerialNumber &&
- p.SystemProductName == other.SystemProductName &&
- p.SystemManufacturer == other.SystemManufacturer &&
- p.Environment.Cloud == other.Environment.Cloud &&
- p.Environment.Platform == other.Environment.Platform &&
- p.Flags.isEqual(other.Flags) &&
- capabilitiesEqual(p.Capabilities, other.Capabilities)
+ return len(metaDiff(p, other)) == 0
}
func (p PeerSystemMeta) isEmpty() bool {
@@ -296,7 +258,7 @@ 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(meta PeerSystemMeta) (updated, versionChanged bool) {
+func (p *Peer) UpdateMetaIfNew(ctx context.Context, meta PeerSystemMeta) (updated, versionChanged bool) {
if meta.isEmpty() {
return updated, versionChanged
}
@@ -308,14 +270,121 @@ func (p *Peer) UpdateMetaIfNew(meta PeerSystemMeta) (updated, versionChanged boo
meta.UIVersion = p.Meta.UIVersion
}
- if p.Meta.isEqual(meta) {
- return updated, versionChanged
+ oldVersion := p.Meta.WtVersion
+
+ diff := metaDiff(p.Meta, meta)
+ if len(diff) != 0 {
+ p.Meta = meta
+ updated = true
}
- p.Meta = meta
- updated = true
+
+ versionInfo := ""
+ if versionChanged {
+ versionInfo = fmt.Sprintf("version changed: %s -> %s, ", oldVersion, meta.WtVersion)
+ }
+
+ if len(diff) > 0 || versionChanged {
+ log.WithContext(ctx).
+ Debugf("peer meta updated, %s%d field(s) changed: %s", versionInfo, len(diff), strings.Join(diff, ", "))
+ }
+
return updated, 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
+ add := func(field string, oldVal, newVal any) {
+ diff = append(diff, fmt.Sprintf("%s: %v -> %v", field, oldVal, newVal))
+ }
+
+ if oldMeta.Hostname != newMeta.Hostname {
+ add("hostname", oldMeta.Hostname, newMeta.Hostname)
+ }
+ if oldMeta.GoOS != newMeta.GoOS {
+ add("goos", oldMeta.GoOS, newMeta.GoOS)
+ }
+ if oldMeta.Kernel != newMeta.Kernel {
+ add("kernel", oldMeta.Kernel, newMeta.Kernel)
+ }
+ if oldMeta.KernelVersion != newMeta.KernelVersion {
+ add("kernel_version", oldMeta.KernelVersion, newMeta.KernelVersion)
+ }
+ if oldMeta.Core != newMeta.Core {
+ add("core", oldMeta.Core, newMeta.Core)
+ }
+ if oldMeta.Platform != newMeta.Platform {
+ add("platform", oldMeta.Platform, newMeta.Platform)
+ }
+ if oldMeta.OS != newMeta.OS {
+ add("os", oldMeta.OS, newMeta.OS)
+ }
+ if oldMeta.OSVersion != newMeta.OSVersion {
+ add("os_version", oldMeta.OSVersion, newMeta.OSVersion)
+ }
+ if oldMeta.WtVersion != newMeta.WtVersion {
+ add("wt_version", oldMeta.WtVersion, newMeta.WtVersion)
+ }
+ if oldMeta.UIVersion != newMeta.UIVersion {
+ add("ui_version", oldMeta.UIVersion, newMeta.UIVersion)
+ }
+ if oldMeta.SystemSerialNumber != newMeta.SystemSerialNumber {
+ add("system_serial_number", oldMeta.SystemSerialNumber, newMeta.SystemSerialNumber)
+ }
+ if oldMeta.SystemProductName != newMeta.SystemProductName {
+ add("system_product_name", oldMeta.SystemProductName, newMeta.SystemProductName)
+ }
+ if oldMeta.SystemManufacturer != newMeta.SystemManufacturer {
+ add("system_manufacturer", oldMeta.SystemManufacturer, newMeta.SystemManufacturer)
+ }
+ if oldMeta.Environment.Cloud != newMeta.Environment.Cloud {
+ add("environment_cloud", oldMeta.Environment.Cloud, newMeta.Environment.Cloud)
+ }
+ if oldMeta.Environment.Platform != newMeta.Environment.Platform {
+ add("environment_platform", oldMeta.Environment.Platform, newMeta.Environment.Platform)
+ }
+ if !oldMeta.Flags.isEqual(newMeta.Flags) {
+ add("flags", fmt.Sprintf("%+v", oldMeta.Flags), fmt.Sprintf("%+v", newMeta.Flags))
+ }
+ if !capabilitiesEqual(oldMeta.Capabilities, newMeta.Capabilities) {
+ add("capabilities", oldMeta.Capabilities, newMeta.Capabilities)
+ }
+
+ if !sameMultiset(oldMeta.NetworkAddresses, newMeta.NetworkAddresses) {
+ add("network_addresses", fmt.Sprintf("%v", oldMeta.NetworkAddresses), fmt.Sprintf("%v", newMeta.NetworkAddresses))
+ }
+
+ if !sameMultiset(oldMeta.Files, newMeta.Files) {
+ add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files))
+ }
+
+ return diff
+}
+
+// sameMultiset reports whether two slices contain the same elements with the
+// same multiplicity, ignoring order. The element type is the comparison key, so
+// every field participates in equality.
+func sameMultiset[T comparable](a, b []T) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ counts := make(map[T]int, len(a))
+ for _, v := range a {
+ counts[v]++
+ }
+ for _, v := range b {
+ counts[v]--
+ if counts[v] == 0 {
+ delete(counts, v)
+ }
+ }
+ return len(counts) == 0
+}
+
// GetLastLogin returns the last login time of the peer.
func (p *Peer) GetLastLogin() time.Time {
if p.LastLogin != nil {
diff --git a/management/server/peer/peer_metadiff_test.go b/management/server/peer/peer_metadiff_test.go
new file mode 100644
index 000000000..1256cdb02
--- /dev/null
+++ b/management/server/peer/peer_metadiff_test.go
@@ -0,0 +1,113 @@
+package peer
+
+import (
+ "net/netip"
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// metaDiffExtraEntries accounts for PeerSystemMeta fields that metaDiff does not
+// map 1:1 to a single diff entry. Today the only such field is Environment, which
+// is exploded into two checks (Cloud, Platform) and therefore yields one extra
+// entry beyond its single struct field. If you teach metaDiff to explode another
+// field into N entries, bump this by N-1; if you collapse a field, lower it.
+const metaDiffExtraEntries = 1
+
+// TestMetaDiff_CoversAllFields fully populates a PeerSystemMeta with non-zero
+// values and diffs it against the zero value, then asserts metaDiff emits exactly
+// one entry per exported field (plus metaDiffExtraEntries for fields it explodes).
+//
+// The expected count is derived from the struct via reflection, so adding a field
+// to PeerSystemMeta raises the expectation automatically — but the actual diff
+// only grows if metaDiff was taught to compare the new field. A mismatch means
+// someone changed the struct without updating metaDiff (or this test's
+// extra-entry accounting), which is exactly what we want to catch.
+func TestMetaDiff_CoversAllFields(t *testing.T) {
+ var full PeerSystemMeta
+ exported := populateAll(t, reflect.ValueOf(&full).Elem())
+ require.NotZero(t, exported, "expected PeerSystemMeta to expose fields")
+
+ diff := metaDiff(PeerSystemMeta{}, full)
+
+ require.Len(t, diff, exported+metaDiffExtraEntries,
+ "metaDiff entry count no longer matches PeerSystemMeta's fields: a field was "+
+ "likely added or removed without updating metaDiff (or metaDiffExtraEntries). "+
+ "diff was: %v", diff)
+
+ require.False(t, full.isEqual(PeerSystemMeta{}),
+ "isEqual must report a fully-populated meta as different from the zero value")
+}
+
+// TestFlags_isEqualChecksEveryField guards the one field that the count-based
+// TestMetaDiff_CoversAllFields cannot: metaDiff collapses all of Flags into a
+// single "flags" diff entry, so a new Flags field that Flags.isEqual forgets to
+// compare would not change the diff count. This flips each Flags field on its own
+// and asserts Flags.isEqual notices, so adding a Flags field without comparing it
+// fails here.
+func TestFlags_isEqualChecksEveryField(t *testing.T) {
+ typ := reflect.TypeOf(Flags{})
+ for i := 0; i < typ.NumField(); i++ {
+ f := typ.Field(i)
+ require.Equal(t, reflect.Bool, f.Type.Kind(),
+ "Flags.%s is not a bool; extend this test to set it non-zero", f.Name)
+
+ var a, b Flags
+ reflect.ValueOf(&b).Elem().Field(i).SetBool(true)
+ require.False(t, a.isEqual(b), "Flags.isEqual ignores field %s", f.Name)
+ }
+}
+
+// populateAll sets every exported field of the struct to a deterministic non-zero
+// value, recursing into nested structs and the element type of struct slices so
+// that each leaf differs from zero. It returns the number of exported fields on
+// the top-level struct. netip.Prefix is treated as an opaque leaf (it has no
+// settable exported fields and is comparable with ==).
+func populateAll(t *testing.T, v reflect.Value) int {
+ t.Helper()
+
+ typ := v.Type()
+ exported := 0
+ for i := 0; i < typ.NumField(); i++ {
+ f := typ.Field(i)
+ if f.PkgPath != "" { // unexported
+ continue
+ }
+ exported++
+ setNonZero(t, v.Field(i))
+ }
+ return exported
+}
+
+// setNonZero assigns a deterministic non-zero value to a field based on its kind,
+// recursing into nested structs and populating one element of slice fields.
+func setNonZero(t *testing.T, field reflect.Value) {
+ t.Helper()
+
+ if field.Type() == reflect.TypeOf(netip.Prefix{}) {
+ field.Set(reflect.ValueOf(netip.MustParsePrefix("10.0.0.0/24")))
+ return
+ }
+
+ switch field.Kind() {
+ case reflect.String:
+ field.SetString("non-zero")
+ case reflect.Bool:
+ field.SetBool(true)
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ field.SetInt(7)
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ field.SetUint(7)
+ case reflect.Float32, reflect.Float64:
+ field.SetFloat(7)
+ case reflect.Struct:
+ populateAll(t, field)
+ case reflect.Slice:
+ s := reflect.MakeSlice(field.Type(), 1, 1)
+ setNonZero(t, s.Index(0))
+ field.Set(s)
+ default:
+ t.Fatalf("unhandled field kind %s; extend setNonZero", field.Kind())
+ }
+}
From 54192a94b7bf3a59e0b86b98845014b5f4a9fb3a Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Fri, 19 Jun 2026 14:10:43 +0200
Subject: [PATCH 13/54] [misc] handle release candidates when fetching tags in
FreeBSD port scripts (#6480)
* [misc] Exclude release candidates when fetching tags in FreeBSD port scripts
---
release_files/freebsd-port-diff.sh | 3 ++-
release_files/freebsd-port-issue-body.sh | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/release_files/freebsd-port-diff.sh b/release_files/freebsd-port-diff.sh
index b030b9164..6ffa141be 100755
--- a/release_files/freebsd-port-diff.sh
+++ b/release_files/freebsd-port-diff.sh
@@ -21,7 +21,8 @@ AWK_FIRST_FIELD='{print $1}'
fetch_all_tags() {
curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \
- grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+' | \
+ grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \
+ grep -iv 'rc' | \
sed 's/.*\/v//' | \
sort -u -V
return 0
diff --git a/release_files/freebsd-port-issue-body.sh b/release_files/freebsd-port-issue-body.sh
index b7ad0f5b1..1c23dbbbe 100755
--- a/release_files/freebsd-port-issue-body.sh
+++ b/release_files/freebsd-port-issue-body.sh
@@ -32,7 +32,8 @@ fetch_current_ports_version() {
fetch_all_tags() {
# Fetch tags from GitHub tags page (no rate limiting, no auth needed)
curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \
- grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+' | \
+ grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \
+ grep -iv 'rc' | \
sed 's/.*\/v//' | \
sort -u -V
return 0
From 883a1a8961ff181bd61cbd23dd603c541190df17 Mon Sep 17 00:00:00 2001
From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
Date: Fri, 19 Jun 2026 16:23:51 +0200
Subject: [PATCH 14/54] [client] Fix profile regressions in `up --profile` and
`status` (#6479)
* Restores behavior to create profile if not there on Up
* Allows to restore nerbird status showing of the profile name
* [client] Reduce upFunc cognitive complexity
Extract the profile switch/auto-create logic from upFunc into a dedicated
switchOrCreateProfile helper. The inlined NotFound-retry branch pushed
upFunc over SonarCloud's cognitive complexity threshold (S3776).
No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context)
* [client] Make up --profile auto-create idempotent under concurrent runs
Don't fail switchOrCreateProfile on a createProfile error: a concurrent
run may create the profile between the NotFound check and our create
call. Retry the switch regardless and only surface the create error if
the switch also fails. Addresses CodeRabbit race-condition feedback.
Co-Authored-By: Claude Opus 4.8 (1M context)
* Share createProfile with addProfileFunc
* But allow conn reusage
* moves switchOrCreateProfile to where it's used
---------
Co-authored-by: Claude Opus 4.8 (1M context)
---
client/cmd/login.go | 2 +-
client/cmd/profile.go | 34 ++++++++++++++++++---------
client/cmd/status.go | 29 ++++++++++++++++++-----
client/cmd/up.go | 54 +++++++++++++++++++++++++++++++++++++------
4 files changed, 94 insertions(+), 25 deletions(-)
diff --git a/client/cmd/login.go b/client/cmd/login.go
index 2f7677901..a7ee960b1 100644
--- a/client/cmd/login.go
+++ b/client/cmd/login.go
@@ -227,7 +227,7 @@ func switchProfile(ctx context.Context, handle string, username string) (profile
Username: &username,
})
if err != nil {
- return "", fmt.Errorf("switch profile failed: %v", err)
+ return "", fmt.Errorf("switch profile failed: %w", err)
}
return profilemanager.ID(resp.Id), nil
diff --git a/client/cmd/profile.go b/client/cmd/profile.go
index 4de2d754e..268034e70 100644
--- a/client/cmd/profile.go
+++ b/client/cmd/profile.go
@@ -138,26 +138,23 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
return err
}
+ currUser, err := user.Current()
+ if err != nil {
+ return fmt.Errorf("get current user: %w", err)
+ }
+
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
if err != nil {
return fmt.Errorf("connect to service CLI interface: %w", err)
}
defer conn.Close()
- currUser, err := user.Current()
- if err != nil {
- return fmt.Errorf("get current user: %w", err)
- }
-
daemonClient := proto.NewDaemonServiceClient(conn)
profileName := args[0]
- resp, err := daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{
- ProfileName: profileName,
- Username: currUser.Username,
- })
+ id, err := addProfileOnDaemon(cmd.Context(), daemonClient, profileName, currUser.Username)
if err != nil {
- return fmt.Errorf("add profile request: %w", err)
+ return err
}
dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName)
@@ -166,7 +163,6 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
}
- id := profilemanager.ID(resp.Id)
cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName))
return nil
@@ -330,3 +326,19 @@ func wrapAmbiguityError(err error, handle string) error {
}
return err
}
+
+// addProfileOnDaemon issues the AddProfile RPC on an existing daemon client
+// and returns the new profile's ID. It is the single entry point for profile
+// creation, shared by `netbird profile add` and the `netbird up --profile
+// ` auto-create path.
+func addProfileOnDaemon(ctx context.Context, client proto.DaemonServiceClient, profileName, username string) (profilemanager.ID, error) {
+ resp, err := client.AddProfile(ctx, &proto.AddProfileRequest{
+ ProfileName: profileName,
+ Username: username,
+ })
+ if err != nil {
+ return "", fmt.Errorf("add profile failed: %w", err)
+ }
+
+ return profilemanager.ID(resp.Id), nil
+}
diff --git a/client/cmd/status.go b/client/cmd/status.go
index 103b3044a..5a7559cf1 100644
--- a/client/cmd/status.go
+++ b/client/cmd/status.go
@@ -11,7 +11,6 @@ import (
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
- "github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
nbstatus "github.com/netbirdio/netbird/client/status"
"github.com/netbirdio/netbird/util"
@@ -111,11 +110,10 @@ func statusFunc(cmd *cobra.Command, args []string) error {
return nil
}
- pm := profilemanager.NewProfileManager()
- var profName string
- if activeProf, err := pm.GetActiveProfile(); err == nil {
- profName = activeProf.Name
- }
+ // Resolve the active profile's display name via the daemon, which runs
+ // as root and can read the per-user profile files. The local profile
+ // manager only knows the active profile ID, not its display name.
+ profName := getActiveProfileName(ctx)
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
Anonymize: anonymizeFlag,
@@ -167,6 +165,25 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (
return resp, nil
}
+// getActiveProfileName asks the daemon for the active profile's display
+// name. The daemon runs as root and can read the per-user profile files to
+// resolve the ID to its human-readable name. Returns an empty string on any
+// error so status output degrades gracefully.
+func getActiveProfileName(ctx context.Context) string {
+ conn, err := DialClientGRPCServer(ctx, daemonAddr)
+ if err != nil {
+ return ""
+ }
+ defer conn.Close()
+
+ resp, err := proto.NewDaemonServiceClient(conn).GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
+ if err != nil {
+ return ""
+ }
+
+ return resp.GetProfileName()
+}
+
func parseFilters() error {
switch strings.ToLower(statusFilter) {
case "", "idle", "connecting", "connected":
diff --git a/client/cmd/up.go b/client/cmd/up.go
index 2761cf74a..0506bc65b 100644
--- a/client/cmd/up.go
+++ b/client/cmd/up.go
@@ -128,15 +128,9 @@ func upFunc(cmd *cobra.Command, args []string) error {
var profileSwitched bool
// switch profile if provided
if profileName != "" {
- resolvedID, err := switchProfile(cmd.Context(), profileName, username.Username)
- if err != nil {
+ if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); err != nil {
return fmt.Errorf("switch profile: %v", err)
}
-
- if err := pm.SwitchProfile(resolvedID); err != nil {
- return fmt.Errorf("switch profile: %v", err)
- }
-
profileSwitched = true
}
@@ -151,6 +145,52 @@ func upFunc(cmd *cobra.Command, args []string) error {
return runInDaemonMode(ctx, cmd, pm, activeProf, profileSwitched)
}
+// switchOrCreateProfile switches the active profile to the one identified by
+// handle, creating it first when it does not exist yet. This restores the
+// pre-0.73 behaviour where `netbird up --profile ` auto-creates a
+// missing profile instead of failing.
+func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error {
+ resolvedID, err := switchProfile(ctx, handle, username)
+ if err != nil {
+ st, ok := gstatus.FromError(err)
+ if !ok || st.Code() != codes.NotFound {
+ return err
+ }
+ // Don't fail immediately on a create error: a concurrent run may
+ // have created the profile between the NotFound above and this
+ // call, in which case the retried switch still succeeds. Only
+ // surface the create error if the switch also fails.
+ _, createErr := createProfile(ctx, handle, username)
+ if resolvedID, err = switchProfile(ctx, handle, username); err != nil {
+ if createErr != nil {
+ return fmt.Errorf("create profile: %w", createErr)
+ }
+ return err
+ }
+ }
+
+ if err := pm.SwitchProfile(resolvedID); err != nil {
+ return err
+ }
+ return nil
+}
+
+// createProfile dials the daemon and creates a new profile with the given
+// display name, returning its generated ID. Use addProfileOnDaemon directly
+// when a daemon client is already available to reuse the connection.
+func createProfile(ctx context.Context, profileName, username string) (profilemanager.ID, error) {
+ conn, err := DialClientGRPCServer(ctx, daemonAddr)
+ if err != nil {
+ //nolint
+ return "", fmt.Errorf("failed to connect to daemon error: %v\n"+
+ "If the daemon is not running please run: "+
+ "\nnetbird service install \nnetbird service start\n", err)
+ }
+ defer conn.Close()
+
+ return addProfileOnDaemon(ctx, proto.NewDaemonServiceClient(conn), profileName, username)
+}
+
func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error {
// override the default profile filepath if provided
if configPath != "" {
From 15a0504fb1bf8046db96da8970437b057fc59a2d Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Sat, 20 Jun 2026 00:32:49 +0900
Subject: [PATCH 15/54] [client] Treat answering upstreams as reachable and
widen DNS health grace window (#6453)
---
client/internal/dns/server.go | 33 +++++++++++--
client/internal/dns/server_test.go | 26 ++++++++++
client/internal/dns/upstream.go | 9 ++--
client/internal/dns/upstream_test.go | 72 ++++++++++++++++++++++++++++
4 files changed, 133 insertions(+), 7 deletions(-)
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index dcd4cb9d0..77446b330 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net/netip"
"net/url"
+ "os"
"slices"
"strings"
"sync"
@@ -38,11 +39,15 @@ const (
// defaultWarningDelayBase is the starting grace window before a
// "Nameserver group unreachable" event fires for a group that's
// never been healthy and only has overlay upstreams with no
- // Connected peer. Per-server and overridable; see warningDelayFor.
- defaultWarningDelayBase = 30 * time.Second
+ // Connected peer. Per-server and overridable via envWarningDelay;
+ // see warningDelay.
+ defaultWarningDelayBase = 60 * time.Second
// warningDelayBonusCap caps the route-count bonus added to the
- // base grace window. See warningDelayFor.
+ // base grace window. See warningDelay.
warningDelayBonusCap = 30 * time.Second
+ // envWarningDelay overrides defaultWarningDelayBase with a Go duration
+ // string (e.g. "90s", "2m"). Invalid or non-positive values are ignored.
+ envWarningDelay = "NB_DNS_HEALTH_WARNING_DELAY"
)
// errNoUsableNameservers signals that a merged-domain group has no usable
@@ -298,7 +303,7 @@ func newDefaultServer(
hostManager: &noopHostConfigurator{},
mgmtCacheResolver: mgmtCacheResolver,
currentConfigHash: ^uint64(0), // Initialize to max uint64 to ensure first config is always applied
- warningDelayBase: defaultWarningDelayBase,
+ warningDelayBase: warningDelayBaseFromEnv(),
healthRefresh: make(chan struct{}, 1),
}
// Wire the local resolver against the peer status recorder so it can
@@ -1154,6 +1159,26 @@ func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPor
return false
}
+// warningDelayBaseFromEnv returns the base grace window, honoring
+// envWarningDelay when it holds a valid positive Go duration. Invalid or
+// non-positive values fall back to defaultWarningDelayBase.
+func warningDelayBaseFromEnv() time.Duration {
+ val := os.Getenv(envWarningDelay)
+ if val == "" {
+ return defaultWarningDelayBase
+ }
+ d, err := time.ParseDuration(val)
+ if err != nil {
+ log.Warnf("invalid %s value %q, using default %v: %v", envWarningDelay, val, defaultWarningDelayBase, err)
+ return defaultWarningDelayBase
+ }
+ if d <= 0 {
+ log.Warnf("%s must be positive, got %v, using default %v", envWarningDelay, d, defaultWarningDelayBase)
+ return defaultWarningDelayBase
+ }
+ return d
+}
+
// warningDelay returns the grace window for the given selected-route
// count. Scales gently: +1s per 100 routes, capped by
// warningDelayBonusCap. Parallel handshakes mean handshake time grows
diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go
index 722c2abd7..53d864115 100644
--- a/client/internal/dns/server_test.go
+++ b/client/internal/dns/server_test.go
@@ -2484,6 +2484,32 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
// rule 3: startup failures while the peer is handshaking, then the peer
// comes up and a query succeeds before the grace window elapses. No
// warning should ever have fired, and no recovery either.
+func TestWarningDelayBaseFromEnv(t *testing.T) {
+ tests := []struct {
+ name string
+ set bool
+ val string
+ want time.Duration
+ }{
+ {name: "unset uses default", set: false, want: defaultWarningDelayBase},
+ {name: "valid override", set: true, val: "90s", want: 90 * time.Second},
+ {name: "valid minutes", set: true, val: "2m", want: 2 * time.Minute},
+ {name: "invalid falls back", set: true, val: "notaduration", want: defaultWarningDelayBase},
+ {name: "zero falls back", set: true, val: "0s", want: defaultWarningDelayBase},
+ {name: "negative falls back", set: true, val: "-30s", want: defaultWarningDelayBase},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(envWarningDelay, tc.val)
+ if !tc.set {
+ os.Unsetenv(envWarningDelay)
+ }
+ assert.Equal(t, tc.want, warningDelayBaseFromEnv(), "grace window base")
+ })
+ }
+}
+
func TestProjection_OverlayRecoversDuringGrace(t *testing.T) {
fx := newProjTestFixture(t)
fx.server.warningDelayBase = 200 * time.Millisecond
diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go
index a4f713d68..9c0d00212 100644
--- a/client/internal/dns/upstream.go
+++ b/client/internal/dns/upstream.go
@@ -443,21 +443,25 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M
return raceResult{}, &upstreamFailure{upstream: upstream, reason: "no response"}
}
+ // A valid response means the upstream is reachable, whatever the Rcode.
+ u.markUpstreamOk(upstream)
+
proto := ""
if upstreamProto != nil {
proto = upstreamProto.protocol
}
if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused {
+ // SERVFAIL and REFUSED are per-question outcomes (DNSSEC-bogus names,
+ // refused zones, transient recursion errors), not reachability
+ // problems: fail over for a better answer but keep the upstream healthy.
if code, ok := nonRetryableEDE(rm); ok {
if !hadEdns {
stripOPT(rm)
}
- u.markUpstreamOk(upstream)
return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil
}
reason := dns.RcodeToString[rm.Rcode]
- u.markUpstreamFail(upstream, reason)
return raceResult{}, &upstreamFailure{upstream: upstream, reason: reason}
}
@@ -465,7 +469,6 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M
stripOPT(rm)
}
- u.markUpstreamOk(upstream)
return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil
}
diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go
index 8b3c589f1..afd2053cc 100644
--- a/client/internal/dns/upstream_test.go
+++ b/client/internal/dns/upstream_test.go
@@ -517,6 +517,78 @@ func TestUpstreamResolver_HealthTracking(t *testing.T) {
assert.NotContains(t, health, bad, "sibling upstream should not be queried when primary answers")
}
+// TestUpstreamResolver_HealthTracking_ResponseMeansReachable verifies that an
+// upstream which answers with SERVFAIL or REFUSED is recorded as healthy:
+// those are per-question outcomes from a reachable server and must not mark
+// the upstream unhealthy. Only transport failures (timeouts) do.
+func TestUpstreamResolver_HealthTracking_ResponseMeansReachable(t *testing.T) {
+ a := netip.MustParseAddrPort("192.0.2.10:53")
+ b := netip.MustParseAddrPort("192.0.2.11:53")
+ timeoutErr := &net.OpError{Op: "read", Err: fmt.Errorf("i/o timeout")}
+
+ tests := []struct {
+ name string
+ respA mockUpstreamResponse
+ respB mockUpstreamResponse
+ wantHealthy bool
+ }{
+ {
+ name: "both SERVFAIL are reachable",
+ respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")},
+ respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")},
+ wantHealthy: true,
+ },
+ {
+ name: "both REFUSED are reachable",
+ respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")},
+ respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")},
+ wantHealthy: true,
+ },
+ {
+ name: "timeout marks unhealthy",
+ respA: mockUpstreamResponse{err: timeoutErr},
+ respB: mockUpstreamResponse{err: timeoutErr},
+ wantHealthy: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ mockClient := &mockUpstreamResolverPerServer{
+ responses: map[string]mockUpstreamResponse{
+ a.String(): tc.respA,
+ b.String(): tc.respB,
+ },
+ rtt: time.Millisecond,
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ resolver := &upstreamResolverBase{
+ ctx: ctx,
+ upstreamClient: mockClient,
+ upstreamTimeout: UpstreamTimeout,
+ }
+ resolver.addRace([]netip.AddrPort{a, b})
+
+ responseWriter := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }}
+ resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA))
+
+ health := resolver.UpstreamHealth()
+ require.Contains(t, health, a, "primary upstream should have a health record")
+ if tc.wantHealthy {
+ assert.False(t, health[a].LastOk.IsZero(), "responding upstream should have LastOk set")
+ assert.True(t, health[a].LastFail.IsZero(), "responding upstream should not be marked failed")
+ assert.Empty(t, health[a].LastErr, "responding upstream should have no error")
+ } else {
+ assert.False(t, health[a].LastFail.IsZero(), "timed-out upstream should be marked failed")
+ assert.NotEmpty(t, health[a].LastErr, "timed-out upstream should record an error")
+ }
+ })
+ }
+}
+
func TestFormatFailures(t *testing.T) {
testCases := []struct {
name string
From 58c79f587878511c478c21638c42376f9c5c401f Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Sat, 20 Jun 2026 00:33:09 +0900
Subject: [PATCH 16/54] [client] Fix DNS custom zone teardown: handler leak and
external CNAME resolution (#6445)
---
client/internal/dns/server.go | 25 +--
client/internal/dns/server_test.go | 261 +++++++++++++++++++----------
2 files changed, 184 insertions(+), 102 deletions(-)
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index 77446b330..7556c66cc 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -140,7 +140,7 @@ type DefaultServer struct {
disableSys bool
mux sync.Mutex
service service
- dnsMuxMap registeredHandlerMap
+ dnsMuxHandlers []handlerWrapper
localResolver *local.Resolver
wgInterface WGIface
hostManager hostManager
@@ -204,8 +204,6 @@ type handlerWrapper struct {
priority int
}
-type registeredHandlerMap map[types.HandlerID]handlerWrapper
-
// DefaultServerConfig holds configuration parameters for NewDefaultServer
type DefaultServerConfig struct {
WgInterface WGIface
@@ -294,7 +292,6 @@ func newDefaultServer(
service: dnsService,
handlerChain: handlerChain,
extraDomains: make(map[domain.Domain]int),
- dnsMuxMap: make(registeredHandlerMap),
localResolver: local.NewResolver(),
wgInterface: wgInterface,
statusRecorder: statusRecorder,
@@ -333,7 +330,7 @@ func (s *DefaultServer) SetRouteSources(selected, active func() route.HAMap) {
type routeSettable interface {
setSelectedRoutes(func() route.HAMap)
}
- for _, entry := range s.dnsMuxMap {
+ for _, entry := range s.dnsMuxHandlers {
if h, ok := entry.handler.(routeSettable); ok {
h.setSelectedRoutes(selected)
}
@@ -983,19 +980,23 @@ func (s *DefaultServer) usableNameServers(nameServers []nbdns.NameServer) []neti
func (s *DefaultServer) updateMux(muxUpdates []handlerWrapper) {
// this will introduce a short period of time when the server is not able to handle DNS requests
- for _, existing := range s.dnsMuxMap {
+ for _, existing := range s.dnsMuxHandlers {
s.deregisterHandler([]string{existing.domain}, existing.priority)
- existing.handler.Stop()
+ // The local resolver is a persistent singleton shared by every custom
+ // zone and reused across config updates. Its chain registrations are
+ // per-config and must be deregistered, but Stop() cancels its lookup
+ // context (breaking external CNAME-target resolution) and clears its
+ // records, so it must not be torn down here.
+ if existing.handler != s.localResolver {
+ existing.handler.Stop()
+ }
}
- muxUpdateMap := make(registeredHandlerMap)
-
for _, update := range muxUpdates {
s.registerHandler([]string{update.domain}, update.handler, update.priority)
- muxUpdateMap[update.handler.ID()] = update
}
- s.dnsMuxMap = muxUpdateMap
+ s.dnsMuxHandlers = muxUpdates
}
// updateNSGroupStates records the new group set and pokes the refresher.
@@ -1229,7 +1230,7 @@ func (s *DefaultServer) groupHasImmediateUpstream(servers []netip.AddrPort, snap
// in more than one handler.
func (s *DefaultServer) collectUpstreamHealth() map[netip.AddrPort]UpstreamHealth {
merged := make(map[netip.AddrPort]UpstreamHealth)
- for _, entry := range s.dnsMuxMap {
+ for _, entry := range s.dnsMuxHandlers {
reporter, ok := entry.handler.(upstreamHealthReporter)
if !ok {
continue
diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go
index 53d864115..4ef790412 100644
--- a/client/internal/dns/server_test.go
+++ b/client/internal/dns/server_test.go
@@ -104,19 +104,6 @@ func init() {
formatter.SetTextFormatter(log.StandardLogger())
}
-func generateDummyHandler(d string, servers []nbdns.NameServer) *upstreamResolverBase {
- var srvs []netip.AddrPort
- for _, srv := range servers {
- srvs = append(srvs, srv.AddrPort())
- }
- u := &upstreamResolverBase{
- domain: domain.Domain(d),
- cancel: func() {},
- }
- u.addRace(srvs)
- return u
-}
-
func TestUpdateDNSServer(t *testing.T) {
nameServers := []nbdns.NameServer{
@@ -132,22 +119,20 @@ func TestUpdateDNSServer(t *testing.T) {
},
}
- dummyHandler := local.NewResolver()
-
testCases := []struct {
name string
- initUpstreamMap registeredHandlerMap
+ initUpstreamMap []handlerWrapper
initLocalZones []nbdns.CustomZone
initSerial uint64
inputSerial uint64
inputUpdate nbdns.Config
shouldFail bool
- expectedUpstreamMap registeredHandlerMap
+ expectedUpstreamMap []handlerWrapper
expectedLocalQs []dns.Question
}{
{
name: "Initial Config Should Succeed",
- initUpstreamMap: make(registeredHandlerMap),
+ initUpstreamMap: nil,
initSerial: 0,
inputSerial: 1,
inputUpdate: nbdns.Config{
@@ -169,20 +154,17 @@ func TestUpdateDNSServer(t *testing.T) {
},
},
},
- expectedUpstreamMap: registeredHandlerMap{
- generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{
+ expectedUpstreamMap: []handlerWrapper{
+ {
domain: "netbird.io",
- handler: dummyHandler,
priority: PriorityUpstream,
},
- dummyHandler.ID(): handlerWrapper{
+ {
domain: "netbird.cloud",
- handler: dummyHandler,
priority: PriorityLocal,
},
- generateDummyHandler(".", nameServers).ID(): handlerWrapper{
+ {
domain: nbdns.RootZone,
- handler: dummyHandler,
priority: PriorityDefault,
},
},
@@ -191,10 +173,10 @@ func TestUpdateDNSServer(t *testing.T) {
{
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: registeredHandlerMap{
- generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
+ initUpstreamMap: []handlerWrapper{
+ {
domain: "netbird.cloud",
- handler: dummyHandler,
+ handler: &mockHandler{},
priority: PriorityUpstream,
},
},
@@ -215,15 +197,13 @@ func TestUpdateDNSServer(t *testing.T) {
},
},
},
- expectedUpstreamMap: registeredHandlerMap{
- generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{
+ expectedUpstreamMap: []handlerWrapper{
+ {
domain: "netbird.io",
- handler: dummyHandler,
priority: PriorityUpstream,
},
- "local-resolver": handlerWrapper{
+ {
domain: "netbird.cloud",
- handler: dummyHandler,
priority: PriorityLocal,
},
},
@@ -232,7 +212,7 @@ func TestUpdateDNSServer(t *testing.T) {
{
name: "Smaller Config Serial Should Be Skipped",
initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
+ initUpstreamMap: nil,
initSerial: 2,
inputSerial: 1,
shouldFail: true,
@@ -240,7 +220,7 @@ func TestUpdateDNSServer(t *testing.T) {
{
name: "Empty NS Group Domain Or Not Primary Element Should Fail",
initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
+ initUpstreamMap: nil,
initSerial: 0,
inputSerial: 1,
inputUpdate: nbdns.Config{
@@ -262,7 +242,7 @@ func TestUpdateDNSServer(t *testing.T) {
{
name: "Invalid NS Group Nameservers list Should Fail",
initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
+ initUpstreamMap: nil,
initSerial: 0,
inputSerial: 1,
inputUpdate: nbdns.Config{
@@ -284,7 +264,7 @@ func TestUpdateDNSServer(t *testing.T) {
{
name: "Invalid Custom Zone Records list Should Skip",
initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
+ initUpstreamMap: nil,
initSerial: 0,
inputSerial: 1,
inputUpdate: nbdns.Config{
@@ -301,42 +281,41 @@ func TestUpdateDNSServer(t *testing.T) {
},
},
},
- expectedUpstreamMap: registeredHandlerMap{generateDummyHandler(".", nameServers).ID(): handlerWrapper{
+ expectedUpstreamMap: []handlerWrapper{{
domain: ".",
- handler: dummyHandler,
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: registeredHandlerMap{
- generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
+ initUpstreamMap: []handlerWrapper{
+ {
domain: zoneRecords[0].Name,
- handler: dummyHandler,
+ handler: &mockHandler{},
priority: PriorityUpstream,
},
},
initSerial: 0,
inputSerial: 1,
inputUpdate: nbdns.Config{ServiceEnable: true},
- expectedUpstreamMap: make(registeredHandlerMap),
+ 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: registeredHandlerMap{
- generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
+ initUpstreamMap: []handlerWrapper{
+ {
domain: zoneRecords[0].Name,
- handler: dummyHandler,
+ handler: &mockHandler{},
priority: PriorityUpstream,
},
},
initSerial: 0,
inputSerial: 1,
inputUpdate: nbdns.Config{ServiceEnable: false},
- expectedUpstreamMap: make(registeredHandlerMap),
+ expectedUpstreamMap: nil,
expectedLocalQs: []dns.Question{},
},
}
@@ -393,7 +372,7 @@ func TestUpdateDNSServer(t *testing.T) {
}
}()
- dnsServer.dnsMuxMap = testCase.initUpstreamMap
+ dnsServer.dnsMuxHandlers = testCase.initUpstreamMap
dnsServer.localResolver.Update(testCase.initLocalZones)
dnsServer.updateSerial = testCase.initSerial
@@ -405,14 +384,20 @@ func TestUpdateDNSServer(t *testing.T) {
t.Fatalf("update dns server should not fail, got error: %v", err)
}
- if len(dnsServer.dnsMuxMap) != len(testCase.expectedUpstreamMap) {
- t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxMap))
+ 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 key := range testCase.expectedUpstreamMap {
- _, found := dnsServer.dnsMuxMap[key]
+ 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, key %s was not found in the dnsMuxMap: %#v", key, dnsServer.dnsMuxMap)
+ t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers)
}
}
@@ -512,8 +497,8 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) {
}
}()
- dnsServer.dnsMuxMap = registeredHandlerMap{
- "id1": handlerWrapper{
+ dnsServer.dnsMuxHandlers = []handlerWrapper{
+ {
domain: zoneRecords[0].Name,
handler: &local.Resolver{},
priority: PriorityUpstream,
@@ -1029,15 +1014,15 @@ func (m *mockService) RegisterMux(string, dns.Handler) {}
func (m *mockService) DeregisterMux(string) {}
func TestDefaultServer_UpdateMux(t *testing.T) {
- baseMatchHandlers := registeredHandlerMap{
- "upstream-group1": {
+ baseMatchHandlers := []handlerWrapper{
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group1",
},
priority: PriorityUpstream,
},
- "upstream-group2": {
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group2",
@@ -1046,15 +1031,15 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
},
}
- baseRootHandlers := registeredHandlerMap{
- "upstream-root1": {
+ baseRootHandlers := []handlerWrapper{
+ {
domain: ".",
handler: &mockHandler{
Id: "upstream-root1",
},
priority: PriorityDefault,
},
- "upstream-root2": {
+ {
domain: ".",
handler: &mockHandler{
Id: "upstream-root2",
@@ -1063,22 +1048,22 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
},
}
- baseMixedHandlers := registeredHandlerMap{
- "upstream-group1": {
+ baseMixedHandlers := []handlerWrapper{
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group1",
},
priority: PriorityUpstream,
},
- "upstream-group2": {
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group2",
},
priority: PriorityUpstream - 1,
},
- "upstream-other": {
+ {
domain: "other.com",
handler: &mockHandler{
Id: "upstream-other",
@@ -1089,7 +1074,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
tests := []struct {
name string
- initialHandlers registeredHandlerMap
+ initialHandlers []handlerWrapper
updates []handlerWrapper
expectedHandlers map[string]string // map[HandlerID]domain
description string
@@ -1373,32 +1358,38 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := &DefaultServer{
- dnsMuxMap: tt.initialHandlers,
- handlerChain: NewHandlerChain(),
- service: &mockService{},
+ dnsMuxHandlers: tt.initialHandlers,
+ handlerChain: NewHandlerChain(),
+ service: &mockService{},
}
// Perform the update
server.updateMux(tt.updates)
// Verify the results
- assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxMap),
+ assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxHandlers),
"Number of handlers after update doesn't match expected")
// Check each expected handler
for id, expectedDomain := range tt.expectedHandlers {
- handler, exists := server.dnsMuxMap[types.HandlerID(id)]
- assert.True(t, exists, "Expected handler %s not found", id)
- if exists {
- assert.Equal(t, expectedDomain, handler.domain,
+ var found *handlerWrapper
+ for i := range server.dnsMuxHandlers {
+ if server.dnsMuxHandlers[i].handler.ID() == types.HandlerID(id) {
+ found = &server.dnsMuxHandlers[i]
+ break
+ }
+ }
+ assert.NotNil(t, found, "Expected handler %s not found", id)
+ if found != nil {
+ assert.Equal(t, expectedDomain, found.domain,
"Domain mismatch for handler %s", id)
}
}
// Verify no unexpected handlers exist
- for HandlerID := range server.dnsMuxMap {
- _, expected := tt.expectedHandlers[string(HandlerID)]
- assert.True(t, expected, "Unexpected handler found: %s", HandlerID)
+ for _, entry := range server.dnsMuxHandlers {
+ _, expected := tt.expectedHandlers[string(entry.handler.ID())]
+ assert.True(t, expected, "Unexpected handler found: %s", entry.handler.ID())
}
// Verify the handlerChain state and order
@@ -1413,7 +1404,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
// Verify handler exists in mux
foundInMux := false
- for _, muxEntry := range server.dnsMuxMap {
+ for _, muxEntry := range server.dnsMuxHandlers {
if chainEntry.Handler == muxEntry.handler &&
chainEntry.Priority == muxEntry.priority &&
chainEntry.Pattern == dns.Fqdn(muxEntry.domain) {
@@ -1422,12 +1413,108 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
}
}
assert.True(t, foundInMux,
- "Handler in chain not found in dnsMuxMap")
+ "Handler in chain not found in dnsMuxHandlers")
}
})
}
}
+// chainHasPattern reports whether the handler chain holds an entry registered
+// for the given fqdn pattern at the given priority.
+func chainHasPattern(s *DefaultServer, pattern string, priority int) bool {
+ for _, h := range s.handlerChain.handlers {
+ if h.OrigPattern == pattern && h.Priority == priority {
+ return true
+ }
+ }
+ return false
+}
+
+// TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval verifies that updateMux
+// tracks each (handler, domain) registration independently when one handler
+// serves multiple zones. Every custom zone is served by the same handler
+// instance (the local resolver, whose ID is the constant "local-resolver"), so
+// removing one zone must deregister exactly that zone's chain entry and leave
+// the others in place. Tracking registrations by handler ID alone collapses all
+// zones onto one entry, leaving removed zones in the chain to answer
+// authoritatively with no records.
+func TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval(t *testing.T) {
+ // One handler serves every custom zone, mirroring s.localResolver.
+ shared := &mockHandler{Id: "local-resolver"}
+
+ server := &DefaultServer{
+ handlerChain: NewHandlerChain(),
+ service: &mockService{},
+ }
+
+ // Two custom zones under the same handler. The surviving zone is registered
+ // last, mirroring the management emission order.
+ server.updateMux([]handlerWrapper{
+ {domain: "userzone.test", handler: shared, priority: PriorityLocal},
+ {domain: "peerzone.test", handler: shared, priority: PriorityLocal},
+ })
+
+ require.True(t, chainHasPattern(server, "userzone.test.", PriorityLocal),
+ "userzone.test should be registered after the first update")
+ require.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal),
+ "peerzone.test should be registered after the first update")
+
+ // Remove one zone, keep the other.
+ server.updateMux([]handlerWrapper{
+ {domain: "peerzone.test", handler: shared, priority: PriorityLocal},
+ })
+
+ assert.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal),
+ "peerzone.test should remain after removing userzone.test")
+ assert.False(t, chainHasPattern(server, "userzone.test.", PriorityLocal),
+ "userzone.test handler must be deregistered, not leaked in the chain")
+}
+
+// TestDefaultServer_UpdateMux_PreservesLocalResolver verifies that updateMux
+// does not tear down the shared local resolver during reconfiguration. The
+// resolver is a process-lifetime singleton reused across config updates;
+// Stop() cancels its lookup context (breaking external CNAME-target
+// resolution) and clears its records. updateMux must deregister its chain
+// entries without stopping it. Records surviving a teardown update is the
+// observable proxy: Stop() would have cleared them.
+func TestDefaultServer_UpdateMux_PreservesLocalResolver(t *testing.T) {
+ resolver := local.NewResolver()
+ require.NoError(t, resolver.RegisterRecord(nbdns.SimpleRecord{
+ Name: "peer.netbird.cloud.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "10.0.0.1",
+ }))
+
+ server := &DefaultServer{
+ handlerChain: NewHandlerChain(),
+ service: &mockService{},
+ localResolver: resolver,
+ }
+
+ server.updateMux([]handlerWrapper{
+ {domain: "netbird.cloud", handler: resolver, priority: PriorityLocal},
+ })
+
+ // Remove the zone. The resolver must survive so its records and lookup
+ // context stay intact for the next registration.
+ server.updateMux(nil)
+
+ var response *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ response = m
+ return nil
+ },
+ }, &dns.Msg{Question: []dns.Question{{Name: "peer.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}})
+
+ require.NotNil(t, response, "local resolver should answer after teardown")
+ assert.Equal(t, dns.RcodeSuccess, response.Rcode,
+ "local resolver records must survive teardown; updateMux must not Stop() the shared resolver")
+ assert.NotEmpty(t, response.Answer, "answer should contain the surviving record")
+}
+
func TestExtraDomains(t *testing.T) {
tests := []struct {
name string
@@ -2049,7 +2136,6 @@ func TestBuildUpstreamHandler_MergesGroupsPerDomain(t *testing.T) {
localResolver: local.NewResolver(),
handlerChain: NewHandlerChain(),
hostManager: &noopHostConfigurator{},
- dnsMuxMap: make(registeredHandlerMap),
}
groups := []*nbdns.NameServerGroup{
@@ -2207,7 +2293,7 @@ func TestEvaluateNSGroupHealth(t *testing.T) {
}
}
-// healthStubHandler is a minimal dnsMuxMap entry that exposes a fixed
+// healthStubHandler is a minimal dnsMuxHandlers entry that exposes a fixed
// UpstreamHealth snapshot, letting tests drive recomputeNSGroupStates
// without spinning up real handlers.
type healthStubHandler struct {
@@ -2283,12 +2369,11 @@ func newProjTestFixture(t *testing.T) *projTestFixture {
ctx: context.Background(),
wgInterface: &mocWGIface{},
statusRecorder: recorder,
- dnsMuxMap: make(registeredHandlerMap),
selectedRoutes: func() route.HAMap { return fx.selected },
activeRoutes: func() route.HAMap { return fx.active },
warningDelayBase: defaultWarningDelayBase,
}
- fx.server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}
+ fx.server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}}
fx.server.mux.Lock()
fx.server.updateNSGroupStates([]*nbdns.NameServerGroup{fx.group})
@@ -2395,7 +2480,6 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) {
ctx: context.Background(),
wgInterface: &mocWGIface{},
statusRecorder: recorder,
- dnsMuxMap: make(registeredHandlerMap),
selectedRoutes: func() route.HAMap { return nil },
activeRoutes: func() route.HAMap { return nil },
warningDelayBase: 50 * time.Millisecond,
@@ -2407,7 +2491,7 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) {
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{
overlayPeer: {LastFail: time.Now(), LastErr: "timeout"},
}}
- server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
+ server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
server.mux.Lock()
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
@@ -2444,7 +2528,6 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
service: NewServiceViaMemory(wgIface),
hostManager: &noopHostConfigurator{},
extraDomains: map[domain.Domain]int{},
- dnsMuxMap: make(registeredHandlerMap),
statusRecorder: peer.NewRecorder("mgm"),
selectedRoutes: func() route.HAMap { return nil },
activeRoutes: func() route.HAMap { return nil },
@@ -2459,7 +2542,7 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}},
}
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{srv: {LastOk: time.Now()}}}
- server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
+ server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
server.mux.Lock()
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
@@ -2621,7 +2704,6 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) {
server := &DefaultServer{
ctx: context.Background(),
statusRecorder: recorder,
- dnsMuxMap: make(registeredHandlerMap),
selectedRoutes: func() route.HAMap { return overlayMap },
activeRoutes: func() route.HAMap { return nil },
warningDelayBase: time.Hour,
@@ -2639,7 +2721,7 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) {
overlay: {LastFail: time.Now(), LastErr: "timeout"},
},
}
- server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
+ server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
server.mux.Lock()
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
@@ -2666,7 +2748,6 @@ func TestDNSLoopPrevention(t *testing.T) {
localResolver: local.NewResolver(),
handlerChain: NewHandlerChain(),
hostManager: &noopHostConfigurator{},
- dnsMuxMap: make(registeredHandlerMap),
}
tests := []struct {
From c9e99659eadaccaa36d4c79395f2902087957419 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 22 Jun 2026 09:43:33 +0200
Subject: [PATCH 17/54] [misc] Bump the actions group across 1 directory with 9
updates (#6451)
Bumps the actions group with 9 updates in the / directory:
| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `6.0.2` | `7.0.0` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.3.0` | `6.4.0` |
| [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.1` | `7.0.0` |
| [vmactions/freebsd-vm](https://github.com/vmactions/freebsd-vm) | `1.4.5` | `1.4.8` |
| [actions/setup-java](https://github.com/actions/setup-java) | `5.2.0` | `5.3.0` |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `4.0.0` | `4.1.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` |
| [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | `7.2.0` | `7.2.2` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.0` | `8.0.1` |
Updates `actions/checkout` from 6.0.2 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)
Updates `actions/setup-go` from 6.3.0 to 6.4.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/4b73464bb391d4059bd26b0524d20df3927bd417...4a3601121dd01d1626a1e23e37211e3254c1c06c)
Updates `codecov/codecov-action` from 6.0.1 to 7.0.0
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/e79a6962e0d4c0c17b229090214935d2e33f8354...fb8b3582c8e4def4969c97caa2f19720cb33a72f)
Updates `vmactions/freebsd-vm` from 1.4.5 to 1.4.8
- [Release notes](https://github.com/vmactions/freebsd-vm/releases)
- [Commits](https://github.com/vmactions/freebsd-vm/compare/d1e65811565151536c0c894fff74f06351ed26e6...b84ab5559b5a1bb4b8ee2737d2506a16e1737636)
Updates `actions/setup-java` from 5.2.0 to 5.3.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/be666c2fcd27ec809703dec50e508c2fdc7f6654...ad2b38190b15e4d6bdf0c97fb4fca8412226d287)
Updates `docker/setup-qemu-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/ce360397dd3f832beb865e1373c09c0e9f86d70a...06116385d9baf250c9f4dcb4858b16962ea869c3)
Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)
Updates `goreleaser/goreleaser-action` from 7.2.0 to 7.2.2
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/4c6ab561adb47e50c45ef534e2155934e91c40c1...5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89)
Updates `actions/download-artifact` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: 6.0.3
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions
- dependency-name: actions/download-artifact
dependency-version: 8.0.1
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions
- dependency-name: actions/setup-go
dependency-version: 6.4.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: actions
- dependency-name: actions/setup-java
dependency-version: 5.3.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: actions
- dependency-name: codecov/codecov-action
dependency-version: 7.0.0
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: actions
- dependency-name: docker/setup-buildx-action
dependency-version: 4.1.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: actions
- dependency-name: docker/setup-qemu-action
dependency-version: 4.1.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: actions
- dependency-name: goreleaser/goreleaser-action
dependency-version: 7.2.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: actions
- dependency-name: vmactions/freebsd-vm
dependency-version: 1.4.6
dependency-type: direct:production
update-type: version-update:semver-patch
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 | 6 +--
.github/workflows/git-town.yml | 2 +-
.github/workflows/golang-test-darwin.yml | 6 +--
.github/workflows/golang-test-freebsd.yml | 4 +-
.github/workflows/golang-test-linux.yml | 52 +++++++++----------
.github/workflows/golang-test-windows.yml | 4 +-
.github/workflows/golangci-lint.yml | 6 +--
.github/workflows/install-script-test.yml | 2 +-
.github/workflows/mobile-build-validation.yml | 10 ++--
.github/workflows/release.yml | 32 ++++++------
.../workflows/test-infrastructure-files.yml | 6 +--
.github/workflows/wasm-build-validation.yml | 8 +--
12 files changed, 69 insertions(+), 69 deletions(-)
diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml
index 8acd645e2..50510368b 100644
--- a/.github/workflows/check-license-dependencies.yml
+++ b/.github/workflows/check-license-dependencies.yml
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -59,12 +59,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: true
diff --git a/.github/workflows/git-town.yml b/.github/workflows/git-town.yml
index 3f145020f..160c2ea38 100644
--- a/.github/workflows/git-town.yml
+++ b/.github/workflows/git-town.yml
@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1
diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index ad84840a2..7ecec0e92 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -16,12 +16,12 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -48,7 +48,7 @@ jobs:
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)
- name: Upload coverage reports to Codecov
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml
index 9a81d3e4c..4243613b1 100644
--- a/.github/workflows/golang-test-freebsd.yml
+++ b/.github/workflows/golang-test-freebsd.yml
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -28,7 +28,7 @@ jobs:
id: test
env:
GO_VERSION: ${{ steps.goversion.outputs.version }}
- uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
+ uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8
with:
usesh: true
copyback: false
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index c17f83222..cd34d1696 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -18,7 +18,7 @@ jobs:
management: ${{ steps.filter.outputs.management }}
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -30,7 +30,7 @@ jobs:
- 'management/**'
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -119,12 +119,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -162,7 +162,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
@@ -175,12 +175,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -246,12 +246,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -290,7 +290,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
@@ -306,12 +306,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -347,7 +347,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
@@ -363,12 +363,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -407,7 +407,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
@@ -424,12 +424,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -484,7 +484,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
@@ -529,12 +529,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -623,12 +623,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -692,12 +692,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -734,7 +734,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: netbirdio/netbird
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index 8712cc879..a6064d574 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -18,12 +18,12 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
id: go
with:
go-version-file: "go.mod"
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 8f6d1ddb0..66882ac05 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: codespell
@@ -40,7 +40,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for duplicate constants
@@ -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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml
index aec9f6300..1514caedc 100644
--- a/.github/workflows/install-script-test.yml
+++ b/.github/workflows/install-script-test.yml
@@ -22,7 +22,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml
index 8e0538104..778462a21 100644
--- a/.github/workflows/mobile-build-validation.yml
+++ b/.github/workflows/mobile-build-validation.yml
@@ -16,11 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: Setup Android SDK
@@ -28,7 +28,7 @@ jobs:
with:
cmdline-tools-version: 8512546
- name: Setup Java
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287
with:
java-version: "11"
distribution: "adopt"
@@ -54,11 +54,11 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: install gomobile
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bd3514d27..4e533687b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -64,7 +64,7 @@ jobs:
if: steps.check_diff.outputs.diff_exists == 'true'
env:
GO_VERSION: ${{ steps.goversion.outputs.version }}
- uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
+ uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8
with:
usesh: true
copyback: false
@@ -135,7 +135,7 @@ jobs:
ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }}
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
persist-credentials: false
@@ -166,7 +166,7 @@ jobs:
fi
- name: Set up Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -186,9 +186,9 @@ jobs:
- name: check git status
run: git --no-pager diff --exit-code
- name: Set up QEMU
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0
+ uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
- name: Login to Docker hub
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
@@ -221,7 +221,7 @@ jobs:
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
- name: Run GoReleaser
id: goreleaser
- uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
+ uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --clean ${{ env.flags }}
@@ -347,7 +347,7 @@ jobs:
release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }}
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
persist-credentials: false
@@ -374,7 +374,7 @@ jobs:
fi
- name: Set up Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -420,7 +420,7 @@ jobs:
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
+ uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
@@ -464,12 +464,12 @@ jobs:
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
cache: false
@@ -488,7 +488,7 @@ jobs:
run: git --no-pager diff --exit-code
- name: Run GoReleaser
id: goreleaser
- uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
+ uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
@@ -522,7 +522,7 @@ jobs:
downloadPath: '${{ github.workspace }}\temp'
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -534,13 +534,13 @@ jobs:
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Download release artifacts
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release
path: release
- name: Download UI release artifacts
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-ui
path: release-ui
diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml
index 9ad1f2f67..258091d8e 100644
--- a/.github/workflows/test-infrastructure-files.yml
+++ b/.github/workflows/test-infrastructure-files.yml
@@ -68,12 +68,12 @@ jobs:
run: sudo apt-get install -y curl
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
@@ -256,7 +256,7 @@ jobs:
run: sudo apt-get install -y jq
- name: Checkout code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml
index 318a127dd..a5ae59720 100644
--- a/.github/workflows/wasm-build-validation.yml
+++ b/.github/workflows/wasm-build-validation.yml
@@ -19,11 +19,11 @@ jobs:
GOARCH: wasm
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: Install dependencies
@@ -44,11 +44,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: "go.mod"
- name: Build Wasm client
From 522b8ed96956cbc23764d887cfce5ea356a027bc Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Mon, 22 Jun 2026 19:41:33 +0900
Subject: [PATCH 18/54] [client] Surface DNS forwarder upstream failures via
Extended DNS Errors (#6441)
---
client/internal/dns/resutil/resolve.go | 32 ++++++++
client/internal/dns/resutil/resolve_test.go | 39 +++++++++
client/internal/dns/upstream.go | 20 +----
client/internal/dns/upstream_test.go | 13 ---
client/internal/dnsfwd/forwarder.go | 46 ++++++++++-
client/internal/dnsfwd/forwarder_test.go | 80 +++++++++++++++++++
.../routemanager/dnsinterceptor/handler.go | 15 ++++
7 files changed, 213 insertions(+), 32 deletions(-)
diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go
index 07a70d6d1..a2599aee7 100644
--- a/client/internal/dns/resutil/resolve.go
+++ b/client/internal/dns/resutil/resolve.go
@@ -207,3 +207,35 @@ func FormatAnswers(answers []dns.RR) string {
}
return "[" + strings.Join(parts, ", ") + "]"
}
+
+// StripOPT removes any OPT pseudo-RRs from the message's Extra section. Per
+// RFC 6891 a responder must not include an OPT RR toward a client that did not
+// advertise EDNS0.
+func StripOPT(msg *dns.Msg) {
+ if len(msg.Extra) == 0 {
+ return
+ }
+ out := msg.Extra[:0]
+ for _, rr := range msg.Extra {
+ if _, ok := rr.(*dns.OPT); ok {
+ continue
+ }
+ out = append(out, rr)
+ }
+ msg.Extra = out
+}
+
+// ExtractEDE returns the first Extended DNS Error (RFC 8914) option carried in
+// the message, if present.
+func ExtractEDE(msg *dns.Msg) (*dns.EDNS0_EDE, bool) {
+ opt := msg.IsEdns0()
+ if opt == nil {
+ return nil, false
+ }
+ for _, o := range opt.Option {
+ if ede, ok := o.(*dns.EDNS0_EDE); ok {
+ return ede, true
+ }
+ }
+ return nil, false
+}
diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go
index 432367c22..e6a8cc6a5 100644
--- a/client/internal/dns/resutil/resolve_test.go
+++ b/client/internal/dns/resutil/resolve_test.go
@@ -120,3 +120,42 @@ func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) {
assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL")
}
+
+func TestStripOPT(t *testing.T) {
+ rm := &dns.Msg{
+ Extra: []dns.RR{
+ &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}},
+ &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)},
+ },
+ }
+ StripOPT(rm)
+ assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept")
+ _, isOPT := rm.Extra[0].(*dns.OPT)
+ assert.False(t, isOPT, "remaining record must not be OPT")
+}
+
+func TestExtractEDE(t *testing.T) {
+ t.Run("no edns", func(t *testing.T) {
+ _, ok := ExtractEDE(&dns.Msg{})
+ assert.False(t, ok, "message without OPT has no EDE")
+ })
+
+ t.Run("edns without ede", func(t *testing.T) {
+ rm := &dns.Msg{}
+ rm.SetEdns0(4096, false)
+ _, ok := ExtractEDE(rm)
+ assert.False(t, ok, "OPT without EDE option returns false")
+ })
+
+ t.Run("with ede", func(t *testing.T) {
+ rm := &dns.Msg{}
+ opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
+ opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: 49152, ExtraText: "upstream timeout"})
+ rm.Extra = append(rm.Extra, opt)
+
+ ede, ok := ExtractEDE(rm)
+ assert.True(t, ok, "EDE option should be found")
+ assert.Equal(t, uint16(49152), ede.InfoCode)
+ assert.Equal(t, "upstream timeout", ede.ExtraText)
+ })
+}
diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go
index 9c0d00212..72fc0450c 100644
--- a/client/internal/dns/upstream.go
+++ b/client/internal/dns/upstream.go
@@ -457,7 +457,7 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M
// problems: fail over for a better answer but keep the upstream healthy.
if code, ok := nonRetryableEDE(rm); ok {
if !hadEdns {
- stripOPT(rm)
+ resutil.StripOPT(rm)
}
return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil
}
@@ -466,7 +466,7 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M
}
if !hadEdns {
- stripOPT(rm)
+ resutil.StripOPT(rm)
}
return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil
@@ -523,22 +523,6 @@ func upstreamUDPSize() uint16 {
return dns.MinMsgSize
}
-// stripOPT removes any OPT pseudo-RRs from the response's Extra section so
-// the response complies with RFC 6891 when the client did not advertise EDNS0.
-func stripOPT(rm *dns.Msg) {
- if len(rm.Extra) == 0 {
- return
- }
- out := rm.Extra[:0]
- for _, rr := range rm.Extra {
- if _, ok := rr.(*dns.OPT); ok {
- continue
- }
- out = append(out, rr)
- }
- rm.Extra = out
-}
-
func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure {
if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) {
return &upstreamFailure{upstream: upstream, reason: err.Error()}
diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go
index afd2053cc..4c2784545 100644
--- a/client/internal/dns/upstream_test.go
+++ b/client/internal/dns/upstream_test.go
@@ -985,19 +985,6 @@ func TestEDEName(t *testing.T) {
assert.Equal(t, "EDE 9999", edeName(9999), "unknown code falls back to numeric")
}
-func TestStripOPT(t *testing.T) {
- rm := &dns.Msg{
- Extra: []dns.RR{
- &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}},
- &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)},
- },
- }
- stripOPT(rm)
- assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept")
- _, isOPT := rm.Extra[0].(*dns.OPT)
- assert.False(t, isOPT, "remaining record must not be OPT")
-}
-
func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) {
upstream1 := netip.MustParseAddrPort("192.0.2.1:53")
upstream2 := netip.MustParseAddrPort("192.0.2.2:53")
diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go
index 2e8ef84ab..c15a8520f 100644
--- a/client/internal/dnsfwd/forwarder.go
+++ b/client/internal/dnsfwd/forwarder.go
@@ -26,6 +26,15 @@ import (
const errResolveFailed = "failed to resolve query for domain=%s: %v"
const upstreamTimeout = 15 * time.Second
+// EDE info codes the forwarder emits on upstream failures so the querying
+// client can see the reason without inspecting this peer's logs. They live in
+// the RFC 8914 Private Use range (49152-65535); the Go resolver never exposes a
+// real upstream EDE here, so these cannot collide with a genuine code.
+const (
+ edeNetbirdUpstreamTimeout uint16 = 49152
+ edeNetbirdUpstreamFailure uint16 = 49153
+)
+
type resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
@@ -220,7 +229,7 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype)
if result.Err != nil {
- f.handleDNSError(ctx, logger, w, question, resp, qname, result, startTime)
+ f.handleDNSError(ctx, logger, w, question, resp, qname, result, query.IsEdns0() != nil, startTime)
return
}
@@ -333,6 +342,7 @@ func (f *DNSForwarder) handleDNSError(
resp *dns.Msg,
domain string,
result resutil.LookupResult,
+ reqHasEdns bool,
startTime time.Time,
) {
qType := question.Qtype
@@ -374,6 +384,10 @@ func (f *DNSForwarder) handleDNSError(
logger.Warnf(errResolveFailed, domain, result.Err)
}
+ if reqHasEdns {
+ attachEDE(resp, edeCodeFor(dnsErr), edeText(dnsErr))
+ }
+
f.writeResponse(logger, w, resp, domain, startTime)
}
@@ -414,3 +428,33 @@ func (f *DNSForwarder) getMatchingEntries(domain string) (route.ResID, []*Forwar
return selectedResId, matches
}
+
+// edeCodeFor maps an upstream lookup error to the NetBird EDE info code.
+func edeCodeFor(dnsErr *net.DNSError) uint16 {
+ if dnsErr != nil && dnsErr.IsTimeout {
+ return edeNetbirdUpstreamTimeout
+ }
+ return edeNetbirdUpstreamFailure
+}
+
+// edeText builds the EDE extra-text describing the class of upstream failure.
+// It deliberately omits the upstream server address, which may be an internal
+// resolver and is exposed to any client permitted to use the route; the full
+// detail stays in the forwarder's local log.
+func edeText(dnsErr *net.DNSError) string {
+ if dnsErr != nil && dnsErr.IsTimeout {
+ return "netbird forwarder: upstream timeout"
+ }
+ return "netbird forwarder: upstream failure"
+}
+
+// attachEDE adds an Extended DNS Error (RFC 8914) option to the response,
+// creating the OPT pseudo-record if the response does not already carry one.
+func attachEDE(resp *dns.Msg, code uint16, text string) {
+ opt := resp.IsEdns0()
+ if opt == nil {
+ resp.SetEdns0(dns.DefaultMsgSize, false)
+ opt = resp.IsEdns0()
+ }
+ opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
+}
diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go
index 7325ef8a7..046595473 100644
--- a/client/internal/dnsfwd/forwarder_test.go
+++ b/client/internal/dnsfwd/forwarder_test.go
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/require"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
+ "github.com/netbirdio/netbird/client/internal/dns/resutil"
"github.com/netbirdio/netbird/client/internal/dns/test"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/route"
@@ -617,6 +618,85 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
}
}
+func TestDNSForwarder_UpstreamFailureEDE(t *testing.T) {
+ tests := []struct {
+ name string
+ lookupErr error
+ reqEdns bool
+ wantEDE bool
+ wantCode uint16
+ wantTextHas string
+ }{
+ {
+ name: "timeout with edns0",
+ lookupErr: &net.DNSError{Err: "i/o timeout", Server: "10.0.0.53:53", IsTimeout: true},
+ reqEdns: true,
+ wantEDE: true,
+ wantCode: edeNetbirdUpstreamTimeout,
+ wantTextHas: "netbird forwarder: upstream timeout",
+ },
+ {
+ name: "server failure with edns0",
+ lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"},
+ reqEdns: true,
+ wantEDE: true,
+ wantCode: edeNetbirdUpstreamFailure,
+ wantTextHas: "netbird forwarder: upstream failure",
+ },
+ {
+ name: "no edns0 in request omits ede",
+ lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"},
+ reqEdns: false,
+ wantEDE: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil)
+ forwarder.resolver = mockResolver
+
+ d, err := domain.FromString("example.com")
+ require.NoError(t, err)
+ forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}})
+
+ mockResolver.On("LookupNetIP", mock.Anything, "ip4", "example.com.").
+ Return([]netip.Addr(nil), tt.lookupErr).Once()
+
+ query := &dns.Msg{}
+ query.SetQuestion("example.com.", dns.TypeA)
+ if tt.reqEdns {
+ query.SetEdns0(dns.DefaultMsgSize, false)
+ }
+
+ var writtenResp *dns.Msg
+ mockWriter := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ writtenResp = m
+ return nil
+ },
+ }
+
+ forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
+ mockResolver.AssertExpectations(t)
+
+ require.NotNil(t, writtenResp, "expected a response")
+ assert.Equal(t, dns.RcodeServerFailure, writtenResp.Rcode, "upstream failure must be SERVFAIL")
+
+ ede, ok := resutil.ExtractEDE(writtenResp)
+ if !tt.wantEDE {
+ assert.False(t, ok, "response must not carry EDE")
+ return
+ }
+ require.True(t, ok, "response must carry EDE")
+ assert.Equal(t, tt.wantCode, ede.InfoCode, "EDE info code")
+ assert.Contains(t, ede.ExtraText, tt.wantTextHas, "EDE extra-text")
+ assert.NotContains(t, ede.ExtraText, "10.0.0.53", "must not leak upstream server address")
+ })
+ }
+}
+
func TestDNSForwarder_TCPTruncation(t *testing.T) {
// Test that large UDP responses are truncated with TC bit set
mockResolver := &MockResolver{}
diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go
index e25cc2a5c..22f3355c8 100644
--- a/client/internal/routemanager/dnsinterceptor/handler.go
+++ b/client/internal/routemanager/dnsinterceptor/handler.go
@@ -251,6 +251,14 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
r.MsgHdr.AuthenticatedData = true
}
+ // Advertise EDNS0 to the forwarder so it may return an Extended DNS Error
+ // describing why a lookup failed. The OPT is stripped from the reply when
+ // the original client did not request EDNS0.
+ hadEdns := r.IsEdns0() != nil
+ if !hadEdns {
+ r.SetEdns0(dns.DefaultMsgSize, false)
+ }
+
upstream := net.JoinHostPort(upstreamIP.String(), strconv.FormatUint(uint64(d.forwarderPort.Load()), 10))
ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout)
defer cancel()
@@ -260,6 +268,13 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
return
}
+ if ede, ok := resutil.ExtractEDE(reply); ok {
+ resutil.SetMeta(w, "ede", fmt.Sprintf("%d %s", ede.InfoCode, ede.ExtraText))
+ }
+ if !hadEdns {
+ resutil.StripOPT(reply)
+ }
+
resutil.SetMeta(w, "peer", peerKey)
reply.Id = r.Id
From cf58bf1ba97ae6d1046ef81ed51fdb3a5915184e Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Mon, 22 Jun 2026 12:43:19 +0200
Subject: [PATCH 19/54] [misc] Add TARGETPLATFORM build argument to Docker
build commands (#6499)
---
.github/workflows/test-infrastructure-files.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml
index 258091d8e..1d7753177 100644
--- a/.github/workflows/test-infrastructure-files.yml
+++ b/.github/workflows/test-infrastructure-files.yml
@@ -207,7 +207,7 @@ jobs:
- name: Build management docker image
working-directory: management
run: |
- docker build -t netbirdio/management:latest .
+ docker build -t netbirdio/management:latest --build-arg TARGETPLATFORM=. .
- name: Build signal binary
working-directory: signal
@@ -216,7 +216,7 @@ jobs:
- name: Build signal docker image
working-directory: signal
run: |
- docker build -t netbirdio/signal:latest .
+ docker build -t netbirdio/signal:latest --build-arg TARGETPLATFORM=. .
- name: Build relay binary
working-directory: relay
@@ -225,7 +225,7 @@ jobs:
- name: Build relay docker image
working-directory: relay
run: |
- docker build -t netbirdio/relay:latest .
+ docker build -t netbirdio/relay:latest --build-arg TARGETPLATFORM=. .
- name: run docker compose up
working-directory: infrastructure_files/artifacts
From f736ef96476792b051cb412e541af8ba8ba605c3 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Mon, 22 Jun 2026 13:27:21 +0200
Subject: [PATCH 20/54] [client/ios] Add Auth.Stop() to cancel an in-progress
interactive login (#6486)
The iOS PKCE login runs in the main-app process, decoupled from the network
extension (the extension's client context is torn down on login-required, which
would otherwise kill the WaitToken goroutine before the OAuth callback arrives).
Because it is decoupled, nothing aborted the flow when the user dismissed the
browser without logging in: WaitToken kept its loopback HTTP server bound to the
redirect port until the flow expired, so the next connect stalled trying to bind
the same port.
Make the Auth context cancellable and add Auth.Stop(), which cancels it. Cancelling
unblocks WaitToken, whose deferred server.Shutdown frees the port immediately. This
mirrors how Android's stopEngine() aborts login via the engine context.
NewAuthWithConfig now also derives a cancellable context; its only iOS caller uses
LoginSync (no interactive server), so behaviour is unchanged there.
---
client/ios/NetBirdSDK/login.go | 26 +++++++++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go
index 9d447ef3f..432133999 100644
--- a/client/ios/NetBirdSDK/login.go
+++ b/client/ios/NetBirdSDK/login.go
@@ -36,6 +36,7 @@ type URLOpener interface {
// Auth can register or login new client
type Auth struct {
ctx context.Context
+ cancel context.CancelFunc
config *profilemanager.Config
cfgPath string
}
@@ -51,8 +52,19 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
return nil, err
}
+ // Use a cancellable context so Stop() can abort an in-progress interactive
+ // login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server
+ // bound to a port) until the OAuth callback arrives or the flow expires;
+ // cancelling the context unblocks WaitToken, which then shuts that server down
+ // and frees the port for the next login attempt. iOS runs login in the main-app
+ // process (decoupled from the network extension), so without this the server
+ // lingers after the user dismisses the browser and the next connect stalls
+ // trying to bind the same port.
+ ctx, cancel := context.WithCancel(context.Background())
+
return &Auth{
- ctx: context.Background(),
+ ctx: ctx,
+ cancel: cancel,
config: cfg,
cfgPath: cfgPath,
}, nil
@@ -60,12 +72,24 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) {
// NewAuthWithConfig instantiate Auth based on existing config
func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth {
+ ctx, cancel := context.WithCancel(ctx)
return &Auth{
ctx: ctx,
+ cancel: cancel,
config: config,
}
}
+// Stop aborts an in-progress interactive login started via Login/LoginWithDeviceName.
+// It cancels the auth context, which unblocks the PKCE WaitToken and shuts down its
+// loopback HTTP server, freeing the redirect port. Safe to call multiple times and
+// safe to call when no login is running.
+func (a *Auth) Stop() {
+ if a.cancel != nil {
+ a.cancel()
+ }
+}
+
// SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info.
// If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO
// is not supported and returns false without saving the configuration. For other errors return false.
From ac9529ea8cced4e14bc0409093f4ce387a797e25 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Mon, 22 Jun 2026 13:52:57 +0200
Subject: [PATCH 21/54] [client] Fix engine lifecyrcle race (#6443)
* [client] always clean up on Engine.Start failure via defer
The rosenpass init paths (NewManager/Run) returned without calling
e.close(), leaking the WireGuard interface and other partially
initialized state on failure. Per-branch cleanup was easy to miss when
adding new early returns.
Convert Start to a named error return and tear down via a single defer
that calls e.close() whenever err != nil, removing the scattered
per-branch close() calls (including the redundant one in initFirewall).
* [client] make Engine single-use and guard against double Start
Create the run context once in NewEngine instead of in Start. This
keeps e.cancel valid for the engine's whole lifetime, so Stop can
cancel a Start that is blocked waiting on the network while holding
syncMsgMux: Stop now cancels before taking the lock, unblocking that
Start so it can release the mutex.
Reject re-entry into Start: a non-nil wgInterface means a prior Start
already ran (ErrEngineAlreadyStarted), and a cancelled run context
means the engine was stopped (ErrEngineAlreadyStopped). Both checks run
before the cleanup defer so a duplicate call cannot tear down the
running engine's state.
* [client] let engine context unblock WaitStreamConnected
WaitStreamConnected only watched the signal client's own context, which
derives from the parent engineCtx rather than the engine's run context.
A Start blocked here (signal stream not yet up) could therefore not be
released by Engine.Stop, since Stop only cancels the engine's run
context.
Pass a context into WaitStreamConnected and select on it too, and have
the engine pass e.ctx, so Stop cancelling e.ctx unblocks a parked Start.
Update the Client interface, the mock, and callers accordingly.
* [client] fix Start/Stop race by making the run loop own engine shutdown
ConnectClient.Stop stopped the engine directly while the run loop's
backoff cycle could still be starting an engine, so Engine.close raced
Engine.Start (e.g. firewall setup reading wgInterface while close nils
it). embed.Client.Start's rollback only avoided a deadlock by cancelling
before Stop; the race itself remained and was caught by -race.
Make the run loop the sole owner of engine shutdown: derive the run
context in NewConnectClient, and have Stop cancel it and wait for the
loop to exit (skipping the wait when the loop never ran) instead of
calling engine.Stop. The loop now always stops the engine on its way
out, dropping the unsynchronised wgInterface check it used to guard that
call. Self-calls from within the loop use runCancel to avoid waiting on
themselves.
embed keeps a defensive pre-Stop cancel(); the daemon's cleanupConnection
gets a TODO to adopt Stop() rather than stopping the engine in parallel.
* [client] init context state in engine tests
Engine tests built the engine context with context.WithCancel(
context.Background()), omitting CtxInitState. Now that the run context
is created in the constructor, the wgIfaceMonitor goroutine can reach
triggerClientRestart during teardown, which calls CtxGetState and
panics on the missing state. Real entry points (up, embed, service)
always CtxInitState; only the tests skipped it.
* [client] interrupt connect backoff on context cancel
The run loop retried with a raw ExponentialBackOff, so a backoff sleep
ignored context cancellation. Now that ConnectClient.Stop waits for the
run loop to exit, a cancel landing during a sleep would block Stop for
the full interval (up to MaxInterval). Wrap the backoff with the run
context so Retry returns promptly on cancel; the retry budget itself
(MaxElapsedTime) is unchanged.
* [client] bound WaitStreamConnected in signal client tests
The tests waited on WaitStreamConnected with context.Background() and the
client's own context was also Background, so a stream that never connects
would hang until the suite timeout. Pass a 5s timeout context and assert
StreamConnected afterwards so the tests fail fast with a clear reason.
* [client] fix WaitStreamConnected stale-channel race
The StreamConnected check and the wait-channel creation took the mutex
separately, so notifyStreamConnected could set the status and close/clear
connectedCh in between: the waiter then created a fresh channel nobody
would ever close and blocked forever. Also, the status read was unlocked
while notify wrote it under the mutex (a data race). Do the check and the
channel fetch in one locked section; drop the now-unused
getStreamStatusChan helper. Pre-existing bug, not introduced by this branch.
* [client] abort Start if context cancelled while waiting for signal stream
receiveSignalEvents blocks in WaitStreamConnected until the signal stream
connects or the context is cancelled. If Stop cancelled e.ctx while Start
was parked there, Start kept going: it started the remaining subsystems on
a cancelled context and marked a shutting-down engine as started. Return
the context error from receiveSignalEvents and propagate it from Start, so
the deferred cleanup runs and the cancellation reaches the caller.
* [client] clean up all started components on Start failure
Start's failure defer only called close(), which covers the wg interface,
firewall, rosenpass and port forwarding but leaves connMgr, srWatcher,
route/DNS/flow/state managers and the monitor goroutines running. A late
failure (e.g. the context-cancelled check after the signal stream) thus
leaked them.
Extract Stop's locked teardown into stopLocked (caller holds syncMsgMux,
does not wait on shutdownWg) and call it from both Stop and Start's defer.
The defer also cancels the run context first so goroutines started before
the failure unwind. Teardown order is unchanged.
---
client/embed/embed.go | 8 +-
client/internal/connect.go | 42 +++++++----
client/internal/engine.go | 110 ++++++++++++++++++----------
client/internal/engine_test.go | 10 +--
client/server/server.go | 4 +
shared/signal/client/client.go | 2 +-
shared/signal/client/client_test.go | 16 +++-
shared/signal/client/grpc.go | 25 ++++---
shared/signal/client/mock.go | 2 +-
9 files changed, 140 insertions(+), 79 deletions(-)
diff --git a/client/embed/embed.go b/client/embed/embed.go
index 0e8991be2..d0d88b177 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -279,9 +279,11 @@ func (c *Client) Start(startCtx context.Context) error {
select {
case <-startCtx.Done():
- // Cancel the client context before stopping: Engine.Start blocks on the
- // signal stream while holding the engine mutex and only unblocks on
- // cancellation. Stopping first would deadlock on that mutex.
+ // ConnectClient.Stop now cancels its own run context and waits for the
+ // run loop to tear the engine down, so this cancel() is no longer
+ // required to break the deadlock and could be removed. It is kept as a
+ // defensive belt-and-suspenders: cancelling the parent context first
+ // guarantees the run loop is unblocked even if Stop's contract regresses.
cancel()
if stopErr := client.Stop(); stopErr != nil {
return fmt.Errorf("stop error after context done. Stop error: %w. Context done: %w", stopErr, startCtx.Err())
diff --git a/client/internal/connect.go b/client/internal/connect.go
index d93b62bb5..7cd2bab22 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -11,6 +11,7 @@ import (
"runtime/debug"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/cenkalti/backoff/v4"
@@ -54,6 +55,10 @@ var androidRunOverride func(c *ConnectClient, runningChan chan struct{}, logPath
type ConnectClient struct {
ctx context.Context
+ runCancel context.CancelFunc
+ runExited chan struct{}
+ runOnce sync.Once
+ runStarted atomic.Bool
config *profilemanager.Config
statusRecorder *peer.Status
@@ -70,8 +75,14 @@ func NewConnectClient(
config *profilemanager.Config,
statusRecorder *peer.Status,
) *ConnectClient {
+ // Derive the run context here so Stop owns the cancel that unblocks the run
+ // loop. runCancel is set once at construction, so Stop can call it without
+ // racing the run loop's startup. Callers therefore need not cancel before Stop.
+ runCtx, runCancel := context.WithCancel(ctx)
return &ConnectClient{
- ctx: ctx,
+ ctx: runCtx,
+ runCancel: runCancel,
+ runExited: make(chan struct{}),
config: config,
statusRecorder: statusRecorder,
engineMutex: sync.Mutex{},
@@ -135,6 +146,11 @@ func (c *ConnectClient) RunOniOS(
}
func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error {
+ // Mark the loop as started and signal exit on return so Stop can wait for
+ // the loop to finish (and skip the wait if the loop never ran).
+ c.runStarted.Store(true)
+ defer c.runOnce.Do(func() { close(c.runExited) })
+
defer func() {
if r := recover(); r != nil {
rec := c.statusRecorder
@@ -290,7 +306,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
log.Debug(err)
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
state.Set(StatusNeedsLogin)
- _ = c.Stop()
+ c.runCancel()
return backoff.Permanent(wrapErr(err)) // unrecoverable error
}
return wrapErr(err)
@@ -410,14 +426,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
c.engine = nil
c.engineMutex.Unlock()
- // todo: consider to remove this condition. Is not thread safe.
- // We should always call Stop(), but we need to verify that it is idempotent
- if engine.wgInterface != nil {
- log.Infof("ensuring %s is removed, Netbird engine context cancelled", engine.wgInterface.Name())
+ log.Infof("ensuring wg interface is removed, Netbird engine context cancelled")
- if err := engine.Stop(); err != nil {
- log.Errorf("Failed to stop engine: %v", err)
- }
+ if err := engine.Stop(); err != nil {
+ log.Errorf("Failed to stop engine: %v", err)
}
c.statusRecorder.ClientTeardown()
@@ -433,12 +445,12 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
}
c.statusRecorder.ClientStart()
- err = backoff.Retry(operation, backOff)
+ err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx))
if err != nil {
log.Debugf("exiting client retry loop due to unrecoverable error: %s", err)
if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) {
state.Set(StatusNeedsLogin)
- _ = c.Stop()
+ c.runCancel()
}
return err
}
@@ -516,11 +528,9 @@ func (c *ConnectClient) Status() StatusType {
}
func (c *ConnectClient) Stop() error {
- engine := c.Engine()
- if engine != nil {
- if err := engine.Stop(); err != nil {
- return fmt.Errorf("stop engine: %w", err)
- }
+ c.runCancel()
+ if c.runStarted.Load() {
+ <-c.runExited
}
return nil
}
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 42712da92..452075da8 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -86,6 +86,8 @@ const (
var ErrResetConnection = fmt.Errorf("reset connection")
+var ErrEngineAlreadyStarted = errors.New("engine already started")
+
type EngineConfig struct {
WgPort int
WgIfaceName string
@@ -199,6 +201,8 @@ type Engine struct {
ctx context.Context
cancel context.CancelFunc
+ started bool
+
wgInterface WGIface
udpMux *udpmux.UniversalUDPMuxDefault
@@ -279,9 +283,15 @@ func NewEngine(
services EngineServices,
mobileDep MobileDependency,
) *Engine {
+ // The engine is single-use: a fresh instance is built per connection
+ // cycle (see Client.run), so the run context is created once here rather
+ // than in Start.
+ ctx, cancel := context.WithCancel(clientCtx)
engine := &Engine{
clientCtx: clientCtx,
clientCancel: clientCancel,
+ ctx: ctx,
+ cancel: cancel,
signal: services.SignalClient,
signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey),
mgmClient: services.MgmClient,
@@ -314,8 +324,34 @@ func (e *Engine) Stop() error {
log.Debugf("tried stopping engine that is nil")
return nil
}
+ e.cancel()
e.syncMsgMux.Lock()
+ e.stopLocked()
+
+ e.syncMsgMux.Unlock()
+
+ timeout := e.calculateShutdownTimeout()
+ log.Debugf("waiting for goroutines to finish with timeout: %v", timeout)
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil {
+ log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout)
+ }
+
+ log.Infof("stopped Netbird Engine")
+
+ return nil
+}
+
+// stopLocked tears down everything Start may have brought up, in the order
+// teardown requires (DNS before the interface goes down, flow manager after).
+// The caller must hold syncMsgMux. It is shared by Stop and by Start's failure
+// path, so a partially-initialized engine is cleaned up the same way; every
+// step is nil-guarded. It does not wait on shutdownWg — the caller does that
+// after releasing the lock, since the goroutines also take syncMsgMux.
+func (e *Engine) stopLocked() {
if e.connMgr != nil {
e.connMgr.Close()
}
@@ -366,10 +402,6 @@ func (e *Engine) Stop() error {
// so dbus and friends don't complain because of a missing interface
e.stopDNSServer()
- if e.cancel != nil {
- e.cancel()
- }
-
e.jobExecutorWG.Wait() // block until job goroutines finish
e.close()
@@ -388,21 +420,6 @@ func (e *Engine) Stop() error {
if err := e.stateManager.PersistState(context.Background()); err != nil {
log.Errorf("failed to persist state: %v", err)
}
-
- e.syncMsgMux.Unlock()
-
- timeout := e.calculateShutdownTimeout()
- log.Debugf("waiting for goroutines to finish with timeout: %v", timeout)
- shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
- defer cancel()
-
- if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil {
- log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout)
- }
-
- log.Infof("stopped Netbird Engine")
-
- return nil
}
// calculateShutdownTimeout returns shutdown timeout: 10s base + 100ms per peer, capped at 30s.
@@ -440,18 +457,38 @@ func waitWithContext(ctx context.Context, wg *sync.WaitGroup) error {
// Start creates a new WireGuard tunnel interface and listens to events from Signal and Management services
// Connections to remote peers are not established here.
// However, they will be established once an event with a list of peers to connect to will be received from Management Service
-func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) error {
+func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) (err error) {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
- if err := iface.ValidateMTU(e.config.MTU); err != nil {
+ // The engine is single-use. Reject a duplicate start and a start on an
+ // already-stopped engine (run context cancelled).
+ if e.started {
+ return ErrEngineAlreadyStarted
+ }
+
+ if ctxErr := e.ctx.Err(); ctxErr != nil {
+ return fmt.Errorf("engine already stopped: %w", ctxErr)
+ }
+
+ e.started = true
+
+ // Tear down any partially-initialized state on a failed start. Cancel the
+ // run context first so goroutines started before the failure (connMgr,
+ // srWatcher, monitors) unwind, then stopLocked mirrors Stop's teardown (we
+ // already hold syncMsgMux), cleaning up route/DNS/flow/state managers too,
+ // not just what close() covers.
+ defer func() {
+ if err != nil {
+ e.cancel()
+ e.stopLocked()
+ }
+ }()
+
+ if err = iface.ValidateMTU(e.config.MTU); err != nil {
return fmt.Errorf("invalid MTU configuration: %w", err)
}
- if e.cancel != nil {
- e.cancel()
- }
- e.ctx, e.cancel = context.WithCancel(e.clientCtx)
e.exposeManager = expose.NewManager(e.ctx, e.mgmClient)
wgIface, err := e.newWgIface()
@@ -485,13 +522,11 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings()
if err != nil {
- e.close()
return fmt.Errorf("read initial settings: %w", err)
}
dnsServer, err := e.newDnsServer(dnsConfig)
if err != nil {
- e.close()
return fmt.Errorf("create dns server: %w", err)
}
e.dnsServer = dnsServer
@@ -526,7 +561,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
if err = e.wgInterfaceCreate(); err != nil {
log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error())
- e.close()
return fmt.Errorf("create wg interface: %w", err)
}
@@ -535,7 +569,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
}
if err := e.createFirewall(); err != nil {
- e.close()
return err
}
@@ -547,7 +580,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.udpMux, err = e.wgInterface.Up()
if err != nil {
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
- e.close()
return fmt.Errorf("up wg interface: %w", err)
}
@@ -572,9 +604,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.acl = acl.NewDefaultManager(e.firewall)
}
- err = e.dnsServer.Initialize()
- if err != nil {
- e.close()
+ if err := e.dnsServer.Initialize(); err != nil {
return fmt.Errorf("initialize dns server: %w", err)
}
@@ -586,7 +616,9 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
e.srWatcher.Start(peer.IsForceRelayed())
- e.receiveSignalEvents()
+ if err = e.receiveSignalEvents(); err != nil {
+ return err
+ }
e.receiveManagementEvents()
e.receiveJobEvents()
@@ -638,7 +670,6 @@ func (e *Engine) createFirewall() error {
func (e *Engine) initFirewall() error {
if err := e.routeManager.SetFirewall(e.firewall); err != nil {
- e.close()
return fmt.Errorf("set firewall: %w", err)
}
@@ -1698,7 +1729,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
}
// receiveSignalEvents connects to the Signal Service event stream to negotiate connection with remote peers
-func (e *Engine) receiveSignalEvents() {
+func (e *Engine) receiveSignalEvents() error {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
@@ -1769,7 +1800,12 @@ func (e *Engine) receiveSignalEvents() {
}
}()
- e.signal.WaitStreamConnected()
+ // todo: consider to remove this blocker. I do not see benefit to block the Start operations
+ e.signal.WaitStreamConnected(e.ctx)
+ if err := e.ctx.Err(); err != nil {
+ return fmt.Errorf("wait for signal stream: %w", err)
+ }
+ return nil
}
func (e *Engine) parseNATExternalIPMappings() []string {
diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go
index 289f1906f..8f29bf072 100644
--- a/client/internal/engine_test.go
+++ b/client/internal/engine_test.go
@@ -247,7 +247,7 @@ func TestEngine_SSH(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
@@ -426,7 +426,7 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
@@ -638,7 +638,7 @@ func TestEngine_Sync(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
// feed updates to Engine via mocked Management client
@@ -817,7 +817,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
wgIfaceName := fmt.Sprintf("utun%d", 104+n)
@@ -1024,7 +1024,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
return
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
defer cancel()
wgIfaceName := fmt.Sprintf("utun%d", 104+n)
diff --git a/client/server/server.go b/client/server/server.go
index a4d53a823..3f6dabc56 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -993,6 +993,10 @@ func (s *Server) cleanupConnection() error {
return nil
}
+ // TODO: consider calling s.connectClient.Stop() instead of engine.Stop().
+ // actCancel() lets the run loop stop the engine too, so both stop it
+ // concurrently; ConnectClient.Stop cancels and waits for the run loop,
+ // making the run loop the sole owner of engine shutdown.
if engine != nil {
if err := engine.Stop(); err != nil {
return err
diff --git a/shared/signal/client/client.go b/shared/signal/client/client.go
index 9dc6ccd37..fb77cb90f 100644
--- a/shared/signal/client/client.go
+++ b/shared/signal/client/client.go
@@ -33,7 +33,7 @@ type Client interface {
Receive(ctx context.Context, msgHandler func(msg *proto.Message) error) error
Ready() bool
IsHealthy() bool
- WaitStreamConnected()
+ WaitStreamConnected(context.Context)
SendToStream(msg *proto.EncryptedMessage) error
Send(msg *proto.Message) error
SetOnReconnectedListener(func())
diff --git a/shared/signal/client/client_test.go b/shared/signal/client/client_test.go
index 1af34e37a..41def08a1 100644
--- a/shared/signal/client/client_test.go
+++ b/shared/signal/client/client_test.go
@@ -65,7 +65,10 @@ var _ = Describe("GrpcClient", func() {
return
}
}()
- clientA.WaitStreamConnected()
+ ctxA, cancelA := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancelA()
+ clientA.WaitStreamConnected(ctxA)
+ Expect(clientA.StreamConnected()).To(BeTrue())
// connect PeerB to Signal
keyB, _ := wgtypes.GenerateKey()
@@ -91,7 +94,10 @@ var _ = Describe("GrpcClient", func() {
}
}()
- clientB.WaitStreamConnected()
+ ctxB, cancelB := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancelB()
+ clientB.WaitStreamConnected(ctxB)
+ Expect(clientB.StreamConnected()).To(BeTrue())
// PeerA initiates ping-pong
err := clientA.Send(&sigProto.Message{
@@ -129,8 +135,10 @@ var _ = Describe("GrpcClient", func() {
return
}
}()
- client.WaitStreamConnected()
- Expect(client).NotTo(BeNil())
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ client.WaitStreamConnected(ctx)
+ Expect(client.StreamConnected()).To(BeTrue())
})
})
diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go
index eb18cea05..2086e0fe6 100644
--- a/shared/signal/client/grpc.go
+++ b/shared/signal/client/grpc.go
@@ -246,15 +246,6 @@ func (c *GrpcClient) notifyStreamConnected() {
}
}
-func (c *GrpcClient) getStreamStatusChan() <-chan struct{} {
- c.mux.Lock()
- defer c.mux.Unlock()
- if c.connectedCh == nil {
- c.connectedCh = make(chan struct{})
- }
- return c.connectedCh
-}
-
func (c *GrpcClient) connect(ctx context.Context, key string) (proto.SignalExchange_ConnectStreamClient, error) {
c.stream = nil
@@ -310,14 +301,24 @@ func (c *GrpcClient) IsHealthy() bool {
}
// WaitStreamConnected waits until the client is connected to the Signal stream
-func (c *GrpcClient) WaitStreamConnected() {
-
+func (c *GrpcClient) WaitStreamConnected(ctx context.Context) {
+ // Check the status and obtain the wait channel atomically: otherwise
+ // notifyStreamConnected could flip the status and close/clear the channel
+ // between the check and the channel creation, leaving us waiting forever on
+ // a stale channel.
+ c.mux.Lock()
if c.status == StreamConnected {
+ c.mux.Unlock()
return
}
+ if c.connectedCh == nil {
+ c.connectedCh = make(chan struct{})
+ }
+ ch := c.connectedCh
+ c.mux.Unlock()
- ch := c.getStreamStatusChan()
select {
+ case <-ctx.Done():
case <-c.ctx.Done():
case <-ch:
}
diff --git a/shared/signal/client/mock.go b/shared/signal/client/mock.go
index 95381a5b0..0c8a083c5 100644
--- a/shared/signal/client/mock.go
+++ b/shared/signal/client/mock.go
@@ -55,7 +55,7 @@ func (sm *MockClient) Ready() bool {
return sm.ReadyFunc()
}
-func (sm *MockClient) WaitStreamConnected() {
+func (sm *MockClient) WaitStreamConnected(context.Context) {
if sm.WaitStreamConnectedFunc == nil {
return
}
From e84f6527f76a463ab17c614ec9675059c3cc830f Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Mon, 22 Jun 2026 15:53:11 +0200
Subject: [PATCH 22/54] [client] fix WaitStreamConnected test call after ctx
signature change (#6503)
watchdog_test.go called WaitStreamConnected() without the context.Context
argument added in #6443, breaking the signal client test build.
---
shared/signal/client/watchdog_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go
index 1905e7562..b780cb969 100644
--- a/shared/signal/client/watchdog_test.go
+++ b/shared/signal/client/watchdog_test.go
@@ -65,7 +65,7 @@ func TestReceiveProbeRoundTrips(t *testing.T) {
streamReady := make(chan struct{})
go func() {
- client.WaitStreamConnected()
+ client.WaitStreamConnected(ctx)
close(streamReady)
}()
select {
From af3b7e449722ed4c59f9da76d529cccd78749c0c Mon Sep 17 00:00:00 2001
From: Bethuel Mmbaga
Date: Mon, 22 Jun 2026 16:58:45 +0300
Subject: [PATCH 23/54] [misc] Add enterprise getting-started and migrate
script (#6501)
---
.goreleaser.yaml | 4 +
.../getting-started-enterprise.sh | 616 +++++++++++++++++
infrastructure_files/migrate-to-enterprise.sh | 638 ++++++++++++++++++
3 files changed, 1258 insertions(+)
create mode 100755 infrastructure_files/getting-started-enterprise.sh
create mode 100755 infrastructure_files/migrate-to-enterprise.sh
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index c068f51d1..a2640dc8e 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -462,9 +462,13 @@ checksum:
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
- glob: ./release_files/install.sh
- glob: ./infrastructure_files/getting-started.sh
+ - glob: ./infrastructure_files/getting-started-enterprise.sh
+ - glob: ./infrastructure_files/migrate-to-enterprise.sh
release:
extra_files:
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
- glob: ./release_files/install.sh
- glob: ./infrastructure_files/getting-started.sh
+ - glob: ./infrastructure_files/getting-started-enterprise.sh
+ - glob: ./infrastructure_files/migrate-to-enterprise.sh
diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh
new file mode 100755
index 000000000..5d2341cbe
--- /dev/null
+++ b/infrastructure_files/getting-started-enterprise.sh
@@ -0,0 +1,616 @@
+#!/bin/bash
+
+set -e
+set -o pipefail
+
+# NetBird Enterprise — Getting Started
+# Single-node bootstrap for a self-hosted NetBird Enterprise stack with the
+# embedded identity provider. Owner is created via first-login flow.
+
+SED_STRIP_PADDING='s/=//g'
+
+check_docker_compose() {
+ if command -v docker-compose &> /dev/null; then
+ echo "docker-compose"
+ return
+ fi
+ if docker compose --help &> /dev/null; then
+ echo "docker compose"
+ return
+ fi
+ echo "docker-compose is not installed or not in PATH. See https://docs.docker.com/engine/install/" > /dev/stderr
+ exit 1
+}
+
+check_openssl() {
+ if ! command -v openssl &> /dev/null; then
+ echo "openssl is not installed or not in PATH." > /dev/stderr
+ exit 1
+ fi
+}
+
+rand_secret() {
+ openssl rand -base64 32 | sed "$SED_STRIP_PADDING"
+}
+
+rand_b64_key() {
+ openssl rand -base64 32
+}
+
+check_nb_domain() {
+ local domain="$1"
+ if [[ -z "$domain" ]]; then
+ echo "The domain cannot be empty." > /dev/stderr
+ return 1
+ fi
+ if [[ "$domain" == "netbird.example.com" ]]; then
+ echo "The domain cannot be netbird.example.com" > /dev/stderr
+ return 1
+ fi
+ if [[ "$domain" =~ ^[0-9.]+$ ]]; then
+ echo "An IP address is not allowed. A real DNS-resolvable domain is required for TLS and the embedded IdP issuer." > /dev/stderr
+ return 1
+ fi
+ if [[ ! "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]]; then
+ echo "The value '$domain' is not a valid FQDN. A real DNS-resolvable domain is required for TLS and the embedded IdP issuer." > /dev/stderr
+ return 1
+ fi
+ return 0
+}
+
+check_domain_resolves() {
+ local domain="$1"
+ if command -v getent &> /dev/null && getent hosts "$domain" &> /dev/null; then return 0; fi
+ if command -v host &> /dev/null && host "$domain" &> /dev/null; then return 0; fi
+ if command -v dig &> /dev/null && [[ -n "$(dig +short "$domain" 2>/dev/null)" ]]; then return 0; fi
+ if command -v nslookup &> /dev/null && nslookup "$domain" &> /dev/null; then return 0; fi
+ return 1
+}
+
+read_nb_domain() {
+ local value=""
+ echo -n "Enter the FQDN for NetBird (must resolve via DNS, e.g. netbird.my-domain.com): " > /dev/stderr
+ read -r value < /dev/tty
+ if ! check_nb_domain "$value"; then
+ read_nb_domain
+ return
+ fi
+ if ! check_domain_resolves "$value"; then
+ echo "" > /dev/stderr
+ echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr
+ echo "Caddy will not be able to issue TLS certificates until it does." > /dev/stderr
+ local confirm=""
+ echo -n "Continue anyway? [y/N]: " > /dev/stderr
+ read -r confirm < /dev/tty
+ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
+ read_nb_domain
+ return
+ fi
+ fi
+ echo "$value"
+}
+
+read_required() {
+ local prompt="$1"
+ local value=""
+ while [[ -z "$value" ]]; do
+ echo -n "$prompt: " > /dev/stderr
+ read -r value < /dev/tty
+ if [[ -z "$value" ]]; then
+ echo "Value cannot be empty." > /dev/stderr
+ fi
+ done
+ echo "$value"
+}
+
+read_secret() {
+ local prompt="$1"
+ local value=""
+ while [[ -z "$value" ]]; do
+ echo -n "$prompt: " > /dev/stderr
+ read -rs value < /dev/tty
+ echo "" > /dev/stderr
+ if [[ -z "$value" ]]; then
+ echo "Value cannot be empty." > /dev/stderr
+ fi
+ done
+ echo "$value"
+}
+
+# read_yes_no "" []
+read_yes_no() {
+ local prompt="$1"
+ local default="${2:-n}"
+ local hint
+ if [[ "$default" == "y" ]]; then
+ hint="[Y/n]"
+ else
+ hint="[y/N]"
+ fi
+ echo -n "${prompt} ${hint}: " > /dev/stderr
+ local ans=""
+ read -r ans < /dev/tty
+ if [[ -z "$ans" ]]; then
+ ans="$default"
+ fi
+ case "$ans" in
+ [Yy] | [Yy][Ee][Ss]) echo "yes" ;;
+ *) echo "no" ;;
+ esac
+}
+
+wait_postgres() {
+ set +e
+ echo -n "Waiting for postgres to become ready"
+ local counter=1
+ while true; do
+ if $DOCKER_COMPOSE_COMMAND exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" &> /dev/null; then
+ break
+ fi
+ if [[ $counter -eq 60 ]]; then
+ echo ""
+ echo "Postgres is taking too long. Recent logs:"
+ $DOCKER_COMPOSE_COMMAND logs --tail=20 postgres
+ exit 1
+ fi
+ echo -n " ."
+ sleep 2
+ counter=$((counter + 1))
+ done
+ echo " done"
+ set -e
+}
+
+init_environment() {
+ check_openssl
+ DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
+
+ if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]] || [[ -f Caddyfile ]]; then
+ echo "Generated files already exist in $(pwd)."
+ echo "If you want to reinitialize the environment, please remove them first:"
+ echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes"
+ echo " rm -f .env docker-compose.yml Caddyfile config.yaml"
+ echo "Be aware this will remove all data from the database."
+ exit 1
+ fi
+
+ echo "NetBird Enterprise bootstrap"
+ echo ""
+ echo "Traffic flow:"
+ echo " Enables traffic events logging on the management server."
+ echo " When enabled, the NetBird stack also runs NATS along with two"
+ echo " additional containers: netbird-receiver (the traffic log receiver"
+ echo " service) and netbird-enricher (the traffic log enricher service)."
+ echo " It still has to be turned on from the dashboard settings afterwards."
+ echo " See https://docs.netbird.io/manage/activity/traffic-events-logging"
+ NETBIRD_TRAFFIC_FLOW=$(read_yes_no "Enable traffic flow" "n")
+
+ echo ""
+ NETBIRD_DOMAIN=$(read_nb_domain)
+
+ echo ""
+
+ NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)")
+
+ GHCR_USERNAME="netbirdExtAccess1"
+ GHCR_TOKEN=$(read_secret "Enter GHCR token (input hidden)")
+
+ POSTGRES_USER="netbird"
+ POSTGRES_DB="netbird"
+ POSTGRES_PASSWORD=$(rand_secret)
+ NETBIRD_ENCRYPTION_KEY=$(rand_b64_key)
+ NETBIRD_RELAY_AUTH_SECRET=$(rand_secret)
+
+ POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC"
+ NETBIRD_RELAY_ENDPOINT="rels://${NETBIRD_DOMAIN}:443"
+
+ echo ""
+ echo "Selected:"
+ echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}"
+ echo " Domain: ${NETBIRD_DOMAIN}"
+ echo ""
+ echo "Rendering files into $(pwd) ..."
+ install -m 600 /dev/null .env
+ render_env >> .env
+ render_docker_compose > docker-compose.yml
+
+ if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
+ sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak
+ fi
+ render_caddyfile > Caddyfile
+ install -m 600 /dev/null config.yaml
+ render_config_yaml >> config.yaml
+
+ echo "Logging in to ghcr.io ..."
+ printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin
+ unset GHCR_TOKEN
+
+ echo ""
+ echo "Pulling images ..."
+ $DOCKER_COMPOSE_COMMAND pull
+
+ echo ""
+ echo "Starting postgres ..."
+ $DOCKER_COMPOSE_COMMAND up -d postgres
+ sleep 2
+ wait_postgres
+
+ echo ""
+ echo "Starting remaining services ..."
+ $DOCKER_COMPOSE_COMMAND up -d
+
+ echo ""
+ echo "Done."
+ echo ""
+ echo "Dashboard: https://${NETBIRD_DOMAIN}"
+ echo ""
+ echo "Open the dashboard in a browser to complete the first-login owner setup."
+ echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env"
+ echo ""
+ echo "Tail logs:"
+ echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server caddy"
+}
+
+# ------------------------------------------------------------------
+# Renderers
+# ------------------------------------------------------------------
+
+render_env() {
+ cat < /dev/null; then
+ echo "docker-compose"
+ return
+ fi
+ if docker compose --help &> /dev/null; then
+ echo "docker compose"
+ return
+ fi
+ echo "docker-compose is not installed or not in PATH." > /dev/stderr
+ exit 1
+}
+
+check_yq() {
+ if ! command -v yq &> /dev/null; then
+ cat > /dev/stderr <<'EOF'
+yq is required to parse and update YAML safely.
+
+ macOS: brew install yq
+ Linux: https://github.com/mikefarah/yq/releases (download binary into PATH)
+ Debian: apt-get install yq (Note: must be the mikefarah Go yq, not the Python wrapper.)
+
+EOF
+ exit 1
+ fi
+ if ! yq --version 2>&1 | grep -q "mikefarah"; then
+ echo "yq is present but appears to be the wrong implementation. The mikefarah Go-based yq is required (https://github.com/mikefarah/yq)." > /dev/stderr
+ exit 1
+ fi
+}
+
+check_openssl() {
+ if ! command -v openssl &> /dev/null; then
+ echo "openssl is not installed or not in PATH." > /dev/stderr
+ exit 1
+ fi
+}
+
+rand_password() {
+ openssl rand -hex 32
+}
+
+read_required() {
+ local prompt="$1"
+ local value=""
+ while [[ -z "$value" ]]; do
+ echo -n "$prompt: " > /dev/stderr
+ read -r value < /dev/tty
+ if [[ -z "$value" ]]; then
+ echo "Value cannot be empty." > /dev/stderr
+ fi
+ done
+ echo "$value"
+}
+
+read_secret() {
+ local prompt="$1"
+ local value=""
+ while [[ -z "$value" ]]; do
+ echo -n "$prompt: " > /dev/stderr
+ read -rs value < /dev/tty
+ echo "" > /dev/stderr
+ if [[ -z "$value" ]]; then
+ echo "Value cannot be empty." > /dev/stderr
+ fi
+ done
+ echo "$value"
+}
+
+read_yes_no() {
+ local prompt="$1"
+ local default="${2:-n}"
+ local hint
+ if [[ "$default" == "y" ]]; then
+ hint="[Y/n]"
+ else
+ hint="[y/N]"
+ fi
+ echo -n "${prompt} ${hint}: " > /dev/stderr
+ local ans=""
+ read -r ans < /dev/tty
+ if [[ -z "$ans" ]]; then
+ ans="$default"
+ fi
+ case "$ans" in
+ [Yy] | [Yy][Ee][Ss]) echo "yes" ;;
+ *) echo "no" ;;
+ esac
+}
+
+# ---------------------------------------------------------------------------
+# Detection — read the operator's existing compose to find service names and
+# paths we need to override. Bail loudly if shape isn't recognised.
+# ---------------------------------------------------------------------------
+
+detect_combined_service() {
+ yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/netbird-server"))) | .[0].key // ""' "$COMPOSE_FILE"
+}
+
+detect_dashboard_service() {
+ yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/dashboard"))) | .[0].key // ""' "$COMPOSE_FILE"
+}
+
+detect_config_yaml_host_path() {
+ yq eval ".services[\"$COMBINED_SERVICE\"].volumes[] | select(. | test(\":/etc/netbird/config.yaml\")) | sub(\":/etc/netbird/config.yaml.*\"; \"\") // \"\"" "$COMPOSE_FILE" | head -1
+}
+
+detect_data_volume() {
+ yq eval ".services[\"$COMBINED_SERVICE\"].volumes[] | select(. | test(\":/var/lib/netbird\")) | sub(\":/var/lib/netbird.*\"; \"\") // \"\"" "$COMPOSE_FILE" | head -1
+}
+
+detect_exposed_address() {
+ yq eval '.server.exposedAddress // ""' "$CONFIG_YAML_HOST"
+}
+
+detect_compose_network() {
+ local tag
+ tag=$(yq eval ".services[\"$COMBINED_SERVICE\"].networks | tag" "$COMPOSE_FILE" 2>/dev/null)
+ case "$tag" in
+ "!!seq")
+ yq eval ".services[\"$COMBINED_SERVICE\"].networks[0]" "$COMPOSE_FILE"
+ ;;
+ "!!map")
+ yq eval ".services[\"$COMBINED_SERVICE\"].networks | keys | .[0]" "$COMPOSE_FILE"
+ ;;
+ *)
+ echo "default"
+ ;;
+ esac
+}
+
+# ---------------------------------------------------------------------------
+# Renderers
+# ---------------------------------------------------------------------------
+
+# Build docker-compose.override.yml from the steps the operator selected.
+# Service names match what we detected on the operator's side.
+render_override() {
+ cat < "$ENTERPRISE_CONFIG_FILE"
+
+ if [[ "$ENABLE_FLOW" == "yes" ]]; then
+ local flow_addr="${NETBIRD_DOMAIN}"
+ yq eval -i "
+ .server.trafficFlow.enabled = true |
+ .server.trafficFlow.address = \"$flow_addr\" |
+ .server.trafficFlow.interval = \"60s\"
+ " "$ENTERPRISE_CONFIG_FILE"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# Execution steps
+# ---------------------------------------------------------------------------
+
+resolve_data_volume() {
+ local short="$1"
+ local actual
+ # Resolve project-prefixed volume name from Docker Compose config first.
+ actual=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval ".volumes.\"$short\".name" - 2>/dev/null)
+ if [[ -n "$actual" && "$actual" != "null" ]]; then
+ echo "$actual"
+ return
+ fi
+ # Relative bind mount: docker-compose resolves it against the compose
+ # file's directory, but `docker run -v` resolves it against the current
+ # working directory. Normalize to an absolute path so both interpretations
+ # agree (and the printed revert command works from any CWD).
+ if [[ "$short" == ./* || "$short" == ../* ]]; then
+ local compose_dir
+ compose_dir="$(cd "$(dirname "$COMPOSE_FILE")" && pwd)"
+ (
+ cd "$compose_dir"
+ cd "$(dirname "$short")"
+ printf '%s/%s\n' "$(pwd)" "$(basename "$short")"
+ )
+ return
+ fi
+ # Not a named volume (e.g. an absolute bind-mount path) — use it as-is.
+ echo "$short"
+}
+
+backup_sqlite() {
+ BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)"
+ mkdir -p "$BACKUP_DIR"
+ local data_volume_actual
+ data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
+ echo "Backing up SQLite store from volume '$data_volume_actual' to $BACKUP_DIR ..."
+ docker run --rm \
+ -v "${data_volume_actual}:/var/lib/netbird:ro" \
+ -v "${BACKUP_DIR}:/backup" \
+ busybox \
+ sh -c 'cp -a /var/lib/netbird/. /backup/ 2>/dev/null || true'
+ local copied
+ copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1)
+ if [[ -z "$copied" ]]; then
+ echo " ⚠ Backup directory is empty — the volume '$data_volume_actual' didn't contain data. Aborting." > /dev/stderr
+ exit 1
+ fi
+ echo " done"
+}
+
+run_migrate_store() {
+ echo "Running migrate-store (SQLite → Postgres) ..."
+ $DOCKER_COMPOSE_COMMAND run --rm "$COMBINED_SERVICE" migrate-store --config /etc/netbird/config.yaml.enterprise --verify
+ echo " done"
+}
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+init_migration() {
+ DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
+ check_yq
+ check_openssl
+
+ COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
+
+ if [[ ! -f "$COMPOSE_FILE" ]]; then
+ echo "$COMPOSE_FILE not found in $(pwd)." > /dev/stderr
+ exit 1
+ fi
+ if [[ -f "$OVERRIDE_FILE" ]] || [[ -f "$ENTERPRISE_CONFIG_FILE" ]]; then
+ echo "Migration artifacts already exist in $(pwd):"
+ [[ -f "$OVERRIDE_FILE" ]] && echo " $OVERRIDE_FILE"
+ [[ -f "$ENTERPRISE_CONFIG_FILE" ]] && echo " $ENTERPRISE_CONFIG_FILE"
+ echo ""
+ echo "Either you've already migrated, or a previous run was interrupted."
+ echo "To re-run cleanly: rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
+ exit 1
+ fi
+
+ COMBINED_SERVICE=$(detect_combined_service)
+ DASHBOARD_SERVICE=$(detect_dashboard_service)
+ CONFIG_YAML_HOST=$(detect_config_yaml_host_path)
+ DATA_VOLUME=$(detect_data_volume)
+ COMPOSE_NETWORK=$(detect_compose_network)
+
+ if [[ -z "$COMBINED_SERVICE" ]]; then
+ echo "Could not find a service running netbirdio/netbird-server* in $COMPOSE_FILE." > /dev/stderr
+ echo "This script targets the community combined-server deployment." > /dev/stderr
+ exit 1
+ fi
+ if [[ -z "$DASHBOARD_SERVICE" ]]; then
+ echo "Could not find a service running netbirdio/dashboard* in $COMPOSE_FILE." > /dev/stderr
+ exit 1
+ fi
+ if [[ -z "$CONFIG_YAML_HOST" ]]; then
+ echo "Could not find a config.yaml mount on $COMBINED_SERVICE (expected to bind-mount to /etc/netbird/config.yaml)." > /dev/stderr
+ exit 1
+ fi
+ if [[ ! -f "$CONFIG_YAML_HOST" ]]; then
+ echo "config.yaml host file not found at $CONFIG_YAML_HOST." > /dev/stderr
+ exit 1
+ fi
+ if [[ -z "$DATA_VOLUME" ]]; then
+ echo "Could not find a volume mounted at /var/lib/netbird on $COMBINED_SERVICE." > /dev/stderr
+ exit 1
+ fi
+
+ echo "Detected existing deployment:"
+ echo " Combined service: $COMBINED_SERVICE"
+ echo " Dashboard: $DASHBOARD_SERVICE"
+ echo " config.yaml: $CONFIG_YAML_HOST"
+ echo " Data volume: $DATA_VOLUME"
+ echo " Network: $COMPOSE_NETWORK"
+ echo ""
+
+ local proceed
+ proceed=$(read_yes_no "Proceed with migration?" "y")
+ if [[ "$proceed" != "yes" ]]; then
+ echo "Aborted."
+ exit 0
+ fi
+
+ # Step 1 — always (this is the point of the script)
+ MIGRATE_IMAGES="yes"
+ echo ""
+ echo "Step 1: Image swap (community → Enterprise). License key required."
+ NB_LICENSE_KEY=$(read_secret " License key")
+ GHCR_USERNAME="netbirdExtAccess1"
+ GHCR_TOKEN=$(read_secret " GHCR token (input hidden)")
+
+ # Step 2 — optional
+ echo ""
+ MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n")
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ echo ""
+ echo " ⚠ Data will be migrated from SQLite to Postgres. The SQLite store"
+ echo " will be backed up automatically. To fully revert later, restore"
+ echo " that backup and delete docker-compose.override.yml +"
+ echo " config.yaml.enterprise."
+ local confirm
+ confirm=$(read_yes_no " Continue?" "y")
+ if [[ "$confirm" != "yes" ]]; then
+ MIGRATE_POSTGRES="no"
+ echo " Skipping Postgres migration."
+ else
+ POSTGRES_PASSWORD=$(rand_password)
+ fi
+ fi
+
+ # Step 3 — optional, only if Postgres is on (flow requires Postgres)
+ echo ""
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ ENABLE_FLOW=$(read_yes_no "Step 3: Enable traffic flow? (requires Postgres)" "n")
+ if [[ "$ENABLE_FLOW" == "yes" ]]; then
+ # Auth secret MUST match server.authSecret from config.yaml
+ NB_FLOW_AUTH_SECRET=$(yq eval '.server.authSecret // ""' "$CONFIG_YAML_HOST")
+ if [[ -z "$NB_FLOW_AUTH_SECRET" ]] || [[ "$NB_FLOW_AUTH_SECRET" == "null" ]]; then
+ echo "Could not read server.authSecret from $CONFIG_YAML_HOST." > /dev/stderr
+ echo "Flow receiver auth must match the combined server's authSecret." > /dev/stderr
+ exit 1
+ fi
+
+ NETBIRD_DOMAIN=$(detect_exposed_address)
+ if [[ -z "$NETBIRD_DOMAIN" ]] || [[ "$NETBIRD_DOMAIN" == "null" ]]; then
+ NETBIRD_DOMAIN=$(read_required " Public NetBird URL (e.g. https://netbird.example.com)")
+ fi
+ # Strip protocol + port to leave just the hostname for the Traefik Host() rule.
+ NETBIRD_HOSTNAME=$(echo "$NETBIRD_DOMAIN" | sed -E 's,^https?://,,' | sed 's,:.*,,' | sed 's,/.*,,')
+
+ # We need the encryption key from the existing config.yaml for the enricher
+ NETBIRD_ENCRYPTION_KEY=$(yq eval '.server.store.encryptionKey // ""' "$CONFIG_YAML_HOST")
+ if [[ -z "$NETBIRD_ENCRYPTION_KEY" ]] || [[ "$NETBIRD_ENCRYPTION_KEY" == "null" ]]; then
+ echo "Could not read server.store.encryptionKey from $CONFIG_YAML_HOST." > /dev/stderr
+ exit 1
+ fi
+ fi
+ else
+ ENABLE_FLOW="no"
+ echo "Step 3 (traffic flow) skipped — requires Postgres."
+ fi
+}
+
+apply_changes() {
+ echo ""
+ echo "Writing $OVERRIDE_FILE ..."
+ install -m 644 /dev/null "$OVERRIDE_FILE"
+ render_override > "$OVERRIDE_FILE"
+
+ if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
+ sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' "$OVERRIDE_FILE" && rm -f "$OVERRIDE_FILE.bak"
+ fi
+
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ echo "Writing $ENTERPRISE_CONFIG_FILE ..."
+ install -m 600 /dev/null "$ENTERPRISE_CONFIG_FILE"
+ render_enterprise_config
+ fi
+
+ # Persist secrets that the override file references via env interpolation.
+ # We write them to a .env file in the current directory; docker compose
+ # picks it up automatically.
+ echo "Writing .env additions (mode 600) ..."
+ local ENV_FILE=".env"
+ touch "$ENV_FILE"
+ chmod 600 "$ENV_FILE"
+ {
+ echo ""
+ echo "# Added by migrate-to-enterprise.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ echo "NB_LICENSE_KEY=${NB_LICENSE_KEY}"
+ if [[ -n "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
+ echo "NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}"
+ fi
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
+ fi
+ if [[ "$ENABLE_FLOW" == "yes" ]]; then
+ echo "NB_FLOW_AUTH_SECRET=${NB_FLOW_AUTH_SECRET}"
+ echo "NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY}"
+ fi
+ } >> "$ENV_FILE"
+
+ echo ""
+ echo "Logging in to ghcr.io ..."
+ printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin
+ unset GHCR_TOKEN
+
+ echo ""
+ echo "Pulling enterprise images ..."
+ $DOCKER_COMPOSE_COMMAND pull
+
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ echo ""
+ echo "Stopping existing services (volumes preserved) ..."
+ $DOCKER_COMPOSE_COMMAND down
+
+ backup_sqlite
+
+ echo ""
+ echo "Starting Postgres ..."
+ $DOCKER_COMPOSE_COMMAND up -d postgres
+
+ # Wait for healthy
+ local counter=0
+ echo -n "Waiting for Postgres to become ready"
+ while ! $DOCKER_COMPOSE_COMMAND exec -T postgres pg_isready -U netbird -d netbird &> /dev/null; do
+ echo -n " ."
+ sleep 2
+ counter=$((counter + 1))
+ if [[ $counter -ge 60 ]]; then
+ echo ""
+ echo "Postgres did not become ready in 120s. Recent logs:"
+ $DOCKER_COMPOSE_COMMAND logs --tail=20 postgres
+ exit 1
+ fi
+ done
+ echo " done"
+
+ run_migrate_store
+ fi
+
+ echo ""
+ echo "Bringing up all services ..."
+ $DOCKER_COMPOSE_COMMAND up -d
+
+ echo ""
+ echo "Migration complete."
+}
+
+print_summary() {
+ echo ""
+ echo "──────────────────────────────────────────────────────────────────────"
+ echo " Summary"
+ echo "──────────────────────────────────────────────────────────────────────"
+ echo " Images: swapped to enterprise"
+ [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " Storage: Postgres (data migrated from SQLite)"
+ [[ "$MIGRATE_POSTGRES" != "yes" ]] && echo " Storage: SQLite (unchanged)"
+ [[ "$ENABLE_FLOW" == "yes" ]] && echo " Traffic flow: enabled"
+ [[ "$ENABLE_FLOW" != "yes" ]] && echo " Traffic flow: disabled"
+ echo ""
+ echo " Generated files (next to your docker-compose.yml):"
+ echo " $OVERRIDE_FILE"
+ [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE"
+ echo " .env (license key + secrets, mode 600)"
+ [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)"
+ echo ""
+ echo " Tail logs:"
+ echo " $DOCKER_COMPOSE_COMMAND logs -f $COMBINED_SERVICE"
+ echo ""
+ echo "──────────────────────────────────────────────────────────────────────"
+ echo " To revert"
+ echo "──────────────────────────────────────────────────────────────────────"
+ echo " $DOCKER_COMPOSE_COMMAND down"
+ if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
+ # Resolve project-prefixed volume names now (before override is removed).
+ local pg_volume data_volume_actual
+ pg_volume=$(resolve_data_volume "netbird_postgres")
+ data_volume_actual=$(resolve_data_volume "$DATA_VOLUME")
+ echo " # Remove the Postgres volume FIRST, before deleting the override file:"
+ echo " docker volume rm $pg_volume"
+ echo " # Restore SQLite from the backup created during this run:"
+ echo " docker run --rm -v ${data_volume_actual}:/var/lib/netbird -v ${BACKUP_DIR}:/backup busybox sh -c 'cp -a /backup/. /var/lib/netbird/'"
+ fi
+ echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
+ echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
+ echo " $DOCKER_COMPOSE_COMMAND up -d"
+ echo "──────────────────────────────────────────────────────────────────────"
+}
+
+# ---------------------------------------------------------------------------
+# Run
+# ---------------------------------------------------------------------------
+
+init_migration
+apply_changes
+print_summary
From 6c26178ad58a1c856c40513913afb1110a779221 Mon Sep 17 00:00:00 2001
From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Date: Mon, 22 Jun 2026 17:36:52 +0200
Subject: [PATCH 24/54] [management] do not use meta diff for login (#6502)
---
management/server/peer.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/management/server/peer.go b/management/server/peer.go
index c54c1dc7b..f219d761c 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -1170,7 +1170,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
}
// This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop
- peer.UpdateMetaIfNew(ctx, login.Meta)
+ peer.Meta = login.Meta
peerGroupIDs, err = getPeerGroupIDs(ctx, am.Store, accountID, peer.ID)
if err != nil {
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 25/54] [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 26/54] [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 27/54] 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 28/54] [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 29/54] [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 30/54] [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 31/54] [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 32/54] [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 33/54] 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 34/54] [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 35/54] [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 36/54] [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 37/54] 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 38/54] [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 39/54] [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 40/54] [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 41/54] [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 42/54] [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 43/54] [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 44/54] 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 45/54] [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 46/54] 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 47/54] [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 48/54] [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 49/54] [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 50/54] [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
From 5711f0e38c69ddbbfb5604a44a5098a6a04d7dcb Mon Sep 17 00:00:00 2001
From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
Date: Mon, 29 Jun 2026 11:02:02 +0200
Subject: [PATCH 51/54] [client] add per-phase timing metrics for sync
processing (#6533)
* Adds metrics sync phases time split to monitor costs
* Address review fixes
* Increment README.md with description on usage with debug bundles
---
client/internal/engine.go | 119 +++++---
client/internal/metrics/influxdb.go | 24 ++
client/internal/metrics/infra/README.md | 69 ++++-
.../dashboards/json/netbird-sync-phases.json | 259 ++++++++++++++++++
client/internal/metrics/infra/ingest/main.go | 13 +
client/internal/metrics/metrics.go | 15 +
client/internal/metrics/push_test.go | 3 +
7 files changed, 468 insertions(+), 34 deletions(-)
create mode 100644 client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json
diff --git a/client/internal/engine.go b/client/internal/engine.go
index e7f1c0501..f7c7e1862 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -895,6 +895,16 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate
e.updateManager.SetVersion(autoUpdateSettings.Version, autoUpdateSettings.AlwaysUpdate)
}
+// phase times a sync sub-phase: it returns a function that records the elapsed
+// duration when called. Starting the timer at the call site keeps inter-phase
+// glue code out of the measurement.
+func (e *Engine) phase(name string) func() {
+ start := time.Now()
+ return func() {
+ e.clientMetrics.RecordSyncPhase(e.ctx, name, time.Since(start))
+ }
+}
+
func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
started := time.Now()
defer func() {
@@ -914,7 +924,10 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
}
- if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
+ done := e.phase("netbird_config")
+ err := e.updateNetbirdConfig(update.GetNetbirdConfig())
+ done()
+ if err != nil {
return err
}
@@ -928,11 +941,16 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
- if err := e.updateChecksIfNew(update.Checks); err != nil {
+ done = e.phase("checks")
+ err = e.updateChecksIfNew(update.Checks)
+ done()
+ if err != nil {
return err
}
+ done = e.phase("persist")
e.persistSyncResponse(update)
+ done()
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -1371,13 +1389,16 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
dnsConfig := toDNSConfig(protoDNSConfig, e.wgInterface.Address())
+ done := e.phase("dns_server")
if err := e.dnsServer.UpdateDNSServer(serial, dnsConfig); err != nil {
log.Errorf("failed to update dns server, err: %v", err)
}
+ done()
e.routeManager.SetDNSForwarderPort(dnsConfig.ForwarderPort)
// apply routes first, route related actions might depend on routing being enabled
+ done = e.phase("routes_classify")
routes := toRoutes(networkMap.GetRoutes())
serverRoutes, clientRoutes := e.routeManager.ClassifyRoutes(routes)
@@ -1386,29 +1407,60 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
e.connMgr.UpdateRouteHAMap(clientRoutes)
log.Debugf("updated lazy connection manager with %d HA groups", len(clientRoutes))
}
+ done()
+ done = e.phase("routes_apply")
dnsRouteFeatureFlag := toDNSFeatureFlag(networkMap)
if err := e.routeManager.UpdateRoutes(serial, serverRoutes, clientRoutes, dnsRouteFeatureFlag); err != nil {
log.Errorf("failed to update routes: %v", err)
}
+ done()
+ done = e.phase("filtering")
if e.acl != nil {
e.acl.ApplyFiltering(networkMap, dnsRouteFeatureFlag)
}
+ done()
+ done = e.phase("dns_forwarder")
fwdEntries := toRouteDomains(e.config.WgPrivateKey.PublicKey().String(), routes)
e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries)
+ done()
// Ingress forward rules
+ done = e.phase("forward_rules")
forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules())
if err != nil {
log.Errorf("failed to update forward rules, err: %v", err)
}
+ done()
log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers()))
+ done = e.phase("offline_peers")
e.updateOfflinePeers(networkMap.GetOfflinePeers())
+ done()
+ remotePeers, err := e.reconcilePeers(networkMap)
+ if err != nil {
+ return err
+ }
+
+ // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
+ done = e.phase("lazy_exclude")
+ excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
+ e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
+ done()
+
+ e.networkSerial = serial
+
+ return nil
+}
+
+// reconcilePeers applies the remote peer list from the network map (removing,
+// modifying and adding peers, then updating SSH config) and returns the remote
+// peers with our own peer filtered out, for use by later sync steps.
+func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) {
// Filter out own peer from the remote peers list
localPubKey := e.config.WgPrivateKey.PublicKey().String()
remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers()))
@@ -1423,42 +1475,43 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
err := e.removeAllPeers()
e.statusRecorder.FinishPeerListModifications()
if err != nil {
- return err
+ return nil, err
}
- } else {
- err := e.removePeers(remotePeers)
- if err != nil {
- return err
- }
-
- err = e.modifyPeers(remotePeers)
- if err != nil {
- return err
- }
-
- err = e.addNewPeers(remotePeers)
- if err != nil {
- return err
- }
-
- e.statusRecorder.FinishPeerListModifications()
-
- e.updatePeerSSHHostKeys(remotePeers)
-
- if err := e.updateSSHClientConfig(remotePeers); err != nil {
- log.Warnf("failed to update SSH client config: %v", err)
- }
-
- e.updateSSHServerAuth(networkMap.GetSshAuth())
+ return remotePeers, nil
}
- // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
- excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
- e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
+ done := e.phase("removed_peers")
+ err := e.removePeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
- e.networkSerial = serial
+ done = e.phase("modified_peers")
+ err = e.modifyPeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
- return nil
+ done = e.phase("added_peers")
+ err = e.addNewPeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
+
+ e.statusRecorder.FinishPeerListModifications()
+
+ e.updatePeerSSHHostKeys(remotePeers)
+
+ if err := e.updateSSHClientConfig(remotePeers); err != nil {
+ log.Warnf("failed to update SSH client config: %v", err)
+ }
+
+ e.updateSSHServerAuth(networkMap.GetSshAuth())
+
+ return remotePeers, nil
}
func toDNSFeatureFlag(networkMap *mgmProto.NetworkMap) bool {
diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go
index 531f6a986..4ba14bf44 100644
--- a/client/internal/metrics/influxdb.go
+++ b/client/internal/metrics/influxdb.go
@@ -120,6 +120,30 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
m.trimLocked()
}
+func (m *influxDBMetrics) RecordSyncPhase(_ context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
+ tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,phase=%s",
+ agentInfo.DeploymentType.String(),
+ agentInfo.Version,
+ agentInfo.OS,
+ agentInfo.Arch,
+ agentInfo.peerID,
+ phase,
+ )
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.samples = append(m.samples, influxSample{
+ measurement: "netbird_sync_phase",
+ tags: tags,
+ fields: map[string]float64{
+ "duration_seconds": duration.Seconds(),
+ },
+ timestamp: time.Now(),
+ })
+ m.trimLocked()
+}
+
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
result := "success"
if !success {
diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md
index 5a93dbd87..7941a30cf 100644
--- a/client/internal/metrics/infra/README.md
+++ b/client/internal/metrics/infra/README.md
@@ -78,6 +78,25 @@ Tags:
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
- `arch`: CPU architecture (amd64, arm64, etc.)
+### Sync Phase Timing
+
+Measurement: `netbird_sync_phase`
+
+Breaks down where time goes inside a single sync, so the total `netbird_sync` duration can be attributed to the sub-step that dominates.
+
+| Field | Description |
+|-------|-------------|
+| `duration_seconds` | Time spent in one sub-phase of sync processing |
+
+Tags:
+- `phase`: the sub-phase — `netbird_config`, `checks`, `persist`, `dns_server`, `routes_classify`, `routes_apply`, `filtering`, `dns_forwarder`, `forward_rules`, `offline_peers`, `removed_peers`, `modified_peers`, `added_peers`, `lazy_exclude`
+- `deployment_type`: "cloud" | "selfhosted" | "unknown"
+- `version`: NetBird version string
+- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
+- `arch`: CPU architecture (amd64, arm64, etc.)
+
+**Note:** this is wall-time per phase — it includes both CPU work and time spent waiting on locks. A slow phase points to *where* the time goes, not *why*; pair it with lock-wait metrics to tell contention apart from real work.
+
### Login Duration
Measurement: `netbird_login`
@@ -191,4 +210,52 @@ docker compose exec influxdb influx query \
# Check ingest server health
curl http://localhost:8087/health
-```
\ No newline at end of file
+```
+
+## Analyzing a Debug Bundle
+
+Metrics collection is always on, so every debug bundle ships a `metrics.txt` in InfluxDB line protocol — a timestamped time series of all recorded events (sync durations, sync phases, connection stages, login). You can replay it into the local stack and graph it, without a running client.
+
+The bundle's `metrics.txt` is a rolling window (capped at 5 days / ~20k samples, see [Buffer Limits](#buffer-limits)). For a connection incident the relevant window is short (connection setup is seconds), so a bundle captured during the issue is enough.
+
+### 1. Start the stack
+
+```bash
+# From this directory (client/internal/metrics/infra)
+INFLUXDB_ADMIN_TOKEN=admin123 INFLUXDB_ADMIN_PASSWORD=admin123 GRAFANA_ADMIN_PASSWORD=admin123 \
+ docker compose up -d
+```
+
+(`admin123` are throwaway local credentials — fine for offline analysis.)
+
+### 2. Clear any previous data
+
+So you only see this bundle:
+
+```bash
+docker exec influxdb influx delete --org netbird --bucket metrics --token admin123 \
+ --start 1970-01-01T00:00:00Z --stop 2100-01-01T00:00:00Z
+```
+
+### 3. Import the bundle's metrics.txt
+
+InfluxDB is not exposed on the host, so import inside the container:
+
+```bash
+docker cp /path/to/bundle/metrics.txt influxdb:/tmp/m.txt
+docker exec influxdb influx write --org netbird --bucket metrics --precision ns \
+ --token admin123 --file /tmp/m.txt
+```
+
+Re-importing the same file is idempotent (same measurement+tags+timestamp overwrites).
+
+### 4. View the dashboards
+
+Grafana on http://localhost:3001 (login `admin` / `admin123`), datasource pre-provisioned:
+
+- **Where sync time goes:** http://localhost:3001/d/netbird-sync-phases/netbird-sync-phases-where-time-goes
+- **General client metrics:** http://localhost:3001/d/netbird-influxdb-metrics
+
+**Set the time range** to cover the bundle's timestamps (e.g. "Last 7 days" or an absolute range matching when the bundle was taken) — with the default short range the panels look empty.
+
+Bundles are distinguishable by the `version` tag; add a tag at import time (e.g. `sed 's/^netbird_\([a-z_]*\),/netbird_\1,bundle=mycase,/' metrics.txt`) if you want to compare several side by side.
\ No newline at end of file
diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json
new file mode 100644
index 000000000..69dbac0ae
--- /dev/null
+++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json
@@ -0,0 +1,259 @@
+{
+ "annotations": {
+ "list": []
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 1,
+ "links": [],
+ "refresh": "",
+ "schemaVersion": 39,
+ "tags": [
+ "netbird",
+ "sync"
+ ],
+ "templating": {
+ "list": [
+ {
+ "current": {
+ "text": "All",
+ "value": "$__all"
+ },
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "definition": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")",
+ "includeAll": true,
+ "label": "version",
+ "multi": true,
+ "name": "version",
+ "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")",
+ "refresh": 2,
+ "type": "query",
+ "allValue": ".*"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-2d",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "",
+ "title": "NetBird Sync Phases (where time goes)",
+ "uid": "netbird-sync-phases",
+ "version": 1,
+ "panels": [
+ {
+ "id": 1,
+ "title": "Time per phase over time (stacked, ms)",
+ "type": "timeseries",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 10,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "custom": {
+ "drawStyle": "bars",
+ "stacking": {
+ "mode": "normal",
+ "group": "A"
+ },
+ "fillOpacity": 80,
+ "lineWidth": 0
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "calcs": [
+ "max",
+ "mean"
+ ]
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"phase\"])\n |> group(columns: [\"phase\"])"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "p95 per phase (ms)",
+ "type": "bargauge",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 11,
+ "w": 12,
+ "x": 0,
+ "y": 10
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "color": {
+ "mode": "continuous-GrYlRd"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "displayMode": "gradient",
+ "orientation": "horizontal",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "showUnfilled": true
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> sort(columns: [\"_value\"], desc: true)"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Per-phase stats (ms): mean / p95 / max",
+ "type": "table",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 11,
+ "w": 12,
+ "x": 12,
+ "y": 10
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms"
+ },
+ "overrides": []
+ },
+ "options": {
+ "showHeader": true,
+ "sortBy": [
+ {
+ "displayName": "max",
+ "desc": true
+ }
+ ]
+ },
+ "transformations": [
+ {
+ "id": "merge",
+ "options": {}
+ }
+ ],
+ "targets": [
+ {
+ "refId": "mean",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> mean()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"mean\"})"
+ },
+ {
+ "refId": "p95",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"p95\"})"
+ },
+ {
+ "refId": "max",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> max()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"max\"})"
+ }
+ ]
+ },
+ {
+ "id": 4,
+ "title": "Total sync duration (netbird_sync, ms) \u2014 reference",
+ "type": "timeseries",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 21
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "custom": {
+ "drawStyle": "points",
+ "pointSize": 5
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "calcs": [
+ "max",
+ "mean"
+ ]
+ },
+ "tooltip": {
+ "mode": "single"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"version\"])\n |> group(columns: [\"version\"])"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go
index a5031a873..623a17e4d 100644
--- a/client/internal/metrics/infra/ingest/main.go
+++ b/client/internal/metrics/infra/ingest/main.go
@@ -59,6 +59,19 @@ var allowedMeasurements = map[string]measurementSpec{
"peer_id": true,
},
},
+ "netbird_sync_phase": {
+ allowedFields: map[string]bool{
+ "duration_seconds": true,
+ },
+ allowedTags: map[string]bool{
+ "deployment_type": true,
+ "version": true,
+ "os": true,
+ "arch": true,
+ "peer_id": true,
+ "phase": true,
+ },
+ },
"netbird_login": {
allowedFields: map[string]bool{
"duration_seconds": true,
diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go
index 4ebb43496..f18082995 100644
--- a/client/internal/metrics/metrics.go
+++ b/client/internal/metrics/metrics.go
@@ -56,6 +56,9 @@ type metricsImplementation interface {
// RecordSyncDuration records how long it took to process a sync message
RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration)
+ // RecordSyncPhase records how long a single sub-phase of sync processing took
+ RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration)
+
// RecordLoginDuration records how long the login to management took
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
@@ -127,6 +130,18 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
}
+// RecordSyncPhase records the duration of a single sub-phase of sync processing
+func (c *ClientMetrics) RecordSyncPhase(ctx context.Context, phase string, duration time.Duration) {
+ if c == nil {
+ return
+ }
+ c.mu.RLock()
+ agentInfo := c.agentInfo
+ c.mu.RUnlock()
+
+ c.impl.RecordSyncPhase(ctx, agentInfo, phase, duration)
+}
+
// RecordLoginDuration records how long the login to management server took
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
if c == nil {
diff --git a/client/internal/metrics/push_test.go b/client/internal/metrics/push_test.go
index 20a509da1..43c1b2c06 100644
--- a/client/internal/metrics/push_test.go
+++ b/client/internal/metrics/push_test.go
@@ -70,6 +70,9 @@ func (m *mockMetrics) RecordConnectionStages(_ context.Context, _ AgentInfo, _ s
func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.Duration) {
}
+func (m *mockMetrics) RecordSyncPhase(_ context.Context, _ AgentInfo, _ string, _ time.Duration) {
+}
+
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
}
From deff8af59f13222d245abfdfc7e2e700c74dd8c7 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Mon, 29 Jun 2026 11:24:25 +0200
Subject: [PATCH 52/54] [client] Wait for signal receive watchdog to stop
before reconnect (#6574)
* [client] Wait for signal receive watchdog to stop before reconnect
The per-stream watchReceiveStream goroutine was started fire-and-forget
and never joined. On reconnect a lingering watchdog could still flip
shared client state (receiveStalled, the disconnect notifier) on the
freshly established stream, since cancelStream only cancels its own
stream context.
Track the watchdog with a WaitGroup and wait for it to exit (after
cancelling its stream) before the operation returns, so each reconnect
starts with no stale watchdog.
* [client] Bind signal receive probe to the stream context
The watchdog probe reused the generic Send, which derives its per-attempt
timeouts from the long-lived client context, so cancelStream could not
interrupt an in-flight probe. After joining the watchdog on reconnect,
watchdogWg.Wait() could then block for the full send-attempt chain.
Split Send into a context-aware send and pass the stream context down
through sendReceiveProbe, so cancelStream aborts any in-flight probe and
the watchdog exits promptly.
---
shared/signal/client/grpc.go | 30 ++++++++++++++++++++-------
shared/signal/client/watchdog_test.go | 2 +-
2 files changed, 24 insertions(+), 8 deletions(-)
diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go
index 611ab0c45..7e8e551b6 100644
--- a/shared/signal/client/grpc.go
+++ b/shared/signal/client/grpc.go
@@ -85,6 +85,7 @@ type GrpcClient struct {
// 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
+ watchdogWg sync.WaitGroup
}
// NewClient creates a new Signal client
@@ -200,10 +201,18 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
// Guard the receive direction: the transport can stay healthy while the
// server stops delivering messages. The watchdog reconnects via cancelStream.
c.markReceived()
- go c.watchReceiveStream(streamCtx, cancelStream)
+ c.watchdogWg.Add(1)
+ go func() {
+ defer c.watchdogWg.Done()
+ c.watchReceiveStream(streamCtx, cancelStream)
+ }()
// start receiving messages from the Signal stream (from other peers through signal)
err = c.receive(stream)
+
+ cancelStream()
+ c.watchdogWg.Wait()
+
if err != nil {
// Check the parent context, not streamCtx: a watchdog-triggered
// cancelStream must reconnect, only a parent cancel is shutdown.
@@ -400,7 +409,12 @@ func (c *GrpcClient) encryptMessage(msg *proto.Message) (*proto.EncryptedMessage
// Send sends a message to the remote Peer through the Signal Exchange.
func (c *GrpcClient) Send(msg *proto.Message) error {
+ return c.send(c.ctx, msg)
+}
+// send delivers a message deriving per-attempt timeouts from parentCtx, so a
+// caller can abort an in-flight send by cancelling that context.
+func (c *GrpcClient) send(parentCtx context.Context, msg *proto.Message) error {
if !c.Ready() {
return fmt.Errorf("no connection to signal")
}
@@ -416,7 +430,7 @@ func (c *GrpcClient) Send(msg *proto.Message) error {
if attempt > 1 {
attemptTimeout = time.Duration(attempt) * 5 * time.Second
}
- ctx, cancel := context.WithTimeout(c.ctx, attemptTimeout)
+ ctx, cancel := context.WithTimeout(parentCtx, attemptTimeout)
_, err = c.realClient.Send(ctx, encryptedMessage)
@@ -486,7 +500,7 @@ func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream contex
}
if probeSentAt.IsZero() {
- if err := c.sendReceiveProbe(); err != nil {
+ if err := c.sendReceiveProbe(ctx); err != nil {
log.Debugf("failed to send signal receive probe: %v", err)
}
probeSentAt = time.Now()
@@ -495,11 +509,13 @@ func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream contex
}
}
-// sendReceiveProbe sends a self-addressed heartbeat. The Signal server routes it
-// back to this client, exercising the exact receive path the watchdog guards.
-func (c *GrpcClient) sendReceiveProbe() error {
+// sendReceiveProbe sends a self-addressed heartbeat bound to ctx, so cancelStream
+// aborts an in-flight probe instead of leaving the watchdog blocked on send timeouts.
+// The Signal server routes it back to this client, exercising the exact receive
+// path the watchdog guards.
+func (c *GrpcClient) sendReceiveProbe(ctx context.Context) error {
self := c.key.PublicKey().String()
- return c.Send(&proto.Message{
+ return c.send(ctx, &proto.Message{
Key: self,
RemoteKey: self,
Body: &proto.Body{Type: proto.Body_HEARTBEAT},
diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go
index bc6b5520b..eeb9aec30 100644
--- a/shared/signal/client/watchdog_test.go
+++ b/shared/signal/client/watchdog_test.go
@@ -74,7 +74,7 @@ func TestReceiveProbeRoundTrips(t *testing.T) {
t.Fatal("signal stream did not connect within timeout")
}
- require.NoError(t, client.sendReceiveProbe())
+ require.NoError(t, client.sendReceiveProbe(ctx))
select {
case <-received:
From 0b594c639a75dc96af7893e85d87729966eec681 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Mon, 29 Jun 2026 11:28:58 +0200
Subject: [PATCH 53/54] [client] report management unhealthy while Sync stream
is failing (#6575)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(mgm): report management unhealthy while Sync stream is failing
The health probe (IsHealthy) only checked the gRPC transport and a
GetServerKey call. GetServerKey succeeds even when the peer cannot sync
(e.g. the server returns "settings not found"), so the probe kept marking
management Connected while the Sync stream failed in a tight retry loop —
pinning the status to "Connected" forever despite no sync ever succeeding.
Track the last Sync stream error and have IsHealthy consult it, so a
healthy transport is no longer enough to report the connection healthy.
* fix(mgm): record disconnected state when sync stream setup fails
The connectToSyncStream failure path in handleSyncStream returned early
without updating syncStreamErr, so the client could still report healthy
even when stream setup failed. Mirror the receiveUpdatesEvents error path
by calling notifyDisconnected and setSyncStreamDisconnected.
---
shared/management/client/grpc.go | 37 ++++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go
index 016cde68a..6f5172376 100644
--- a/shared/management/client/grpc.go
+++ b/shared/management/client/grpc.go
@@ -55,6 +55,14 @@ type GrpcClient struct {
connStateCallback ConnStateNotifier
connStateCallbackLock sync.RWMutex
serverURL string
+
+ // syncStreamErr holds the last Sync stream error, or nil while the stream
+ // is established and healthy. GetServerKey succeeds even when the peer
+ // cannot sync (e.g. the server returns "settings not found"), so the
+ // health probe must consult this to avoid reporting a healthy management
+ // connection while the Sync stream keeps failing.
+ syncStreamMu sync.RWMutex
+ syncStreamErr error
}
type ExposeRequest struct {
@@ -364,6 +372,8 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
stream, err := c.connectToSyncStream(ctx, serverPubKey, sysInfo)
if err != nil {
log.Debugf("failed to open Management Service stream: %s", err)
+ c.notifyDisconnected(err)
+ c.setSyncStreamDisconnected(err)
if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied {
return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer
}
@@ -372,11 +382,13 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
log.Infof("connected to the Management Service stream")
c.notifyConnected()
+ c.setSyncStreamConnected()
// blocking until error
err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler)
if err != nil {
c.notifyDisconnected(err)
+ c.setSyncStreamDisconnected(err)
if ctx.Err() != nil {
log.Debugf("management connection context has been canceled, this usually indicates shutdown")
return nil
@@ -530,6 +542,13 @@ func (c *GrpcClient) IsHealthy() bool {
log.Warnf("health check returned: %s", err)
return false
}
+
+ if syncErr := c.syncStreamError(); syncErr != nil {
+ c.notifyDisconnected(syncErr)
+ log.Warnf("management transport is up but the Sync stream is unhealthy: %s", syncErr)
+ return false
+ }
+
c.notifyConnected()
return true
}
@@ -771,6 +790,24 @@ func (c *GrpcClient) SyncMeta(sysInfo *system.Info) error {
return err
}
+func (c *GrpcClient) setSyncStreamConnected() {
+ c.syncStreamMu.Lock()
+ defer c.syncStreamMu.Unlock()
+ c.syncStreamErr = nil
+}
+
+func (c *GrpcClient) setSyncStreamDisconnected(err error) {
+ c.syncStreamMu.Lock()
+ defer c.syncStreamMu.Unlock()
+ c.syncStreamErr = err
+}
+
+func (c *GrpcClient) syncStreamError() error {
+ c.syncStreamMu.RLock()
+ defer c.syncStreamMu.RUnlock()
+ return c.syncStreamErr
+}
+
func (c *GrpcClient) notifyDisconnected(err error) {
c.connStateCallbackLock.RLock()
defer c.connStateCallbackLock.RUnlock()
From b434cda0627a01538bf977f89b8e4834d95d2fed Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Mon, 29 Jun 2026 19:16:47 +0900
Subject: [PATCH 54/54] [client] Refresh signal receive liveness when worker
handoff drains (#6594)
---
shared/signal/client/grpc.go | 3 ++
shared/signal/client/watchdog_test.go | 70 +++++++++++++++++++++++++++
2 files changed, 73 insertions(+)
diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go
index 7e8e551b6..a07867263 100644
--- a/shared/signal/client/grpc.go
+++ b/shared/signal/client/grpc.go
@@ -557,6 +557,9 @@ func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) er
if err := c.decryptionWorker.AddMsg(c.ctx, msg); err != nil {
log.Errorf("failed to add message to decryption worker: %v", err)
}
+ // Refresh liveness before clearing the flag so the window between here and
+ // the next Recv does not read a stale timestamp as a dead stream.
+ c.markReceived()
c.receiveHandoffBlocked.Store(false)
}
}
diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go
index eeb9aec30..a8bbafa29 100644
--- a/shared/signal/client/watchdog_test.go
+++ b/shared/signal/client/watchdog_test.go
@@ -2,6 +2,7 @@ package client
import (
"context"
+ "io"
"net"
"testing"
"time"
@@ -106,3 +107,72 @@ func TestReceiveAliveTreatsHandoffBlockAsLiveness(t *testing.T) {
c.markReceived()
require.True(t, c.receiveAlive(), "a freshly received frame must keep the stream alive")
}
+
+// fakeRecvStream feeds the receive loop frames from a channel and reports EOF
+// once the channel is closed. Only Recv is exercised by the loop.
+type fakeRecvStream struct {
+ sigProto.SignalExchange_ConnectStreamClient
+ frames chan *sigProto.EncryptedMessage
+}
+
+func (s *fakeRecvStream) Recv() (*sigProto.EncryptedMessage, error) {
+ msg, ok := <-s.frames
+ if !ok {
+ return nil, io.EOF
+ }
+ return msg, nil
+}
+
+// TestReceiveLoopRefreshesLivenessAfterBlockedHandoff drives the real receive
+// loop into a handoff that blocks past the inactivity threshold, then checks the
+// window after the handoff drains but before the next Recv. The loop must have
+// refreshed the timestamp on unblocking, otherwise that window reads the stale
+// pre-handoff timestamp as a dead stream and the watchdog tears down a healthy
+// connection.
+func TestReceiveLoopRefreshesLivenessAfterBlockedHandoff(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ c := &GrpcClient{ctx: ctx}
+
+ handling := make(chan struct{}, 8)
+ gate := make(chan struct{})
+ decrypt := func(*sigProto.EncryptedMessage) (*sigProto.Message, error) { return &sigProto.Message{}, nil }
+ handler := func(*sigProto.Message) error {
+ handling <- struct{}{}
+ <-gate
+ return nil
+ }
+ c.decryptionWorker = NewWorker(decrypt, handler)
+ workerCtx, workerCancel := context.WithCancel(context.Background())
+ go c.decryptionWorker.Work(workerCtx)
+ t.Cleanup(workerCancel)
+
+ frames := make(chan *sigProto.EncryptedMessage)
+ t.Cleanup(func() { close(frames) })
+ go func() { _ = c.receive(&fakeRecvStream{frames: frames}) }()
+
+ // First frame: the worker drains it and parks in the blocking handler.
+ frames <- &sigProto.EncryptedMessage{}
+ <-handling
+ // Second frame fills the worker's single-slot pool.
+ frames <- &sigProto.EncryptedMessage{}
+ // Third frame: the pool is full, so the loop parks on the handoff.
+ frames <- &sigProto.EncryptedMessage{}
+
+ require.Eventually(t, c.receiveHandoffBlocked.Load, time.Second, time.Millisecond,
+ "receive loop should park on the worker handoff")
+
+ // Simulate the handoff having blocked past the inactivity threshold.
+ c.lastReceived.Store(time.Now().Add(-2 * receiveInactivityThreshold).UnixNano())
+ require.True(t, c.receiveAlive(), "a loop parked on the handoff must stay alive")
+
+ // Drain the worker so the handoff returns and the loop resumes reading.
+ close(gate)
+
+ // Once the handoff clears, the loop is parked on the next Recv with no frame
+ // pending. The stream must still read as alive in that window.
+ require.Eventually(t, func() bool { return !c.receiveHandoffBlocked.Load() }, time.Second, time.Millisecond,
+ "handoff should drain once the worker is released")
+ require.True(t, c.receiveAlive(),
+ "the loop must refresh liveness when the handoff drains, before the next Recv")
+}