diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index e944be291..6f9705efe 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -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) diff --git a/management/internals/modules/peers/manager_mock.go b/management/internals/modules/peers/manager_mock.go index 8c26d43b1..ee97ba308 100644 --- a/management/internals/modules/peers/manager_mock.go +++ b/management/internals/modules/peers/manager_mock.go @@ -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() diff --git a/management/internals/modules/peers/manager_test.go b/management/internals/modules/peers/manager_test.go new file mode 100644 index 000000000..2645276b7 --- /dev/null +++ b/management/internals/modules/peers/manager_test.go @@ -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) +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 13bbaa6fa..e466005fe 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -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) } diff --git a/management/server/api/v1alpha1/handler.go b/management/server/api/v1alpha1/handler.go index 3af8f4871..21b9bbcec 100644 --- a/management/server/api/v1alpha1/handler.go +++ b/management/server/api/v1alpha1/handler.go @@ -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) diff --git a/management/server/api/v1alpha1/peers/peers_handler.go b/management/server/api/v1alpha1/peers/peers_handler.go index 8e0593723..50841858c 100644 --- a/management/server/api/v1alpha1/peers/peers_handler.go +++ b/management/server/api/v1alpha1/peers/peers_handler.go @@ -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 == "" { diff --git a/management/server/api/v1alpha1/peers/peers_handler_pagination_test.go b/management/server/api/v1alpha1/peers/peers_handler_pagination_test.go new file mode 100644 index 000000000..4d1449a14 --- /dev/null +++ b/management/server/api/v1alpha1/peers/peers_handler_pagination_test.go @@ -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) + }) + } +} diff --git a/management/server/api/v1alpha1/peers/peers_handler_test.go b/management/server/api/v1alpha1/peers/peers_handler_test.go index 492d0e12d..af7b31d10 100644 --- a/management/server/api/v1alpha1/peers/peers_handler_test.go +++ b/management/server/api/v1alpha1/peers/peers_handler_test.go @@ -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) diff --git a/management/server/http/handlers/groups/groups_handler_test.go b/management/server/http/handlers/groups/groups_handler_test.go index 3e322db4e..f61131da0 100644 --- a/management/server/http/handlers/groups/groups_handler_test.go +++ b/management/server/http/handlers/groups/groups_handler_test.go @@ -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" { diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go index 7054082cc..558a6d1bd 100644 --- a/management/server/http/handlers/peers/peers_handler_test.go +++ b/management/server/http/handlers/peers/peers_handler_test.go @@ -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)) diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 079e60548..50d18ca9b 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -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") } diff --git a/management/server/peer.go b/management/server/peer.go index f21e5ebc9..aa3abcb86 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -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 diff --git a/management/server/peer_test.go b/management/server/peer_test.go index f6a7cc4c4..691630a3b 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -730,7 +730,6 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } assert.NotNil(t, peers) - assert.Len(t, peers, testCase.expectedPeerCount) }) diff --git a/management/server/store/sql_store_peer.go b/management/server/store/sql_store_peer.go index ec5e1993c..657d2a6e0 100644 --- a/management/server/store/sql_store_peer.go +++ b/management/server/store/sql_store_peer.go @@ -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 diff --git a/management/server/store/sql_store_peer_test.go b/management/server/store/sql_store_peer_test.go index 41dc9edcb..0b467e37b 100644 --- a/management/server/store/sql_store_peer_test.go +++ b/management/server/store/sql_store_peer_test.go @@ -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) }) diff --git a/management/server/store/sql_store_peers_filter_test.go b/management/server/store/sql_store_peers_filter_test.go new file mode 100644 index 000000000..dabfcf2ae --- /dev/null +++ b/management/server/store/sql_store_peers_filter_test.go @@ -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) + }) + } + }) +} diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index f695a150d..24f9a410d 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -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") diff --git a/management/server/store/store.go b/management/server/store/store.go index 01aaf4892..174e97b48 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -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 { diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 956cac4b8..8b89219e5 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -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() diff --git a/management/server/testdata/peers_pagination.sql b/management/server/testdata/peers_pagination.sql new file mode 100644 index 000000000..12e7d9d40 --- /dev/null +++ b/management/server/testdata/peers_pagination.sql @@ -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"'); diff --git a/shared/management/http/apiv1alpha1/bundle.yaml b/shared/management/http/apiv1alpha1/bundle.yaml index c8245ab49..5ac8011ea 100644 --- a/shared/management/http/apiv1alpha1/bundle.yaml +++ b/shared/management/http/apiv1alpha1/bundle.yaml @@ -150,22 +150,30 @@ components: description: A human-readable error message. example: "couldn't parse JSON request" PeerBatch: - allOf: - - $ref: '#/components/schemas/Peer' - - type: object - properties: - created_at: - description: Peer creation date (UTC) - type: string - format: date-time - example: "2023-05-05T09:00:35.477782Z" - accessible_peers_count: - description: Number of accessible peers - type: integer - example: 5 - required: - - created_at - - accessible_peers_count + type: object + properties: + peer_count: + description: Total number of peers + type: integer + example: 5 + page: + description: Current page number + type: integer + example: 5 + page_size: + description: Page size + type: integer + example: 5 + peers: + description: List of peers + type: array + items: + $ref: '#/components/schemas/Peer' + required: + - peer_count + - page + - page_size + - peers PeerMinimum: type: object properties: @@ -701,6 +709,30 @@ components: minimum: 1 maximum: 250 default: 100 + - name: user_id + in: query + description: filter peers by their user_id + required: false + schema: + type: string + - name: group_ids + in: query + description: filter peers by group_ids + required: false + schema: + type: array + items: + type: string + - name: peer_ids + in: query + description: filter peers by their ids + required: false + schema: + type: array + minItems: 1 + maxItems: 50 + items: + type: string - name: connected in: query description: Filter by peer connected status @@ -724,16 +756,58 @@ components: enum: - linux - windows - - mac + - darwin - android - ios - js - - name: search + - name: ip in: query - description: filter peers by name, dns_label, ip, os, version, serial_number, owner name, owner email, group names, mac + description: filter peers by ip required: false schema: type: string + - name: mac + in: query + description: filter peers by mac address + required: false + schema: + type: string + - name: name + in: query + description: filter peers by hostname + required: false + schema: + type: string + - name: hostname + in: query + description: filter peers by hostname + required: false + schema: + type: string + - name: kind + in: query + description: filter peers by host kind + required: false + schema: + type: string + enum: + - server + - user_device + - name: sort_by + in: query + description: sorting of peers + required: false + example: sort_by=name,asc&sort_by=last_seen,desc&sort_by=version + schema: + type: array + items: + type: string + enum: + - name + - last_seen + - version + - asc + - desc security: - BearerAuth: [] - TokenAuth: [] @@ -743,9 +817,7 @@ components: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/PeerBatch' + $ref: '#/components/schemas/PeerBatch' '400': "$ref": "#/components/responses/bad_request" '401': diff --git a/shared/management/http/apiv1alpha1/peer/components.yaml b/shared/management/http/apiv1alpha1/peer/components.yaml index e2a7b9240..8c197d8e0 100644 --- a/shared/management/http/apiv1alpha1/peer/components.yaml +++ b/shared/management/http/apiv1alpha1/peer/components.yaml @@ -239,19 +239,27 @@ components: type: boolean example: false PeerBatch: - allOf: - - $ref: '#/components/schemas/Peer' - - type: object - properties: - created_at: - description: Peer creation date (UTC) - type: string - format: date-time - example: "2023-05-05T09:00:35.477782Z" - accessible_peers_count: - description: Number of accessible peers - type: integer - example: 5 - required: - - created_at - - accessible_peers_count \ No newline at end of file + type: object + properties: + peer_count: + description: Total number of peers + type: integer + example: 5 + page: + description: Current page number + type: integer + example: 5 + page_size: + description: Page size + type: integer + example: 5 + peers: + description: List of peers + type: array + items: + $ref: '#/components/schemas/Peer' + required: + - peer_count + - page + - page_size + - peers \ No newline at end of file diff --git a/shared/management/http/apiv1alpha1/peer/peers.yaml b/shared/management/http/apiv1alpha1/peer/peers.yaml index c360018fc..5a32260ad 100644 --- a/shared/management/http/apiv1alpha1/peer/peers.yaml +++ b/shared/management/http/apiv1alpha1/peer/peers.yaml @@ -20,6 +20,30 @@ get: minimum: 1 maximum: 250 default: 100 + - name: user_id + in: query + description: filter peers by their user_id + required: false + schema: + type: string + - name: group_ids + in: query + description: filter peers by group_ids + required: false + schema: + type: array + items: + type: string + - name: peer_ids + in: query + description: filter peers by their ids + required: false + schema: + type: array + minItems: 1 + maxItems: 50 + items: + type: string - name: connected in: query description: Filter by peer connected status @@ -43,16 +67,58 @@ get: enum: - linux - windows - - mac + - darwin - android - ios - js - - name: search + - name: ip in: query - description: filter peers by name, dns_label, ip, os, version, serial_number, owner name, owner email, group names, mac + description: filter peers by ip required: false schema: type: string + - name: mac + in: query + description: filter peers by mac address + required: false + schema: + type: string + - name: name + in: query + description: filter peers by hostname + required: false + schema: + type: string + - name: hostname + in: query + description: filter peers by hostname + required: false + schema: + type: string + - name: kind + in: query + description: filter peers by host kind + required: false + schema: + type: string + enum: + - server + - user_device + - name: sort_by + in: query + description: sorting of peers + required: false + example: sort_by=name,asc&sort_by=last_seen,desc&sort_by=version + schema: + type: array + items: + type: string + enum: + - name + - last_seen + - version + - asc + - desc security: - BearerAuth: [ ] - TokenAuth: [ ] @@ -62,9 +128,7 @@ get: content: application/json: schema: - type: array - items: - $ref: './components.yaml#/components/schemas/PeerBatch' + $ref: './components.yaml#/components/schemas/PeerBatch' '400': "$ref": "../openapi.yaml#/components/responses/bad_request" '401': diff --git a/shared/management/http/apiv1alpha1/types.gen.go b/shared/management/http/apiv1alpha1/types.gen.go index 3e9ede3e4..14ac4e7f4 100644 --- a/shared/management/http/apiv1alpha1/types.gen.go +++ b/shared/management/http/apiv1alpha1/types.gen.go @@ -41,13 +41,17 @@ type ErrorResponse struct { } type PeerBatch struct { - Peer - // CreatedAt Peer creation date (UTC). - // CreatedAt example value is defined in the OpenAPI schema. - CreatedAt time.Time `json:"created_at"` - // AccessiblePeersCount Number of accessible peers. - // AccessiblePeersCount example value is defined in the OpenAPI schema. - AccessiblePeersCount int `json:"accessible_peers_count"` + // PeerCount Total number of peers. + // PeerCount example value is defined in the OpenAPI schema. + PeerCount int `json:"peer_count"` + // Page Current page number. + // Page example value is defined in the OpenAPI schema. + Page int `json:"page"` + // PageSize Page size. + // PageSize example value is defined in the OpenAPI schema. + PageSize int `json:"page_size"` + // Peers List of peers. + Peers []Peer `json:"peers"` } type PeerMinimum struct {