Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
11 changes: 6 additions & 5 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
34 changes: 32 additions & 2 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
package client

import (
"net/netip"
"sync"
"sync/atomic"
"time"

loginCrypto "github.com/mmo-dev-team/l2go-auth/internal/crypto"
Expand All @@ -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()
Expand Down
15 changes: 13 additions & 2 deletions internal/client/gs_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package client

import (
"sync/atomic"
"time"

"github.com/mmo-dev-team/l2go-auth/pkg/network"
Expand All @@ -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()
Expand All @@ -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
}

Expand Down
33 changes: 29 additions & 4 deletions internal/crypto/crypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions internal/crypto/crypt_bench_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
21 changes: 21 additions & 0 deletions internal/crypto/crypt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions internal/db/db.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions internal/db/l2auth.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions internal/db/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading