From dd8559fe7c49534cdc4bad9298c248f7e4e0d330 Mon Sep 17 00:00:00 2001 From: Allan ELKAIM Date: Fri, 26 Jun 2026 14:51:05 +0200 Subject: [PATCH 1/2] [management] expose peer MAC addresses and make peers searchable by MAC Peers already store per-interface MAC addresses in meta_network_addresses, but they were neither returned by the peers API nor searchable. Add a network_addresses field (net_ip + mac) to the Peer/PeerBatch responses and a dedicated `mac` query parameter on GET /api/peers that matches the MAC JSON column, enabling `GET /api/peers?mac=` and dashboard search by MAC. --- .../network_map/controller/repository.go | 2 +- management/internals/modules/peers/manager.go | 2 +- management/server/account.go | 10 ++-- management/server/account/manager.go | 2 +- management/server/account/manager_mock.go | 8 ++-- management/server/account_test.go | 18 ++++---- .../handlers/accounts/accounts_handler.go | 2 +- .../http/handlers/groups/groups_handler.go | 10 ++-- .../handlers/groups/groups_handler_test.go | 2 +- .../http/handlers/peers/peers_handler.go | 16 ++++++- .../http/handlers/peers/peers_handler_test.go | 2 +- management/server/integrated_validator.go | 2 +- management/server/mock_server/account_mock.go | 6 +-- management/server/peer.go | 4 +- management/server/peer_test.go | 4 +- management/server/store/sql_store.go | 7 ++- management/server/store/sql_store_test.go | 46 ++++++++++++++++++- management/server/store/store.go | 2 +- management/server/store/store_mock.go | 8 ++-- shared/management/http/api/openapi.yml | 24 ++++++++++ shared/management/http/api/types.gen.go | 15 ++++++ 21 files changed, 146 insertions(+), 46 deletions(-) diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go index 5c3195f1693..c11af0b69fc 100644 --- a/management/internals/controllers/network_map/controller/repository.go +++ b/management/internals/controllers/network_map/controller/repository.go @@ -44,7 +44,7 @@ func (r *repository) GetAccountNetwork(ctx context.Context, accountID string) (* } func (r *repository) GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error) { - return r.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + return r.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") } func (r *repository) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) { diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 3274ec52470..e944be29105 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -97,7 +97,7 @@ func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) return m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) } - return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") } func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) { diff --git a/management/server/account.go b/management/server/account.go index 6ccf673f528..c35817d01a4 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -2358,7 +2358,7 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t return err } - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "", "") if err != nil { return err } @@ -2395,7 +2395,7 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t // v6 address get one allocated. When disabled, all v6 addresses are cleared. // When the v6 range changes, all v6 addresses are reallocated. func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) error { - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "", "") if err != nil { return fmt.Errorf("get peers: %w", err) } @@ -2408,7 +2408,7 @@ func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transac } func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) error { - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "", "") if err != nil { return fmt.Errorf("get peers: %w", err) } @@ -2569,7 +2569,7 @@ func (am *DefaultAccountManager) buildIPv6AllowedPeers(ctx context.Context, tran // Embedded proxy peers sit outside regular group membership but must // participate in any v6-enabled overlay to reach v6-only peers. - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") if err != nil { return nil, fmt.Errorf("get peers: %w", err) } @@ -2640,7 +2640,7 @@ func (am *DefaultAccountManager) updatePeerIPInTransaction(ctx context.Context, return nil } - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "", "") if err != nil { return fmt.Errorf("get account peers: %w", err) } diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 154c9ab188b..2ac8584f452 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -62,7 +62,7 @@ type Manager interface { GetUserByID(ctx context.Context, id string) (*types.User, error) GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) - GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) + GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error DeletePeer(ctx context.Context, accountID, peerID, userID string) error diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index f31f63d0e37..60075b169d8 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -982,18 +982,18 @@ func (mr *MockManagerMockRecorder) GetPeerNetwork(ctx, peerID any) *gomock.Call } // GetPeers mocks base method. -func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*peer.Peer, error) { +func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*peer.Peer, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPeers", ctx, accountID, userID, nameFilter, ipFilter) + ret := m.ctrl.Call(m, "GetPeers", ctx, accountID, userID, nameFilter, ipFilter, macFilter) ret0, _ := ret[0].([]*peer.Peer) ret1, _ := ret[1].(error) return ret0, ret1 } // GetPeers indicates an expected call of GetPeers. -func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter any) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter, macFilter any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter, macFilter) } // GetPolicy mocks base method. diff --git a/management/server/account_test.go b/management/server/account_test.go index bd7bf2d9779..c85c09c85d5 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -2462,7 +2462,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerApproval(t *testing.T) _, err = manager.UpdateAccountSettings(ctx, accountID, userID, newSettings) require.NoError(t, err) - accountPeers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + accountPeers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, peer := range accountPeers { @@ -4458,7 +4458,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) require.Len(t, peers, len(before)) for _, p := range peers { @@ -4476,7 +4476,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) for _, p := range peers { assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change for host-bit-set equivalent range", p.ID) @@ -4490,7 +4490,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) for _, p := range peers { assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change when NetworkRange omitted", p.ID) @@ -4506,7 +4506,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) for _, p := range peers { assert.True(t, newRange.Contains(p.IP), "peer %s should be in new range %s, got %s", p.ID, newRange, p.IP) @@ -4524,7 +4524,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin require.NoError(t, err) require.NotEmpty(t, settings.IPv6EnabledGroups, "new account should have IPv6 enabled for All group") - peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, p := range peers { assert.True(t, p.IPv6.IsValid(), "peer %s should have IPv6 with All group enabled", p.ID) @@ -4552,7 +4552,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin assert.Equal(t, []string{partialGroup.ID}, updatedSettings.IPv6EnabledGroups) // peer1 and peer2 should have IPv6; peer3 should not. - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) peerMap := make(map[string]*nbpeer.Peer, len(peers)) for _, p := range peers { @@ -4572,7 +4572,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin require.NoError(t, err) assert.Empty(t, updatedSettings.IPv6EnabledGroups) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, p := range peers { assert.False(t, p.IPv6.IsValid(), "peer %s should have no IPv6 when groups cleared", p.ID) @@ -4587,7 +4587,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) peerMap = make(map[string]*nbpeer.Peer, len(peers)) for _, p := range peers { diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index c4cba5962cb..795214c3189 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -127,7 +127,7 @@ func (h *handler) validateNetworkRange(ctx context.Context, accountID, userID st } func (h *handler) validateCapacity(ctx context.Context, accountID, userID string, prefix netip.Prefix) error { - peers, err := h.accountManager.GetPeers(ctx, accountID, userID, "", "") + peers, err := h.accountManager.GetPeers(ctx, accountID, userID, "", "", "") if err != nil { return status.Errorf(status.Internal, "get peer count: %v", err) } diff --git a/management/server/http/handlers/groups/groups_handler.go b/management/server/http/handlers/groups/groups_handler.go index f8d161a8783..e75e3297e83 100644 --- a/management/server/http/handlers/groups/groups_handler.go +++ b/management/server/http/handlers/groups/groups_handler.go @@ -58,7 +58,7 @@ func (h *handler) getAllGroups(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -77,7 +77,7 @@ func (h *handler) getAllGroups(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -172,7 +172,7 @@ func (h *handler) updateGroup(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -232,7 +232,7 @@ func (h *handler) createGroup(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -293,7 +293,7 @@ func (h *handler) getGroup(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return diff --git a/management/server/http/handlers/groups/groups_handler_test.go b/management/server/http/handlers/groups/groups_handler_test.go index 57e23863066..921fa6b6896 100644 --- a/management/server/http/handlers/groups/groups_handler_test.go +++ b/management/server/http/handlers/groups/groups_handler_test.go @@ -78,7 +78,7 @@ func initGroupTestData(initGroups ...*types.Group) *handler { return nil, status.Errorf(status.NotFound, "unknown group name") }, - GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { + GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { return maps.Values(TestPeers), nil }, DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error { diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 773b640e0f8..8a9bf1f7035 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -317,10 +317,11 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) { nameFilter := r.URL.Query().Get("name") ipFilter := r.URL.Query().Get("ip") + macFilter := r.URL.Query().Get("mac") accountID, userID := userAuth.AccountId, userAuth.UserId - peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter) + peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter, macFilter) if err != nil { util.WriteError(r.Context(), err, w) return @@ -571,6 +572,17 @@ func peerToAccessiblePeer(peer *nbpeer.Peer, dnsDomain string) api.AccessiblePee } } +func toNetworkAddresses(addrs []nbpeer.NetworkAddress) *[]api.NetworkAddress { + if len(addrs) == 0 { + return nil + } + out := make([]api.NetworkAddress, 0, len(addrs)) + for _, a := range addrs { + out = append(out, api.NetworkAddress{NetIp: a.NetIP.String(), Mac: a.Mac}) + } + return &out +} + func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsDomain string, approved bool, reason string) *api.Peer { osVersion := peer.Meta.OSVersion if osVersion == "" { @@ -583,6 +595,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD Name: peer.Name, Ip: peer.IP.String(), Ipv6: peerIPv6String(peer), + NetworkAddresses: toNetworkAddresses(peer.Meta.NetworkAddresses), ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, @@ -639,6 +652,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn Name: peer.Name, Ip: peer.IP.String(), Ipv6: peerIPv6String(peer), + NetworkAddresses: toNetworkAddresses(peer.Meta.NetworkAddresses), ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go index 592d64d1a34..70f6eafff53 100644 --- a/management/server/http/handlers/peers/peers_handler_test.go +++ b/management/server/http/handlers/peers/peers_handler_test.go @@ -173,7 +173,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { return nil, fmt.Errorf("user not found") } }, - GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { + GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { return peers, nil }, GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) { diff --git a/management/server/integrated_validator.go b/management/server/integrated_validator.go index 9ec1f491eb0..5928a8ed23b 100644 --- a/management/server/integrated_validator.go +++ b/management/server/integrated_validator.go @@ -100,7 +100,7 @@ func (am *DefaultAccountManager) GetValidatedPeers(ctx context.Context, accountI return nil, nil, err } - peers, err = am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") if err != nil { return nil, nil, err } diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 2f871c3e28e..3313bf99c3a 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 string) ([]*nbpeer.Peer, error) + GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, 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) @@ -807,9 +807,9 @@ 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 string) ([]*nbpeer.Peer, error) { +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) + return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter) } return nil, status.Errorf(codes.Unimplemented, "method GetPeers is not implemented") } diff --git a/management/server/peer.go b/management/server/peer.go index 9f55722520d..5d5863fa7d9 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -47,7 +47,7 @@ const ( // GetPeers returns peers visible to the user within an account. // Users with "peers:read" see all peers. Otherwise, users see only their own peers, or none if restricted by account settings. -func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { +func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) if err != nil { return nil, err @@ -59,7 +59,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID } if allowed { - return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter) + return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter, macFilter) } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 22f2b9b6fe3..8db137ab6d3 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -718,7 +718,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } - peers, err := manager.GetPeers(context.Background(), accountID, someUser, "", "") + peers, err := manager.GetPeers(context.Background(), accountID, someUser, "", "", "") if err != nil { t.Fatal(err) return @@ -934,7 +934,7 @@ func BenchmarkGetPeers(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := manager.GetPeers(context.Background(), accountID, userID, "", "") + _, err := manager.GetPeers(context.Background(), accountID, userID, "", "", "") if err != nil { b.Fatalf("GetPeers failed: %v", err) } diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 08ec453959b..9b0ec4f58af 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -3513,7 +3513,7 @@ func (s *SqlStore) GetPeerGroupIDs(ctx context.Context, lockStrength LockingStre } // GetAccountPeers retrieves peers for an account. -func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { +func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { var peers []*nbpeer.Peer tx := s.db if lockStrength != LockingStrengthNone { @@ -3527,6 +3527,11 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre if ipFilter != "" { query = query.Where("ip LIKE ? OR ipv6 LIKE ?", "%"+ipFilter+"%", "%"+ipFilter+"%") } + // MAC addresses live in the JSON-serialized meta_network_addresses column, + // so we match the raw JSON text rather than a dedicated column. + 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) diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index fbcff525747..80080313077 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -2903,7 +2903,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.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter, "") require.NoError(t, err) require.Len(t, peers, tt.expectedCount) }) @@ -2911,6 +2911,48 @@ func TestSqlStore_GetAccountPeers(t *testing.T) { } +func TestSqlStore_GetAccountPeers_FilterByMac(t *testing.T) { + ctx := context.Background() + store, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + accountID := "test-account-mac" + userID := "test-user-mac" + account := newAccountWithId(ctx, accountID, userID, "example.com") + account.Peers["peer-mac-1"] = &nbpeer.Peer{ + ID: "peer-mac-1", + AccountID: accountID, + Key: "peer-mac-key-1", + Name: "macpeer", + IP: netip.MustParseAddr("100.64.0.10"), + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.11/24"), Mac: "00:93:37:bd:83:0f"}, + }, + }, + } + require.NoError(t, store.SaveAccount(ctx, account)) + + tests := []struct { + name string + macFilter string + expectedCount int + }{ + {name: "full mac matches", macFilter: "00:93:37:bd:83:0f", expectedCount: 1}, + {name: "mac prefix matches", macFilter: "00:93:37", expectedCount: 1}, + {name: "unknown mac does not match", macFilter: "11:22:33:44:55:66", expectedCount: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", tt.macFilter) + require.NoError(t, err) + require.Len(t, peers, tt.expectedCount) + }) + } +} + func TestSqlStore_GetAccountPeersWithExpiration(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) t.Cleanup(cleanup) @@ -4200,7 +4242,7 @@ func TestSqlStore_ApproveAccountPeers(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, count) - allPeers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "") + allPeers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, peer := range allPeers { diff --git a/management/server/store/store.go b/management/server/store/store.go index 6886536b951..c89dd619ffd 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -158,7 +158,7 @@ type Store interface { RemoveResourceFromGroup(ctx context.Context, accountId string, groupID string, resourceID string) error 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 string) ([]*nbpeer.Peer, error) + GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, 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) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 04f79d30af3..b5b3924e723 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1360,18 +1360,18 @@ func (mr *MockStoreMockRecorder) GetAccountOwner(ctx, lockStrength, accountID an } // GetAccountPeers mocks base method. -func (m *MockStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*peer.Peer, error) { +func (m *MockStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*peer.Peer, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountPeers", ctx, lockStrength, accountID, nameFilter, ipFilter) + ret := m.ctrl.Call(m, "GetAccountPeers", ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter) ret0, _ := ret[0].([]*peer.Peer) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAccountPeers indicates an expected call of GetAccountPeers. -func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, nameFilter, ipFilter any) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter) } // GetAccountPeersWithExpiration mocks base method. diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 5ad682c6b37..4d2baf0959f 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -826,6 +826,20 @@ components: - ssh_enabled - login_expiration_enabled - inactivity_expiration_enabled + NetworkAddress: + type: object + properties: + net_ip: + description: IP address with CIDR of the interface + type: string + example: 192.168.0.11/24 + mac: + description: MAC address of the interface + type: string + example: "00:93:37:bd:83:0f" + required: + - net_ip + - mac Peer: allOf: - $ref: '#/components/schemas/PeerMinimum' @@ -845,6 +859,11 @@ components: type: string format: ipv6 example: "fd00:4e42:ab12::1" + network_addresses: + description: Network interfaces (IP + MAC) reported by the peer + type: array + items: + $ref: '#/components/schemas/NetworkAddress' connection_ip: description: Peer's public connection IP address type: string @@ -7516,6 +7535,11 @@ paths: schema: type: string description: Filter peers by IP address + - in: query + name: mac + schema: + type: string + description: Filter peers by MAC address of a network interface security: - BearerAuth: [ ] - TokenAuth: [ ] diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index b5a7a80aca9..3bd1da06e0d 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -3826,6 +3826,15 @@ type Network struct { RoutingPeersCount int `json:"routing_peers_count"` } +// NetworkAddress defines model for NetworkAddress. +type NetworkAddress struct { + // Mac MAC address of the interface + Mac string `json:"mac"` + + // NetIp IP address with CIDR of the interface + NetIp string `json:"net_ip"` +} + // NetworkRequest defines model for NetworkRequest. type NetworkRequest struct { // Description Network description @@ -4275,6 +4284,9 @@ type Peer struct { // Name Peer's hostname Name string `json:"name"` + // NetworkAddresses Network interfaces (IP + MAC) reported by the peer + NetworkAddresses *[]NetworkAddress `json:"network_addresses,omitempty"` + // Os Peer's operating system and version Os string `json:"os"` @@ -4369,6 +4381,9 @@ type PeerBatch struct { // Name Peer's hostname Name string `json:"name"` + // NetworkAddresses Network interfaces (IP + MAC) reported by the peer + NetworkAddresses *[]NetworkAddress `json:"network_addresses,omitempty"` + // Os Peer's operating system and version Os string `json:"os"` From d40682c31d91a4ba660c409556fcde7449470e09 Mon Sep 17 00:00:00 2001 From: Allan ELKAIM Date: Mon, 14 Sep 2026 15:26:34 +0200 Subject: [PATCH 2/2] [management] Regenerate peer API types and test MAC filtering The generated peer query types were missing the mac parameter, and MAC filtering needed coverage beyond the basic store test. Run the OpenAPI generator and test MAC filtering through the HTTP handler, account manager, and SQL store. Cover partial matches, multiple interfaces, combined filters, and account isolation. Verify network address serialization and omission in peer responses. Validation: focused tests pass; lint reports zero issues. --- .../http/handlers/peers/peers_handler_test.go | 44 ++++++++++++ management/server/peer_test.go | 72 +++++++++++++++++++ shared/management/http/api/types.gen.go | 3 + 3 files changed, 119 insertions(+) diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go index 70f6eafff53..7054082ccdf 100644 --- a/management/server/http/handlers/peers/peers_handler_test.go +++ b/management/server/http/handlers/peers/peers_handler_test.go @@ -364,6 +364,50 @@ func TestGetPeers(t *testing.T) { } } +func TestPeerResponseNetworkAddresses(t *testing.T) { + tests := []struct { + name string + addresses []nbpeer.NetworkAddress + wantJSON string + }{ + {name: "not reported"}, + {name: "empty", addresses: []nbpeer.NetworkAddress{}}, + { + name: "multiple interfaces", + addresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.11/24"), Mac: "00:93:37:bd:83:0f"}, + {NetIP: netip.MustParsePrefix("2001:db8::123/64"), Mac: "00:93:37:bd:83:10"}, + }, + wantJSON: `[{"net_ip":"192.168.0.11/24","mac":"00:93:37:bd:83:0f"},{"net_ip":"2001:db8::123/64","mac":"00:93:37:bd:83:10"}]`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peer := &nbpeer.Peer{ + Status: &nbpeer.PeerStatus{}, + Meta: nbpeer.PeerSystemMeta{NetworkAddresses: tt.addresses}, + } + responses := map[string]any{ + "single peer": toSinglePeerResponse(peer, nil, "example.com", true, ""), + "peer list": toPeerListItemResponse(peer, nil, "example.com", 0), + } + for name, response := range responses { + t.Run(name, func(t *testing.T) { + body, err := json.Marshal(response) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &fields)) + if tt.wantJSON == "" { + assert.NotContains(t, fields, "network_addresses", "unreported interfaces should be omitted") + return + } + assert.JSONEq(t, tt.wantJSON, string(fields["network_addresses"]), "response should preserve interface addresses and MACs") + }) + } + }) + } +} + func TestGetAccessiblePeers(t *testing.T) { peer1 := &nbpeer.Peer{ ID: "peer1", diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 8db137ab6d3..9049331773e 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -4,10 +4,14 @@ import ( "context" "crypto/sha256" b64 "encoding/base64" + "encoding/json" "fmt" "io" "net" + "net/http" + "net/http/httptest" "net/netip" + "net/url" "os" "runtime" "strconv" @@ -33,12 +37,15 @@ import ( "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/internals/shared/grpc" nbcache "github.com/netbirdio/netbird/management/server/cache" + nbcontext "github.com/netbirdio/netbird/management/server/context" + peershandler "github.com/netbirdio/netbird/management/server/http/handlers/peers" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/util" @@ -731,6 +738,71 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { } } +func TestDefaultAccountManager_GetPeers_FilterByMac(t *testing.T) { + ctx := context.Background() + manager, _, err := createManager(t) + require.NoError(t, err) + account := newAccountWithId(ctx, "mac-account", "mac-admin", "", "", "", false) + account.Peers["matching"] = &nbpeer.Peer{ + ID: "matching", Key: "matching-key", Name: "laptop", DNSLabel: "laptop", + IP: netip.MustParseAddr("100.64.0.10"), Status: &nbpeer.PeerStatus{}, + Meta: nbpeer.PeerSystemMeta{NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.11/24"), Mac: "00:93:37:bd:83:0f"}, + {NetIP: netip.MustParsePrefix("192.168.1.11/24"), Mac: "aa:bb:cc:dd:ee:ff"}, + }}, + } + account.Peers["other"] = &nbpeer.Peer{ + ID: "other", Key: "other-key", Name: "desktop", DNSLabel: "desktop", + IP: netip.MustParseAddr("100.64.0.20"), Status: &nbpeer.PeerStatus{}, + } + require.NoError(t, manager.Store.SaveAccount(ctx, account)) + otherAccount := newAccountWithId(ctx, "other-account", "other-admin", "", "", "", false) + otherPeer := account.Peers["matching"].Copy() + otherPeer.ID, otherPeer.Key = "outside-account", "outside-key" + otherAccount.Peers[otherPeer.ID] = otherPeer + require.NoError(t, manager.Store.SaveAccount(ctx, otherAccount)) + handler := peershandler.NewHandler(manager, manager.networkMapController, manager.permissionsManager) + + tests := []struct { + name, nameFilter, ipFilter, macFilter string + wantIDs []string + }{ + {name: "no filter", wantIDs: []string{"matching", "other"}}, + {name: "full MAC", macFilter: "00:93:37:bd:83:0f", wantIDs: []string{"matching"}}, + {name: "partial MAC", macFilter: "93:37:bd", wantIDs: []string{"matching"}}, + {name: "second interface", macFilter: "aa:bb:cc:dd:ee:ff", wantIDs: []string{"matching"}}, + {name: "unknown MAC", macFilter: "11:22:33:44:55:66"}, + {name: "combined filters", nameFilter: "laptop", ipFilter: "100.64.0.10", macFilter: "00:93:37", wantIDs: []string{"matching"}}, + {name: "name mismatch", nameFilter: "desktop", macFilter: "00:93:37"}, + {name: "IP mismatch", ipFilter: "100.64.0.20", macFilter: "00:93:37"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := manager.GetPeers(ctx, account.Id, "mac-admin", tt.nameFilter, tt.ipFilter, tt.macFilter) + require.NoError(t, err) + ids := make([]string, 0, len(peers)) + for _, peer := range peers { + ids = append(ids, peer.ID) + } + assert.ElementsMatch(t, tt.wantIDs, ids, "filters should return only matching peers in the account") + + query := url.Values{"name": {tt.nameFilter}, "ip": {tt.ipFilter}, "mac": {tt.macFilter}} + req := httptest.NewRequest(http.MethodGet, "/api/peers?"+query.Encode(), nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{AccountId: account.Id, UserId: "mac-admin"}) + recorder := httptest.NewRecorder() + handler.GetAllPeers(recorder, req) + require.Equal(t, http.StatusOK, recorder.Code, "peer listing should succeed: %s", recorder.Body.String()) + var response []api.PeerBatch + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + responseIDs := make([]string, 0, len(response)) + for _, peer := range response { + responseIDs = append(responseIDs, peer.Id) + } + assert.ElementsMatch(t, tt.wantIDs, responseIDs, "HTTP query filters should reach the store") + }) + } +} + func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccountManager, *update_channel.PeersUpdateManager, string, string, error) { b.Helper() diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 3bd1da06e0d..dac1fb04253 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -6306,6 +6306,9 @@ type GetApiPeersParams struct { // Ip Filter peers by IP address Ip *string `form:"ip,omitempty" json:"ip,omitempty"` + + // Mac Filter peers by MAC address of a network interface + Mac *string `form:"mac,omitempty" json:"mac,omitempty"` } // GetApiPeersPeerIdIngressPortsParams defines parameters for GetApiPeersPeerIdIngressPorts.