From ad456879f10594c2f4bf5c9ff49621491935c672 Mon Sep 17 00:00:00 2001 From: whiteo Date: Mon, 22 Jun 2026 21:11:03 +0200 Subject: [PATCH 1/2] perf: harden hot paths, fix connection-path data races, externalize runtime tunables --- cmd/main.go | 11 +- internal/client/client.go | 34 ++++- internal/client/gs_client.go | 15 ++- internal/crypto/crypt.go | 33 ++++- internal/crypto/crypt_bench_test.go | 38 ++++++ internal/crypto/crypt_test.go | 21 +++ internal/db/db.go | 5 + internal/db/l2auth.sql.go | 5 + internal/db/models.go | 5 + internal/listener/client_listener.go | 47 +++---- .../listener/client_listener_race_test.go | 91 +++++++++++++ internal/listener/client_listener_test.go | 1 + internal/listener/gs_listener.go | 18 +-- internal/metrics/metrics.go | 11 ++ internal/middleware/rate_limiter.go | 126 +++++++++--------- .../middleware/rate_limiter_bench_test.go | 104 +++++++++++++++ internal/middleware/rate_limiter_race_test.go | 89 +++++++++++++ internal/packet/client/gs_login.go | 4 +- internal/packet/client/login.go | 44 +++--- internal/packet/client/server_list.go | 3 +- internal/packet/gs/init.go | 9 +- internal/packet/gs/validate.go | 1 + internal/service/authenticator.go | 6 +- internal/service/authenticator_test.go | 3 +- internal/service/ban_manager.go | 18 +-- internal/service/ban_manager_test.go | 4 +- internal/service/server_list.go | 20 ++- internal/service/server_list_test.go | 6 +- internal/service/session_registry.go | 2 +- internal/session/session.go | 59 +++++--- internal/session/session_race_test.go | 64 +++++++++ internal/session/session_test.go | 47 +++++++ pkg/crypto/scrambled_key.go | 50 ++++--- pkg/crypto/scrambled_key_bench_test.go | 69 ++++++++++ pkg/crypto/scrambled_key_test.go | 73 ++++++++++ pkg/network/packet_reader.go | 29 ++-- pkg/network/packet_test.go | 44 ++++++ 37 files changed, 985 insertions(+), 224 deletions(-) create mode 100644 internal/crypto/crypt_bench_test.go create mode 100644 internal/listener/client_listener_race_test.go create mode 100644 internal/middleware/rate_limiter_bench_test.go create mode 100644 internal/middleware/rate_limiter_race_test.go create mode 100644 internal/session/session_race_test.go create mode 100644 internal/session/session_test.go create mode 100644 pkg/crypto/scrambled_key_bench_test.go create mode 100644 pkg/crypto/scrambled_key_test.go diff --git a/cmd/main.go b/cmd/main.go index 752f1a6..dd9957b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -47,7 +47,7 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) // Start session garbage collection with a 120-second interval - session.StartGC(120*time.Second, ctx.Done()) + session.StartGC(time.Duration(120)*time.Second, ctx.Done()) cfgDB := cfg.Database dbPool, err := database.OpenPool( @@ -75,8 +75,8 @@ func main() { serverList := service.NewServerList() authenticator := service.NewAuthenticator(queries, cfg.Account.AutoCreateAcc) sessions := service.NewSessionRegistry() - bans := service.NewBanManager(ctx, queries, cfg.Account.AttemptsLoginCount) - limiter := middleware.NewRateLimiter(cfg.LoginRateLimit, time.Second) + bans := service.NewBanManager(ctx, queries, cfg.Account.AttemptsLoginCount, time.Duration(300)*time.Second) + limiter := middleware.NewRateLimiter(cfg.LoginRateLimit, time.Duration(30)*time.Second) kickManager := service.NewKickManager() clientListener := listener.NewClientListener( @@ -87,11 +87,12 @@ func main() { bans, limiter, kickManager, - 30*time.Second, // 30-second client connection timeout + time.Duration(30)*time.Second, // 30-second client connection timeout + time.Duration(30)*time.Second, // 30-second client handoff TTL ) log.Info().Msg("Client listener initialized") - gameServerListener := listener.NewGameServerListener(serverList, sessions, 30*time.Second) // 30-second game server connection timeout + gameServerListener := listener.NewGameServerListener(serverList, sessions, time.Duration(30)*time.Second) log.Info().Msg("Game server listener initialized") // Wire kick manager diff --git a/internal/client/client.go b/internal/client/client.go index 6fb837d..842bd34 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -6,6 +6,9 @@ package client import ( + "net/netip" + "sync" + "sync/atomic" "time" loginCrypto "github.com/mmo-dev-team/l2go-auth/internal/crypto" @@ -29,19 +32,46 @@ const ( // Client represents a connected user on the Login Server. type Client struct { - LastActivity time.Time Conn gnet.Conn Crypt *loginCrypto.Crypt ScrambledKey *crypto.ScrambledKey - SessionKey crypto.SessionKey + RemoteAddr netip.Addr RemoteIP string Account string + lastActivity atomic.Int64 AccountID int64 + SessionKey crypto.SessionKey + idMu sync.RWMutex LastServer int32 SessionID int32 State State } +// Touch records the current time as the last activity timestamp (atomic, lock-free). +func (c *Client) Touch() { + c.lastActivity.Store(time.Now().UnixNano()) +} + +// LastActivityNanos returns the last activity timestamp as Unix nanoseconds. +func (c *Client) LastActivityNanos() int64 { + return c.lastActivity.Load() +} + +// SetAccount publishes the authenticated account name under the identity. +func (c *Client) SetAccount(account string) { + c.idMu.Lock() + c.Account = account + c.idMu.Unlock() +} + +// AccountName reads the account name under the identity lock. +func (c *Client) AccountName() string { + c.idMu.RLock() + a := c.Account + c.idMu.RUnlock() + return a +} + // SendAsync sends a packet to the client asynchronously. func (c *Client) SendAsync(opcode byte, build func(w *network.PacketWriter)) error { w := network.GetPacketWriter() diff --git a/internal/client/gs_client.go b/internal/client/gs_client.go index 5baa446..cbe2a0b 100644 --- a/internal/client/gs_client.go +++ b/internal/client/gs_client.go @@ -6,6 +6,7 @@ package client import ( + "sync/atomic" "time" "github.com/mmo-dev-team/l2go-auth/pkg/network" @@ -16,12 +17,22 @@ import ( // GameServerClient represents a connected Game Server on the internal listener. type GameServerClient struct { - LastActivity time.Time Conn gnet.Conn RemoteIP string + lastActivity atomic.Int64 ServerID int32 } +// Touch records the current time as the last activity timestamp (atomic, lock-free). +func (gsc *GameServerClient) Touch() { + gsc.lastActivity.Store(time.Now().UnixNano()) +} + +// LastActivityNanos returns the last activity timestamp as Unix nanoseconds. +func (gsc *GameServerClient) LastActivityNanos() int64 { + return gsc.lastActivity.Load() +} + // Send constructs a packet with the given opcode and body and sends it to the Game Server. func (gsc *GameServerClient) Send(opcode byte, build func(w *network.PacketWriter)) error { w := network.GetPacketWriter() @@ -37,7 +48,7 @@ func (gsc *GameServerClient) Send(opcode byte, build func(w *network.PacketWrite return err } - gsc.LastActivity = time.Now() + gsc.Touch() return nil } diff --git a/internal/crypto/crypt.go b/internal/crypto/crypt.go index 8a38b96..490d1df 100644 --- a/internal/crypto/crypt.go +++ b/internal/crypto/crypt.go @@ -13,15 +13,18 @@ import ( "github.com/mmo-dev-team/l2go-auth/pkg/network" ) -// StaticKey is the default blowfish key used for the initial handshake. -var StaticKey = []byte{0x6b, 0x60, 0xcb, 0x5b, 0x82, 0xce, 0x90, 0xb1, 0xcc, 0x2b, 0x6c, 0x55, 0x6c, 0x6c, 0x6c, 0x6c} - // Crypt handles packet encryption and decryption using the Blowfish algorithm and a custom XOR-based checksum. type Crypt struct { - updatedKey bool cipher *crypto.BlowfishCipher + updatedKey bool } +// StaticKey is the default blowfish key used for the initial handshake. +var StaticKey = []byte{0x6b, 0x60, 0xcb, 0x5b, 0x82, 0xce, 0x90, 0xb1, 0xcc, 0x2b, 0x6c, 0x55, 0x6c, 0x6c, 0x6c, 0x6c} + +// staticCipher is the read-only Blowfish cipher for the initial handshake (Init) packet. +var staticCipher = crypto.NewBlowfishCipher(StaticKey) + // NewCrypt creates a new Crypt instance with the given Blowfish key. func NewCrypt(key []byte) *Crypt { return &Crypt{ @@ -73,6 +76,28 @@ func (c *Crypt) Decrypt(data []byte, size int) bool { return true } +// EncryptStatic encrypts the initial Init packet in place using the shared static Blowfish cipher. +func EncryptStatic(w *network.PacketWriter) { + payloadLen := len(w.Bytes()) - 2 // Exclude the 2-byte packet length header + + reserve := 16 // Init packet reserves 16 bytes for Blowfish padding and XOR key + w.Extend(reserve) + payloadLen += reserve + + pad := 8 - (payloadLen % 8) // Blowfish requires 8-byte block alignment + if pad != 8 { + w.Extend(pad) + payloadLen += pad + } + + data := w.Bytes()[2:] // Skip length header + + xorKey := rand.Uint32() + encXorPass(data, uint32(payloadLen), xorKey) + + staticCipher.CipherRange(data, 0, payloadLen) +} + // encXorPass applies a custom XOR-based checksum and obfuscation pass to the data. func encXorPass(raw []byte, size uint32, key uint32) { if uint32(len(raw)) < size || size < 8 { diff --git a/internal/crypto/crypt_bench_test.go b/internal/crypto/crypt_bench_test.go new file mode 100644 index 0000000..13473be --- /dev/null +++ b/internal/crypto/crypt_bench_test.go @@ -0,0 +1,38 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + +package crypto + +import ( + "testing" + + "github.com/mmo-dev-team/l2go-auth/pkg/network" +) + +// BenchmarkEncryptStatic is the current per-accept Init encryption: a shared read-only Blowfish cipher. +func BenchmarkEncryptStatic(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + w := network.GetPacketWriter() + buildInitPayload(w) + EncryptStatic(w) + network.PutPacketWriter(w) + } +} + +func buildInitPayload(w *network.PacketWriter) { + w.WriteByte(0x00) + w.WriteInt32(12345) + w.WriteInt32(0x0000c621) + var modulus [128]byte + w.WriteBytes(modulus[:]) + w.WriteUint32(0x29DD954E) + w.WriteUint32(0x77C39CFC) + w.WriteUint32(0x97ADB620) + w.WriteUint32(0x07BDE0F7) + var secret [16]byte + w.WriteBytes(secret[:]) + w.WriteByte(0x00) +} diff --git a/internal/crypto/crypt_test.go b/internal/crypto/crypt_test.go index e95fbe3..10266c5 100644 --- a/internal/crypto/crypt_test.go +++ b/internal/crypto/crypt_test.go @@ -31,6 +31,27 @@ func TestCryptEncryptionPadding(t *testing.T) { } } +func TestEncryptStaticPadding(t *testing.T) { + w := network.GetPacketWriter() + defer network.PutPacketWriter(w) + + w.WriteByte(0x00) + w.WriteInt32(12345) + w.PrependLength() + + before := len(w.Bytes()) + EncryptStatic(w) + + data := w.Bytes() + payloadSize := len(data) - 2 + if payloadSize%8 != 0 { + t.Errorf("Expected payload size to be multiple of 8, got %d", payloadSize) + } + if len(data) <= before { + t.Errorf("Expected EncryptStatic to grow the buffer by reserve+padding, before=%d after=%d", before, len(data)) + } +} + func TestCryptDecryptSizeValidation(t *testing.T) { key := StaticKey crypt := NewCrypt(key) diff --git a/internal/db/db.go b/internal/db/db.go index 9d485b5..764b1e0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,3 +1,8 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.30.0 diff --git a/internal/db/l2auth.sql.go b/internal/db/l2auth.sql.go index f7d8de4..1427e5f 100644 --- a/internal/db/l2auth.sql.go +++ b/internal/db/l2auth.sql.go @@ -1,3 +1,8 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.30.0 diff --git a/internal/db/models.go b/internal/db/models.go index edd86ab..d0bbd2c 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -1,3 +1,8 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.30.0 diff --git a/internal/listener/client_listener.go b/internal/listener/client_listener.go index 249e2da..f3cf318 100644 --- a/internal/listener/client_listener.go +++ b/internal/listener/client_listener.go @@ -27,11 +27,11 @@ import ( "github.com/rs/zerolog/log" ) +var globalSessionID atomic.Int32 + // ClientListener handles incoming network connections from game clients. -// It manages client sessions, authentication, and packet routing. type ClientListener struct { - clientMu sync.Mutex - timeout time.Duration + kicker service.Kicker *gnet.BuiltinEventEngine Engine *gnet.Engine queries *db.Queries @@ -40,10 +40,11 @@ type ClientListener struct { sessions *service.SessionRegistry bans *service.BanManager limiter *middleware.RateLimiter - kicker service.Kicker registry *clientPacket.Registry clients map[gnet.Conn]*client.Client stopCh chan struct{} + timeout time.Duration + clientMu sync.Mutex } // NewClientListener creates a new instance of ClientListener. @@ -56,6 +57,7 @@ func NewClientListener( limiter *middleware.RateLimiter, kicker service.Kicker, timeout time.Duration, + handoffTTL time.Duration, ) *ClientListener { listener := &ClientListener{ queries: queries, @@ -77,7 +79,7 @@ func NewClientListener( listener.registry.Register(clientPacket.ClientAuthGG, clientPacket.AuthGG) listener.registry.Register(clientPacket.ClientLogin, loginCtrl.HandleLogin) listener.registry.Register(clientPacket.ClientServerList, serverListCtrl.HandleServerList) - listener.registry.Register(clientPacket.ClientGameServerLogin, clientPacket.GameServerLogin(auth)) + listener.registry.Register(clientPacket.ClientGameServerLogin, clientPacket.GameServerLogin(auth, handoffTTL)) return listener } @@ -89,18 +91,15 @@ func (l *ClientListener) OnBoot(eng gnet.Engine) (action gnet.Action) { } // OnOpen is called when a new connection is opened. -// It sends the initial server packet (Init) to the client. func (l *ClientListener) OnOpen(c gnet.Conn) (out []byte, action gnet.Action) { ip := getIP(c) + ipAddr, _ := netip.ParseAddr(ip) - // 1. Check Rate Limit (Connection Flood Protection) - if l.limiter.IsLimited(ip) { + // 1. Connection flood protection — single atomic check-and-record per accept. + if !l.limiter.Allow(ipAddr) { log.Warn().Str("ip", ip).Msg("Rate limit exceeded, dropping connection") return nil, gnet.Close } - l.limiter.AddAttempt(ip) - - ipAddr, _ := netip.ParseAddr(ip) if l.bans.IsBanned(ipAddr) { log.Warn().Str("ip", ip).Msg("Banned IP attempt") @@ -122,13 +121,14 @@ func (l *ClientListener) OnOpen(c gnet.Conn) (out []byte, action gnet.Action) { cl := &client.Client{ Conn: c, RemoteIP: ip, + RemoteAddr: ipAddr, Crypt: crypt, ScrambledKey: scrambledKey, SessionKey: *sessionKey, SessionID: sessionID, State: client.StateConnected, - LastActivity: time.Now(), } + cl.Touch() l.clientMu.Lock() l.clients[c] = cl @@ -154,8 +154,7 @@ func (l *ClientListener) OnOpen(c gnet.Conn) (out []byte, action gnet.Action) { w.WriteBytes(secretKey) w.WriteByte(0x00) // Null terminator for the key - staticCrypt := loginCrypto.NewCrypt(loginCrypto.StaticKey) - staticCrypt.Encrypt(w) + loginCrypto.EncryptStatic(w) w.PrependLength() @@ -219,7 +218,7 @@ func (l *ClientListener) OnTraffic(c gnet.Conn) (action gnet.Action) { return gnet.Close } - cl.LastActivity = time.Now() + cl.Touch() buf = buf[size:] c.Discard(size) @@ -234,11 +233,11 @@ func (l *ClientListener) OnClose(c gnet.Conn, _ error) (action gnet.Action) { cl, ok := l.clients[c] if ok { delete(l.clients, c) - if cl.Account != "" { - if sess, active := l.sessions.Get(cl.Account); active && sess.SessionID == cl.SessionID { + if account := cl.AccountName(); account != "" { + if sess, active := l.sessions.Get(account); active && sess.SessionID == cl.SessionID { if sess.ServerID == 0 { - l.sessions.Unregister(cl.Account, cl.SessionID) - l.serverList.RemoveAccount(cl.Account) + l.sessions.Unregister(account, cl.SessionID) + l.serverList.RemoveAccount(account) } } } @@ -259,13 +258,13 @@ func (l *ClientListener) OnShutdown(_ gnet.Engine) { } // OnTick is called periodically by the engine. -// It checks for timed-out client connections. func (l *ClientListener) OnTick() (delay time.Duration, action gnet.Action) { - now := time.Now() + nowNanos := time.Now().UnixNano() + timeout := int64(l.timeout) l.clientMu.Lock() for conn, cl := range l.clients { - if now.Sub(cl.LastActivity) > l.timeout { + if nowNanos-cl.LastActivityNanos() > timeout { log.Warn().Str("ip", cl.RemoteIP).Msg("Connection timeout") conn.Close() delete(l.clients, conn) @@ -283,7 +282,7 @@ func (l *ClientListener) KickAccount(username string) { defer l.clientMu.Unlock() for conn, cl := range l.clients { - if cl.Account == username { + if cl.AccountName() == username { log.Info().Str("account", username).Msg("Kicking account from Login Server") conn.Close() delete(l.clients, conn) @@ -299,8 +298,6 @@ func getIP(c gnet.Conn) string { return c.RemoteAddr().String() } -var globalSessionID atomic.Int32 - func generateSessionID() int32 { return globalSessionID.Add(1) ^ int32(time.Now().UnixNano()) } diff --git a/internal/listener/client_listener_race_test.go b/internal/listener/client_listener_race_test.go new file mode 100644 index 0000000..fb2cc76 --- /dev/null +++ b/internal/listener/client_listener_race_test.go @@ -0,0 +1,91 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + +package listener + +import ( + "net" + "strconv" + "sync" + "testing" + "time" + + "github.com/mmo-dev-team/l2go-auth/internal/client" + + "github.com/panjf2000/gnet/v2" +) + +// TestClientListener_ConcurrentFieldAccess reproduces the production contention +// that previously raced: the off-loop login worker publishes Account and touches +// LastActivity while the timeout sweep (OnTick) and KickAccount scan those same +// fields from other goroutines. With -race this fails on the unsynchronized +// version and passes once Account is mutex-guarded and LastActivity is atomic. +func TestClientListener_ConcurrentFieldAccess(t *testing.T) { + const n = 64 + + l := &ClientListener{ + clients: make(map[gnet.Conn]*client.Client), + timeout: time.Hour, // large so OnTick never deletes; it still reads LastActivity + } + + cls := make([]*client.Client, 0, n) + for i := 0; i < n; i++ { + conn := &MockConn{addr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 10000 + i}} + cl := &client.Client{Conn: conn} + cl.Touch() + l.clients[conn] = cl + cls = append(cls, cl) + } + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // Writers: mimic processLogin publishing identity + OnTraffic touching activity. + for w := 0; w < 4; w++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + name := "user" + strconv.Itoa(id) + for { + select { + case <-stop: + return + default: + } + for _, cl := range cls { + cl.SetAccount(name) + cl.Touch() + } + } + }(w) + } + + // Readers: mimic KickAccount (scans AccountName) and OnTick (scans LastActivity). + for r := 0; r < 4; r++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + l.KickAccount("no-such-account") // scans AccountName under clientMu, matches nothing + l.OnTick() // scans LastActivityNanos under clientMu + } + }() + } + + time.Sleep(150 * time.Millisecond) + close(stop) + wg.Wait() + + if len(l.clients) != n { + t.Fatalf("expected %d clients to remain, got %d", n, len(l.clients)) + } +} + +func (m *MockConn) Close() error { return nil } diff --git a/internal/listener/client_listener_test.go b/internal/listener/client_listener_test.go index 9a2a4ff..dd1fb4a 100644 --- a/internal/listener/client_listener_test.go +++ b/internal/listener/client_listener_test.go @@ -43,6 +43,7 @@ func TestClientListener_OnOpen(t *testing.T) { // 1. Setup crypto dependencies crypto.InitRSAPool(2) crypto.GenerateLoginBlowFishKeys() + crypto.GenerateGameBlowFishKeys() // 2. Setup mock dependencies l := &ClientListener{ diff --git a/internal/listener/gs_listener.go b/internal/listener/gs_listener.go index eafaa2c..be2f4d5 100644 --- a/internal/listener/gs_listener.go +++ b/internal/listener/gs_listener.go @@ -21,8 +21,6 @@ import ( // GameServerListener handles network connections from game servers. type GameServerListener struct { - serverMu sync.Mutex - timeout time.Duration *gnet.BuiltinEventEngine Engine *gnet.Engine serverList *service.ServerList @@ -30,6 +28,8 @@ type GameServerListener struct { registry *gs.Registry servers map[gnet.Conn]*client.GameServerClient stopCh chan struct{} + timeout time.Duration + serverMu sync.Mutex } // NewGameServerListener creates a new instance of GameServerListener. @@ -73,10 +73,10 @@ func (gsl *GameServerListener) OnOpen(c gnet.Conn) (out []byte, action gnet.Acti ip := getIP(c) gsc := &client.GameServerClient{ - Conn: c, - RemoteIP: ip, - LastActivity: time.Now(), + Conn: c, + RemoteIP: ip, } + gsc.Touch() gsl.serverMu.Lock() gsl.servers[c] = gsc @@ -135,7 +135,7 @@ func (gsl *GameServerListener) OnTraffic(c gnet.Conn) (action gnet.Action) { return gnet.Close } - gsc.LastActivity = time.Now() + gsc.Touch() buf = buf[size:] c.Discard(size) @@ -171,13 +171,13 @@ func (gsl *GameServerListener) OnShutdown(_ gnet.Engine) { } // OnTick is called periodically to perform maintenance tasks. -// It checks for timed-out game server connections. func (gsl *GameServerListener) OnTick() (delay time.Duration, action gnet.Action) { - now := time.Now() + nowNanos := time.Now().UnixNano() + timeout := int64(gsl.timeout) gsl.serverMu.Lock() for conn, cl := range gsl.servers { - if now.Sub(cl.LastActivity) > gsl.timeout { + if nowNanos-cl.LastActivityNanos() > timeout { log.Warn().Str("ip", cl.RemoteIP).Msg("Connection timeout") conn.Close() delete(gsl.servers, conn) diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 846eb6d..744f57c 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -42,3 +42,14 @@ var ( Buckets: prometheus.DefBuckets, }) ) + +var ( + LoginSuccess = LoginAttempts.WithLabelValues("success", "") + LoginFailInvalidPwd = LoginAttempts.WithLabelValues("fail", "invalid_password") + LoginFailNotFound = LoginAttempts.WithLabelValues("fail", "account_not_found") + LoginFailBanned = LoginAttempts.WithLabelValues("fail", "account_banned") + LoginFailSystem = LoginAttempts.WithLabelValues("fail", "system_error") + + DBFindAccount = DBQueryDuration.WithLabelValues("FindAccount") + DBRegisterAccount = DBQueryDuration.WithLabelValues("RegisterAccount") +) diff --git a/internal/middleware/rate_limiter.go b/internal/middleware/rate_limiter.go index 3fe3d88..0dc72c1 100644 --- a/internal/middleware/rate_limiter.go +++ b/internal/middleware/rate_limiter.go @@ -6,32 +6,73 @@ package middleware import ( + "net/netip" "sync" "time" ) -// RateLimiter implements a simple sliding-window rate limiting mechanism for IP addresses. +const rlShardCount = 64 // Must be a power of 2 + +// rlEntry is a fixed-window counter. +type rlEntry struct { + windowStart int64 // Unix-nanos start of the current window + count int32 +} + +// rlShard isolates a subset of addresses under its own lock to cut contention. +type rlShard struct { + entries map[netip.Addr]rlEntry + mu sync.Mutex +} + +// RateLimiter implements a sharded fixed-window rate limiter keyed by IP address. type RateLimiter struct { - mu sync.Mutex - attempts map[string][]time.Time - limit int - window time.Duration + shards [rlShardCount]*rlShard + window time.Duration + limit int32 } // NewRateLimiter creates a new RateLimiter with the specified limit and time window. func NewRateLimiter(limit int, window time.Duration) *RateLimiter { rl := &RateLimiter{ - attempts: make(map[string][]time.Time), - limit: limit, - window: window, + limit: int32(limit), + window: window, + } + for i := range rl.shards { + rl.shards[i] = &rlShard{entries: make(map[netip.Addr]rlEntry, 1024)} } rl.startCleanup() return rl } +// Allow records an attempt for ip and reports whether it is within the limit. +// It is safe for concurrent use and runs in O(1) with no steady-state allocations. +func (r *RateLimiter) Allow(ip netip.Addr) bool { + now := time.Now().UnixNano() + win := int64(r.window) + + sh := r.shardFor(ip) + sh.mu.Lock() + + e := sh.entries[ip] + if now-e.windowStart >= win { + e.windowStart = now + e.count = 0 + } + + if e.count >= r.limit { + sh.mu.Unlock() + return false + } + + e.count++ + sh.entries[ip] = e + sh.mu.Unlock() + return true +} + func (r *RateLimiter) startCleanup() { go func() { - // Periodically clean up expired entries every 10 minutes ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() @@ -41,63 +82,26 @@ func (r *RateLimiter) startCleanup() { }() } +// cleanup evicts addresses whose window has fully expired. func (r *RateLimiter) cleanup() { - r.mu.Lock() - defer r.mu.Unlock() - - now := time.Now() - for ip, attempts := range r.attempts { - valid := attempts[:0] - for _, t := range attempts { - if now.Sub(t) <= r.window { - valid = append(valid, t) + cutoff := time.Now().UnixNano() - int64(r.window) + for _, sh := range r.shards { + sh.mu.Lock() + for ip, e := range sh.entries { + if e.windowStart < cutoff { + delete(sh.entries, ip) } } - if len(valid) == 0 { - delete(r.attempts, ip) - } else { - r.attempts[ip] = valid - } + sh.mu.Unlock() } } -// IsLimited checks if the provided IP address has exceeded the rate limit. -func (r *RateLimiter) IsLimited(ip string) bool { - r.mu.Lock() - defer r.mu.Unlock() - - now := time.Now() - attempts := r.attempts[ip] - - count := 0 - for _, t := range attempts { - if now.Sub(t) <= r.window { - count++ - } +func (r *RateLimiter) shardFor(ip netip.Addr) *rlShard { + b := ip.As16() // [16]byte by value — no allocation + var h uint32 = 2166136261 + for i := 0; i < 16; i++ { + h ^= uint32(b[i]) + h *= 16777619 } - return count >= r.limit -} - -// AddAttempt records a new attempt for the given IP address. -func (r *RateLimiter) AddAttempt(ip string) { - r.mu.Lock() - defer r.mu.Unlock() - - now := time.Now() - attempts := r.attempts[ip] - - valid := attempts[:0] - for _, t := range attempts { - if now.Sub(t) <= r.window { - valid = append(valid, t) - } - } - r.attempts[ip] = append(valid, now) -} - -// Reset clears all recorded attempts for the specified IP address. -func (r *RateLimiter) Reset(ip string) { - r.mu.Lock() - defer r.mu.Unlock() - delete(r.attempts, ip) + return r.shards[h&(rlShardCount-1)] } diff --git a/internal/middleware/rate_limiter_bench_test.go b/internal/middleware/rate_limiter_bench_test.go new file mode 100644 index 0000000..901fc49 --- /dev/null +++ b/internal/middleware/rate_limiter_bench_test.go @@ -0,0 +1,104 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + +package middleware + +import ( + "net" + "net/netip" + "sync" + "testing" + "time" +) + +// oldRateLimiter reproduces the pre-fix implementation. +type oldRateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time + limit int + window time.Duration +} + +func (r *oldRateLimiter) IsLimited(ip string) bool { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now() + count := 0 + for _, t := range r.attempts[ip] { + if now.Sub(t) <= r.window { + count++ + } + } + return count >= r.limit +} + +func (r *oldRateLimiter) AddAttempt(ip string) { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now() + attempts := r.attempts[ip] + valid := attempts[:0] + for _, t := range attempts { + if now.Sub(t) <= r.window { + valid = append(valid, t) + } + } + r.attempts[ip] = append(valid, now) +} + +// BenchmarkRateLimit_Old mimics OnOpen pre-fix: getIP returns a string. +func BenchmarkRateLimit_Old(b *testing.B) { + r := &oldRateLimiter{attempts: make(map[string][]time.Time), limit: 1 << 30, window: time.Second} + tcp := &net.TCPAddr{IP: net.IPv4(192, 168, 1, 50), Port: 5000} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + ip := tcp.IP.String() // OnOpen derived the key as a string + if !r.IsLimited(ip) { + r.AddAttempt(ip) + } + } +} + +// BenchmarkRateLimit_New is the current path: parse once to netip.Addr, single +// atomic Allow (prune+count+record under one shard lock). +func BenchmarkRateLimit_New(b *testing.B) { + r := NewRateLimiter(1<<30, time.Second) + addr := netip.AddrFrom4([4]byte{192, 168, 1, 50}) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + r.Allow(addr) + } +} + +// BenchmarkRateLimit_OldParallel / NewParallel show contention behavior across +// many source IPs (global mutex vs 64 shards). +func BenchmarkRateLimit_OldParallel(b *testing.B) { + r := &oldRateLimiter{attempts: make(map[string][]time.Time), limit: 1 << 30, window: time.Second} + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + n := 0 + for pb.Next() { + ip := net.IPv4(10, 0, byte(n>>8), byte(n)).String() + if !r.IsLimited(ip) { + r.AddAttempt(ip) + } + n++ + } + }) +} + +func BenchmarkRateLimit_NewParallel(b *testing.B) { + r := NewRateLimiter(1<<30, time.Second) + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + n := 0 + for pb.Next() { + r.Allow(netip.AddrFrom4([4]byte{10, 0, byte(n >> 8), byte(n)})) + n++ + } + }) +} diff --git a/internal/middleware/rate_limiter_race_test.go b/internal/middleware/rate_limiter_race_test.go new file mode 100644 index 0000000..8089ffb --- /dev/null +++ b/internal/middleware/rate_limiter_race_test.go @@ -0,0 +1,89 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + +package middleware + +import ( + "net/netip" + "sync" + "testing" + "time" +) + +// TestRateLimiter_ConcurrentAllow hammers Allow from many goroutines across a mix +// of addresses (same-shard contention and cross-shard) to verify the single-lock. +func TestRateLimiter_ConcurrentAllow(t *testing.T) { + rl := NewRateLimiter(5, 50*time.Millisecond) + + addrs := make([]netip.Addr, 0, 32) + for i := 0; i < 32; i++ { + addrs = append(addrs, netip.AddrFrom4([4]byte{10, 0, byte(i / 256), byte(i)})) + } + + var wg sync.WaitGroup + stop := make(chan struct{}) + + for g := 0; g < 16; g++ { + wg.Add(1) + go func(seed int) { + defer wg.Done() + i := seed + for { + select { + case <-stop: + return + default: + } + rl.Allow(addrs[i%len(addrs)]) + i++ + } + }(g) + } + + // Concurrent cleanup sweeps racing the Allow calls. + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + rl.cleanup() + } + }() + + time.Sleep(150 * time.Millisecond) + close(stop) + wg.Wait() +} + +// TestRateLimiter_LimitEnforced confirms the fixed window admits exactly `limit`. +func TestRateLimiter_LimitEnforced(t *testing.T) { + rl := NewRateLimiter(5, time.Hour) // long window: no reset during the test + ip := netip.MustParseAddr("203.0.113.7") + + const callers = 50 + var allowed int64 + var mu sync.Mutex + var wg sync.WaitGroup + for i := 0; i < callers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if rl.Allow(ip) { + mu.Lock() + allowed++ + mu.Unlock() + } + }() + } + wg.Wait() + + if allowed != 5 { + t.Fatalf("expected exactly 5 allowed within the window, got %d", allowed) + } +} diff --git a/internal/packet/client/gs_login.go b/internal/packet/client/gs_login.go index 1af9b92..e42fc61 100644 --- a/internal/packet/client/gs_login.go +++ b/internal/packet/client/gs_login.go @@ -19,7 +19,7 @@ import ( ) // GameServerLogin creates a handler for the game server login request. -func GameServerLogin(auth *service.Authenticator) Handler { +func GameServerLogin(auth *service.Authenticator, handoffTTL time.Duration) Handler { return func(c *client.Client, r *network.PacketReader) error { if c.State != client.StateServerList { log.Warn().Str("ip", c.RemoteIP).Msg("GameServerLogin before server list") @@ -55,7 +55,7 @@ func GameServerLogin(auth *service.Authenticator) Handler { } s := &session.Session{ - ExpiresAt: time.Now().Add(1 * time.Minute), + ExpiresAt: time.Now().Add(handoffTTL), Key: &c.SessionKey, ServerID: serverID, AccountID: c.AccountID, diff --git a/internal/packet/client/login.go b/internal/packet/client/login.go index deb2364..f8f5851 100644 --- a/internal/packet/client/login.go +++ b/internal/packet/client/login.go @@ -8,7 +8,6 @@ package client import ( "context" "errors" - "net/netip" "runtime" "strings" "time" @@ -20,11 +19,11 @@ import ( "github.com/mmo-dev-team/l2go-auth/pkg/crypto" "github.com/mmo-dev-team/l2go-auth/pkg/network" + "github.com/prometheus/client_golang/prometheus" "github.com/rs/zerolog/log" ) -// LoginController manages the account authentication flow, including RSA decryption, -// password hashing verification, and session registration. +// LoginController manages the account authentication flow. type LoginController struct { auth *service.Authenticator sessions *service.SessionRegistry @@ -70,10 +69,7 @@ func (ctrl *LoginController) HandleLogin(c *client.Client, r *network.PacketRead return nil } -// processLogin runs off the event loop. The login protocol is strictly -// request/response — the client cannot send its next packet until it receives -// our reply — so the connection-state mutations here are safely ordered before -// the next OnTraffic for this connection. All replies go out via AsyncWrite. +// processLogin runs off the event loop. func (ctrl *LoginController) processLogin(c *client.Client, block1, block2 []byte) { ctrl.sem <- struct{}{} defer func() { <-ctrl.sem }() @@ -87,13 +83,9 @@ func (ctrl *LoginController) processLogin(c *client.Client, block1, block2 []byt account, err := ctrl.auth.Authenticate(context.Background(), username, password, c.RemoteIP) if err != nil { - metrics.LoginAttempts.WithLabelValues("fail", getFailLabel(err)).Inc() + failCounter(err).Inc() log.Warn().Err(err).Str("ip", c.RemoteIP).Str("username", username).Msg("Authentication failed") - ipAddr, nErr := netip.ParseAddr(c.RemoteIP) - if nErr != nil { - ipAddr = netip.IPv4Unspecified() - } - ctrl.bans.RecordFailure(ipAddr) + ctrl.bans.RecordFailure(c.RemoteAddr) if errors.Is(err, service.ErrAccountBanned) { _ = c.SendAndCloseAsync(ServerLoginBanned, func(w *network.PacketWriter) { @@ -105,7 +97,7 @@ func (ctrl *LoginController) processLogin(c *client.Client, block1, block2 []byt return } - metrics.LoginAttempts.WithLabelValues("success", "").Inc() + metrics.LoginSuccess.Inc() kickOld, allow := ctrl.sessions.TryRegister(username) if !allow { _ = sendLoginFail(c, 0x07) // REASON_ACCOUNT_IN_USE @@ -122,15 +114,11 @@ func (ctrl *LoginController) processLogin(c *client.Client, block1, block2 []byt } } - ipAddr, nErr := netip.ParseAddr(c.RemoteIP) - if nErr != nil { - ipAddr = netip.IPv4Unspecified() - } - ctrl.bans.ResetAttempts(ipAddr) + ctrl.bans.ResetAttempts(c.RemoteAddr) ctrl.sessions.Register(username, c.SessionID) c.AccountID = account.ID - c.Account = username + c.SetAccount(username) c.State = client.StateAuthedLogin c.LastServer = account.LastServer @@ -149,8 +137,7 @@ func (ctrl *LoginController) processLogin(c *client.Client, block1, block2 []byt }) } -// readLoginBlocks copies the one or two RSA-encrypted credential blocks out of -// the pooled reader so they survive past the handler return. +// readLoginBlocks copies the one or two RSA-encrypted credential blocks. func readLoginBlocks(r *network.PacketReader) (block1, block2 []byte, err error) { b1, err := r.ReadBytes(128) if err != nil { @@ -169,8 +156,7 @@ func readLoginBlocks(r *network.PacketReader) (block1, block2 []byte, err error) return block1, block2, nil } -// decodeCredentials RSA-decrypts the credential blocks and extracts the -// username/password. block2 == nil selects the legacy single-block layout. +// decodeCredentials RSA-decrypts the credential blocks. func decodeCredentials(key *crypto.ScrambledKey, block1, block2 []byte) (string, string, error) { var decrypted [256]byte @@ -201,16 +187,16 @@ func decodeCredentials(key *crypto.ScrambledKey, block1, block2 []byte) (string, return strings.ToLower(username), password, nil } -func getFailLabel(err error) string { +func failCounter(err error) prometheus.Counter { switch { case errors.Is(err, service.ErrInvalidPassword): - return "invalid_password" + return metrics.LoginFailInvalidPwd case errors.Is(err, service.ErrAccountNotFound): - return "account_not_found" + return metrics.LoginFailNotFound case errors.Is(err, service.ErrAccountBanned): - return "account_banned" + return metrics.LoginFailBanned default: - return "system_error" + return metrics.LoginFailSystem } } diff --git a/internal/packet/client/server_list.go b/internal/packet/client/server_list.go index 056f84b..d6769f3 100644 --- a/internal/packet/client/server_list.go +++ b/internal/packet/client/server_list.go @@ -17,8 +17,7 @@ import ( "github.com/rs/zerolog/log" ) -// ServerDashboardController handles the request for the game server list -// and provides account-specific character counts for each server. +// ServerDashboardController handles the request for the game server list. type ServerDashboardController struct { ServerSvc *service.ServerList Kicker service.Kicker diff --git a/internal/packet/gs/init.go b/internal/packet/gs/init.go index 72c94e3..43b07c7 100644 --- a/internal/packet/gs/init.go +++ b/internal/packet/gs/init.go @@ -87,7 +87,7 @@ func (ctrl *ServerInitController) HandleInit(gsc *client.GameServerClient, r *ne return err } for i := 1; i < int(hostCount); i++ { - if _, err = r.ReadString(); err != nil { + if err = r.SkipString(); err != nil { return err } } @@ -106,10 +106,11 @@ func (ctrl *ServerInitController) HandleInit(gsc *client.GameServerClient, r *ne return err } - gsc.ServerID = int32(serverID) + sid := int32(serverID) + gsc.ServerID = sid srvConfig := service.Server{ - ID: int32(serverID), + ID: sid, IP: serverHost, Port: int32(serverPort), CurrentPlayers: 0, @@ -123,7 +124,7 @@ func (ctrl *ServerInitController) HandleInit(gsc *client.GameServerClient, r *ne ctrl.ServerSvc.Register(&srvConfig) log.Info(). - Int32("server_id", int32(serverID)). + Int32("server_id", sid). Str("host", serverHost). Int16("port", serverPort). Msg("Game Server authenticated and registered") diff --git a/internal/packet/gs/validate.go b/internal/packet/gs/validate.go index d197a0f..8bd96bf 100644 --- a/internal/packet/gs/validate.go +++ b/internal/packet/gs/validate.go @@ -41,6 +41,7 @@ func HandleValidate(gsc *client.GameServerClient, r *network.PacketReader) error } sess, ok := session.ValidateAndDelete(LoginOkID1) + // Account is compared case-insensitively. success := ok && strings.EqualFold(sess.Account, account) && sess.Key.CheckPlayPair(LoginOkID1, LoginOkID2, PlayOkID1, PlayOkID2) diff --git a/internal/service/authenticator.go b/internal/service/authenticator.go index 0e6bdd7..5a41f5a 100644 --- a/internal/service/authenticator.go +++ b/internal/service/authenticator.go @@ -46,7 +46,7 @@ func NewAuthenticator(queries *db.Queries, autoCreate bool) *Authenticator { func (a *Authenticator) Authenticate(ctx context.Context, username, password, ip string) (db.FindAccountRow, error) { start := time.Now() account, err := a.queries.FindAccount(ctx, username) - metrics.DBQueryDuration.WithLabelValues("FindAccount").Observe(time.Since(start).Seconds()) + metrics.DBFindAccount.Observe(time.Since(start).Seconds()) if errors.Is(err, pgx.ErrNoRows) { if a.autoCreate { @@ -121,12 +121,12 @@ func (a *Authenticator) createAccount(ctx context.Context, username, password, i Pwd: hashedStr, LastIp: targetIP, }) - metrics.DBQueryDuration.WithLabelValues("RegisterAccount").Observe(time.Since(start).Seconds()) + metrics.DBRegisterAccount.Observe(time.Since(start).Seconds()) if err != nil { log.Error().Err(err).Str("username", username).Msg("Failed to execute account storage mutation") return "", 0, err } - log.Debug().Str("username", username).Str("ip", ip).Msg("New account auto-registered via network gate") + log.Debug().Str("username", username).Str("ip", ip).Msg("New account auto-registered via network") return hashedStr, accountID, nil } diff --git a/internal/service/authenticator_test.go b/internal/service/authenticator_test.go index 3ddebf2..311e500 100644 --- a/internal/service/authenticator_test.go +++ b/internal/service/authenticator_test.go @@ -7,6 +7,7 @@ package service import ( "context" + "errors" "regexp" "testing" @@ -72,7 +73,7 @@ func TestAuthenticator_Authenticate(t *testing.T) { AddRow(int64(1), username, string(hashedPassword), int32(0), int32(0), false)) _, err := auth.Authenticate(context.Background(), username, "wrongpass", "127.0.0.1") - if err != ErrInvalidPassword { + if !errors.Is(err, ErrInvalidPassword) { t.Errorf("expected ErrInvalidPassword, got %v", err) } diff --git a/internal/service/ban_manager.go b/internal/service/ban_manager.go index bc166f7..784ac8a 100644 --- a/internal/service/ban_manager.go +++ b/internal/service/ban_manager.go @@ -19,18 +19,20 @@ import ( // BanManager manages IP-based bans and login attempt tracking to prevent brute-force attacks. type BanManager struct { queries *db.Queries - maxAttempts int ctx context.Context - mu sync.RWMutex ipBans map[netip.Addr]time.Time attempts map[netip.Addr]int + banDuration time.Duration + mu sync.RWMutex + maxAttempts int } // NewBanManager creates a new BanManager and loads existing active bans from the database. -func NewBanManager(ctx context.Context, queries *db.Queries, maxAttempts int) *BanManager { +func NewBanManager(ctx context.Context, queries *db.Queries, maxAttempts int, banDuration time.Duration) *BanManager { bm := &BanManager{ queries: queries, maxAttempts: maxAttempts, + banDuration: banDuration, ctx: ctx, ipBans: make(map[netip.Addr]time.Time), attempts: make(map[netip.Addr]int), @@ -50,13 +52,7 @@ func NewBanManager(ctx context.Context, queries *db.Queries, maxAttempts int) *B return bm } -// IsBanned checks if a given IP address is currently banned. Loopback (127.0.0.1 / -// ::1) is never banned — it is the host itself (local tools, load tests) and must not -// be able to lock itself out via the brute-force throttle. - - -// IsBanned checks if a given IP address is currently banned. -// Loopback (127.0.0.1 / ::1) is never banned. +// IsBanned checks if a given IP address is currently banned. 127.0.0.1 is never banned. func (m *BanManager) IsBanned(ip netip.Addr) bool { if ip.IsLoopback() { return false @@ -103,7 +99,7 @@ func (m *BanManager) RecordFailure(ip netip.Addr) { count := m.attempts[ip] if count >= m.maxAttempts { - expiry := time.Now().Add(15 * time.Minute) + expiry := time.Now().Add(m.banDuration) m.ipBans[ip] = expiry delete(m.attempts, ip) m.mu.Unlock() diff --git a/internal/service/ban_manager_test.go b/internal/service/ban_manager_test.go index 6369962..a3f15ae 100644 --- a/internal/service/ban_manager_test.go +++ b/internal/service/ban_manager_test.go @@ -9,6 +9,7 @@ import ( "context" "net/netip" "testing" + "time" "github.com/mmo-dev-team/l2go-auth/internal/db" @@ -28,7 +29,7 @@ func TestBanManager_Logic(t *testing.T) { queries := db.New(mock) maxAttempts := 3 - bm := NewBanManager(context.Background(), queries, maxAttempts) + bm := NewBanManager(context.Background(), queries, maxAttempts, 15*time.Minute) ip := netip.MustParseAddr("1.2.3.4") @@ -46,7 +47,6 @@ func TestBanManager_Logic(t *testing.T) { } // 3rd failure - should trigger ban - // Note: RecordFailure spawns a goroutine for DB insert, we just check the internal map bm.RecordFailure(ip) if !bm.IsBanned(ip) { t.Error("Should be banned after 3rd failure") diff --git a/internal/service/server_list.go b/internal/service/server_list.go index ac3e90c..bc72fd2 100644 --- a/internal/service/server_list.go +++ b/internal/service/server_list.go @@ -11,6 +11,15 @@ import ( "github.com/rs/zerolog/log" ) +// MaxSupportedServers defines the upper bound of Game Servers in the cluster. +const MaxSupportedServers = 32 + +// Game Server status attribute codes carried in the ServerStatus packet. +const ( + InfoTypeStatus = 0x01 // Server availability (Up/Down/Busy) + InfoTypeCurrentPlayers = 0x02 // Current online player count +) + // Server represents a Game Server and its current state. type Server struct { IP string @@ -30,15 +39,12 @@ type ServerSnapshot struct { Count int } -// MaxSupportedServers defines the upper bound of Game Servers in the cluster. -const MaxSupportedServers = 16 - // ServerList manages the collection of registered Game Servers and account character counts. type ServerList struct { - mu sync.RWMutex + charCounts map[string][MaxSupportedServers]byte servers [MaxSupportedServers]Server + mu sync.RWMutex active [MaxSupportedServers]bool - charCounts map[string][MaxSupportedServers]byte } // NewServerList creates a new ServerList instance. @@ -96,9 +102,9 @@ func (s *ServerList) UpdateStatus(id int32, infoType int, value int32) { srv := &s.servers[id] switch infoType { - case 0x01: // Status update (Up/Down/Busy) + case InfoTypeStatus: srv.Status = byte(value) - case 0x02: // Current Players update + case InfoTypeCurrentPlayers: srv.CurrentPlayers = int16(value) } } diff --git a/internal/service/server_list_test.go b/internal/service/server_list_test.go index 21609b2..aebd72b 100644 --- a/internal/service/server_list_test.go +++ b/internal/service/server_list_test.go @@ -20,7 +20,7 @@ func TestServerList_UpdateStatus(t *testing.T) { sl.Register(srv) t.Run("Update Current Players", func(t *testing.T) { - sl.UpdateStatus(1, 0x02, 50) + sl.UpdateStatus(1, InfoTypeCurrentPlayers, 50) snap := sl.GetServers() found := false for i := 0; i < snap.Count; i++ { @@ -37,7 +37,7 @@ func TestServerList_UpdateStatus(t *testing.T) { }) t.Run("Update Status", func(t *testing.T) { - sl.UpdateStatus(1, 0x01, 2) // Busy + sl.UpdateStatus(1, InfoTypeStatus, 2) // Busy snap := sl.GetServers() for i := 0; i < snap.Count; i++ { if snap.Servers[i].ID == 1 { @@ -49,7 +49,7 @@ func TestServerList_UpdateStatus(t *testing.T) { }) t.Run("Update with invalid ID does nothing", func(t *testing.T) { - sl.UpdateStatus(99, 0x02, 10) + sl.UpdateStatus(99, InfoTypeCurrentPlayers, 10) snap := sl.GetServers() for i := 0; i < snap.Count; i++ { if snap.Servers[i].ID == 1 { diff --git a/internal/service/session_registry.go b/internal/service/session_registry.go index 2d17b67..e76e917 100644 --- a/internal/service/session_registry.go +++ b/internal/service/session_registry.go @@ -22,8 +22,8 @@ type SessionInfo struct { // Shard reduces lock contention dramatically by isolating mutations. type Shard struct { - mu sync.RWMutex active map[string]SessionInfo + mu sync.RWMutex } // SessionRegistry manages active user sessions using concurrent sharding. diff --git a/internal/session/session.go b/internal/session/session.go index 8a24fb0..82dc7f2 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -12,6 +12,8 @@ import ( "github.com/mmo-dev-team/l2go-auth/pkg/crypto" ) +const sessionShardCount = 64 // Must be a power of 2 + // Session represents a temporary authentication session for account handoff to a Game Server. type Session struct { ExpiresAt time.Time @@ -21,30 +23,45 @@ type Session struct { ServerID int32 } -var ( - sessions sync.Map -) +// shard isolates a subset of sessions under its own lock. +type shard struct { + sessions map[int32]*Session + mu sync.Mutex +} + +var shards [sessionShardCount]*shard + +func init() { + for i := range shards { + shards[i] = &shard{sessions: make(map[int32]*Session, 256)} + } +} + +func shardFor(id int32) *shard { + return shards[uint32(id)&(sessionShardCount-1)] +} // Put stores a session by its ID. func Put(id int32, session *Session) { - sessions.Store(id, session) + sh := shardFor(id) + sh.mu.Lock() + sh.sessions[id] = session + sh.mu.Unlock() } // ValidateAndDelete retrieves and removes a session by its ID if it exists and is not expired. func ValidateAndDelete(id int32) (*Session, bool) { - val, ok := sessions.Load(id) - if !ok { - return nil, false + sh := shardFor(id) + sh.mu.Lock() + session, ok := sh.sessions[id] + if ok { + delete(sh.sessions, id) } + sh.mu.Unlock() - session := val.(*Session) - - if time.Now().After(session.ExpiresAt) { - sessions.Delete(id) + if !ok || time.Now().After(session.ExpiresAt) { return nil, false } - - sessions.Delete(id) return session, true } @@ -57,15 +74,15 @@ func StartGC(interval time.Duration, stop <-chan struct{}) { select { case <-t.C: now := time.Now() - - sessions.Range(func(key, value interface{}) bool { - session := value.(*Session) - if now.After(session.ExpiresAt) { - sessions.Delete(key) + for _, sh := range shards { + sh.mu.Lock() + for id, session := range sh.sessions { + if now.After(session.ExpiresAt) { + delete(sh.sessions, id) + } } - return true - }) - + sh.mu.Unlock() + } case <-stop: return } diff --git a/internal/session/session_race_test.go b/internal/session/session_race_test.go new file mode 100644 index 0000000..169bec6 --- /dev/null +++ b/internal/session/session_race_test.go @@ -0,0 +1,64 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + +package session + +import ( + "sync" + "testing" + "time" +) + +// TestSession_ConcurrentHandoff exercises the sharded handoff store under the +// real access pattern: client logins Put sessions while game-server validations +// ValidateAndDelete them and the GC sweep runs concurrently. Run with -race. +func TestSession_ConcurrentHandoff(t *testing.T) { + stopGC := make(chan struct{}) + StartGC(10*time.Millisecond, stopGC) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // Producers: simulate GameServerLogin handoff. + for p := 0; p < 8; p++ { + wg.Add(1) + go func(base int) { + defer wg.Done() + id := int32(base * 100000) + for { + select { + case <-stop: + return + default: + } + Put(id, &Session{ExpiresAt: time.Now().Add(time.Minute), Account: "acc"}) + id++ + } + }(p) + } + + // Consumers: simulate GS validate consuming sessions by id. + for c := 0; c < 8; c++ { + wg.Add(1) + go func(base int) { + defer wg.Done() + id := int32(base * 100000) + for { + select { + case <-stop: + return + default: + } + ValidateAndDelete(id) + id++ + } + }(c) + } + + time.Sleep(150 * time.Millisecond) + close(stop) + wg.Wait() + close(stopGC) +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..d14dc7c --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,47 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + +package session + +import ( + "testing" + "time" +) + +// Negative ids keep these cases isolated from the concurrent race test. +func TestSession_PutValidateDelete(t *testing.T) { + const id = -1001 + Put(id, &Session{ExpiresAt: time.Now().Add(time.Minute), Account: "acc", ServerID: 7}) + + sess, ok := ValidateAndDelete(id) + if !ok { + t.Fatal("expected a live session to validate") + } + if sess.Account != "acc" || sess.ServerID != 7 { + t.Fatalf("unexpected session payload: %+v", sess) + } + + if _, ok := ValidateAndDelete(id); ok { + t.Error("expected the session to be gone after the first validate") + } +} + +func TestSession_Expired(t *testing.T) { + const id = -1002 + Put(id, &Session{ExpiresAt: time.Now().Add(-time.Second), Account: "old"}) + + if _, ok := ValidateAndDelete(id); ok { + t.Error("expected an expired session to be rejected") + } + if _, ok := ValidateAndDelete(id); ok { + t.Error("expected the expired session to also be removed") + } +} + +func TestSession_Missing(t *testing.T) { + if _, ok := ValidateAndDelete(-999999); ok { + t.Error("expected missing id to report not found") + } +} diff --git a/pkg/crypto/scrambled_key.go b/pkg/crypto/scrambled_key.go index c40753a..d710552 100644 --- a/pkg/crypto/scrambled_key.go +++ b/pkg/crypto/scrambled_key.go @@ -10,25 +10,33 @@ import ( "crypto/rsa" "errors" "math/big" + "sync" "sync/atomic" ) +const ( + RSAKeyBits = 1024 // RSA key size in bits + RSAModulusSize = 128 // RSA modulus size in bytes (for 1024-bit key) +) + // ScrambledKey holds an RSA private key and its scrambled modulus. type ScrambledKey struct { PrivateKey *rsa.PrivateKey // The RSA private key used for decryption Modulus [RSAModulusSize]byte // The scrambled public modulus (N) } -const ( - RSAKeyBits = 1024 // RSA key size in bits - RSAModulusSize = 128 // RSA modulus size in bytes (for 1024-bit key) -) +// rsaScratch holds the big.Int temporaries for one CRT decryption. +type rsaScratch struct { + c, m1, m2, h, m big.Int +} var ( rsaPool []ScrambledKey rsaIdx atomic.Uint32 ) +var rsaScratchPool = sync.Pool{New: func() any { return new(rsaScratch) }} + // InitRSAPool initializes a pool of pre-generated RSA keys to avoid expensive generation during login. func InitRSAPool(size int) { if size <= 0 { @@ -75,31 +83,29 @@ func RSADecrypt(key *ScrambledKey, ciphertext []byte) ([]byte, error) { return nil, errors.New("crypto: invalid ciphertext size") } - c := new(big.Int).SetBytes(ciphertext) priv := key.PrivateKey + s := rsaScratchPool.Get().(*rsaScratch) - m1 := new(big.Int).Exp(c, priv.Precomputed.Dp, priv.Primes[0]) - m2 := new(big.Int).Exp(c, priv.Precomputed.Dq, priv.Primes[1]) + s.c.SetBytes(ciphertext) - h := new(big.Int).Sub(m1, m2) - if h.Sign() < 0 { - h.Add(h, priv.Primes[0]) - } - h.Mul(h, priv.Precomputed.Qinv) - h.Mod(h, priv.Primes[0]) + s.m1.Exp(&s.c, priv.Precomputed.Dp, priv.Primes[0]) + s.m2.Exp(&s.c, priv.Precomputed.Dq, priv.Primes[1]) - m := new(big.Int).Mul(h, priv.Primes[1]) - m.Add(m, m2) + s.h.Sub(&s.m1, &s.m2) + if s.h.Sign() < 0 { + s.h.Add(&s.h, priv.Primes[0]) + } + s.h.Mul(&s.h, priv.Precomputed.Qinv) + s.h.Mod(&s.h, priv.Primes[0]) - plain := m.Bytes() + s.m.Mul(&s.h, priv.Primes[1]) + s.m.Add(&s.m, &s.m2) - if len(plain) < RSAModulusSize { - padded := make([]byte, RSAModulusSize) - copy(padded[RSAModulusSize-len(plain):], plain) // Pad with leading zeros - return padded, nil - } + out := make([]byte, RSAModulusSize) + s.m.FillBytes(out) // Always left-pads to 128 bytes; m < N (1024-bit) so it fits - return plain, nil + rsaScratchPool.Put(s) + return out, nil } func fillAndScrambleModulus(dst *[RSAModulusSize]byte, modulus *big.Int) { diff --git a/pkg/crypto/scrambled_key_bench_test.go b/pkg/crypto/scrambled_key_bench_test.go new file mode 100644 index 0000000..f9b283f --- /dev/null +++ b/pkg/crypto/scrambled_key_bench_test.go @@ -0,0 +1,69 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + +package crypto + +import ( + "math/big" + "testing" +) + +// rsaDecryptOld reproduces the pre-fix decrypt: a fresh big.Int per temporary +// (~5 allocations) plus a conditional padding allocation. +func rsaDecryptOld(key *ScrambledKey, ciphertext []byte) []byte { + c := new(big.Int).SetBytes(ciphertext) + priv := key.PrivateKey + m1 := new(big.Int).Exp(c, priv.Precomputed.Dp, priv.Primes[0]) + m2 := new(big.Int).Exp(c, priv.Precomputed.Dq, priv.Primes[1]) + h := new(big.Int).Sub(m1, m2) + if h.Sign() < 0 { + h.Add(h, priv.Primes[0]) + } + h.Mul(h, priv.Precomputed.Qinv) + h.Mod(h, priv.Primes[0]) + m := new(big.Int).Mul(h, priv.Primes[1]) + m.Add(m, m2) + plain := m.Bytes() + if len(plain) < RSAModulusSize { + padded := make([]byte, RSAModulusSize) + copy(padded[RSAModulusSize-len(plain):], plain) + return padded + } + return plain +} + +func benchCiphertext(b *testing.B) (*ScrambledKey, []byte) { + b.Helper() + InitRSAPool(1) + key, err := GetScrambledKey() + if err != nil { + b.Fatal(err) + } + // Build a valid ciphertext: c = m^e mod N for an arbitrary m < N. + m := big.NewInt(0xDEADBEEFCAFE) + e := big.NewInt(int64(key.PrivateKey.PublicKey.E)) + c := new(big.Int).Exp(m, e, key.PrivateKey.PublicKey.N) + ct := make([]byte, RSAModulusSize) + c.FillBytes(ct) + return key, ct +} + +func BenchmarkRSADecrypt_New(b *testing.B) { + key, ct := benchCiphertext(b) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = RSADecrypt(key, ct) + } +} + +func BenchmarkRSADecrypt_Old(b *testing.B) { + key, ct := benchCiphertext(b) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = rsaDecryptOld(key, ct) + } +} diff --git a/pkg/crypto/scrambled_key_test.go b/pkg/crypto/scrambled_key_test.go new file mode 100644 index 0000000..51c84be --- /dev/null +++ b/pkg/crypto/scrambled_key_test.go @@ -0,0 +1,73 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Copyright (c) 2026 whiteo. All rights reserved. + +package crypto + +import ( + "math/big" + "testing" +) + +// TestRSADecrypt_RoundTrip verifies the CRT decrypt with the pooled scratch +// big.Ints recovers the original message m from c = m^e mod N. +func TestRSADecrypt_RoundTrip(t *testing.T) { + InitRSAPool(1) + key, err := GetScrambledKey() + if err != nil { + t.Fatalf("GetScrambledKey: %v", err) + } + + m := big.NewInt(0xDEADBEEFCAFE) + e := big.NewInt(int64(key.PrivateKey.PublicKey.E)) + c := new(big.Int).Exp(m, e, key.PrivateKey.PublicKey.N) + + ct := make([]byte, RSAModulusSize) + c.FillBytes(ct) + + out, err := RSADecrypt(key, ct) + if err != nil { + t.Fatalf("RSADecrypt: %v", err) + } + if len(out) != RSAModulusSize { + t.Fatalf("expected %d-byte output, got %d", RSAModulusSize, len(out)) + } + + recovered := new(big.Int).SetBytes(out) + if recovered.Cmp(m) != 0 { + t.Fatalf("round-trip mismatch: got %x, want %x", recovered, m) + } +} + +// TestRSADecrypt_InvalidSize rejects ciphertext that is not exactly the modulus size. +func TestRSADecrypt_InvalidSize(t *testing.T) { + InitRSAPool(1) + key, err := GetScrambledKey() + if err != nil { + t.Fatalf("GetScrambledKey: %v", err) + } + + if _, err := RSADecrypt(key, make([]byte, RSAModulusSize-1)); err == nil { + t.Error("expected error for undersized ciphertext") + } + if _, err := RSADecrypt(key, make([]byte, RSAModulusSize+1)); err == nil { + t.Error("expected error for oversized ciphertext") + } +} + +// TestGetScrambledKey_RoundRobin confirms consecutive picks rotate across the pool. +func TestGetScrambledKey_RoundRobin(t *testing.T) { + InitRSAPool(2) + first, err := GetScrambledKey() + if err != nil { + t.Fatalf("GetScrambledKey: %v", err) + } + second, err := GetScrambledKey() + if err != nil { + t.Fatalf("GetScrambledKey: %v", err) + } + if first == second { + t.Error("expected round-robin to hand out distinct keys for a pool of 2") + } +} diff --git a/pkg/network/packet_reader.go b/pkg/network/packet_reader.go index 862a641..ef1c1e4 100644 --- a/pkg/network/packet_reader.go +++ b/pkg/network/packet_reader.go @@ -173,23 +173,36 @@ func (r *PacketReader) ReadString() (string, error) { u16len := len(raw) / 2 - var u16 []uint16 - var pBuf *[]uint16 if u16len <= 1024 { - pBuf = decodePool.Get().(*[]uint16) - u16 = (*pBuf)[:u16len] - defer decodePool.Put(pBuf) - } else { - u16 = make([]uint16, u16len) + pBuf := decodePool.Get().(*[]uint16) + u16 := (*pBuf)[:u16len] + for i := 0; i < u16len; i++ { + u16[i] = uint16(raw[2*i]) | uint16(raw[2*i+1])<<8 + } + s := string(utf16.Decode(u16)) + decodePool.Put(pBuf) + return s, nil } + u16 := make([]uint16, u16len) for i := 0; i < u16len; i++ { u16[i] = uint16(raw[2*i]) | uint16(raw[2*i+1])<<8 } - return string(utf16.Decode(u16)), nil } +// SkipString advances the read position past a null-terminated UTF-16LE string without allocating. +func (r *PacketReader) SkipString() error { + remainder := r.buf[r.pos:] + for i := 0; i+1 < len(remainder); i += 2 { + if remainder[i] == 0 && remainder[i+1] == 0 { + r.pos += i + 2 + return nil + } + } + return errors.New("packet: missing null terminator for string") +} + func (r *PacketReader) ensure(n int) error { if r.pos+n > len(r.buf) { return errors.New("packet: out of bounds") diff --git a/pkg/network/packet_test.go b/pkg/network/packet_test.go index 024aaee..658cba8 100644 --- a/pkg/network/packet_test.go +++ b/pkg/network/packet_test.go @@ -78,6 +78,50 @@ func TestPacketReaderBounds(t *testing.T) { } } +// TestSkipString advances past a UTF-16LE string without decoding it. +func TestSkipString(t *testing.T) { + w := GetPacketWriter() + defer PutPacketWriter(w) + + w.WriteString("skip-me") + w.WriteByte(0xAB) + data := w.Bytes() + + r, err := GetPacketReader(data) + if err != nil { + t.Fatalf("Failed to create reader: %v", err) + } + defer PutPacketReader(r) + + if _, err = r.ReadUint16(); err != nil { // consume the 2-byte length header + t.Fatalf("ReadUint16 header: %v", err) + } + + if err = r.SkipString(); err != nil { + t.Fatalf("SkipString returned error: %v", err) + } + + b, err := r.ReadByte() + if err != nil { + t.Fatalf("ReadByte after SkipString: %v", err) + } + if b != 0xAB { + t.Errorf("Expected to land on 0xAB after skip, got 0x%X", b) + } +} + +func TestSkipStringMissingTerminator(t *testing.T) { + r, err := GetPacketReader([]byte{0x41, 0x00, 0x42, 0x00}) + if err != nil { + t.Fatalf("Failed to create reader: %v", err) + } + defer PutPacketReader(r) + + if err = r.SkipString(); err == nil { + t.Error("Expected error when no null terminator is present") + } +} + func FuzzPacketReader(f *testing.F) { // Seed corpus with some semi-valid looking data f.Add([]byte{0x01, 0x02, 0x03, 0x04, 0x00, 0x00}) From 1e76cc784e427063b817eb845f48f6a7191d7dcb Mon Sep 17 00:00:00 2001 From: whiteo Date: Mon, 22 Jun 2026 21:17:20 +0200 Subject: [PATCH 2/2] doc: update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index aaa87c3..f1dd139 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ production-grade reliability using an event-driven networking model. * **Fuzz Tested:** The packet parser has been stress-tested with over 7 million iterations of random data (Go Fuzzing) to ensure zero panics from malformed packets. * **Anti-Bruteforce:** Integrated `BanManager` that tracks failed attempts and automatically jails IPs. - * **Rate Limiting:** Built-in TCP connection rate limiting to protect against connection flood attacks. + * **Rate Limiting:** Sharded (64 shards), fixed-window per-IP connection rate limiting to protect against connection flood attacks with minimal lock contention. * **Optimized Cryptography:** * Pre-generated **RSA Key Pool** (32 keys) to prevent CPU spikes during mass login events. * Custom Blowfish implementation compliant with the L2 protocol. @@ -90,7 +90,7 @@ The application is configured using environment variables. You can find a templa | `DB_MAX_CONN_IDLE_TIME` | Maximum amount of time a connection may be idle (seconds) | `60` | | `ATTEMPTS_LOGIN_COUNT` | Failed login attempts before IP ban | `5` | | `AUTO_CREATE_ACCOUNT` | Enable/Disable auto account creation | `true` | -| `LOGIN_RATE_LIMIT` | Max login requests per second | `10` | +| `LOGIN_RATE_LIMIT` | Max connection attempts per IP within a 30-second window | `10` | ### Running the Server