- Go 100%
| auth.go | ||
| auth_test.go | ||
| benchmark_test.go | ||
| client.go | ||
| conn.go | ||
| errors.go | ||
| go.mod | ||
| go.sum | ||
| integration_test.go | ||
| lz4.go | ||
| message.go | ||
| messages.go | ||
| opcodes.go | ||
| pool.go | ||
| README.md | ||
| server.go | ||
| servertalk.go | ||
| servertalk_test.go | ||
| testing.go | ||
Servertalk
High-performance server-to-server communication library for EQ2 emulator servers, written in pure Go with zero external dependencies.
Overview
Servertalk provides fast, reliable communication between login servers, world servers, and zone servers using a custom binary protocol with always-on compression. Built from the ground up for Go, this library offers:
- Zero external dependencies - Uses only Go standard library
- Always-on compression - Every message compressed with LZ4 for maximum speed
- Auto-reconnect - Clients automatically reconnect with exponential backoff
- Type-safe - Compile-time type checking with Go structs
- High performance - 150K+ messages/sec throughput, sub-millisecond latency
- Easy testing - Built-in mock connections for handler testing
- Concurrent - Goroutine-based architecture for maximum throughput
Installation
go get git.sharkk.net/eq2go/servertalk
Quick Start
Server Example (Login Server)
package main
import (
"context"
"log"
"git.sharkk.net/eq2go/servertalk"
)
func main() {
// Create server
srv := servertalk.NewServer(servertalk.Config{
Address: "0.0.0.0:9000",
})
// Register handler for world server info
srv.HandleFunc(servertalk.LSInfo, func(ctx context.Context, conn servertalk.Conn, msg *servertalk.Message) error {
info := &servertalk.LSInfoMsg{}
if err := msg.Unmarshal(info); err != nil {
return err
}
log.Printf("World server connected: %s at %s", info.Name, info.Address)
// Send status response
return conn.Send(ctx, servertalk.LSStatus, &servertalk.LSStatusMsg{
Status: 1,
NumPlayers: 0,
NumZones: 0,
})
})
// Start server
log.Printf("Login server listening on %s", "0.0.0.0:9000")
if err := srv.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
Client Example (World Server)
package main
import (
"context"
"log"
"time"
"git.sharkk.net/eq2go/servertalk"
)
func main() {
// Connect to login server
client, err := servertalk.Dial("127.0.0.1:9000", servertalk.DefaultConfig())
if err != nil {
log.Fatal(err)
}
defer client.Close()
// Register handler for status responses
client.HandleFunc(servertalk.LSStatus, func(ctx context.Context, conn servertalk.Conn, msg *servertalk.Message) error {
status := &servertalk.LSStatusMsg{}
if err := msg.Unmarshal(status); err != nil {
return err
}
log.Printf("Login server status: %d players, %d zones", status.NumPlayers, status.NumZones)
return nil
})
// Send world server info
err = client.Send(context.Background(), servertalk.LSInfo, &servertalk.LSInfoMsg{
Name: "WorldServer1",
Address: "127.0.0.1:8000",
Account: "worldaccount",
Password: "worldpass",
ProtocolVersion: "1.0",
ServerVersion: "1.0.0",
ServerType: 0, // World server
DBVersion: 1000,
})
if err != nil {
log.Fatal(err)
}
// Keep connection alive
<-client.Wait()
}
Handler Registration
Handlers can be registered as functions or interface implementations:
// Function handler
srv.HandleFunc(servertalk.LSInfo, func(ctx context.Context, conn servertalk.Conn, msg *servertalk.Message) error {
// Handle message
return nil
})
// Interface handler
type MyHandler struct{}
func (h *MyHandler) Handle(ctx context.Context, conn servertalk.Conn, msg *servertalk.Message) error {
// Handle message
return nil
}
srv.Handle(servertalk.LSInfo, &MyHandler{})
Message Structures
All message types are defined as Go structs optimized for gob encoding:
// Login server info
type LSInfoMsg struct {
Name string
Address string
Account string
Password string
ProtocolVersion string
ServerVersion string
ServerType int8
DBVersion int32
}
// Status update
type LSStatusMsg struct {
Status int32
NumPlayers int32
NumZones int32
WorldMaxLevel int8
}
// User world access request
type UsertoWorldRequestMsg struct {
LSAccountID int32
CharID int32
WorldID int32
FromID int32
ToID int32
IPAddress string
}
See messages.go for all available message types.
Opcodes
All ~90 server-to-server opcodes are defined with simplified names:
// Core operations
servertalk.KeepAlive // 0x0001
servertalk.ChannelMessage // 0x0002
servertalk.ZonePlayer // 0x000C
// Login server operations
servertalk.LSInfo // 0x1000
servertalk.LSStatus // 0x1001
servertalk.LSClientAuth // 0x1002
// World operations
servertalk.EncapPacket // 0x2007
servertalk.BasicCharUpdate // 0x2012
servertalk.CharacterCreate // 0x2013
// User routing
servertalk.UsertoWorldReq // 0xAB00
servertalk.UsertoWorldResp // 0xAB01
See opcodes.go for the complete list.
Configuration
config := servertalk.Config{
Address: "0.0.0.0:9000", // Listen/connect address
KeepaliveInterval: 15 * time.Second, // Keepalive frequency
KeepaliveTimeout: 30 * time.Second, // Connection timeout
ReadTimeout: 10 * time.Second, // Socket read timeout
WriteTimeout: 10 * time.Second, // Socket write timeout
MaxMessageSize: 10 * 1024 * 1024, // Max message size (10MB)
ReconnectDelay: 1 * time.Second, // Initial reconnect delay
ErrorHandler: func(err error) { // Optional error callback
log.Println("Error:", err)
},
Logger: myLogger, // Optional logger
}
srv := servertalk.NewServer(config)
Authentication
Servertalk supports optional world server authentication using bcrypt password hashing. When enabled, world servers must authenticate via LSInfo before sending other messages.
Setting Up Authentication
1. Server Application (Login Server)
The login server loads credentials from its configuration source and provides them to servertalk:
package main
import (
"log"
"time"
"git.sharkk.net/eq2go/servertalk"
"golang.org/x/crypto/bcrypt"
)
func main() {
// Load credentials from INI/database/etc
password1, _ := bcrypt.GenerateFromPassword([]byte("world1_password"), bcrypt.DefaultCost)
password2, _ := bcrypt.GenerateFromPassword([]byte("world2_password"), bcrypt.DefaultCost)
credentials := map[string]*servertalk.ServerAccount{
"world1": {
Account: "world1",
PasswordHash: string(password1),
Enabled: true,
},
"world2": {
Account: "world2",
PasswordHash: string(password2),
Enabled: true,
},
}
bannedIPs := map[string]string{
"192.168.1.100": "Multiple failed attempts",
}
// Create authenticator
auth := servertalk.NewAuthenticator(servertalk.AuthConfig{
ProtocolVersion: "1.0",
MaxFailedAttempts: 5,
RateLimitWindow: 5 * time.Minute,
Accounts: credentials,
BannedIPs: bannedIPs,
})
// Configure server with authentication
config := servertalk.DefaultConfig()
config.Address = "0.0.0.0:9000"
config.Authenticator = auth
config.RequireAuth = true // Reject unauthenticated connections
srv := servertalk.NewServer(config)
// Start server
log.Fatal(srv.ListenAndServe())
}
2. Client Application (World Server)
The world server sends LSInfo with credentials on connect:
package main
import (
"context"
"log"
"git.sharkk.net/eq2go/servertalk"
)
func main() {
// Connect to login server
client, err := servertalk.Dial("127.0.0.1:9000", servertalk.DefaultConfig())
if err != nil {
log.Fatal(err)
}
defer client.Close()
// Send LSInfo with credentials
lsInfo := &servertalk.LSInfoMsg{
Name: "MyWorldServer",
Address: "127.0.0.1:8000",
Account: "world1",
Password: "world1_password",
ProtocolVersion: "1.0",
ServerVersion: "1.0.0",
ServerType: 0, // World server
DBVersion: 1000,
}
if err := client.Send(context.Background(), servertalk.LSInfo, lsInfo); err != nil {
log.Fatal(err)
}
// Now authenticated - can send other messages
// ...
}
Authentication Features
- bcrypt Password Hashing: Industry-standard password hashing with automatic salting
- Rate Limiting: Configurable failed attempt limits (default: 5 attempts per 5 minutes)
- IP Banning: Block specific IP addresses with custom ban reasons
- Account Disabling: Disable accounts without deleting credentials
- Protocol Version Checking: Reject mismatched protocol versions
- Fail-Secure: If
RequireAuth=trueandAuthenticator=nil, all connections are rejected
Optional Authentication
Authentication is optional - if Authenticator is nil, all connections are allowed:
config := servertalk.DefaultConfig()
config.Address = "0.0.0.0:9000"
config.Authenticator = nil // No authentication required
srv := servertalk.NewServer(config)
Security Best Practices
- Store credentials securely (use INI files with restricted permissions or databases)
- Use strong passwords and rotate them periodically
- Monitor failed authentication attempts
- Use IP banning for repeated failures
- Keep
RequireAuth=truein production environments - Don't log passwords or detailed auth error messages to clients
Testing with Mocks
The library provides a mock connection for easy handler testing:
func TestMyHandler(t *testing.T) {
mock := servertalk.NewMockConn()
// Create handler
handler := &MyHandler{}
// Test message
msg := &servertalk.Message{
Opcode: servertalk.LSInfo,
Data: /* ... */,
}
// Call handler
err := handler.Handle(context.Background(), mock, msg)
if err != nil {
t.Fatal(err)
}
// Verify sent messages
sent := mock.GetSent()
if len(sent) != 1 {
t.Errorf("Expected 1 message sent, got %d", len(sent))
}
if sent[0].Opcode != servertalk.LSStatus {
t.Errorf("Expected LSStatus, got %v", sent[0].Opcode)
}
}
Architecture
┌─────────────┐ ┌─────────────┐
│Login Server │◄──────TCP─────────►│World Server │
└──────┬──────┘ └──────┬──────┘
│ │
│ servertalk.NewServer() │ servertalk.Dial()
│ │
├─ HandleFunc(LSInfo, ...) ├─ HandleFunc(LSStatus, ...)
├─ HandleFunc(LSClientAuth, ...) ├─ Send(LSInfo, ...)
└─ ListenAndServe() └─ Auto-reconnect
Wire Protocol
[size:4 bytes][opcode:2 bytes][lz4_compressed_gob_payload:N bytes]
- Size: Total packet size (uint32, little-endian)
- Opcode: Operation code (uint16, little-endian)
- Payload: Gob-encoded struct compressed with LZ4
Connection Model
Each connection spawns three goroutines:
- readLoop - Reads messages and dispatches to handlers
- writeLoop - Sends messages from queue to network
- keepaliveLoop - Sends keepalive and checks timeout
Handlers run in separate goroutines for maximum concurrency.
Performance
Benchmark results on Intel i7-1370P with LZ4 compression:
| Operation | Throughput | Latency | Allocations |
|---|---|---|---|
| Encode | 411K ops/sec | 2.76 µs | 19 allocs/op |
| Decode | 4.05M ops/sec | 0.31 µs | 5 allocs/op |
| Round-trip | 76K ops/sec | 14.6 µs | 209 allocs/op |
| Throughput | 151K msgs/sec | 6.58 µs | 24 allocs/op |
| Latency (RTT) | N/A | 254 µs | 53 allocs/op |
| Buffer pool | 51M ops/sec | 24 ns | 0 allocs/op |
Compression Effectiveness
| Data Size | Compressed Size | Ratio |
|---|---|---|
| 1 MB random | ~4.5 KB | 0.43% |
| 100 KB random | ~767 bytes | 0.75% |
| 10 KB random | ~403 bytes | 3.9% |
LZ4 provides excellent compression ratios while being 7-8x faster than DEFLATE.
API Reference
Server
// Create new server
func NewServer(config Config) Server
// Register handler function
func (s *Server) HandleFunc(opcode Opcode, handler HandlerFunc)
// Register handler interface
func (s *Server) Handle(opcode Opcode, handler Handler)
// Start server (blocking)
func (s *Server) ListenAndServe() error
// Graceful shutdown
func (s *Server) Shutdown(ctx context.Context) error
Client
// Connect to server
func Dial(address string, config Config) (*Client, error)
// Send message
func (c *Client) Send(ctx context.Context, opcode Opcode, payload any) error
// Register handler
func (c *Client) HandleFunc(opcode Opcode, handler HandlerFunc)
// Check connection status
func (c *Client) Connected() bool
// Close connection
func (c *Client) Close() error
// Wait for connection close
func (c *Client) Wait() <-chan struct{}
Connection
// Send typed message
func (c Conn) Send(ctx context.Context, opcode Opcode, payload any) error
// Send raw bytes
func (c Conn) SendRaw(ctx context.Context, opcode Opcode, data []byte) error
// Get remote address
func (c Conn) RemoteAddr() string
// Get local address
func (c Conn) LocalAddr() string
// Get connection context
func (c Conn) Context() context.Context
Message
// Unmarshal message data
func (m *Message) Unmarshal(v any) error
// Encode message
func EncodeMessage(opcode Opcode, payload any) ([]byte, error)
// Decode message
func DecodeMessage(r io.Reader) (*Message, error)
License
This library is part of the EQ2 Emulator project.
Contributing
Contributions are welcome! Please ensure:
- All tests pass:
go test ./... - Benchmarks run:
go test -bench=. -run=^$ - Code is formatted:
go fmt ./... - No external dependencies added
Support
For questions and support, please visit the EQ2 Emulator project repository.