[management] Add support for pagination in GetAllPeers call (#8190)

* use libopenapi OpenAPI generator

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* added a handler for v1alpha1/peers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* wire up request validator

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* wired up spec-based validation

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use sync validation; set base url to v1alpha1

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* moved stuff around, extracted runtime libopenapu deps into runtime_tooling

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* testing /peers path params

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* fixed tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* added user schemas and paths

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use api validation in tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* post-merge fixes

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* deleted tmp command used to test validator integration

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* require request body in post/put requests in order to force validation

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* making linter happy

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* switch to api/v1alpha1

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* go mod tidy

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* keep the original order of middleware: metrics, cors, then auth

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* return 422 on validation errors

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* more cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* v1alpha1 spec cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* no need for a double-pointer

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* more linter fixes

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* removed more double pointers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use di to inject api_v0 and api_v1 http routers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* do not re-add middleware on repeated call to ApiHandler

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* support for pagination and filtering in /peers call

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* calling ApiRouter() now also calls ApiV1Router()

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* adding peer filters

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* introduced paginated GetAllPeers call

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* wire up pagination

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* convert pagination state to query scope

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* peer filter wip

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* implemented peer filter

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* test for filtering by os

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* support for sorting of peers

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* support for ordering + tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* more tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* wire up total number of records in paginated GetAllPeers call

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* cleaned up GetAllPeers response

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* pacify linter

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* cleanups and fixes

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* moar cleanups

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* pacify linter

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* remove duplicated test

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* make linter happy

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* moving GetAllPeersPaginated to peer manager, p. 1

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* moving GetAllPeersPaginated to peer manager, p. 2

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* added peer manager tests for GetPeersPaginated

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* order peers in GetAccountPeersPaginated by their ids by default

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* always order by peer_id, filter out proxies

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* fixed broken tests

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* support for filtering of peers by peer_id in GetAllPeers()

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* filtering by peer_ids

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* use lowercase in 'like' queries, drop ordering by ip

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* drop sort_by ip key

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* drop forced_approve filter

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* replace mac with darwin in peer os filter

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* fix peers query parameter: use peer_ids

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* fixed broken peer_test

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

* responded to feedback

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>

---------

Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
This commit is contained in:
dmitri-netbird
2026-10-19 22:19:39 +02:00
committed by GitHub
parent 36fe467aba
commit 8fef487e42
24 changed files with 1571 additions and 230 deletions
+31 -16
View File
@@ -28,8 +28,8 @@ import (
type Manager interface {
GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error)
GetPeerAccountID(ctx context.Context, peerID string) (string, error)
GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error)
GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error)
GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error)
DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error
SetNetworkMapController(networkMapController network_map.Controller)
SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator)
@@ -51,8 +51,7 @@ type managerImpl struct {
permissionsManager permissions.Manager
integratedPeerValidator integrated_validator.IntegratedValidator
accountManager account.Manager
networkMapController network_map.Controller
networkMapController network_map.Controller
}
func NewManager(store store.Store, permissionsManager permissions.Manager) Manager {
@@ -87,19 +86,6 @@ func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID str
return m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
}
func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) {
allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
if !allowed {
return m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
}
return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "")
}
func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) {
return m.store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID)
}
@@ -108,6 +94,35 @@ func (m *managerImpl) GetPeersByGroupIDs(ctx context.Context, accountID string,
return m.store.GetPeersByGroupIDs(ctx, accountID, groupsIDs)
}
func (m *managerImpl) GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error) {
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
return nil, 0, err
}
allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, 0, status.NewPermissionValidationError(err)
}
if allowed {
return m.store.GetAccountPeersPaginated(ctx, store.LockingStrengthNone, accountID, pagination, filters, sorting)
}
settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return nil, 0, fmt.Errorf("failed to get account settings: %w", err)
}
if user.IsRestrictable() && settings.RegularUsersViewBlocked {
return []*peer.Peer{}, 0, nil
}
peers, err := m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
return peers, len(peers), err
}
// GetPeerByTunnelIP delegates to the store's indexed lookup.
func (m *managerImpl) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) {
return m.store.GetPeerByIP(ctx, store.LockingStrengthNone, accountID, ip)
@@ -18,6 +18,7 @@ import (
account "github.com/netbirdio/netbird/management/server/account"
integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
peer "github.com/netbirdio/netbird/management/server/peer"
store "github.com/netbirdio/netbird/management/server/store"
types "github.com/netbirdio/netbird/management/server/types"
gomock "go.uber.org/mock/gomock"
)
@@ -74,21 +75,6 @@ func (mr *MockManagerMockRecorder) DeletePeers(ctx, accountID, peerIDs, userID,
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeers", reflect.TypeOf((*MockManager)(nil).DeletePeers), ctx, accountID, peerIDs, userID, checkConnected)
}
// GetAllPeers mocks base method.
func (m *MockManager) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAllPeers", ctx, accountID, userID)
ret0, _ := ret[0].([]*peer.Peer)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAllPeers indicates an expected call of GetAllPeers.
func (mr *MockManagerMockRecorder) GetAllPeers(ctx, accountID, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPeers", reflect.TypeOf((*MockManager)(nil).GetAllPeers), ctx, accountID, userID)
}
// GetPeer mocks base method.
func (m *MockManager) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) {
m.ctrl.T.Helper()
@@ -180,6 +166,22 @@ func (mr *MockManagerMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupsIDs
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockManager)(nil).GetPeersByGroupIDs), ctx, accountID, groupsIDs)
}
// GetPeersPaginated mocks base method.
func (m *MockManager) GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPeersPaginated", ctx, accountID, userID, pagination, filters, sorting)
ret0, _ := ret[0].([]*peer.Peer)
ret1, _ := ret[1].(int)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// GetPeersPaginated indicates an expected call of GetPeersPaginated.
func (mr *MockManagerMockRecorder) GetPeersPaginated(ctx, accountID, userID, pagination, filters, sorting any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersPaginated", reflect.TypeOf((*MockManager)(nil).GetPeersPaginated), ctx, accountID, userID, pagination, filters, sorting)
}
// SetAccountManager mocks base method.
func (m *MockManager) SetAccountManager(accountManager account.Manager) {
m.ctrl.T.Helper()
@@ -0,0 +1,197 @@
package peers
import (
"context"
"fmt"
"testing"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
func TestManager_GetPeersPaginated_HappyPath(t *testing.T) {
ctrl := gomock.NewController(t)
// GetUserByUserID
// GetAccountPeersPaginated
// GetAccountSettings
// GetUserPeers
mockStore := store.NewMockStore(ctrl)
// ValidateUserPermissions
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(true, context.Background(), nil)
mockStore.EXPECT().GetAccountPeersPaginated(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"),
gomock.Eq(store.PaginationState{Page: 11, PageSize: 111}), gomock.Eq(store.PeerFilters{UserId: "test-user"}),
gomock.Eq(store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})).Times(1).
Return([]*peer.Peer{{ID: "test-peer"}}, 10, nil)
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers)
assert.Equal(t, 10, total)
assert.NoError(t, err)
}
func TestManager_GetPeersPaginated_GetUserWithError(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(nil, fmt.Errorf("boom!"))
_, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, 0, total)
assert.ErrorContains(t, err, "boom!")
}
func TestManager_GetPeersPaginated_NoPermission(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(false, context.Background(), nil)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
Return(&types.Settings{RegularUsersViewBlocked: false}, nil)
mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1).
Return([]*peer.Peer{{ID: "test-peer"}}, nil)
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers)
assert.Equal(t, 1, total)
assert.NoError(t, err)
}
func TestManager_GetPeersPaginated_NoPermission_GetUserPeers_WithError(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(false, context.Background(), nil)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
Return(&types.Settings{RegularUsersViewBlocked: false}, nil)
mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1).
Return(nil, fmt.Errorf("boom!"))
_, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, 0, total)
assert.ErrorContains(t, err, "boom!")
}
func TestManager_GetPeersPaginated_GetAccountSettings_WithError(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(false, context.Background(), nil)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
Return(nil, fmt.Errorf("boom!"))
_, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, 0, total)
assert.ErrorContains(t, err, "boom!")
}
func TestManager_GetPeersPaginated_NoPermission_User_IsRestrictable_ViewUnblocked(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
Role: types.UserRoleUser,
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(false, context.Background(), nil)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
Return(&types.Settings{RegularUsersViewBlocked: false}, nil)
mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1).
Return([]*peer.Peer{{ID: "test-peer"}}, nil)
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers)
assert.Equal(t, 1, total)
assert.NoError(t, err)
}
func TestManager_GetPeersPaginated_NoPermission_User_IsRestrictable_ViewBlocked(t *testing.T) {
ctrl := gomock.NewController(t)
mockStore := store.NewMockStore(ctrl)
permissionsManager := permissions.NewMockManager(ctrl)
m := NewManager(mockStore, permissionsManager)
mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1).
Return(&types.User{
Id: "test-user",
Role: types.UserRoleUser,
}, nil)
permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1).
Return(false, context.Background(), nil)
mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1).
Return(&types.Settings{RegularUsersViewBlocked: true}, nil)
peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user",
store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"},
store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})
assert.Equal(t, make([]*peer.Peer, 0), peers)
assert.Equal(t, 0, total)
assert.NoError(t, err)
}
+1 -1
View File
@@ -165,7 +165,7 @@ func (s *BaseServer) EventStore() activity.Store {
func (s *BaseServer) APIHandler() http.Handler {
_ = CreateNamed(s, "http_v1api", func() http.Handler {
apiv1Router := s.ApiV1Router()
_, err := v1alpha1.NewAPIV1Handler(context.Background(), apiv1Router, s.AccountManager(), s.NetworkMapController(), s.PermissionsManager())
_, err := v1alpha1.NewAPIV1Handler(context.Background(), apiv1Router, s.AccountManager(), s.PeersManager(), s.NetworkMapController(), s.PermissionsManager())
if err != nil {
log.Fatalf("failed to create API handler: %v", err)
}
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/netbirdio/netbird/management/server/permissions"
peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/server/api/v1alpha1/peers"
"github.com/netbirdio/netbird/management/server/api/v1alpha1/users"
)
@@ -20,6 +21,7 @@ func NewAPIV1Handler(
ctx context.Context,
router *mux.Router,
accountManager account.Manager,
peerManager peersmodule.Manager,
networkMapController network_map.Controller,
permissionsManager permissions.Manager) (http.Handler, error) {
@@ -30,7 +32,7 @@ func NewAPIV1Handler(
router.Use(v1validatorMiddleware.Handler)
peersHandler := peers.NewHandler(accountManager, networkMapController, permissionsManager)
peersHandler := peers.NewHandler(accountManager, peerManager, networkMapController, permissionsManager)
_ = peersHandler.WithEndpointsForRouter(router)
usersHandler := users.NewHandler(accountManager)
@@ -6,17 +6,22 @@ import (
"fmt"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/api/v1alpha1/groups"
nbcontext "github.com/netbirdio/netbird/management/server/context"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
"github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status"
@@ -26,12 +31,14 @@ type Handler struct {
accountManager account.Manager
permissionsManager permissions.Manager
networkMapController network_map.Controller
peersManager peers.Manager
}
// NewHandler creates a new peers Handler
func NewHandler(accountManager account.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler {
func NewHandler(accountManager account.Manager, peerManager peers.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler {
return &Handler{
accountManager: accountManager,
peersManager: peerManager,
networkMapController: networkMapController,
permissionsManager: permissionsManager,
}
@@ -210,14 +217,32 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) {
return
}
nameFilter := r.URL.Query().Get("name")
ipFilter := r.URL.Query().Get("ip")
macFilter := r.URL.Query().Get("mac")
pagination, err := paginationFromQuery(r.URL.Query())
if err != nil {
log.WithContext(r.Context()).Errorf("error parsing query: %v", err)
util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w)
return
}
filters, err := filtersFromQuery(r.URL.Query())
if err != nil {
log.WithContext(r.Context()).Errorf("error parsing query: %v", err)
util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w)
return
}
sorting, err := sortingOrderFromQuery(r.URL.Query())
if err != nil {
log.WithContext(r.Context()).Errorf("error parsing query: %v", err)
util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w)
return
}
accountID, userID := userAuth.AccountId, userAuth.UserId
peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter, macFilter)
peers, peerCount, err := h.peersManager.GetPeersPaginated(r.Context(), accountID, userID, pagination, filters, sorting)
if err != nil {
log.WithContext(r.Context()).Errorf("error getting peers: %v", err)
util.WriteError(r.Context(), err, w)
return
}
@@ -232,35 +257,135 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) {
grps, _ := h.accountManager.GetAllGroups(r.Context(), accountID, userID)
grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers))
respBody := make([]*apiv1alpha1.PeerBatch, 0, len(peers))
for _, peer := range peers {
if peer.ProxyMeta.Embedded {
continue
}
respBody = append(respBody, toPeerListItemResponse(peer, grpsInfoMap[peer.ID], dnsDomain, 0))
}
validPeersMap, invalidPeersMap, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
validPeers, invalidPeers, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
if err != nil {
log.WithContext(r.Context()).Errorf("failed to get validated peers: %v", err)
util.WriteError(r.Context(), fmt.Errorf("internal error"), w)
return
}
h.setApprovalRequiredFlag(respBody, validPeersMap, invalidPeersMap)
respBody := apiv1alpha1.PeerBatch{Peers: make([]apiv1alpha1.Peer, 0, len(peers))}
for _, peer := range peers {
_, valid := validPeers[peer.ID]
reason := invalidPeers[peer.ID]
respBody.Peers = append(respBody.Peers, *toSinglePeerResponse(peer, grpsInfoMap[peer.ID], dnsDomain, valid, reason))
}
respBody.Page = pagination.Page
respBody.PageSize = pagination.PageSize
respBody.PeerCount = peerCount
util.WriteJSONObject(r.Context(), w, respBody)
}
func (h *Handler) setApprovalRequiredFlag(respBody []*apiv1alpha1.PeerBatch, validPeersMap map[string]struct{}, invalidPeersMap map[string]string) {
for _, peer := range respBody {
_, ok := validPeersMap[peer.Id]
if !ok {
peer.ApprovalRequired = true
func filtersFromQuery(v url.Values) (store.PeerFilters, error) {
connected, err := parseBool(v.Get("connected"))
if err != nil {
return store.PeerFilters{}, err
}
reason := invalidPeersMap[peer.Id]
peer.DisapprovalReason = &reason
approvalRequired, err := parseBool(v.Get("approval_required"))
if err != nil {
return store.PeerFilters{}, err
}
var isServer *bool
if v.Get("kind") != "" {
isServer = new(v.Get("kind") == "server")
if *isServer && v.Get("user_id") != "" {
return store.PeerFilters{}, fmt.Errorf("incompatible filters: kind == server and a non-empty user_id")
}
}
return store.PeerFilters{
UserId: v.Get("user_id"),
GroupIds: v["group_ids"],
Connected: connected,
ApprovalRequired: approvalRequired,
Os: v["os"],
IP: v.Get("ip"),
MAC: v.Get("mac"),
Name: v.Get("name"),
Hostname: v.Get("hostname"),
IsServer: isServer,
PeersIds: v["peer_ids"],
}, nil
}
func parseBool(maybeBool string) (*bool, error) {
if maybeBool == "" {
return nil, nil //nolint:nilnil
}
parsed, err := strconv.ParseBool(maybeBool)
if err != nil {
return nil, err
}
return new(parsed), nil
}
func paginationFromQuery(v url.Values) (store.PaginationState, error) {
page, err := strconv.Atoi(v.Get("page"))
if err != nil {
return store.PaginationState{}, err
}
pageSize, err := strconv.Atoi(v.Get("page_size"))
if err != nil {
return store.PaginationState{}, err
}
return store.PaginationState{
Page: page,
PageSize: pageSize,
}, nil
}
func sortingOrderFromQuery(v url.Values) (store.PeerSorting, error) {
toret := store.PeerSorting{}
sortby, ok := v["sort_by"]
if !ok {
return toret, nil
}
for i := 0; i < len(sortby); {
// ascending sorting order by default
sortkey := store.PeerSortingKey{Order: store.AscOrderBy}
field, order := parseFieldAndOrder(sortby[i])
switch field {
case "name":
sortkey.Field = store.PeerSortByName
case "last_seen":
sortkey.Field = store.PeerSortByLastSeen
case "version":
sortkey.Field = store.PeerSortByVersion
default:
return toret, fmt.Errorf("unable to parse sorting order '%s'", sortby)
}
switch order {
case "asc", "":
// nop, already set
case "desc":
sortkey.Order = store.DescOrderBy
default:
return store.PeerSorting{}, fmt.Errorf("unable to parse sorting order '%s'", sortby)
}
toret.Fields = append(toret.Fields, sortkey)
i++
}
return toret, nil
}
func parseFieldAndOrder(fieldOrder string) (string, string) {
maybeFieldAndOrder := strings.SplitN(fieldOrder, ",", 2)
if len(maybeFieldAndOrder) == 2 {
return strings.TrimSpace(maybeFieldAndOrder[0]), strings.TrimSpace(maybeFieldAndOrder[1])
}
return strings.TrimSpace(maybeFieldAndOrder[0]), ""
}
func parseIPv6(s *string) (netip.Addr, error) {
@@ -337,61 +462,6 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinim
return apiPeer
}
func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinimum, dnsDomain string, accessiblePeersCount int) *apiv1alpha1.PeerBatch {
osVersion := peer.Meta.OSVersion
if osVersion == "" {
osVersion = peer.Meta.Core
}
return &apiv1alpha1.PeerBatch{
CreatedAt: peer.CreatedAt,
AccessiblePeersCount: accessiblePeersCount,
Peer: apiv1alpha1.Peer{
PeerMinimum: apiv1alpha1.PeerMinimum{
Id: peer.ID,
Name: peer.Name,
},
Ip: peer.IP.String(),
Ipv6: peerIPv6String(peer),
ConnectionIp: peer.Location.ConnectionIP.String(),
Connected: peer.Status.Connected,
LastSeen: peer.Status.LastSeen,
Os: fmt.Sprintf("%s %s", peer.Meta.OS, osVersion),
KernelVersion: peer.Meta.KernelVersion,
GeonameId: int(peer.Location.GeoNameID),
Version: peer.Meta.WtVersion,
Groups: groupsInfo,
SshEnabled: peer.SSHEnabled,
Hostname: peer.Meta.Hostname,
UserId: peer.UserID,
UiVersion: peer.Meta.UIVersion,
DnsLabel: fqdn(peer, dnsDomain),
ExtraDnsLabels: fqdnList(peer.ExtraDNSLabels, dnsDomain),
LoginExpirationEnabled: peer.LoginExpirationEnabled,
LastLogin: peer.GetLastLogin(),
LoginExpired: peer.Status.LoginExpired,
CountryCode: apiv1alpha1.CountryCode(peer.Location.CountryCode),
CityName: apiv1alpha1.CityName(peer.Location.CityName),
SerialNumber: peer.Meta.SystemSerialNumber,
InactivityExpirationEnabled: peer.InactivityExpirationEnabled,
Ephemeral: peer.Ephemeral,
LocalFlags: &apiv1alpha1.PeerLocalFlags{
BlockInbound: &peer.Meta.Flags.BlockInbound,
BlockLanAccess: &peer.Meta.Flags.BlockLANAccess,
DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes,
DisableDns: &peer.Meta.Flags.DisableDNS,
DisableFirewall: &peer.Meta.Flags.DisableFirewall,
DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes,
LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled,
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
},
},
}
}
func fqdn(peer *nbpeer.Peer, dnsDomain string) string {
fqdn := peer.FQDN(dnsDomain)
if fqdn == "" {
@@ -0,0 +1,191 @@
package peers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers"
nbcontext "github.com/netbirdio/netbird/management/server/context"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/auth"
"github.com/netbirdio/netbird/shared/management/http/apiv1alpha1"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)
func TestGetPeersPagination(t *testing.T) {
peer := &nbpeer.Peer{
ID: testPeerID,
Status: &nbpeer.PeerStatus{Connected: true},
Meta: nbpeer.PeerSystemMeta{},
}
tt := []struct {
name string
expectedStatus int
requestPath string
setExpectations func(p *Handler)
}{
{
name: "GetPeers default paging, filtering, and sorting",
requestPath: "/peers",
expectedStatus: http.StatusOK,
setExpectations: func(p *Handler) {
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}), gomock.Any(), gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
},
},
{
name: "GetPeers paging",
requestPath: "/peers?page=10&page_size=250",
expectedStatus: http.StatusOK,
setExpectations: func(p *Handler) {
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(store.PaginationState{Page: 10, PageSize: 250}),
gomock.Any(), gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
},
},
{
name: "GetPeers paging with invalid page",
requestPath: "/peers?page=aaa&page_size=250",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers paging with invalid page size",
requestPath: "/peers?page=10&page_size=aaa",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers filtering all valid params",
requestPath: "/peers?user_id=test-user&group_ids=1&group_ids=2&group_ids=3&group_ids=4&peer_ids=peer-1&peer_ids=peer-2&connected=true&approval_required=false&os=linux&os=windows&os=darwin&os=android&os=ios&os=js&ip=10.10.10.10&mac=00:11:22:33:44:55&name=test&hostname=test-host&kind=user_device",
expectedStatus: http.StatusOK,
setExpectations: func(p *Handler) {
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}),
gomock.Eq(store.PeerFilters{
UserId: "test-user",
GroupIds: []string{"1", "2", "3", "4"},
Connected: new(true),
ApprovalRequired: new(false),
Os: []string{"linux", "windows", "darwin", "android", "ios", "js"},
IP: "10.10.10.10",
MAC: "00:11:22:33:44:55",
Name: "test",
Hostname: "test-host",
IsServer: new(false),
PeersIds: []string{"peer-1", "peer-2"},
}),
gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
},
},
{
name: "GetPeers filtering with kind server",
requestPath: "/peers?kind=server",
expectedStatus: http.StatusOK,
setExpectations: func(p *Handler) {
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}),
gomock.Eq(store.PeerFilters{IsServer: new(true)}),
gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
},
},
{
name: "GetPeers filtering with invalid connected value",
requestPath: "/peers?connected=aaa",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers filtering with invalid approval_required value",
requestPath: "/peers?approval_required=aaa",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers filtering with invalid os value",
requestPath: "/peers?os=nosuchos",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers filtering with invalid kind value",
requestPath: "/peers?kind=nosuchkind",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers filtering with conflicting user_id and kind",
requestPath: "/peers?user_id=test-user&kind=server",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers sorting preserves field ordering",
requestPath: "/peers?sort_by=last_seen&sort_by=name&sort_by=version",
expectedStatus: http.StatusOK,
setExpectations: func(p *Handler) {
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(),
gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}),
gomock.Any(),
gomock.Eq(store.PeerSorting{
Fields: []store.PeerSortingKey{
{Field: store.PeerSortByLastSeen, Order: store.AscOrderBy},
{Field: store.PeerSortByName, Order: store.AscOrderBy},
{Field: store.PeerSortByVersion, Order: store.AscOrderBy},
},
})).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes()
},
},
{
name: "GetPeers sorting with invalid field",
requestPath: "/peers?sort_by=nosuchfield,asc",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers sorting with invalid field",
requestPath: "/peers?sort_by=nosuchfield,asc",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
{
name: "GetPeers sorting with invalid sorting order",
requestPath: "/peers?sort_by=name,nosuchorder&sort_by=version",
expectedStatus: http.StatusUnprocessableEntity,
setExpectations: func(p *Handler) {},
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
p := initTestMetaData(t, peer)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tc.requestPath, nil)
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: "admin_user",
Domain: "hotmail.com",
AccountId: "test_id",
})
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
assert.NoError(t, err)
router := mux.NewRouter()
router.Use(v1validator.Handler)
router = p.WithEndpointsForRouter(router)
tc.setExpectations(p)
router.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
assert.Equal(t, tc.expectedStatus, recorder.Code)
})
}
}
@@ -18,6 +18,7 @@ import (
"golang.org/x/exp/maps"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers"
nbcontext "github.com/netbirdio/netbird/management/server/context"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions"
@@ -127,7 +128,10 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
}).
AnyTimes()
peerManager := peersmodule.NewMockManager(ctrl2)
return &Handler{
peersManager: peerManager,
accountManager: &mock_server.MockAccountManager{
UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) {
var p *nbpeer.Peer
@@ -173,8 +177,8 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
return nil, fmt.Errorf("user not found")
}
},
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
return peers, nil
GetPeersFunc: func(_ context.Context, accountID, userID string, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
return peers, len(peers), nil
},
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
peersID := make([]string, len(peers))
@@ -220,9 +224,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
}
}
// Tests the GetAllPeers endpoint reachable in the route /api/peers
// Use the metadata generated by initTestMetaData() to check for values
func TestGetPeers(t *testing.T) {
func TestGetPeer(t *testing.T) {
peer := &nbpeer.Peer{
ID: testPeerID,
@@ -262,23 +264,13 @@ func TestGetPeers(t *testing.T) {
requestPath string
contentType string
requestBody io.Reader
expectedArray bool
expectedPeer *nbpeer.Peer
}{
{
name: "GetPeersMetaData",
requestType: http.MethodGet,
requestPath: "/peers",
expectedStatus: http.StatusOK,
expectedArray: true,
expectedPeer: peer,
},
{
name: "GetPeer with update channel",
requestType: http.MethodGet,
requestPath: "/peers/" + testPeerID,
expectedStatus: http.StatusOK,
expectedArray: false,
expectedPeer: peer,
},
{
@@ -287,7 +279,6 @@ func TestGetPeers(t *testing.T) {
requestPath: "/peers/" + testPeerID,
contentType: "application/json",
expectedStatus: http.StatusOK,
expectedArray: false,
requestBody: bytes.NewBufferString("{\"login_expiration_enabled\":true,\"name\":\"New Name\",\"ssh_enabled\":true, \"inactivity_expiration_enabled\":true}"),
expectedPeer: expectedUpdatedPeer,
},
@@ -330,32 +321,123 @@ func TestGetPeers(t *testing.T) {
t.Fatalf("I don't know what I expected; %v", err)
}
var got *apiv1alpha1.Peer
if tc.expectedArray {
respBody := []*apiv1alpha1.Peer{}
err = json.Unmarshal(content, &respBody)
if err != nil {
t.Fatalf("Sent content is not in correct json format; %v", err)
}
// hardcode this check for now as we only have two peers in this suite
assert.Equal(t, len(respBody), 2)
for _, peer := range respBody {
if peer.Id == testPeerID {
got = peer
}
}
} else {
got = &apiv1alpha1.Peer{}
err = json.Unmarshal(content, got)
if err != nil {
t.Fatalf("Sent content is not in correct json format; %v", err)
}
var got apiv1alpha1.Peer
err = json.Unmarshal(content, &got)
if err != nil {
t.Fatalf("Sent content is not in correct json format; %v", err)
}
t.Log(got)
assert.Equal(t, tc.expectedPeer.Name, got.Name)
assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version)
assert.Equal(t, tc.expectedPeer.IP.String(), got.Ip)
assert.Equal(t, "OS core", got.Os)
assert.Equal(t, tc.expectedPeer.LoginExpirationEnabled, got.LoginExpirationEnabled)
assert.Equal(t, tc.expectedPeer.SSHEnabled, got.SshEnabled)
assert.Equal(t, tc.expectedPeer.Status.Connected, got.Connected)
assert.Equal(t, tc.expectedPeer.Meta.SystemSerialNumber, got.SerialNumber)
})
}
}
func TestGetPeers(t *testing.T) {
peer := &nbpeer.Peer{
ID: testPeerID,
Key: "key",
IP: netip.MustParseAddr("100.64.0.1"),
IPv6: netip.MustParseAddr("fd00::1"),
Status: &nbpeer.PeerStatus{Connected: true},
Name: "PeerName",
LoginExpirationEnabled: false,
Meta: nbpeer.PeerSystemMeta{
Hostname: "hostname",
GoOS: "GoOS",
Kernel: "kernel",
Core: "core",
Platform: "platform",
OS: "OS",
WtVersion: "development",
SystemSerialNumber: "C02XJ0J0JGH7",
},
}
peer1 := peer.Copy()
peer1.ID = noUpdateChannelTestPeerID
tt := []struct {
name string
expectedStatus int
requestType string
requestPath string
contentType string
expectedPeer *nbpeer.Peer
}{
{
name: "GetPeersMetaData",
requestType: http.MethodGet,
requestPath: "/peers",
expectedStatus: http.StatusOK,
expectedPeer: peer,
},
}
p := initTestMetaData(t, peer, peer1)
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(tc.requestType, tc.requestPath, nil)
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
UserId: "admin_user",
Domain: "hotmail.com",
AccountId: "test_id",
})
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware()
assert.NoError(t, err)
router := mux.NewRouter()
router.Use(v1validator.Handler)
router = p.WithEndpointsForRouter(router)
p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return([]*nbpeer.Peer{peer, peer1}, 2, nil)
router.ServeHTTP(recorder, req)
res := recorder.Result()
defer res.Body.Close()
if status := recorder.Code; status != tc.expectedStatus {
t.Fatalf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
content, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("I don't know what I expected; %v", err)
}
var got *apiv1alpha1.Peer
respBody := apiv1alpha1.PeerBatch{}
err = json.Unmarshal(content, &respBody)
if err != nil {
t.Fatalf("Sent content is not in correct json format; %v", err)
}
assert.Equal(t, respBody.Page, 1)
assert.Equal(t, respBody.PageSize, 100)
assert.Equal(t, respBody.PeerCount, 2)
assert.Equal(t, len(respBody.Peers), 2)
for _, peer := range respBody.Peers {
if peer.Id == testPeerID {
got = &peer
}
}
assert.Equal(t, tc.expectedPeer.Name, got.Name)
assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version)
@@ -78,8 +78,8 @@ func initGroupTestData(initGroups ...*types.Group) *handler {
return nil, status.Errorf(status.NotFound, "unknown group name")
},
GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
return maps.Values(TestPeers), nil
GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
return maps.Values(TestPeers), len(maps.Values(TestPeers)), nil
},
DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error {
if groupID == "linked-grp" {
@@ -173,8 +173,8 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
return nil, fmt.Errorf("user not found")
}
},
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
return peers, nil
GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) {
return peers, len(peers), nil
},
GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) {
peersID := make([]string, len(peers))
@@ -39,7 +39,7 @@ type MockAccountManager struct {
GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error)
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, macFilter string) ([]*nbpeer.Peer, error)
GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, 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, []*nmdata.PostureChecks, int64, error)
@@ -820,7 +820,8 @@ func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, user
// GetPeers mocks GetPeers of the AccountManager interface
func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
if am.GetPeersFunc != nil {
return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter)
peers, _, err := am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter)
return peers, err
}
return nil, status.Errorf(codes.Unimplemented, "method GetPeers is not implemented")
}
+3 -1
View File
@@ -73,7 +73,9 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
return []*nbpeer.Peer{}, nil
}
return am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
peers, err := am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID)
return peers, err
}
// MarkPeerConnected marks a peer as connected with optimistic-locked
-1
View File
@@ -730,7 +730,6 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) {
return
}
assert.NotNil(t, peers)
assert.Len(t, peers, testCase.expectedPeerCount)
})
+157 -2
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net"
"net/netip"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -494,7 +495,6 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking
return &peer, nil
}
// GetAccountPeers retrieves peers for an account.
func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) {
var peers []*nbpeer.Peer
tx := s.db
@@ -514,7 +514,6 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
if macFilter != "" {
query = query.Where("meta_network_addresses LIKE ?", "%"+macFilter+"%")
}
if err := query.Find(&peers).Error; err != nil {
log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err)
return nil, status.Errorf(status.Internal, "failed to get peers from store")
@@ -523,6 +522,162 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre
return peers, nil
}
// GetAccountPeers retrieves peers for an account.
func (s *SqlStore) GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*nbpeer.Peer, int, error) {
// main query
var peers []*nbpeer.Peer
{
tx := s.db
if lockStrength != LockingStrengthNone {
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
}
query := tx.Where("peers.account_id=?", accountID)
query = query.Where("(peers.proxy_meta_embedded=false or peers.proxy_meta_embedded is null)")
query = filters.AsConditions(query)
query = pagination.AsScope(query)
query = sorting.AsOrderBy(ctx, query)
if err := query.Distinct().Find(&peers).Error; err != nil {
log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err)
return nil, 0, status.Errorf(status.Internal, "failed to get peers from store")
}
}
// total number of records
var peerCount int64
{
tx := s.db
if lockStrength != LockingStrengthNone {
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
}
query := tx.Where("peers.account_id=?", accountID)
query = query.Where("(peers.proxy_meta_embedded=false or peers.proxy_meta_embedded is null)")
query = filters.AsConditions(query)
if err := query.Table("peers").Select("count(distinct(peers.id))").Count(&peerCount).Error; err != nil {
log.WithContext(ctx).Errorf("failed to get total number of peers from the store: %s", err)
return nil, 0, status.Errorf(status.Internal, "failed to get total number of peers from store")
}
}
return peers, int(peerCount), nil
}
type PaginationState struct {
Page int
PageSize int
}
func (p *PaginationState) AsScope(db *gorm.DB) *gorm.DB {
// empty pagination state
if p.PageSize == 0 {
return db
}
offset := max(p.Page-1, 0) * p.PageSize
return db.Offset(offset).Limit(p.PageSize)
}
type PeerFilters struct {
UserId string
GroupIds []string
Connected *bool
ApprovalRequired *bool
Os []string
IP string
MAC string
Hostname string
Name string
IsServer *bool
PeersIds []string
}
func (f *PeerFilters) AsConditions(db *gorm.DB) *gorm.DB {
if len(f.GroupIds) > 0 {
db = db.Joins("join group_peers on peers.id=group_peers.peer_id and peers.account_id=group_peers.account_id").Where("group_peers.group_id in (?)", f.GroupIds)
}
if f.UserId != "" {
db = db.Where("user_id=?", f.UserId)
}
if f.Connected != nil {
db = db.Where("peer_status_connected=?", *f.Connected)
}
if f.ApprovalRequired != nil {
db = db.Where("peer_status_requires_approval=?", *f.ApprovalRequired)
}
if len(f.Os) > 0 {
db = db.Where("meta_go_os in (?)", f.Os)
}
if f.IP != "" {
db = db.Where("ip like ? or lower(ipv6) like ?", fmt.Sprintf("%%%s%%", f.IP), fmt.Sprintf("%%%s%%", strings.ToLower(f.IP)))
}
if f.MAC != "" {
db = db.Where("lower(meta_network_addresses) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.MAC)))
}
if f.Hostname != "" {
db = db.Where("lower(meta_hostname) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.Hostname)))
}
if f.Name != "" {
db = db.Where("lower(name) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.Name)))
}
if f.IsServer != nil {
if *f.IsServer {
db = db.Where("(user_id='' or user_id is null)")
} else {
db = db.Where("user_id<>'' and user_id is not null")
}
}
if len(f.PeersIds) > 0 {
db = db.Where("id in (?)", f.PeersIds)
}
return db
}
type PeerSortByField int
const (
PeerSortByName PeerSortByField = iota
PeerSortByLastSeen
PeerSortByVersion
)
type OrderBy int
const (
AscOrderBy OrderBy = iota
DescOrderBy
)
type PeerSortingKey struct {
Field PeerSortByField
Order OrderBy
}
type PeerSorting struct {
Fields []PeerSortingKey
}
func (s *PeerSorting) AsOrderBy(ctx context.Context, db *gorm.DB) *gorm.DB {
order := make([]clause.OrderByColumn, 0)
for _, key := range s.Fields {
switch key.Field {
case PeerSortByName:
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.name"}, Desc: key.Order == DescOrderBy})
case PeerSortByLastSeen:
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.peer_status_last_seen"}, Desc: key.Order == DescOrderBy})
case PeerSortByVersion:
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "meta_wt_version"}, Desc: key.Order == DescOrderBy})
default:
log.WithContext(ctx).Warnf("invalid order by column '%v', skipping", key.Field)
}
}
// always order/break ties by id asc order
order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.id"}, Desc: false})
return db.Order(clause.OrderBy{Columns: order})
}
// GetUserPeers retrieves peers for a user.
func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) {
tx := s.db
@@ -512,7 +512,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter, "")
peers, _, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, tt.accountID, PaginationState{}, PeerFilters{Hostname: tt.nameFilter, IP: tt.ipFilter}, PeerSorting{})
require.NoError(t, err)
require.Len(t, peers, tt.expectedCount)
})
@@ -555,7 +555,7 @@ func TestSqlStore_GetAccountPeers_FilterByMac(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
peers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", tt.macFilter)
peers, _, err := store.GetAccountPeersPaginated(ctx, LockingStrengthNone, accountID, PaginationState{}, PeerFilters{MAC: tt.macFilter}, PeerSorting{})
require.NoError(t, err)
require.Len(t, peers, tt.expectedCount)
})
@@ -0,0 +1,250 @@
package store
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestSqlStore_FilterPeers(t *testing.T) {
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
var tests = []struct {
description string
filters PeerFilters
expectedLength int
expectedPeers []string
}{
{
description: "filter by user_id",
filters: PeerFilters{UserId: "user-id-1"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by connected status",
filters: PeerFilters{Connected: new(true)},
expectedLength: 2,
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
},
{
description: "filter by approval required status",
filters: PeerFilters{ApprovalRequired: new(true)},
expectedLength: 2,
expectedPeers: []string{"peer-2-name", "peer-3-name"},
},
{
description: "filter by ipv4",
filters: PeerFilters{IP: "10.10.10"},
expectedLength: 2,
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
},
{
description: "filter by ipv6",
filters: PeerFilters{IP: "ba80:6aa5:89f1:44d7:8701:869"},
expectedLength: 2,
expectedPeers: []string{"peer-2-name", "peer-3-name"},
},
{
description: "filter by ipv6 (uppercase)",
filters: PeerFilters{IP: "BA80:6AA5:89f1:44d7:8701:869"},
expectedLength: 2,
expectedPeers: []string{"peer-2-name", "peer-3-name"},
},
{
description: "filter by mac",
filters: PeerFilters{MAC: ":15:5d:24:0c"},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
},
{
description: "filter by mac (uppercase)",
filters: PeerFilters{MAC: ":15:5D:24:0C"},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
},
{
description: "filter by kind (server)",
filters: PeerFilters{IsServer: new(true), Os: []string{"linux"}},
expectedLength: 3,
expectedPeers: []string{"peer-4-name", "peer-5-name", "peer-6-name"},
},
{
description: "filter by kind (user_device)",
filters: PeerFilters{IsServer: new(false)},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"},
},
{
description: "filter by group_ids",
filters: PeerFilters{GroupIds: []string{"group-one-resource-id", "group-two-resources-id"}},
expectedLength: 2,
expectedPeers: []string{"Peer-1-name", "peer-2-name"},
},
{
description: "filter by os",
filters: PeerFilters{Os: []string{"linux", "ios"}},
expectedLength: 5,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-4-name", "peer-5-name", "peer-6-name"},
},
{
description: "filter by peer id",
filters: PeerFilters{PeersIds: []string{"peer-id-one", "peer-id-five", "peer-id-three"}},
expectedLength: 3,
expectedPeers: []string{"Peer-1-name", "peer-3-name", "peer-5-name"},
},
{
description: "filter by name",
filters: PeerFilters{Name: "peer-1"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by name (upper-case)",
filters: PeerFilters{Name: "PEER-1"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by hostname",
filters: PeerFilters{Hostname: "one-name.local"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
{
description: "filter by hostname (upper-case)",
filters: PeerFilters{Hostname: "ONE-NAME.LOCAL"},
expectedLength: 1,
expectedPeers: []string{"Peer-1-name"},
},
}
for _, tt := range tests {
t.Run(tt.description+" "+engine, func(t *testing.T) {
t.Helper()
peers, count, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
PaginationState{}, tt.filters, PeerSorting{})
require.NoError(t, err)
require.Len(t, peers, tt.expectedLength)
require.Equal(t, tt.expectedLength, count)
actualPeerNames := make([]string, 0, len(peers))
for _, p := range peers {
actualPeerNames = append(actualPeerNames, p.Name)
}
require.ElementsMatch(t, actualPeerNames, tt.expectedPeers)
})
}
})
}
func TestSqlStore_PeersPaging(t *testing.T) {
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
var tests = []struct {
description string
page int
pageSize int
expectedLength int
expectedCount int
expectedPeers []string
}{
{
description: "page 1, size 4",
page: 1,
pageSize: 4,
expectedLength: 4,
expectedCount: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-4-name"},
},
{
description: "page 2, size 4",
page: 2,
pageSize: 4,
expectedLength: 2,
expectedCount: 6,
expectedPeers: []string{"peer-5-name", "peer-6-name"},
},
}
for _, tt := range tests {
t.Run(tt.description+" "+engine, func(t *testing.T) {
t.Helper()
peers, count, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
PaginationState{Page: tt.page, PageSize: tt.pageSize}, PeerFilters{}, PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName}}})
require.NoError(t, err)
require.Len(t, peers, tt.expectedLength)
require.Equal(t, tt.expectedCount, count)
actualPeerNames := make([]string, 0, len(peers))
for _, p := range peers {
actualPeerNames = append(actualPeerNames, p.Name)
}
require.ElementsMatch(t, actualPeerNames, tt.expectedPeers)
})
}
})
}
func TestSqlStore_PeersSorting(t *testing.T) {
runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) {
var tests = []struct {
description string
sorting PeerSorting
expectedLength int
expectedPeers []string
}{
{
description: "default order (by id ascending)",
sorting: PeerSorting{Fields: []PeerSortingKey{}},
expectedLength: 6,
expectedPeers: []string{"peer-5-name", "peer-4-name", "Peer-1-name", "peer-6-name", "peer-3-name", "peer-2-name"},
},
{
description: "by name ascending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName}}},
expectedLength: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-4-name", "peer-5-name", "peer-6-name"},
},
{
description: "by name descending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName, Order: DescOrderBy}}},
expectedLength: 6,
expectedPeers: []string{"peer-6-name", "peer-5-name", "peer-4-name", "peer-3-name", "peer-2-name", "Peer-1-name"},
},
{
description: "by last_seen ascending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByLastSeen}}},
expectedLength: 6,
expectedPeers: []string{"peer-6-name", "peer-4-name", "peer-5-name", "peer-3-name", "peer-2-name", "Peer-1-name"},
},
{
description: "by last_seen descending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByLastSeen, Order: DescOrderBy}}},
expectedLength: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-5-name", "peer-4-name", "peer-6-name"},
},
{
description: "by version ascending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByVersion}}},
expectedLength: 6,
expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-4-name", "peer-3-name", "peer-6-name", "peer-5-name"},
},
{
description: "by version descending",
sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByVersion, Order: DescOrderBy}}},
expectedLength: 6,
expectedPeers: []string{"peer-5-name", "peer-6-name", "peer-3-name", "peer-4-name", "peer-2-name", "Peer-1-name"},
},
}
for _, tt := range tests {
t.Run(tt.description+" "+engine, func(t *testing.T) {
t.Helper()
peers, _, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1",
PaginationState{}, PeerFilters{}, tt.sorting)
require.NoError(t, err)
require.Len(t, peers, tt.expectedLength)
actualPeerNames := make([]string, 0, len(peers))
for _, p := range peers {
actualPeerNames = append(actualPeerNames, p.Name)
}
require.Equal(t, tt.expectedPeers, actualPeerNames)
})
}
})
}
+16
View File
@@ -42,6 +42,22 @@ func runTestForAllEngines(t *testing.T, testDataFile string, f func(t *testing.T
}
}
func runTestForAllEnginesNoSqliteSeed(t *testing.T, testDataFile string, f func(t *testing.T, engine string, store Store)) {
t.Helper()
for _, engine := range supportedEngines {
if os.Getenv("NETBIRD_STORE_ENGINE") != "" && os.Getenv("NETBIRD_STORE_ENGINE") != string(engine) {
continue
}
t.Setenv("NETBIRD_STORE_ENGINE", string(engine))
store, cleanUp, err := NewTestStore(context.Background(), testDataFile, t.TempDir())
assert.NoError(t, err, "engine: ", string(engine))
t.Cleanup(cleanUp)
assert.NoError(t, err)
f(t, string(engine), store)
os.Unsetenv("NETBIRD_STORE_ENGINE")
}
}
func Test_NewStore(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("The SQLite store is not properly supported by Windows yet")
+100 -17
View File
@@ -23,6 +23,7 @@ import (
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
@@ -161,6 +162,7 @@ type Store interface {
AddPeerToAccount(ctx context.Context, peer *nbpeer.Peer) error
GetPeerByPeerPubKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (*nbpeer.Peer, error)
GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error)
GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*nbpeer.Peer, int, error)
GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error)
GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) (*nbpeer.Peer, error)
GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*nbpeer.Peer, error)
@@ -723,7 +725,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) (
}
if filename != "" {
err = LoadSQL(conn.DB(nil), filename)
err = LoadSQL(conn.DB(nil), filename, translaterForSql(kind))
if err != nil {
_ = conn.Close()
return nil, nil, fmt.Errorf("failed to load SQL file: %v", err)
@@ -747,7 +749,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) (
maxRetries := 2
for i := 0; i < maxRetries; i++ {
sqlStore, cleanup, err = getSqlStoreEngine(ctx, store, kind)
sqlStore, cleanup, err = getSqlStoreEngine(ctx, store, kind, dataDir)
if err == nil {
return sqlStore, cleanup, nil
}
@@ -759,6 +761,59 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) (
return nil, nil, fmt.Errorf("failed to create test store after %d attempts: %v", maxRetries, err)
}
func NewTestStore(ctx context.Context, filename string, dataDir string) (Store, func(), error) {
kind := getStoreEngineFromEnv()
if kind == "" {
kind = types.SqliteStoreEngine
}
var sqlStore *SqlStore
var cleanup func()
var err error
maxRetries := 2
for i := 0; i < maxRetries; i++ {
sqlStore, cleanup, err = getSqlStoreEngine(ctx, nil, kind, dataDir)
if err == nil {
break
}
if i < maxRetries-1 {
time.Sleep(100 * time.Millisecond)
}
}
if err != nil {
return nil, nil, fmt.Errorf("failed to create test store after %d attempts: %v", maxRetries, err)
}
if filename != "" {
err = LoadSQL(sqlStore.db, filename, translaterForSql(kind))
if err != nil {
_ = sqlStore.Close(ctx)
return nil, nil, fmt.Errorf("failed to load SQL file: %v", err)
}
}
err = addAllGroupToAccount(ctx, sqlStore)
if err != nil {
_ = sqlStore.Close(ctx)
return nil, nil, fmt.Errorf("failed to add all group to account: %v", err)
}
return sqlStore, cleanup, nil
}
// use to translate double-quote escaped reserved names (ansi)
// to mysql tick-escaped ones
func translaterForSql(kind types.Engine) *strings.Replacer {
if kind != types.MysqlStoreEngine {
return strings.NewReplacer()
}
return strings.NewReplacer(
"\"key\"", "`key`",
"\"groups\"", "`groups`",
)
}
func addAllGroupToAccount(ctx context.Context, store Store) error {
allAccounts := store.GetAllAccounts(ctx)
for _, account := range allAccounts {
@@ -782,8 +837,8 @@ func addAllGroupToAccount(ctx context.Context, store Store) error {
return nil
}
func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (Store, func(), error) {
store := sqliteStore
func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.Engine, dataDir string) (*SqlStore, func(), error) {
var store *SqlStore
var cleanup func()
var err error
switch kind {
@@ -795,6 +850,15 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En
cleanup = func() {
// sqlite doesn't need to be cleaned up
}
store = sqliteStore
var conn *db.Conn
if store == nil {
conn, err = db.OpenSqliteFile(ctx, dataDir, db.SqliteFileName)
if err != nil {
return nil, nil, fmt.Errorf("failed to create test store: %v", err)
}
store, err = NewSqlStore(ctx, conn, nil, false)
}
}
if err != nil {
return nil, cleanup, fmt.Errorf("failed to create test store: %v", err)
@@ -803,7 +867,7 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En
closeConnection := func() {
cleanup()
store.Close(ctx)
if store != sqliteStore {
if sqliteStore != nil && store != sqliteStore {
// The sqlite store only seeded the engine under test; without this
// every test leaks its connection and the opener goroutines.
sqliteStore.Close(ctx)
@@ -813,7 +877,7 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En
return store, closeConnection, nil
}
func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) {
func newReusedPostgresStore(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (*SqlStore, func(), error) {
dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy)
if !ok || dsn == "" {
var err error
@@ -846,16 +910,25 @@ func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Eng
return nil, nil, err
}
store, err = newPostgresqlStoreFromSqlStore(ctx, store, dsn, nil, true)
if err != nil {
cleanup()
return nil, nil, err
var store *SqlStore
if sqliteStore != nil {
store, err = newPostgresqlStoreFromSqlStore(ctx, sqliteStore, dsn, nil, true)
if err != nil {
cleanup()
return nil, nil, err
}
} else {
store, err = NewPostgresqlStoreForTests(ctx, dsn, nil, false)
if err != nil {
cleanup()
return nil, nil, err
}
}
return store, cleanup, nil
}
func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) {
func newReusedMysqlStore(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (*SqlStore, func(), error) {
dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy)
if !ok || dsn == "" {
var err error
@@ -900,10 +973,19 @@ func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine
return nil, nil, err
}
store, err = newMysqlStoreFromSqlStore(ctx, store, dsn, nil, true)
if err != nil {
cleanup()
return nil, nil, err
var store *SqlStore
if sqliteStore != nil {
store, err = newMysqlStoreFromSqlStore(ctx, sqliteStore, dsn, nil, true)
if err != nil {
cleanup()
return nil, nil, err
}
} else {
store, err = NewMysqlStore(ctx, dsn, nil, false)
if err != nil {
cleanup()
return nil, nil, err
}
}
return store, cleanup, nil
@@ -1135,6 +1217,7 @@ func createRandomDB(dsn string, admin *gorm.DB, engine types.Engine, template st
dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{
SkipDefaultTransaction: true,
PrepareStmt: false,
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Errorf("failed to connect for dropping database %s: %v", dbName, err)
@@ -1205,7 +1288,7 @@ func replaceDBName(dsn, newDBName string) string {
return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`)
}
func LoadSQL(db *gorm.DB, filepath string) error {
func LoadSQL(db *gorm.DB, filepath string, translator *strings.Replacer) error {
sqlContent, err := os.ReadFile(filepath)
if err != nil {
return err
@@ -1214,7 +1297,7 @@ func LoadSQL(db *gorm.DB, filepath string) error {
queries := strings.Split(string(sqlContent), ";")
for _, query := range queries {
query = strings.TrimSpace(query)
query = translator.Replace(strings.TrimSpace(query))
if query != "" {
err := db.Exec(query).Error
if err != nil {
+16
View File
@@ -1344,6 +1344,22 @@ func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, n
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter)
}
// GetAccountPeersPaginated mocks base method.
func (m *MockStore) GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*peer.Peer, int, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAccountPeersPaginated", ctx, lockStrength, accountID, pagination, filters, sorting)
ret0, _ := ret[0].([]*peer.Peer)
ret1, _ := ret[1].(int)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// GetAccountPeersPaginated indicates an expected call of GetAccountPeersPaginated.
func (mr *MockStoreMockRecorder) GetAccountPeersPaginated(ctx, lockStrength, accountID, pagination, filters, sorting any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeersPaginated", reflect.TypeOf((*MockStore)(nil).GetAccountPeersPaginated), ctx, lockStrength, accountID, pagination, filters, sorting)
}
// GetAccountPeersWithExpiration mocks base method.
func (m *MockStore) GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*peer.Peer, error) {
m.ctrl.T.Helper()
+112
View File
@@ -0,0 +1,112 @@
insert into accounts (id, domain, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups,
settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled)
VALUES('account-1','account-1-test.com','network-1','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]',
true, 86400000000000, false,
86400000000000, null, '["group-one-resource-id"]', false,
false, 'disabled', false, false);
insert into accounts (id, domain,network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups,
settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled,
settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled,
settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled)
VALUES('account-2','account-2-test.com','network-2','{"IP":"110.0.0.0","Mask":"//8AAA=="}','{"IP":"fddf:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',2,null,
true, 86400000000000, false,
86400000000000, null, '["group-two-resources-id"]', false,
false, 'disabled', false, false);
insert into "groups" (id, account_id, name, resources, public_id) VALUES('group-one-resource-id','account-1','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public');
insert into "groups" (id, account_id, name, resources, public_id) VALUES('group-two-resources-id','account-1','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','group-two-resources-id-public');
insert into "groups" (id, account_id, name, resources, public_id) VALUES('group-no-resources-id','account-1','group-3-name', null,'group-no-resources-id-public');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-one','group-one-resource-id');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-one','group-two-resources-id');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-two','group-two-resources-id');
insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-three','group-no-resources-id');
insert into peers (id, account_id, "key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
meta_hostname,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-one','account-1','key-1','ssh-key-1','Peer-1-name','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8677:4940"',
false,true,false,'2026-08-06 13:25:59.12999','cluster-1.netbird.services',
'0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'peer-one-name.local', 'DE','Berlin','"46.201.148.187"');
insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
meta_hostname,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-two','account-1','key-2','ssh-key-2','peer-2-name','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8698:4940"',
true,true,false,'2026-08-06 12:25:59.12999','cluster-2.netbird.services',
'0.76.1','ios','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0,
'peer-two-name.local', 'DE','Berlin','"46.201.149.187"');
insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses,
meta_files,
meta_capabilities, meta_flags, meta_sync_message_version, meta_hostname,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-three','account-1','key-3','ssh-key-3','peer-3-name','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"',
true,false,false,'2026-08-06 11:25:59.12999','cluster-3.netbird.services',
'0.76.3','windows','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'peer-three-name.local','DE','Berlin','"46.201.150.187"');
insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
meta_hostname,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-four','account-1','key-4','ssh-key-4','peer-4-name','peer-4','["extra-peer-4"]','',true,true,'2026-08-06 12:25:59.12999','"10.10.20.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8669:4940"',
false,false,false,'2026-08-06 07:25:59.12999','cluster-4.netbird.services',
'0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:7d:44:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:6d:34:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'peer-four-name.local','DE','Berlin','"46.201.150.187"');
insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
meta_hostname,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-five','account-1','key-5','ssh-key-5','peer-5-name','peer-5','["extra-peer-5"]','',true,true,'2026-08-06 12:25:59.12999','"10.10.9.2"','"fdf6:ba80:6aa5:89f1:44d7:8701:8669:4941"',
false,false,false,'2026-08-06 09:25:59.12999','cluster-4.netbird.services',
'0.78.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:7d:44:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:6d:34:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'peer-five-name.local','DE','Berlin','"46.201.150.187"');
insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
meta_hostname,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-six','account-1','key-6','ssh-key-6','peer-6-name','peer-6','["extra-peer-6"]','',true,true,'2026-08-06 12:25:59.12999','"10.10.8.3"','"fdf6:ba80:6aa5:89f1:44d7:8701:8669:4942"',
false,false,false,'2026-08-06 03:25:59.12999','cluster-4.netbird.services',
'0.77.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:7d:44:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:6d:34:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'peer-six-name.local','DE','Berlin','"46.201.150.187"');
-- peer-id-8 should never appear in any of the results, as we are filtering out proxy peers
insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
meta_hostname,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-8','account-1','key-8','ssh-key-8','peer-8-name','peer-8','["extra-peer-8"]','',true,true,'2026-08-06 12:25:59.12999','"10.10.88.3"','"fdf6:ba80:6aa5:89f1:44d7:8701:8669:4943"',
false,false,true,'2026-08-06 03:25:59.12999','cluster-4.netbird.services',
'0.77.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:7d:44:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:6d:34:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'peer-eight-name.local','DE','Berlin','"46.201.150.187"');
insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files,
meta_capabilities, meta_flags, meta_sync_message_version,
meta_hostname,
location_country_code, location_city_name, location_connection_ip)
values('peer-id-7','account-2','key-7','ssh-key-7','peer-7-name','peer-7','["extra-peer-7"]','',true,true,'2026-08-06 12:25:59.12999','"10.10.8.3"','"fdf6:ba80:6aa5:89f1:44d7:8701:8669:4942"',
false,false,false,'2026-08-06 03:25:59.12999','cluster-4.netbird.services',
'0.77.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:7d:44:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:6d:34:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]',
'[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1,
'peer-seven-name.local','DE','Berlin','"46.201.150.187"');