No description
Find a file
2025-12-31 08:40:02 -06:00
auth.go Authentication fix 2025-12-31 08:40:02 -06:00
auth_test.go first commit 2025-12-28 16:06:01 -06:00
benchmark_test.go first commit 2025-12-28 16:06:01 -06:00
client.go first commit 2025-12-28 16:06:01 -06:00
conn.go first commit 2025-12-28 16:06:01 -06:00
errors.go first commit 2025-12-28 16:06:01 -06:00
go.mod first commit 2025-12-28 16:06:01 -06:00
go.sum first commit 2025-12-28 16:06:01 -06:00
integration_test.go first commit 2025-12-28 16:06:01 -06:00
lz4.go first commit 2025-12-28 16:06:01 -06:00
message.go first commit 2025-12-28 16:06:01 -06:00
messages.go first commit 2025-12-28 16:06:01 -06:00
opcodes.go first commit 2025-12-28 16:06:01 -06:00
pool.go first commit 2025-12-28 16:06:01 -06:00
README.md first commit 2025-12-28 16:06:01 -06:00
server.go first commit 2025-12-28 16:06:01 -06:00
servertalk.go first commit 2025-12-28 16:06:01 -06:00
servertalk_test.go first commit 2025-12-28 16:06:01 -06:00
testing.go first commit 2025-12-28 16:06:01 -06:00

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=true and Authenticator=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=true in 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:

  1. readLoop - Reads messages and dispatches to handlers
  2. writeLoop - Sends messages from queue to network
  3. 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.