From 82d03c262471663bbe9cf74d3d5444501c11bb7f Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Wed, 30 Jul 2025 08:36:38 -0500 Subject: [PATCH] first pass on login server --- cmd/login_server/client.go | 230 ++++++++++++++ cmd/login_server/config.go | 163 ++++++++++ cmd/login_server/database.go | 395 ++++++++++++++++++++++++ cmd/login_server/handlers.go | 513 +++++++++++++++++++++++++++++++ cmd/login_server/login_server.go | 208 +++++++++++++ cmd/login_server/main.go | 74 +++++ cmd/login_server/web.go | 143 +++++++++ cmd/login_server/world_server.go | 356 +++++++++++++++++++++ go.mod | 16 + go.sum | 27 ++ 10 files changed, 2125 insertions(+) create mode 100644 cmd/login_server/client.go create mode 100644 cmd/login_server/config.go create mode 100644 cmd/login_server/database.go create mode 100644 cmd/login_server/handlers.go create mode 100644 cmd/login_server/login_server.go create mode 100644 cmd/login_server/main.go create mode 100644 cmd/login_server/web.go create mode 100644 cmd/login_server/world_server.go create mode 100644 go.sum diff --git a/cmd/login_server/client.go b/cmd/login_server/client.go new file mode 100644 index 0000000..e0bceb6 --- /dev/null +++ b/cmd/login_server/client.go @@ -0,0 +1,230 @@ +package main + +import ( + "eq2emu/internal/common/opcodes" + "eq2emu/internal/udp" + "fmt" + "log" + "time" +) + +// LoginClient represents a connected client session +type LoginClient struct { + connection *udp.Connection + server *LoginServer + account *Account + lastActivity time.Time + authenticated bool + version uint16 + sessionID string + + // Client state + needsWorldList bool + sentCharacterList bool + pendingPlayCharID int32 + createRequest *CharacterCreateRequest +} + +// Account represents an authenticated user account +type Account struct { + ID int32 + Username string + LSAdmin bool + WorldAdmin bool + Characters []*Character + LastLogin time.Time + IPAddress string + ClientVersion uint16 +} + +// Character represents a character in the database +type Character struct { + ID int32 + AccountID int32 + ServerID int32 + Name string + Level int8 + Race int8 + Class int8 + Gender int8 + CreatedDate time.Time + Deleted bool +} + +// CharacterCreateRequest holds pending character creation data +type CharacterCreateRequest struct { + ServerID int32 + Name string + Race int8 + Gender int8 + Class int8 + Face int8 + Hair int8 + HairColor int8 + SkinColor int8 + EyeColor int8 + Timestamp time.Time +} + +// NewLoginClient creates a new login client instance +func NewLoginClient(conn *udp.Connection, server *LoginServer) *LoginClient { + return &LoginClient{ + connection: conn, + server: server, + lastActivity: time.Now(), + sessionID: fmt.Sprintf("%d", conn.GetSessionID()), + needsWorldList: true, + sentCharacterList: false, + } +} + +// ProcessPacket handles incoming packets from the client +func (lc *LoginClient) ProcessPacket(packet *udp.ApplicationPacket) { + lc.lastActivity = time.Now() + + switch packet.Opcode { + case opcodes.OpLoginRequestMsg: + lc.handleLoginRequest(packet) + case opcodes.OpAllWSDescRequestMsg: + lc.handleWorldListRequest(packet) + case opcodes.OpAllCharactersDescRequestMsg: + lc.handleCharacterListRequest(packet) + case opcodes.OpCreateCharacterRequestMsg: + lc.handleCharacterCreateRequest(packet) + case opcodes.OpDeleteCharacterRequestMsg: + lc.handleCharacterDeleteRequest(packet) + case opcodes.OpPlayCharacterRequestMsg: + lc.handlePlayCharacterRequest(packet) + case opcodes.OpKeymapLoadMsg: + // Client keymap request - usually ignored + break + default: + log.Printf("Unknown packet opcode from client %s: 0x%04X", lc.sessionID, packet.Opcode) + } +} + +// handleLoginRequest processes client login attempts +func (lc *LoginClient) handleLoginRequest(packet *udp.ApplicationPacket) { + lc.server.IncrementLoginAttempts() + + // Parse login request packet + loginReq, err := lc.parseLoginRequest(packet.Data) + if err != nil { + log.Printf("Failed to parse login request from %s: %v", lc.sessionID, err) + lc.sendLoginDenied() + return + } + + lc.version = loginReq.Version + + // Check if client version is supported + if !lc.server.GetConfig().IsVersionSupported(lc.version) { + log.Printf("Unsupported client version %d from %s", lc.version, lc.sessionID) + lc.sendLoginDeniedBadVersion() + return + } + + // Authenticate with database + account, err := lc.server.database.AuthenticateAccount(loginReq.Username, loginReq.Password) + if err != nil { + log.Printf("Authentication failed for %s: %v", loginReq.Username, err) + lc.sendLoginDenied() + return + } + + if account == nil { + log.Printf("Invalid credentials for %s", loginReq.Username) + lc.sendLoginDenied() + return + } + + // Check for existing session + lc.server.clientMutex.RLock() + for _, existingClient := range lc.server.clients { + if existingClient.account != nil && existingClient.account.ID == account.ID && existingClient != lc { + log.Printf("Account %s already logged in, disconnecting previous session", account.Username) + existingClient.Disconnect() + break + } + } + lc.server.clientMutex.RUnlock() + + // Update account info + account.LastLogin = time.Now() + account.IPAddress = lc.connection.GetStats().State.String() // Get IP from connection + account.ClientVersion = lc.version + lc.server.database.UpdateAccountLogin(account) + + lc.account = account + lc.authenticated = true + lc.server.IncrementSuccessfulLogins() + + log.Printf("User %s successfully authenticated", account.Username) + lc.sendLoginAccepted() +} + +// handleWorldListRequest sends the list of available world servers +func (lc *LoginClient) handleWorldListRequest(packet *udp.ApplicationPacket) { + if !lc.authenticated { + lc.Disconnect() + return + } + + lc.sendWorldList() + lc.needsWorldList = false + + // Load and send character list + if !lc.sentCharacterList { + characters, err := lc.server.database.LoadCharacters(lc.account.ID, lc.version) + if err != nil { + log.Printf("Failed to load characters for account %d: %v", lc.account.ID, err) + } else { + lc.account.Characters = characters + lc.sentCharacterList = true + } + lc.sendCharacterList() + } +} + +// handleCharacterListRequest handles explicit character list requests +func (lc *LoginClient) handleCharacterListRequest(packet *udp.ApplicationPacket) { + if !lc.authenticated { + lc.Disconnect() + return + } + + lc.sendCharacterList() +} + +// IsStale returns true if the client connection should be cleaned up +func (lc *LoginClient) IsStale() bool { + return time.Since(lc.lastActivity) > 5*time.Minute +} + +// Disconnect closes the client connection and cleans up +func (lc *LoginClient) Disconnect() { + if lc.connection != nil { + lc.connection.Close() + } + + // Clean up any pending requests + lc.createRequest = nil + + log.Printf("Client %s disconnected", lc.sessionID) + lc.server.RemoveClient(lc.sessionID) +} + +// GetAccount returns the authenticated account +func (lc *LoginClient) GetAccount() *Account { + return lc.account +} + +// GetVersion returns the client version +func (lc *LoginClient) GetVersion() uint16 { + return lc.version +} + +// GetSessionID returns the session identifier +func (lc *LoginClient) GetSessionID() string { + return lc.sessionID +} diff --git a/cmd/login_server/config.go b/cmd/login_server/config.go new file mode 100644 index 0000000..7ba16b2 --- /dev/null +++ b/cmd/login_server/config.go @@ -0,0 +1,163 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" +) + +// Config holds all login server configuration +type Config struct { + Port int `json:"port"` + MaxConnections int `json:"max_connections"` + TimeoutSeconds int `json:"timeout_seconds"` + EnableCompression bool `json:"enable_compression"` + EnableEncryption bool `json:"enable_encryption"` + AllowAccountCreation bool `json:"allow_account_creation"` + DefaultSubscriptionLevel uint32 `json:"default_subscription_level"` + ExpansionFlag uint16 `json:"expansion_flag"` + CitiesFlag uint8 `json:"cities_flag"` + EnabledRaces uint32 `json:"enabled_races"` + SupportedVersions []uint16 `json:"supported_versions"` + Database DatabaseConfig `json:"database"` + WebServer WebServerConfig `json:"web_server"` + WorldServers []WorldServerConfig `json:"world_servers"` +} + +// WebServerConfig holds web server settings +type WebServerConfig struct { + Enabled bool `json:"enabled"` + Address string `json:"address"` + Port int `json:"port"` + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + KeyPassword string `json:"key_password"` + Username string `json:"username"` + Password string `json:"password"` +} + +// WorldServerConfig holds world server connection info +type WorldServerConfig struct { + ID int32 `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Port int `json:"port"` + SharedKey string `json:"shared_key"` + AutoConnect bool `json:"auto_connect"` +} + +// DefaultConfig returns sensible default configuration +func DefaultConfig() *Config { + return &Config{ + Port: 5999, + MaxConnections: 1000, + TimeoutSeconds: 45, + EnableCompression: true, + EnableEncryption: true, + AllowAccountCreation: false, + DefaultSubscriptionLevel: 0xFFFFFFFF, + ExpansionFlag: 0x7CFF, + CitiesFlag: 0xFF, + EnabledRaces: 0xFFFF, + SupportedVersions: []uint16{283, 373, 546, 561, 1096, 1208}, + Database: DatabaseConfig{ + FilePath: "eq2login.db", + MaxConnections: 10, + BusyTimeout: 5000, + }, + WebServer: WebServerConfig{ + Enabled: true, + Address: "0.0.0.0", + Port: 8080, + }, + } +} + +// LoadConfig loads configuration from a JSON file +func LoadConfig(filename string) (*Config, error) { + // Start with defaults + config := DefaultConfig() + + // Check if config file exists + if _, err := os.Stat(filename); os.IsNotExist(err) { + // Create default config file + if err := SaveConfig(filename, config); err != nil { + return nil, fmt.Errorf("failed to create default config: %w", err) + } + fmt.Printf("Created default configuration file: %s\n", filename) + return config, nil + } + + // Read existing config file + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // Parse JSON + if err := json.Unmarshal(data, config); err != nil { + return nil, fmt.Errorf("failed to parse config JSON: %w", err) + } + + // Validate configuration + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + return config, nil +} + +// SaveConfig saves configuration to a JSON file +func SaveConfig(filename string, config *Config) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(filename, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Port < 1 || c.Port > 65535 { + return fmt.Errorf("invalid port: %d", c.Port) + } + + if c.MaxConnections < 1 { + return fmt.Errorf("max_connections must be positive") + } + + if c.TimeoutSeconds < 1 { + return fmt.Errorf("timeout_seconds must be positive") + } + + if len(c.SupportedVersions) == 0 { + return fmt.Errorf("must specify at least one supported version") + } + + return nil +} + +// IsVersionSupported checks if a client version is supported +func (c *Config) IsVersionSupported(version uint16) bool { + for _, supported := range c.SupportedVersions { + if supported == version { + return true + } + } + return false +} + +// GetWorldServerConfig returns configuration for a specific world server +func (c *Config) GetWorldServerConfig(id int32) *WorldServerConfig { + for i := range c.WorldServers { + if c.WorldServers[i].ID == id { + return &c.WorldServers[i] + } + } + return nil +} diff --git a/cmd/login_server/database.go b/cmd/login_server/database.go new file mode 100644 index 0000000..b86f9d7 --- /dev/null +++ b/cmd/login_server/database.go @@ -0,0 +1,395 @@ +package main + +import ( + "fmt" + "log" + "time" + + "golang.org/x/crypto/bcrypt" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" +) + +// Database handles all database operations for the login server +type Database struct { + conn *sqlite.Conn +} + +// DatabaseConfig holds database connection settings +type DatabaseConfig struct { + FilePath string `json:"file_path"` + MaxConnections int `json:"max_connections"` + BusyTimeout int `json:"busy_timeout_ms"` +} + +// NewDatabase creates a new database connection +func NewDatabase(config DatabaseConfig) (*Database, error) { + // Open SQLite database + db, err := sqlite.OpenConn(config.FilePath, sqlite.OpenReadWrite|sqlite.OpenCreate) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Enable foreign keys + if err := sqlitex.ExecuteTransient(db, "PRAGMA foreign_keys = ON", nil); err != nil { + return nil, fmt.Errorf("failed to enable foreign keys: %w", err) + } + + // Set busy timeout + if config.BusyTimeout > 0 { + query := fmt.Sprintf("PRAGMA busy_timeout = %d", config.BusyTimeout) + if err := sqlitex.ExecuteTransient(db, query, nil); err != nil { + return nil, fmt.Errorf("failed to set busy timeout: %w", err) + } + } + + log.Println("SQLite database connection established") + return &Database{conn: db}, nil +} + +// Close closes the database connection +func (d *Database) Close() error { + return d.conn.Close() +} + +// AuthenticateAccount verifies user credentials and returns account info +func (d *Database) AuthenticateAccount(username, password string) (*Account, error) { + query := ` + SELECT id, username, password_hash, ls_admin, world_admin, + created_date, last_login, client_version + FROM accounts + WHERE username = ? AND active = 1` + + stmt, err := d.conn.Prepare(query) + if err != nil { + return nil, fmt.Errorf("prepare statement failed: %w", err) + } + defer stmt.Finalize() + + stmt.BindText(1, username) + + hasRow, err := stmt.Step() + if err != nil { + return nil, fmt.Errorf("query failed: %w", err) + } + if !hasRow { + return nil, nil // Account not found + } + + var account Account + var passwordHash string + var createdDate, lastLogin string + var clientVersion int64 + + account.ID = int32(stmt.ColumnInt64(0)) + account.Username = stmt.ColumnText(1) + passwordHash = stmt.ColumnText(2) + account.LSAdmin = stmt.ColumnInt(3) != 0 + account.WorldAdmin = stmt.ColumnInt(4) != 0 + createdDate = stmt.ColumnText(5) + lastLogin = stmt.ColumnText(6) + clientVersion = stmt.ColumnInt64(7) + + // Verify password + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil { + return nil, nil // Invalid password + } + + // Parse timestamps + if lastLogin != "" { + if t, err := time.Parse("2006-01-02 15:04:05", lastLogin); err == nil { + account.LastLogin = t + } + } + + account.ClientVersion = uint16(clientVersion) + return &account, nil +} + +// UpdateAccountLogin updates account login timestamp and IP +func (d *Database) UpdateAccountLogin(account *Account) error { + query := ` + UPDATE accounts + SET last_login = ?, last_ip = ?, client_version = ? + WHERE id = ?` + + return sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{ + Args: []any{ + account.LastLogin.Format("2006-01-02 15:04:05"), + account.IPAddress, + account.ClientVersion, + account.ID, + }, + }) +} + +// LoadCharacters loads all characters for an account +func (d *Database) LoadCharacters(accountID int32, version uint16) ([]*Character, error) { + query := ` + SELECT id, server_id, name, level, race, gender, class, + created_date, deleted + FROM characters + WHERE account_id = ? + ORDER BY created_date ASC` + + stmt, err := d.conn.Prepare(query) + if err != nil { + return nil, fmt.Errorf("prepare failed: %w", err) + } + defer stmt.Finalize() + + stmt.BindInt64(1, int64(accountID)) + + var characters []*Character + for { + hasRow, err := stmt.Step() + if err != nil { + return nil, fmt.Errorf("query failed: %w", err) + } + if !hasRow { + break + } + + char := &Character{AccountID: accountID} + + char.ID = int32(stmt.ColumnInt64(0)) + char.ServerID = int32(stmt.ColumnInt64(1)) + char.Name = stmt.ColumnText(2) + char.Level = int8(stmt.ColumnInt(3)) + char.Race = int8(stmt.ColumnInt(4)) + char.Gender = int8(stmt.ColumnInt(5)) + char.Class = int8(stmt.ColumnInt(6)) + + if dateStr := stmt.ColumnText(7); dateStr != "" { + if t, err := time.Parse("2006-01-02 15:04:05", dateStr); err == nil { + char.CreatedDate = t + } + } + + char.Deleted = stmt.ColumnInt(8) != 0 + characters = append(characters, char) + } + + return characters, stmt.Err() +} + +// CharacterNameExists checks if a character name is already taken +func (d *Database) CharacterNameExists(name string, serverID int32) (bool, error) { + query := ` + SELECT COUNT(*) + FROM characters + WHERE name = ? AND server_id = ? AND deleted = 0` + + stmt, err := d.conn.Prepare(query) + if err != nil { + return false, err + } + defer stmt.Finalize() + + stmt.BindText(1, name) + stmt.BindInt64(2, int64(serverID)) + + hasRow, err := stmt.Step() + if err != nil { + return false, err + } + if hasRow { + return stmt.ColumnInt(0) > 0, nil + } + + return false, nil +} + +// CreateCharacter creates a new character in the database +func (d *Database) CreateCharacter(char *Character) (int32, error) { + query := ` + INSERT INTO characters (account_id, server_id, name, level, race, + gender, class, created_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + + err := sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{ + Args: []any{ + char.AccountID, char.ServerID, char.Name, char.Level, + char.Race, char.Gender, char.Class, + char.CreatedDate.Format("2006-01-02 15:04:05"), + }, + }) + if err != nil { + return 0, fmt.Errorf("failed to create character: %w", err) + } + + id := int32(d.conn.LastInsertRowID()) + log.Printf("Created character %s (ID: %d) for account %d", + char.Name, id, char.AccountID) + + return id, nil +} + +// DeleteCharacter marks a character as deleted +func (d *Database) DeleteCharacter(charID, accountID int32) error { + query := ` + UPDATE characters + SET deleted = 1, deleted_date = CURRENT_TIMESTAMP + WHERE id = ? AND account_id = ?` + + err := sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{ + Args: []any{charID, accountID}, + }) + if err != nil { + return fmt.Errorf("failed to delete character: %w", err) + } + + // Check if any rows were affected + stmt, _ := d.conn.Prepare("SELECT changes()") + defer stmt.Finalize() + if stmt.Step() && stmt.ColumnInt(0) == 0 { + return fmt.Errorf("character not found or not owned by account") + } + + log.Printf("Deleted character %d for account %d", charID, accountID) + return nil +} + +// GetWorldServers returns all configured world servers +func (d *Database) GetWorldServers() ([]*WorldServer, error) { + query := ` + SELECT id, name, description, ip_address, port, status, + population, locked, hidden, created_date + FROM world_servers + WHERE active = 1 + ORDER BY sort_order, name` + + stmt, err := d.conn.Prepare(query) + if err != nil { + return nil, fmt.Errorf("prepare failed: %w", err) + } + defer stmt.Finalize() + + var servers []*WorldServer + for { + hasRow, err := stmt.Step() + if err != nil { + return nil, fmt.Errorf("query failed: %w", err) + } + if !hasRow { + break + } + + server := &WorldServer{} + + server.ID = int32(stmt.ColumnInt64(0)) + server.Name = stmt.ColumnText(1) + server.Description = stmt.ColumnText(2) + server.IPAddress = stmt.ColumnText(3) + server.Port = stmt.ColumnInt(4) + server.Status = stmt.ColumnText(5) + server.Population = int32(stmt.ColumnInt64(6)) + server.Locked = stmt.ColumnInt(7) != 0 + server.Hidden = stmt.ColumnInt(8) != 0 + + if dateStr := stmt.ColumnText(9); dateStr != "" { + if t, err := time.Parse("2006-01-02 15:04:05", dateStr); err == nil { + server.CreatedDate = t + } + } + + server.Online = server.Status == "online" + server.PopulationLevel = d.calculatePopulationLevel(server.Population) + + servers = append(servers, server) + } + + return servers, stmt.Err() +} + +// UpdateWorldServerStats updates world server statistics +func (d *Database) UpdateWorldServerStats(serverID int32, stats *WorldServerStats) error { + query := ` + INSERT OR REPLACE INTO world_server_stats + (server_id, timestamp, population, zones_active, players_online, uptime_seconds) + VALUES (?, CURRENT_TIMESTAMP, ?, ?, ?, ?)` + + return sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{ + Args: []any{ + serverID, stats.Population, + stats.ZonesActive, stats.PlayersOnline, stats.UptimeSeconds, + }, + }) +} + +// CleanupOldEntries removes old log entries and statistics +func (d *Database) CleanupOldEntries() error { + queries := []string{ + "DELETE FROM login_attempts WHERE timestamp < datetime('now', '-30 days')", + "DELETE FROM world_server_stats WHERE timestamp < datetime('now', '-7 days')", + "DELETE FROM client_logs WHERE timestamp < datetime('now', '-14 days')", + } + + for _, query := range queries { + if err := sqlitex.ExecuteTransient(d.conn, query, nil); err != nil { + log.Printf("Cleanup query failed: %v", err) + } + } + + return nil +} + +// LogLoginAttempt records a login attempt for security monitoring +func (d *Database) LogLoginAttempt(username, ipAddress string, success bool) error { + query := ` + INSERT INTO login_attempts (username, ip_address, success, timestamp) + VALUES (?, ?, ?, CURRENT_TIMESTAMP)` + + return sqlitex.Execute(d.conn, query, &sqlitex.ExecOptions{ + Args: []any{username, ipAddress, success}, + }) +} + +// GetMaxCharsSetting returns the maximum characters per account +func (d *Database) GetMaxCharsSetting() int32 { + var maxChars int32 = 7 // Default + + query := "SELECT value FROM server_settings WHERE name = 'max_characters_per_account'" + stmt, err := d.conn.Prepare(query) + if err != nil { + return maxChars + } + defer stmt.Finalize() + + hasRow, err := stmt.Step() + if err != nil { + return maxChars + } + if hasRow { + if val := stmt.ColumnText(0); val != "" { + if parsed := stmt.ColumnInt64(0); parsed > 0 { + maxChars = int32(parsed) + } + } + } + + return maxChars +} + +// GetAccountBonus returns veteran bonus flags for an account +func (d *Database) GetAccountBonus(accountID int32) uint8 { + var bonus uint8 = 0 + + query := "SELECT veteran_bonus FROM accounts WHERE id = ?" + stmt, err := d.conn.Prepare(query) + if err != nil { + return bonus + } + defer stmt.Finalize() + + stmt.BindInt64(1, int64(accountID)) + hasRow, err := stmt.Step() + if err != nil { + return bonus + } + if hasRow { + bonus = uint8(stmt.ColumnInt(0)) + } + + return bonus +} diff --git a/cmd/login_server/handlers.go b/cmd/login_server/handlers.go new file mode 100644 index 0000000..b793554 --- /dev/null +++ b/cmd/login_server/handlers.go @@ -0,0 +1,513 @@ +package main + +import ( + "encoding/binary" + "eq2emu/internal/common/opcodes" + "eq2emu/internal/udp" + "fmt" + "log" + "time" +) + +// LoginRequest represents parsed login request data +type LoginRequest struct { + Username string + Password string + Version uint16 +} + +// parseLoginRequest parses the login request packet data +func (lc *LoginClient) parseLoginRequest(data []byte) (*LoginRequest, error) { + if len(data) < 10 { + return nil, fmt.Errorf("packet too small") + } + + offset := 0 + + // Skip access code (16-bit string) + accessCodeLen := binary.LittleEndian.Uint16(data[offset:]) + offset += 2 + int(accessCodeLen) + + // Skip unknown1 (16-bit string) + if offset+2 > len(data) { + return nil, fmt.Errorf("invalid packet format") + } + unknown1Len := binary.LittleEndian.Uint16(data[offset:]) + offset += 2 + int(unknown1Len) + + // Parse username + if offset+2 > len(data) { + return nil, fmt.Errorf("invalid packet format") + } + usernameLen := binary.LittleEndian.Uint16(data[offset:]) + offset += 2 + if offset+int(usernameLen) > len(data) { + return nil, fmt.Errorf("invalid username length") + } + username := string(data[offset : offset+int(usernameLen)]) + offset += int(usernameLen) + + // Parse password + if offset+2 > len(data) { + return nil, fmt.Errorf("invalid packet format") + } + passwordLen := binary.LittleEndian.Uint16(data[offset:]) + offset += 2 + if offset+int(passwordLen) > len(data) { + return nil, fmt.Errorf("invalid password length") + } + password := string(data[offset : offset+int(passwordLen)]) + offset += int(passwordLen) + + // Skip unknown fields and get version + version := uint16(0) + if offset+18 <= len(data) { // Skip 4 unknown strings + version + // Simple parsing - in real implementation, parse all unknown fields properly + version = binary.LittleEndian.Uint16(data[len(data)-10:]) + } + + return &LoginRequest{ + Username: username, + Password: password, + Version: version, + }, nil +} + +// sendLoginDenied sends login failure response +func (lc *LoginClient) sendLoginDenied() { + data := make([]byte, 12) + data[0] = 1 // reply_code: Invalid username or password + binary.LittleEndian.PutUint32(data[4:], 0xFFFFFFFF) + binary.LittleEndian.PutUint32(data[8:], 0xFFFFFFFF) + + packet := &udp.ApplicationPacket{ + Opcode: opcodes.OpLoginReplyMsg, + Data: data, + } + lc.connection.SendPacket(packet) + + // Disconnect after short delay + time.AfterFunc(1*time.Second, func() { + lc.Disconnect() + }) +} + +// sendLoginDeniedBadVersion sends bad version response +func (lc *LoginClient) sendLoginDeniedBadVersion() { + data := make([]byte, 12) + data[0] = 6 // reply_code: Version mismatch + binary.LittleEndian.PutUint32(data[4:], 0xFFFFFFFF) + binary.LittleEndian.PutUint32(data[8:], 0xFFFFFFFF) + + packet := &udp.ApplicationPacket{ + Opcode: opcodes.OpLoginReplyMsg, + Data: data, + } + lc.connection.SendPacket(packet) + + time.AfterFunc(1*time.Second, func() { + lc.Disconnect() + }) +} + +// sendLoginAccepted sends successful login response +func (lc *LoginClient) sendLoginAccepted() { + config := lc.server.GetConfig() + + // Build login response packet + data := make([]byte, 64) // Base size, will expand as needed + offset := 0 + + // Account ID + binary.LittleEndian.PutUint32(data[offset:], uint32(lc.account.ID)) + offset += 4 + + // Login response code (0 = success) + data[offset] = 0 + offset++ + + // Do not force SOGA flag + data[offset] = 1 + offset++ + + // Subscription level + binary.LittleEndian.PutUint32(data[offset:], config.DefaultSubscriptionLevel) + offset += 4 + + // Race flags (enabled races) + binary.LittleEndian.PutUint32(data[offset:], 0x1FFFFF) + offset += 4 + + // Class flags (enabled classes) + binary.LittleEndian.PutUint32(data[offset:], 0x7FFFFFE) + offset += 4 + + // Username (16-bit string) + username := lc.account.Username + binary.LittleEndian.PutUint16(data[offset:], uint16(len(username))) + offset += 2 + copy(data[offset:], username) + offset += len(username) + + // Expansion flags + binary.LittleEndian.PutUint16(data[offset:], config.ExpansionFlag) + offset += 2 + + // Additional flags + data[offset] = 0xFF + data[offset+1] = 0xFF + data[offset+2] = 0xFF + offset += 3 + + // Class access flag + data[offset] = 0xFF + offset++ + + // Enabled races + binary.LittleEndian.PutUint32(data[offset:], config.EnabledRaces) + offset += 4 + + // Cities flag + data[offset] = config.CitiesFlag + offset++ + + packet := &udp.ApplicationPacket{ + Opcode: opcodes.OpLoginReplyMsg, + Data: data[:offset], + } + lc.connection.SendPacket(packet) +} + +// sendWorldList sends available world servers to client +func (lc *LoginClient) sendWorldList() { + worlds := lc.server.worldList.GetActiveWorlds() + + // Build world list packet + data := make([]byte, 0, 1024) + + // Number of worlds + worldCount := uint8(len(worlds)) + data = append(data, worldCount) + + for _, world := range worlds { + // World ID + worldID := make([]byte, 4) + binary.LittleEndian.PutUint32(worldID, uint32(world.ID)) + data = append(data, worldID...) + + // World name (16-bit string) + nameLen := make([]byte, 2) + binary.LittleEndian.PutUint16(nameLen, uint16(len(world.Name))) + data = append(data, nameLen...) + data = append(data, []byte(world.Name)...) + + // World status flags + var flags uint8 + if world.Online { + flags |= 0x01 + } + if world.Locked { + flags |= 0x02 + } + if world.Hidden { + flags |= 0x04 + } + data = append(data, flags) + + // Population (0-3, where 3 = full) + data = append(data, world.PopulationLevel) + } + + packet := &udp.ApplicationPacket{ + Opcode: opcodes.OpWorldListMsg, + Data: data, + } + lc.connection.SendPacket(packet) +} + +// sendCharacterList sends character list to client +func (lc *LoginClient) sendCharacterList() { + if lc.account == nil { + return + } + + data := make([]byte, 0, 2048) + + // Number of characters + charCount := uint8(len(lc.account.Characters)) + data = append(data, charCount) + + // Character data + for _, char := range lc.account.Characters { + if char.Deleted { + continue + } + + // Character ID + charID := make([]byte, 4) + binary.LittleEndian.PutUint32(charID, uint32(char.ID)) + data = append(data, charID...) + + // Server ID + serverID := make([]byte, 4) + binary.LittleEndian.PutUint32(serverID, uint32(char.ServerID)) + data = append(data, serverID...) + + // Character name (16-bit string) + nameLen := make([]byte, 2) + binary.LittleEndian.PutUint16(nameLen, uint16(len(char.Name))) + data = append(data, nameLen...) + data = append(data, []byte(char.Name)...) + + // Character stats + data = append(data, byte(char.Race)) + data = append(data, byte(char.Gender)) + data = append(data, byte(char.Class)) + data = append(data, byte(char.Level)) + + // Creation timestamp + timestamp := make([]byte, 4) + binary.LittleEndian.PutUint32(timestamp, uint32(char.CreatedDate.Unix())) + data = append(data, timestamp...) + } + + // Account info + accountID := make([]byte, 4) + binary.LittleEndian.PutUint32(accountID, uint32(lc.account.ID)) + data = append(data, accountID...) + + // Max characters + data = append(data, 0xFF, 0xFF, 0xFF, 0xFF) // unknown1 + data = append(data, 0x00, 0x00) // unknown2 + data = append(data, 0x07, 0x00, 0x00, 0x00) // max chars (7) + data = append(data, 0x00) // unknown4 + + packet := &udp.ApplicationPacket{ + Opcode: opcodes.OpAllCharactersDescReplyMsg, + Data: data, + } + lc.connection.SendPacket(packet) +} + +// handleCharacterCreateRequest processes character creation +func (lc *LoginClient) handleCharacterCreateRequest(packet *udp.ApplicationPacket) { + if !lc.authenticated { + lc.Disconnect() + return + } + + // Parse character creation data (simplified) + if len(packet.Data) < 20 { + lc.sendCharacterCreateFailed(1) // Generic error + return + } + + // Extract basic info (this would need full parsing in real implementation) + serverID := binary.LittleEndian.Uint32(packet.Data[0:4]) + nameLen := binary.LittleEndian.Uint16(packet.Data[4:6]) + name := string(packet.Data[6 : 6+nameLen]) + + // Validate character name + if len(name) < 3 || len(name) > 20 { + lc.sendCharacterCreateFailed(9) // Bad name length + return + } + + // Check if name is taken + exists, err := lc.server.database.CharacterNameExists(name, int32(serverID)) + if err != nil { + log.Printf("Error checking character name: %v", err) + lc.sendCharacterCreateFailed(1) + return + } + + if exists { + lc.sendCharacterCreateFailed(12) // Name taken + return + } + + // Create character in database + char := &Character{ + AccountID: lc.account.ID, + ServerID: int32(serverID), + Name: name, + Level: 1, + Race: 1, // Would be parsed from packet + Gender: 1, // Would be parsed from packet + Class: 1, // Would be parsed from packet + CreatedDate: time.Now(), + } + + charID, err := lc.server.database.CreateCharacter(char) + if err != nil { + log.Printf("Error creating character: %v", err) + lc.sendCharacterCreateFailed(1) + return + } + + char.ID = charID + lc.account.Characters = append(lc.account.Characters, char) + + lc.sendCharacterCreateSuccess(char) + lc.sendCharacterList() // Refresh character list +} + +// sendCharacterCreateSuccess sends successful character creation response +func (lc *LoginClient) sendCharacterCreateSuccess(char *Character) { + data := make([]byte, 64) + offset := 0 + + // Account ID + binary.LittleEndian.PutUint32(data[offset:], uint32(lc.account.ID)) + offset += 4 + + // Response code (0 = success) + binary.LittleEndian.PutUint32(data[offset:], 0) + offset += 4 + + // Character name + nameLen := uint16(len(char.Name)) + binary.LittleEndian.PutUint16(data[offset:], nameLen) + offset += 2 + copy(data[offset:], char.Name) + offset += int(nameLen) + + packet := &udp.ApplicationPacket{ + Opcode: opcodes.OpCreateCharacterReplyMsg, + Data: data[:offset], + } + lc.connection.SendPacket(packet) +} + +// sendCharacterCreateFailed sends character creation failure response +func (lc *LoginClient) sendCharacterCreateFailed(reason uint8) { + data := make([]byte, 16) + + binary.LittleEndian.PutUint32(data[0:], uint32(lc.account.ID)) + data[4] = reason + + packet := &udp.ApplicationPacket{ + Opcode: opcodes.OpCreateCharacterReplyMsg, + Data: data, + } + lc.connection.SendPacket(packet) +} + +// handleCharacterDeleteRequest processes character deletion +func (lc *LoginClient) handleCharacterDeleteRequest(packet *udp.ApplicationPacket) { + if !lc.authenticated { + lc.Disconnect() + return + } + + // Parse deletion request (simplified) + if len(packet.Data) < 12 { + return + } + + charID := binary.LittleEndian.Uint32(packet.Data[0:4]) + serverID := binary.LittleEndian.Uint32(packet.Data[4:8]) + + // Verify character belongs to this account + var char *Character + for _, c := range lc.account.Characters { + if c.ID == int32(charID) && c.ServerID == int32(serverID) { + char = c + break + } + } + + if char == nil { + log.Printf("Account %d attempted to delete character %d that doesn't belong to them", lc.account.ID, charID) + return + } + + // Mark character as deleted + err := lc.server.database.DeleteCharacter(int32(charID), lc.account.ID) + if err != nil { + log.Printf("Error deleting character: %v", err) + return + } + + char.Deleted = true + + // Send deletion response + data := make([]byte, 24) + data[0] = 1 // Success + binary.LittleEndian.PutUint32(data[4:], serverID) + binary.LittleEndian.PutUint32(data[8:], charID) + binary.LittleEndian.PutUint32(data[12:], uint32(lc.account.ID)) + + packet = &udp.ApplicationPacket{ + Opcode: opcodes.OpDeleteCharacterReplyMsg, + Data: data, + } + lc.connection.SendPacket(packet) + + lc.sendCharacterList() // Refresh character list +} + +// handlePlayCharacterRequest processes character selection for gameplay +func (lc *LoginClient) handlePlayCharacterRequest(packet *udp.ApplicationPacket) { + if !lc.authenticated { + lc.Disconnect() + return + } + + // Parse play request + if len(packet.Data) < 8 { + lc.sendPlayFailed(1) + return + } + + charID := binary.LittleEndian.Uint32(packet.Data[0:4]) + serverID := binary.LittleEndian.Uint32(packet.Data[4:8]) + + // Find world server + world := lc.server.worldList.GetWorld(int32(serverID)) + if world == nil || !world.Online { + lc.sendPlayFailed(2) // Server unavailable + return + } + + // Verify character ownership + var char *Character + for _, c := range lc.account.Characters { + if c.ID == int32(charID) && c.ServerID == int32(serverID) { + char = c + break + } + } + + if char == nil { + lc.sendPlayFailed(1) // Character not found + return + } + + lc.pendingPlayCharID = int32(charID) + + // Send play request to world server + err := lc.server.worldList.SendPlayRequest(world, lc.account.ID, int32(charID)) + if err != nil { + log.Printf("Error sending play request to world server: %v", err) + lc.sendPlayFailed(1) + return + } + + // World server will respond with connection details + log.Printf("Account %s requesting to play character %s on server %s", + lc.account.Username, char.Name, world.Name) +} + +// sendPlayFailed sends play character failure response +func (lc *LoginClient) sendPlayFailed(reason uint8) { + data := make([]byte, 16) + data[0] = reason + binary.LittleEndian.PutUint32(data[4:], uint32(lc.account.ID)) + + packet := &udp.ApplicationPacket{ + Opcode: opcodes.OpPlayCharacterReplyMsg, + Data: data, + } + lc.connection.SendPacket(packet) +} diff --git a/cmd/login_server/login_server.go b/cmd/login_server/login_server.go new file mode 100644 index 0000000..3875620 --- /dev/null +++ b/cmd/login_server/login_server.go @@ -0,0 +1,208 @@ +package main + +import ( + "eq2emu/internal/udp" + "fmt" + "log" + "sync" + "time" +) + +// LoginServer manages the main login server functionality +type LoginServer struct { + config *Config + server *udp.Server + database *Database + worldList *WorldList + webServer *WebServer + clients map[string]*LoginClient + clientMutex sync.RWMutex + + // Statistics + stats struct { + ConnectionCount int32 + LoginAttempts int32 + SuccessfulLogins int32 + StartTime time.Time + } +} + +// NewLoginServer creates a new login server instance +func NewLoginServer(config *Config) (*LoginServer, error) { + ls := &LoginServer{ + config: config, + clients: make(map[string]*LoginClient), + } + + ls.stats.StartTime = time.Now() + + // Initialize database + db, err := NewDatabase(config.Database) + if err != nil { + return nil, fmt.Errorf("database initialization failed: %w", err) + } + ls.database = db + + // Initialize world list + ls.worldList = NewWorldList(db) + + // Create UDP server with login packet handler + udpConfig := udp.DefaultConfig() + udpConfig.MaxConnections = config.MaxConnections + udpConfig.Timeout = time.Duration(config.TimeoutSeconds) * time.Second + udpConfig.EnableCompression = config.EnableCompression + udpConfig.EnableEncryption = config.EnableEncryption + + server, err := udp.NewServer(fmt.Sprintf(":%d", config.Port), ls.handlePacket, udpConfig) + if err != nil { + return nil, fmt.Errorf("UDP server creation failed: %w", err) + } + ls.server = server + + // Initialize web server if configured + if config.WebServer.Enabled { + webServer, err := NewWebServer(config.WebServer, ls) + if err != nil { + log.Printf("Web server initialization failed: %v", err) + } else { + ls.webServer = webServer + } + } + + return ls, nil +} + +// Start begins accepting connections and processing packets +func (ls *LoginServer) Start() error { + log.Println("Starting login server components...") + + // Start world list monitoring + go ls.worldList.Start() + + // Start web server if configured + if ls.webServer != nil { + go ls.webServer.Start() + } + + // Start UDP server + return ls.server.Start() +} + +// Stop gracefully shuts down the server +func (ls *LoginServer) Stop() { + log.Println("Stopping login server...") + + if ls.webServer != nil { + ls.webServer.Stop() + } + + ls.worldList.Stop() + ls.server.Stop() + ls.database.Close() +} + +// handlePacket processes incoming packets from clients +func (ls *LoginServer) handlePacket(conn *udp.Connection, packet *udp.ApplicationPacket) { + clientKey := conn.GetSessionID() + + // Get or create client + ls.clientMutex.Lock() + client, exists := ls.clients[fmt.Sprintf("%d", clientKey)] + if !exists { + client = NewLoginClient(conn, ls) + ls.clients[fmt.Sprintf("%d", clientKey)] = client + } + ls.clientMutex.Unlock() + + // Process packet + client.ProcessPacket(packet) +} + +// RemoveClient removes a client from the active clients list +func (ls *LoginServer) RemoveClient(sessionID string) { + ls.clientMutex.Lock() + delete(ls.clients, sessionID) + ls.clientMutex.Unlock() +} + +// UpdateStats updates server statistics +func (ls *LoginServer) UpdateStats() { + ls.clientMutex.RLock() + ls.stats.ConnectionCount = int32(len(ls.clients)) + ls.clientMutex.RUnlock() + + // Update world server statistics + ls.worldList.UpdateStats() + + // Clean up old database entries + ls.database.CleanupOldEntries() + + log.Printf("Stats: %d connections, %d login attempts, %d successful logins", + ls.stats.ConnectionCount, ls.stats.LoginAttempts, ls.stats.SuccessfulLogins) +} + +// CleanupStaleConnections removes inactive connections +func (ls *LoginServer) CleanupStaleConnections() { + var staleClients []string + + ls.clientMutex.RLock() + for sessionID, client := range ls.clients { + if client.IsStale() { + staleClients = append(staleClients, sessionID) + } + } + ls.clientMutex.RUnlock() + + ls.clientMutex.Lock() + for _, sessionID := range staleClients { + if client, exists := ls.clients[sessionID]; exists { + client.Disconnect() + delete(ls.clients, sessionID) + } + } + ls.clientMutex.Unlock() + + if len(staleClients) > 0 { + log.Printf("Cleaned up %d stale connections", len(staleClients)) + } +} + +// GetStats returns current server statistics +func (ls *LoginServer) GetStats() map[string]any { + ls.clientMutex.RLock() + connectionCount := len(ls.clients) + ls.clientMutex.RUnlock() + + return map[string]any{ + "connection_count": connectionCount, + "login_attempts": ls.stats.LoginAttempts, + "successful_logins": ls.stats.SuccessfulLogins, + "uptime_seconds": int(time.Since(ls.stats.StartTime).Seconds()), + "world_server_count": ls.worldList.GetActiveCount(), + } +} + +// IncrementLoginAttempts atomically increments login attempt counter +func (ls *LoginServer) IncrementLoginAttempts() { + ls.stats.LoginAttempts++ +} + +// IncrementSuccessfulLogins atomically increments successful login counter +func (ls *LoginServer) IncrementSuccessfulLogins() { + ls.stats.SuccessfulLogins++ +} + +// GetDatabase returns the database instance +func (ls *LoginServer) GetDatabase() *Database { + return ls.database +} + +// GetWorldList returns the world list instance +func (ls *LoginServer) GetWorldList() *WorldList { + return ls.worldList +} + +// GetConfig returns the server configuration +func (ls *LoginServer) GetConfig() *Config { + return ls.config +} diff --git a/cmd/login_server/main.go b/cmd/login_server/main.go new file mode 100644 index 0000000..b9efef0 --- /dev/null +++ b/cmd/login_server/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + "time" +) + +var ( + loginServer *LoginServer + runLoops = true +) + +func main() { + // Initialize logging + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Println("EQ2Emulator Login Server Starting...") + + // Load configuration + config, err := LoadConfig("login_config.json") + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // Initialize login server + loginServer, err = NewLoginServer(config) + if err != nil { + log.Fatalf("Failed to create login server: %v", err) + } + + // Setup signal handling + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Start the server + go func() { + if err := loginServer.Start(); err != nil { + log.Fatalf("Server failed to start: %v", err) + } + }() + + // Start maintenance routines + go startMaintenanceTimers() + + log.Printf("Login server listening on port %d", config.Port) + + // Wait for shutdown signal + <-sigChan + log.Println("Shutdown signal received, stopping server...") + + runLoops = false + loginServer.Stop() + log.Println("Server stopped.") +} + +// startMaintenanceTimers starts periodic maintenance tasks +func startMaintenanceTimers() { + statsTicker := time.NewTicker(60 * time.Second) + cleanupTicker := time.NewTicker(5 * time.Minute) + + defer statsTicker.Stop() + defer cleanupTicker.Stop() + + for runLoops { + select { + case <-statsTicker.C: + loginServer.UpdateStats() + case <-cleanupTicker.C: + loginServer.CleanupStaleConnections() + } + } +} diff --git a/cmd/login_server/web.go b/cmd/login_server/web.go new file mode 100644 index 0000000..3b303ea --- /dev/null +++ b/cmd/login_server/web.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +// WebServer provides HTTP endpoints for monitoring +type WebServer struct { + config WebServerConfig + loginServer *LoginServer + server *http.Server +} + +// NewWebServer creates a new web monitoring server +func NewWebServer(config WebServerConfig, loginServer *LoginServer) (*WebServer, error) { + ws := &WebServer{ + config: config, + loginServer: loginServer, + } + + mux := http.NewServeMux() + mux.HandleFunc("/status", ws.handleStatus) + mux.HandleFunc("/worlds", ws.handleWorlds) + mux.HandleFunc("/stats", ws.handleStats) + mux.HandleFunc("/health", ws.handleHealth) + + ws.server = &http.Server{ + Addr: fmt.Sprintf("%s:%d", config.Address, config.Port), + Handler: ws.basicAuth(mux), + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + + return ws, nil +} + +// Start begins the web server +func (ws *WebServer) Start() { + log.Printf("Starting web server on %s", ws.server.Addr) + + var err error + if ws.config.CertFile != "" && ws.config.KeyFile != "" { + err = ws.server.ListenAndServeTLS(ws.config.CertFile, ws.config.KeyFile) + } else { + err = ws.server.ListenAndServe() + } + + if err != http.ErrServerClosed { + log.Printf("Web server error: %v", err) + } +} + +// Stop shuts down the web server +func (ws *WebServer) Stop() { + if ws.server != nil { + ws.server.Close() + } +} + +// basicAuth provides basic HTTP authentication +func (ws *WebServer) basicAuth(next http.Handler) http.Handler { + if ws.config.Username == "" { + return next + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != ws.config.Username || password != ws.config.Password { + w.Header().Set("WWW-Authenticate", `Basic realm="Login Server"`) + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Unauthorized")) + return + } + next.ServeHTTP(w, r) + }) +} + +// handleStatus returns server status information +func (ws *WebServer) handleStatus(w http.ResponseWriter, r *http.Request) { + stats := ws.loginServer.GetStats() + + status := map[string]any{ + "service": "eq2emu-login-server", + "version": "1.0.0", + "status": "running", + "timestamp": time.Now().UTC(), + "statistics": stats, + } + + ws.writeJSON(w, status) +} + +// handleWorlds returns world server information +func (ws *WebServer) handleWorlds(w http.ResponseWriter, r *http.Request) { + worlds := ws.loginServer.worldList.GetActiveWorlds() + worldStats := ws.loginServer.worldList.GetStats() + + response := map[string]any{ + "world_servers": worlds, + "statistics": worldStats, + } + + ws.writeJSON(w, response) +} + +// handleStats returns detailed server statistics +func (ws *WebServer) handleStats(w http.ResponseWriter, r *http.Request) { + serverStats := ws.loginServer.GetStats() + worldStats := ws.loginServer.worldList.GetStats() + + stats := map[string]any{ + "server": serverStats, + "worlds": worldStats, + "timestamp": time.Now().UTC(), + } + + ws.writeJSON(w, stats) +} + +// handleHealth returns basic health check +func (ws *WebServer) handleHealth(w http.ResponseWriter, r *http.Request) { + health := map[string]any{ + "status": "healthy", + "timestamp": time.Now().UTC(), + } + + ws.writeJSON(w, health) +} + +// writeJSON writes JSON response with proper headers +func (ws *WebServer) writeJSON(w http.ResponseWriter, data any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + + if err := json.NewEncoder(w).Encode(data); err != nil { + log.Printf("JSON encoding error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} diff --git a/cmd/login_server/world_server.go b/cmd/login_server/world_server.go new file mode 100644 index 0000000..40ecf5e --- /dev/null +++ b/cmd/login_server/world_server.go @@ -0,0 +1,356 @@ +package main + +import ( + "fmt" + "log" + "sync" + "time" +) + +// WorldServer represents a game world server +type WorldServer struct { + ID int32 + Name string + Description string + IPAddress string + Port int + Status string + Population int32 + PopulationLevel uint8 + Locked bool + Hidden bool + Online bool + CreatedDate time.Time + LastUpdate time.Time +} + +// WorldServerStats holds runtime statistics +type WorldServerStats struct { + Population int32 + ZonesActive int32 + PlayersOnline int32 + UptimeSeconds int64 +} + +// WorldList manages all world servers +type WorldList struct { + servers map[int32]*WorldServer + mutex sync.RWMutex + database *Database + updateTicker *time.Ticker + stopChan chan struct{} +} + +// NewWorldList creates a new world list manager +func NewWorldList(database *Database) *WorldList { + return &WorldList{ + servers: make(map[int32]*WorldServer), + database: database, + stopChan: make(chan struct{}), + } +} + +// Start begins world server monitoring +func (wl *WorldList) Start() { + log.Println("Starting world list monitoring...") + + // Load world servers from database + if err := wl.LoadFromDatabase(); err != nil { + log.Printf("Failed to load world servers: %v", err) + } + + // Start periodic updates + wl.updateTicker = time.NewTicker(30 * time.Second) + + go func() { + for { + select { + case <-wl.updateTicker.C: + wl.UpdateStats() + case <-wl.stopChan: + return + } + } + }() +} + +// Stop shuts down world server monitoring +func (wl *WorldList) Stop() { + log.Println("Stopping world list monitoring...") + + if wl.updateTicker != nil { + wl.updateTicker.Stop() + } + + close(wl.stopChan) +} + +// LoadFromDatabase loads world servers from the database +func (wl *WorldList) LoadFromDatabase() error { + servers, err := wl.database.GetWorldServers() + if err != nil { + return fmt.Errorf("failed to load world servers: %w", err) + } + + wl.mutex.Lock() + defer wl.mutex.Unlock() + + // Clear existing servers + wl.servers = make(map[int32]*WorldServer) + + // Add loaded servers + for _, server := range servers { + wl.servers[server.ID] = server + log.Printf("Loaded world server: %s (ID: %d)", server.Name, server.ID) + } + + log.Printf("Loaded %d world servers", len(servers)) + return nil +} + +// GetActiveWorlds returns all online world servers +func (wl *WorldList) GetActiveWorlds() []*WorldServer { + wl.mutex.RLock() + defer wl.mutex.RUnlock() + + var active []*WorldServer + for _, server := range wl.servers { + if server.Online && !server.Hidden { + active = append(active, server) + } + } + + return active +} + +// GetWorld returns a specific world server by ID +func (wl *WorldList) GetWorld(id int32) *WorldServer { + wl.mutex.RLock() + defer wl.mutex.RUnlock() + + return wl.servers[id] +} + +// GetActiveCount returns the number of online world servers +func (wl *WorldList) GetActiveCount() int { + wl.mutex.RLock() + defer wl.mutex.RUnlock() + + count := 0 + for _, server := range wl.servers { + if server.Online { + count++ + } + } + + return count +} + +// UpdateServerStatus updates a world server's status +func (wl *WorldList) UpdateServerStatus(id int32, online bool, population int32, locked bool) { + wl.mutex.Lock() + defer wl.mutex.Unlock() + + server, exists := wl.servers[id] + if !exists { + log.Printf("Attempted to update unknown server ID: %d", id) + return + } + + server.Online = online + server.Population = population + server.Locked = locked + server.LastUpdate = time.Now() + + // Update population level + server.PopulationLevel = wl.calculatePopulationLevel(population) + + if online { + server.Status = "online" + } else { + server.Status = "offline" + } + + log.Printf("Updated server %s: online=%t, population=%d, locked=%t", + server.Name, online, population, locked) +} + +// calculatePopulationLevel converts population to display level +func (wl *WorldList) calculatePopulationLevel(population int32) uint8 { + switch { + case population >= 1000: + return 3 // Full + case population >= 500: + return 2 // High + case population >= 100: + return 1 // Medium + default: + return 0 // Low + } +} + +// UpdateStats updates world server statistics +func (wl *WorldList) UpdateStats() { + wl.mutex.RLock() + servers := make([]*WorldServer, 0, len(wl.servers)) + for _, server := range wl.servers { + servers = append(servers, server) + } + wl.mutex.RUnlock() + + // Update statistics for each server + for _, server := range servers { + if server.Online { + stats := &WorldServerStats{ + Population: server.Population, + ZonesActive: 0, // Would be updated by world server + PlayersOnline: server.Population, + UptimeSeconds: int64(time.Since(server.LastUpdate).Seconds()), + } + + if err := wl.database.UpdateWorldServerStats(server.ID, stats); err != nil { + log.Printf("Failed to update stats for server %d: %v", server.ID, err) + } + } + } +} + +// SendPlayRequest sends a character play request to a world server +func (wl *WorldList) SendPlayRequest(world *WorldServer, accountID, charID int32) error { + // In a real implementation, this would establish communication with the world server + // and send the play request. For now, we'll simulate the response. + + log.Printf("Sending play request to world server %s for account %d, character %d", + world.Name, accountID, charID) + + // Simulate world server response after a short delay + go func() { + time.Sleep(100 * time.Millisecond) + + // For demonstration, we'll always succeed + // In reality, the world server would validate the character and respond + accessKey := generateAccessKey() + + // This would normally come from the world server's response + wl.HandlePlayResponse(world.ID, accountID, charID, true, + world.IPAddress, world.Port, accessKey) + }() + + return nil +} + +// HandlePlayResponse processes a play response from a world server +func (wl *WorldList) HandlePlayResponse(worldID, accountID, charID int32, + success bool, ipAddress string, port int, accessKey int32) { + + // Find the client that requested this + // This is simplified - in reality you'd track pending requests + log.Printf("Play response from world %d: success=%t, access_key=%d", + worldID, success, accessKey) + + // Send response to appropriate client + // This would need to be implemented with proper client tracking +} + +// generateAccessKey generates a random access key for world server connections +func generateAccessKey() int32 { + return int32(time.Now().UnixNano() & 0x7FFFFFFF) +} + +// AddServer adds a new world server (for dynamic registration) +func (wl *WorldList) AddServer(server *WorldServer) { + wl.mutex.Lock() + defer wl.mutex.Unlock() + + wl.servers[server.ID] = server + log.Printf("Added world server: %s (ID: %d)", server.Name, server.ID) +} + +// RemoveServer removes a world server +func (wl *WorldList) RemoveServer(id int32) { + wl.mutex.Lock() + defer wl.mutex.Unlock() + + if server, exists := wl.servers[id]; exists { + delete(wl.servers, id) + log.Printf("Removed world server: %s (ID: %d)", server.Name, id) + } +} + +// GetServerList returns a formatted server list for client packets +func (wl *WorldList) GetServerList() []byte { + wl.mutex.RLock() + defer wl.mutex.RUnlock() + + // Build server list packet data + data := make([]byte, 0, 1024) + + // Count active servers + activeCount := 0 + for _, server := range wl.servers { + if !server.Hidden { + activeCount++ + } + } + + // Add server count + data = append(data, byte(activeCount)) + + // Add server data + for _, server := range wl.servers { + if server.Hidden { + continue + } + + // Server ID (4 bytes) + serverIDBytes := make([]byte, 4) + serverIDBytes[0] = byte(server.ID) + serverIDBytes[1] = byte(server.ID >> 8) + serverIDBytes[2] = byte(server.ID >> 16) + serverIDBytes[3] = byte(server.ID >> 24) + data = append(data, serverIDBytes...) + + // Server name (null-terminated) + data = append(data, []byte(server.Name)...) + data = append(data, 0) + + // Server flags + var flags byte + if server.Online { + flags |= 0x01 + } + if server.Locked { + flags |= 0x02 + } + data = append(data, flags) + + // Population level + data = append(data, server.PopulationLevel) + } + + return data +} + +// GetStats returns world list statistics +func (wl *WorldList) GetStats() map[string]any { + wl.mutex.RLock() + defer wl.mutex.RUnlock() + + totalServers := len(wl.servers) + onlineServers := 0 + totalPopulation := int32(0) + + for _, server := range wl.servers { + if server.Online { + onlineServers++ + totalPopulation += server.Population + } + } + + return map[string]any{ + "total_servers": totalServers, + "online_servers": onlineServers, + "offline_servers": totalServers - onlineServers, + "total_population": totalPopulation, + } +} diff --git a/go.mod b/go.mod index b388e3f..70826a1 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,19 @@ module eq2emu go 1.24.5 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sys v0.34.0 // indirect + modernc.org/libc v1.65.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.37.1 // indirect + zombiezen.com/go/sqlite v1.4.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c469a5f --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= +zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= +zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc=