From 93cb226a5e082507f29a2bf1bc4805a8195dbc7e Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:29:24 +0200 Subject: [PATCH] [management] fix login filter (#7739) --- .../internals/shared/grpc/loginfilter.go | 25 ++- .../internals/shared/grpc/loginfilter_test.go | 182 ++++++++++++++++++ 2 files changed, 203 insertions(+), 4 deletions(-) diff --git a/management/internals/shared/grpc/loginfilter.go b/management/internals/shared/grpc/loginfilter.go index cc69b7d6e..01ae67cc3 100644 --- a/management/internals/shared/grpc/loginfilter.go +++ b/management/internals/shared/grpc/loginfilter.go @@ -14,6 +14,7 @@ const ( baseBlockDuration = 10 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit reconnLimitForBan = 30 // Number of reconnections within the reconnTreshold that triggers a ban metaChangeLimit = 5 // Number of reconnections with different metadata that triggers a ban of one peer + maxBanLevel = 6 // Highest ban level; the ban duration doubles per level up to this one ) type lfConfig struct { @@ -21,6 +22,7 @@ type lfConfig struct { baseBlockDuration time.Duration reconnLimitForBan int metaChangeLimit int + maxBanLevel int } func initCfg() *lfConfig { @@ -29,6 +31,7 @@ func initCfg() *lfConfig { baseBlockDuration: baseBlockDuration, reconnLimitForBan: reconnLimitForBan, metaChangeLimit: metaChangeLimit, + maxBanLevel: maxBanLevel, } } @@ -102,11 +105,18 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) { return } - if state.isBanned && now.After(state.banExpiresAt) { + if state.isBanned { + if now.Before(state.banExpiresAt) { + return + } state.isBanned = false } - if state.banLevel > 0 && now.Sub(state.lastSeen) > (2*l.cfg.baseBlockDuration) { + quietSince := state.lastSeen + if state.banExpiresAt.After(quietSince) { + quietSince = state.banExpiresAt + } + if state.banLevel > 0 && now.Sub(quietSince) > (2*l.cfg.baseBlockDuration) { state.banLevel = 0 } @@ -124,10 +134,17 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) { return } + if now.Sub(state.sessionStart) >= l.cfg.reconnThreshold { + state.sessionStart = now + state.sessionCounter = 0 + } + state.sessionCounter++ - if state.sessionCounter > l.cfg.reconnLimitForBan && now.Sub(state.sessionStart) < l.cfg.reconnThreshold { + if state.sessionCounter > l.cfg.reconnLimitForBan { state.isBanned = true - state.banLevel++ + if state.banLevel < l.cfg.maxBanLevel { + state.banLevel++ + } backoffFactor := math.Pow(2, float64(state.banLevel-1)) duration := time.Duration(float64(l.cfg.baseBlockDuration) * backoffFactor) diff --git a/management/internals/shared/grpc/loginfilter_test.go b/management/internals/shared/grpc/loginfilter_test.go index d9df26420..edc256550 100644 --- a/management/internals/shared/grpc/loginfilter_test.go +++ b/management/internals/shared/grpc/loginfilter_test.go @@ -20,6 +20,7 @@ func testAdvancedCfg() *lfConfig { baseBlockDuration: 100 * time.Millisecond, reconnLimitForBan: 3, metaChangeLimit: 2, + maxBanLevel: 3, } } @@ -157,6 +158,187 @@ func (s *LoginFilterTestSuite) TestMetaChangeIsAllowedAfterWindowResets() { s.Equal(1, s.filter.logged[pubKey].metaChangeCounter, "meta change counter should reset") } +func (s *LoginFilterTestSuite) TestReconnectStormAfterQuietPeriodTriggersBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + s.filter.addLogin(pubKey, meta) + s.Require().Contains(s.filter.logged, pubKey) + s.filter.logged[pubKey].sessionStart = time.Now().Add(-(s.filter.cfg.reconnThreshold + time.Second)) + + s.filter.addLogin(pubKey, meta) + s.Equal(1, s.filter.logged[pubKey].sessionCounter, "expired window should restart the count") + + for i := 1; i < limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.True(s.filter.allowLogin(pubKey, meta)) + s.False(s.filter.logged[pubKey].isBanned) + + s.filter.addLogin(pubKey, meta) + + s.False(s.filter.allowLogin(pubKey, meta)) + s.True(s.filter.logged[pubKey].isBanned) +} + +func (s *LoginFilterTestSuite) TestReconnectStormAfterBanExpiresTriggersBanAgain() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.Require().Contains(s.filter.logged, pubKey) + s.Require().True(s.filter.logged[pubKey].isBanned) + + expired := time.Now().Add(-(s.filter.cfg.baseBlockDuration + time.Second)) + s.filter.logged[pubKey].banExpiresAt = expired + s.filter.logged[pubKey].sessionStart = expired + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(2, s.filter.logged[pubKey].banLevel) +} + +func (s *LoginFilterTestSuite) TestSlowReconnectsAcrossWindowsDoNotBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + for i := 0; i < limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.Require().Contains(s.filter.logged, pubKey) + s.filter.logged[pubKey].sessionStart = time.Now().Add(-(s.filter.cfg.reconnThreshold + time.Second)) + + for i := 0; i < limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.allowLogin(pubKey, meta)) + s.False(s.filter.logged[pubKey].isBanned) +} + +func (s *LoginFilterTestSuite) TestBanLevelEscalatesWhenStormResumesRightAfterBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + banTime := time.Now().Add(-3 * s.filter.cfg.baseBlockDuration) + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + isBanned: true, + banLevel: 1, + banExpiresAt: time.Now().Add(-time.Millisecond), + sessionStart: banTime, + lastSeen: banTime, + } + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(2, s.filter.logged[pubKey].banLevel) +} + +func (s *LoginFilterTestSuite) TestBanLevelResetsAfterQuietPeriodFollowingBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + quiet := 2*s.filter.cfg.baseBlockDuration + time.Second + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + banLevel: 2, + banExpiresAt: time.Now().Add(-s.filter.cfg.baseBlockDuration), + lastSeen: time.Now().Add(-2 * quiet), + } + + s.filter.addLogin(pubKey, meta) + s.Equal(2, s.filter.logged[pubKey].banLevel, "ban ended more recently than the quiet period") + + s.filter.logged[pubKey].banExpiresAt = time.Now().Add(-quiet) + s.filter.logged[pubKey].lastSeen = time.Now().Add(-2 * quiet) + + s.filter.addLogin(pubKey, meta) + s.Equal(0, s.filter.logged[pubKey].banLevel) +} + +func (s *LoginFilterTestSuite) TestBanDurationIsCappedAtMaxLevel() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + maxLevel := s.filter.cfg.maxBanLevel + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + banLevel: maxLevel, + sessionStart: time.Now(), + lastSeen: time.Now(), + } + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(maxLevel, s.filter.logged[pubKey].banLevel) + expected := s.filter.cfg.baseBlockDuration << (maxLevel - 1) + s.InDelta(expected, s.filter.logged[pubKey].banExpiresAt.Sub(s.filter.logged[pubKey].lastSeen), float64(time.Millisecond)) +} + +func (s *LoginFilterTestSuite) TestEstablishedPeerReconnectingOnceIsAllowed() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + longAgo := time.Now().Add(-time.Hour) + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + sessionCounter: 1, + sessionStart: longAgo, + lastSeen: longAgo, + metaChangeWindowStart: longAgo, + metaChangeCounter: 1, + } + + s.True(s.filter.allowLogin(pubKey, meta)) + s.filter.addLogin(pubKey, meta) + + s.True(s.filter.allowLogin(pubKey, meta)) + s.False(s.filter.logged[pubKey].isBanned) + s.Equal(1, s.filter.logged[pubKey].sessionCounter) +} + +func (s *LoginFilterTestSuite) TestLoginsDuringActiveBanDoNotExtendIt() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.Require().Contains(s.filter.logged, pubKey) + s.Require().True(s.filter.logged[pubKey].isBanned) + expiresAt := time.Now().Add(time.Hour) + s.filter.logged[pubKey].banExpiresAt = expiresAt + lastSeen := s.filter.logged[pubKey].lastSeen + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(1, s.filter.logged[pubKey].banLevel) + s.Equal(expiresAt, s.filter.logged[pubKey].banExpiresAt) + s.Equal(lastSeen, s.filter.logged[pubKey].lastSeen) + s.Equal(0, s.filter.logged[pubKey].sessionCounter) +} + func BenchmarkHashingMethods(b *testing.B) { meta := nbpeer.PeerSystemMeta{ WtVersion: "1.25.1",