diff --git a/management/server/account.go b/management/server/account.go index 82f5ee4a3..40c257bb2 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -101,6 +101,8 @@ type DefaultAccountManager struct { accountUpdateLocks sync.Map updateAccountPeersBufferInterval atomic.Int64 + + loginFilter *loginFilter } // getJWTGroupsChanges calculates the changes needed to sync a user's JWT groups. @@ -194,6 +196,7 @@ func BuildManager( proxyController: proxyController, settingsManager: settingsManager, permissionsManager: permissionsManager, + loginFilter: newLoginFilter(), } am.startWarmup(ctx) @@ -1561,7 +1564,7 @@ func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, account if err != nil { log.WithContext(ctx).Warnf("failed marking peer as disconnected %s %v", peerPubKey, err) } - + am.loginFilter.removeLogin(peerPubKey) return nil } diff --git a/management/server/grpcserver.go b/management/server/grpcserver.go index 2b27f9e0f..1b74b926b 100644 --- a/management/server/grpcserver.go +++ b/management/server/grpcserver.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "fmt" "net" "net/netip" @@ -338,6 +339,9 @@ func mapError(ctx context.Context, err error) error { default: } } + if errors.Is(err, internalStatus.ErrPeerAlreadyLoggedIn) { + return status.Error(codes.AlreadyExists, internalStatus.ErrPeerAlreadyLoggedIn.Error()) + } log.WithContext(ctx).Errorf("got an unhandled error: %s", err) return status.Errorf(codes.Internal, "failed handling request") } diff --git a/management/server/loginfilter.go b/management/server/loginfilter.go new file mode 100644 index 000000000..0892d210e --- /dev/null +++ b/management/server/loginfilter.go @@ -0,0 +1,58 @@ +package server + +import ( + "strings" + "sync" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +const ( + loginFilterSize = 100_000 // Size of the login filter map, making it large enough for a future +) + +type loginFilter struct { + mu sync.RWMutex + logged map[string]string +} + +func newLoginFilter() *loginFilter { + return &loginFilter{ + logged: make(map[string]string, loginFilterSize), + } +} + +func (l *loginFilter) addLogin(wgPubKey, metaHash string) { + l.mu.Lock() + defer l.mu.Unlock() + l.logged[wgPubKey] = metaHash +} + +func (l *loginFilter) allowLogin(wgPubKey, metaHash string) bool { + l.mu.RLock() + defer l.mu.RUnlock() + if loggedMetaHash, ok := l.logged[wgPubKey]; ok { + return loggedMetaHash == metaHash + } + return true +} + +func (l *loginFilter) removeLogin(wgPubKey string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.logged, wgPubKey) +} + +func metaHash(meta nbpeer.PeerSystemMeta) string { + estimatedSize := len(meta.WtVersion) + len(meta.OSVersion) + len(meta.KernelVersion) + len(meta.Hostname) + + var b strings.Builder + b.Grow(estimatedSize) + + b.WriteString(meta.WtVersion) + b.WriteString(meta.OSVersion) + b.WriteString(meta.KernelVersion) + b.WriteString(meta.Hostname) + + return b.String() +} diff --git a/management/server/loginfilter_test.go b/management/server/loginfilter_test.go new file mode 100644 index 000000000..15985495c --- /dev/null +++ b/management/server/loginfilter_test.go @@ -0,0 +1,47 @@ +package server + +import ( + "fmt" + "hash/fnv" + "testing" + + "github.com/netbirdio/netbird/management/server/peer" + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +func BenchmarkMetaHash(b *testing.B) { + meta := peer.PeerSystemMeta{ + WtVersion: "1.0.0", + OSVersion: "Linux 5.4.0", + KernelVersion: "5.4.0-42-generic", + Hostname: "test-host", + } + b.Run("fnv", func(b *testing.B) { + for i := 0; i < b.N; i++ { + metaHashFnv(meta) + } + }) + b.Run("builder", func(b *testing.B) { + for i := 0; i < b.N; i++ { + metaHash(meta) + } + }) + b.Run("strings", func(b *testing.B) { + for i := 0; i < b.N; i++ { + metaHashStrings(meta) + } + }) +} + +func metaHashStrings(meta nbpeer.PeerSystemMeta) string { + return meta.WtVersion + meta.OSVersion + meta.KernelVersion + meta.Hostname +} + +func metaHashFnv(meta nbpeer.PeerSystemMeta) string { + h := fnv.New64a() + h.Write([]byte(meta.WtVersion)) + h.Write([]byte(meta.OSVersion)) + h.Write([]byte(meta.KernelVersion)) + h.Write([]byte(meta.Hostname)) + return fmt.Sprintf("%x", h.Sum64()) +} diff --git a/management/server/peer.go b/management/server/peer.go index 4a468a6cd..7a15d30eb 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -789,6 +789,11 @@ func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, lo // LoginPeer logs in or registers a peer. // If peer doesn't exist the function checks whether a setup key or a user is present and registers a new peer if so. func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { + metahash := metaHash(login.Meta) + if !am.loginFilter.allowLogin(login.WireGuardPubKey, metahash) { + return nil, nil, nil, status.ErrPeerAlreadyLoggedIn + } + accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, login.WireGuardPubKey) if err != nil { return am.handlePeerLoginNotFound(ctx, login, err) @@ -900,6 +905,8 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer am.BufferUpdateAccountPeers(ctx, accountID) } + am.loginFilter.addLogin(login.WireGuardPubKey, metahash) + return am.getValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer) } diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 3d782f04c..163d053b0 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -10,6 +10,7 @@ import ( "net/netip" "os" "runtime" + "strconv" "strings" "testing" "time" @@ -1579,7 +1580,6 @@ func Test_LoginPeer(t *testing.T) { testCases := []struct { name string setupKey string - wireGuardPubKey string expectExtraDNSLabelsMismatch bool extraDNSLabels []string expectLoginError bool @@ -1679,6 +1679,88 @@ func Test_LoginPeer(t *testing.T) { } } +func Test_LoginPeerMultipleAccess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "testdata/extended-store.sql", t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + eventStore := &activity.InMemoryEventStore{} + + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + assert.NoError(t, err) + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + settingsMockManager := settings.NewMockManager(ctrl) + permissionsManager := permissions.NewManager(s) + + am, err := BuildManager(context.Background(), s, NewPeersUpdateManager(nil), nil, "", "netbird.cloud", eventStore, nil, false, MocIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager) + assert.NoError(t, err) + + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + _, err = s.GetAccount(context.Background(), existingAccountID) + require.NoError(t, err, "Failed to get existing account, check testdata/extended-store.sql. Account ID: %s", existingAccountID) + + setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" + + peer := &nbpeer.Peer{ + ID: xid.New().String(), + AccountID: existingAccountID, + UserID: "", + IP: net.IP{123, 123, 123, 123}, + Meta: nbpeer.PeerSystemMeta{ + Hostname: "Peer", + GoOS: "linux", + }, + Name: "PeerName", + DNSLabel: "peer.test", + Status: &nbpeer.PeerStatus{Connected: false, LastSeen: time.Now()}, + SSHEnabled: false, + } + _, _, _, err = am.AddPeer(context.Background(), setupKey, "", peer) + require.NoError(t, err, "Expected no error when adding peer with setup key: %s", setupKey) + + testCases := []struct { + name string + n int + }{ + { + name: "10 logins", + n: 10, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := 1 // First login is always successful + for i := range tc.n { + loginInput := types.PeerLogin{ + WireGuardPubKey: peer.ID, + SSHKey: "test-ssh-key", + Meta: nbpeer.PeerSystemMeta{ + Hostname: "peer" + strconv.Itoa(i), + }, + UserID: "", + SetupKey: setupKey, + ConnectionIP: net.ParseIP("192.0.2.100"), + } + _, _, _, loginErr := am.LoginPeer(context.Background(), loginInput) + if loginErr != nil { + actual++ + } + time.Sleep(time.Millisecond * 100) + } + require.Equal(t, tc.n-1, actual, "Expected %d insuccessful logins, got %d", tc.n, actual) + }) + } +} + func TestPeerAccountPeersUpdate(t *testing.T) { manager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) diff --git a/management/server/status/error.go b/management/server/status/error.go index 8fbe0bad9..6ccf8ab0a 100644 --- a/management/server/status/error.go +++ b/management/server/status/error.go @@ -42,7 +42,10 @@ const ( // Type is a type of the Error type Type int32 -var ErrExtraSettingsNotFound = fmt.Errorf("extra settings not found") +var ( + ErrExtraSettingsNotFound = fmt.Errorf("extra settings not found") + ErrPeerAlreadyLoggedIn = errors.New("peer with the same public key is already logged in") +) // Error is an internal error type Error struct {