From e722533fdbd8419eed1b45426819efd93fa45c30 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 24 Nov 2025 01:10:20 +0100 Subject: [PATCH] feat(tools): adds a small tools to monitor players. --- CHANGELOG.md | 9 + tools/usercheck/.gitignore | 2 + tools/usercheck/README.md | 54 +++++ tools/usercheck/commands.go | 438 ++++++++++++++++++++++++++++++++++++ tools/usercheck/db.go | 93 ++++++++ tools/usercheck/main.go | 69 ++++++ 6 files changed, 665 insertions(+) create mode 100644 tools/usercheck/.gitignore create mode 100644 tools/usercheck/README.md create mode 100644 tools/usercheck/commands.go create mode 100644 tools/usercheck/db.go create mode 100644 tools/usercheck/main.go diff --git a/CHANGELOG.md b/CHANGELOG.md index db5e4e024..c5ec2e3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New `usercheck` CLI tool in `tools/usercheck/` for querying connected users and server status + - `list` command: List all currently connected users with character details + - `count` command: Show count of connected users per server/channel + - `search` command: Search for specific connected users by name + - `servers` command: Display server/channel status and player counts + - `history` command: Show recent login history for a player + ### Changed - Upgraded Go version requirement from 1.19 to 1.25 diff --git a/tools/usercheck/.gitignore b/tools/usercheck/.gitignore new file mode 100644 index 000000000..bb8e34980 --- /dev/null +++ b/tools/usercheck/.gitignore @@ -0,0 +1,2 @@ +usercheck +usercheck.exe diff --git a/tools/usercheck/README.md b/tools/usercheck/README.md new file mode 100644 index 000000000..a86e5c145 --- /dev/null +++ b/tools/usercheck/README.md @@ -0,0 +1,54 @@ +# usercheck + +CLI tool to query connected users and server status from the Erupe database. + +## Build + +```bash +go build -o usercheck +``` + +## Usage + +```bash +./usercheck [options] +``` + +### Commands + +| Command | Description | +|---------|-------------| +| `list` | List all currently connected users | +| `count` | Show count of connected users per server | +| `search` | Search for a connected user by name | +| `servers` | Show server/channel status | +| `history` | Show login history for a player | + +### Examples + +```bash +# List connected users +./usercheck list -password "dbpass" + +# Verbose output with last login times +./usercheck list -v -password "dbpass" + +# Search for a player +./usercheck search -name "Hunter" -password "dbpass" + +# Show server status +./usercheck servers -password "dbpass" + +# Player login history +./usercheck history -name "Hunter" -password "dbpass" +``` + +### Database Options + +| Flag | Env Variable | Default | +|------|--------------|---------| +| `-host` | `ERUPE_DB_HOST` | localhost | +| `-port` | - | 5432 | +| `-user` | `ERUPE_DB_USER` | postgres | +| `-password` | `ERUPE_DB_PASSWORD` | (required) | +| `-dbname` | `ERUPE_DB_NAME` | erupe | diff --git a/tools/usercheck/commands.go b/tools/usercheck/commands.go new file mode 100644 index 000000000..e857f74ec --- /dev/null +++ b/tools/usercheck/commands.go @@ -0,0 +1,438 @@ +package main + +import ( + "flag" + "fmt" + "os" + "text/tabwriter" + "time" +) + +// runList lists all currently connected users across all channels. +// +// Queries the sign_sessions table joined with characters and servers +// to show all active sessions with character details. +// +// Options: +// - Database connection flags (host, port, user, password, dbname) +// - v: Verbose output with additional details +func runList(args []string) { + fs := flag.NewFlagSet("list", flag.ExitOnError) + cfg := &DBConfig{} + addDBFlags(fs, cfg) + verbose := fs.Bool("v", false, "Verbose output with additional details") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + db, err := connectDB(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer func() { _ = db.Close() }() + + query := ` + SELECT + ss.char_id, + COALESCE(c.name, '') as char_name, + COALESCE(ss.server_id, 0) as server_id, + COALESCE(s.world_name, 'Unknown') as world_name, + COALESCE(c.user_id, 0) as user_id, + COALESCE(u.username, '') as username, + c.last_login, + COALESCE(c.hrp, 0) as hr, + COALESCE(c.gr, 0) as gr + FROM sign_sessions ss + LEFT JOIN characters c ON ss.char_id = c.id + LEFT JOIN servers s ON ss.server_id = s.server_id + LEFT JOIN users u ON c.user_id = u.id + WHERE ss.char_id IS NOT NULL AND ss.server_id IS NOT NULL + ORDER BY s.world_name, c.name + ` + + rows, err := db.Query(query) + if err != nil { + fmt.Fprintf(os.Stderr, "Error querying database: %v\n", err) + os.Exit(1) + } + defer func() { _ = rows.Close() }() + + var users []ConnectedUser + for rows.Next() { + var u ConnectedUser + err := rows.Scan(&u.CharID, &u.CharName, &u.ServerID, &u.ServerName, &u.UserID, &u.Username, &u.LastLogin, &u.HR, &u.GR) + if err != nil { + fmt.Fprintf(os.Stderr, "Error scanning row: %v\n", err) + continue + } + users = append(users, u) + } + + if len(users) == 0 { + fmt.Println("No users currently connected.") + return + } + + fmt.Printf("=== Connected Users (%d total) ===\n\n", len(users)) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if *verbose { + _, _ = fmt.Fprintln(w, "CHAR ID\tNAME\tSERVER\tHR\tGR\tUSERNAME\tLAST LOGIN") + _, _ = fmt.Fprintln(w, "-------\t----\t------\t--\t--\t--------\t----------") + for _, u := range users { + lastLogin := "N/A" + if u.LastLogin.Valid { + lastLogin = u.LastLogin.Time.Format("2006-01-02 15:04:05") + } + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%d\t%d\t%s\t%s\n", + u.CharID, u.CharName, u.ServerName, u.HR, u.GR, u.Username, lastLogin) + } + } else { + _, _ = fmt.Fprintln(w, "CHAR ID\tNAME\tSERVER\tHR\tGR") + _, _ = fmt.Fprintln(w, "-------\t----\t------\t--\t--") + for _, u := range users { + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%d\t%d\n", + u.CharID, u.CharName, u.ServerName, u.HR, u.GR) + } + } + _ = w.Flush() +} + +// runCount shows the count of connected users per server/channel. +// +// Options: +// - Database connection flags (host, port, user, password, dbname) +func runCount(args []string) { + fs := flag.NewFlagSet("count", flag.ExitOnError) + cfg := &DBConfig{} + addDBFlags(fs, cfg) + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + db, err := connectDB(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer func() { _ = db.Close() }() + + // Get total from sign_sessions + var totalConnected int + err = db.QueryRow(` + SELECT COUNT(*) FROM sign_sessions + WHERE char_id IS NOT NULL AND server_id IS NOT NULL + `).Scan(&totalConnected) + if err != nil { + fmt.Fprintf(os.Stderr, "Error querying total: %v\n", err) + os.Exit(1) + } + + // Get count per server + query := ` + SELECT + s.server_id, + s.world_name, + s.current_players, + s.land + FROM servers s + ORDER BY s.world_name, s.land + ` + + rows, err := db.Query(query) + if err != nil { + fmt.Fprintf(os.Stderr, "Error querying servers: %v\n", err) + os.Exit(1) + } + defer func() { _ = rows.Close() }() + + fmt.Printf("=== Connected Users Summary ===\n\n") + fmt.Printf("Total Connected: %d\n\n", totalConnected) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "SERVER ID\tWORLD\tLAND\tPLAYERS") + _, _ = fmt.Fprintln(w, "---------\t-----\t----\t-------") + + for rows.Next() { + var serverID uint16 + var worldName string + var currentPlayers, land int + if err := rows.Scan(&serverID, &worldName, ¤tPlayers, &land); err != nil { + continue + } + _, _ = fmt.Fprintf(w, "%d\t%s\t%d\t%d\n", serverID, worldName, land, currentPlayers) + } + _ = w.Flush() +} + +// runSearch searches for a specific connected user by name. +// +// Options: +// - name: Player name to search for (partial match, case-insensitive) +// - Database connection flags (host, port, user, password, dbname) +func runSearch(args []string) { + fs := flag.NewFlagSet("search", flag.ExitOnError) + cfg := &DBConfig{} + addDBFlags(fs, cfg) + name := fs.String("name", "", "Player name to search for (partial match)") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + if *name == "" { + fmt.Fprintf(os.Stderr, "Error: -name flag is required\n") + fs.Usage() + os.Exit(1) + } + + db, err := connectDB(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer func() { _ = db.Close() }() + + query := ` + SELECT + ss.char_id, + COALESCE(c.name, '') as char_name, + COALESCE(ss.server_id, 0) as server_id, + COALESCE(s.world_name, 'Unknown') as world_name, + COALESCE(c.user_id, 0) as user_id, + COALESCE(u.username, '') as username, + c.last_login, + COALESCE(c.hrp, 0) as hr, + COALESCE(c.gr, 0) as gr + FROM sign_sessions ss + LEFT JOIN characters c ON ss.char_id = c.id + LEFT JOIN servers s ON ss.server_id = s.server_id + LEFT JOIN users u ON c.user_id = u.id + WHERE ss.char_id IS NOT NULL + AND ss.server_id IS NOT NULL + AND LOWER(c.name) LIKE LOWER($1) + ORDER BY c.name + ` + + rows, err := db.Query(query, "%"+*name+"%") + if err != nil { + fmt.Fprintf(os.Stderr, "Error querying database: %v\n", err) + os.Exit(1) + } + defer func() { _ = rows.Close() }() + + var users []ConnectedUser + for rows.Next() { + var u ConnectedUser + err := rows.Scan(&u.CharID, &u.CharName, &u.ServerID, &u.ServerName, &u.UserID, &u.Username, &u.LastLogin, &u.HR, &u.GR) + if err != nil { + continue + } + users = append(users, u) + } + + if len(users) == 0 { + fmt.Printf("No connected users found matching '%s'\n", *name) + return + } + + fmt.Printf("=== Search Results for '%s' (%d found) ===\n\n", *name, len(users)) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "CHAR ID\tNAME\tSERVER\tHR\tGR\tUSERNAME") + _, _ = fmt.Fprintln(w, "-------\t----\t------\t--\t--\t--------") + for _, u := range users { + _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%d\t%d\t%s\n", + u.CharID, u.CharName, u.ServerName, u.HR, u.GR, u.Username) + } + _ = w.Flush() +} + +// runServers shows server/channel status and player counts. +// +// Options: +// - Database connection flags (host, port, user, password, dbname) +func runServers(args []string) { + fs := flag.NewFlagSet("servers", flag.ExitOnError) + cfg := &DBConfig{} + addDBFlags(fs, cfg) + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + db, err := connectDB(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer func() { _ = db.Close() }() + + query := ` + SELECT + server_id, + world_name, + world_description, + land, + current_players, + season + FROM servers + ORDER BY world_name, land + ` + + rows, err := db.Query(query) + if err != nil { + fmt.Fprintf(os.Stderr, "Error querying servers: %v\n", err) + os.Exit(1) + } + defer func() { _ = rows.Close() }() + + var servers []ServerStatus + var totalPlayers int + for rows.Next() { + var s ServerStatus + if err := rows.Scan(&s.ServerID, &s.WorldName, &s.WorldDesc, &s.Land, &s.CurrentPlayers, &s.Season); err != nil { + continue + } + servers = append(servers, s) + totalPlayers += s.CurrentPlayers + } + + if len(servers) == 0 { + fmt.Println("No servers found. Is the Erupe server running?") + return + } + + fmt.Printf("=== Server Status ===\n\n") + fmt.Printf("Total Servers: %d\n", len(servers)) + fmt.Printf("Total Players: %d\n\n", totalPlayers) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tWORLD\tLAND\tPLAYERS\tSEASON\tDESCRIPTION") + _, _ = fmt.Fprintln(w, "--\t-----\t----\t-------\t------\t-----------") + + seasonNames := map[int]string{0: "Green", 1: "Orange", 2: "Blue"} + for _, s := range servers { + seasonName := seasonNames[s.Season] + if seasonName == "" { + seasonName = fmt.Sprintf("%d", s.Season) + } + // Truncate description if too long + desc := s.WorldDesc + if len(desc) > 30 { + desc = desc[:27] + "..." + } + _, _ = fmt.Fprintf(w, "%d\t%s\t%d\t%d\t%s\t%s\n", + s.ServerID, s.WorldName, s.Land, s.CurrentPlayers, seasonName, desc) + } + _ = w.Flush() +} + +// runHistory shows recent login history for a player. +// +// Options: +// - name: Player name to search for (partial match, case-insensitive) +// - limit: Maximum number of entries to show (default: 20) +// - all: Show all characters, not just with recent logins +// - Database connection flags (host, port, user, password, dbname) +func runHistory(args []string) { + fs := flag.NewFlagSet("history", flag.ExitOnError) + cfg := &DBConfig{} + addDBFlags(fs, cfg) + name := fs.String("name", "", "Player name to search for (partial match)") + limit := fs.Int("limit", 20, "Maximum number of entries to show") + showAll := fs.Bool("all", false, "Show all characters (including those without recent logins)") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + if *name == "" { + fmt.Fprintf(os.Stderr, "Error: -name flag is required\n") + fs.Usage() + os.Exit(1) + } + + db, err := connectDB(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer func() { _ = db.Close() }() + + whereClause := "WHERE LOWER(c.name) LIKE LOWER($1)" + if !*showAll { + whereClause += " AND c.last_login IS NOT NULL" + } + + query := fmt.Sprintf(` + SELECT + c.id as char_id, + c.name as char_name, + c.last_login, + COALESCE(c.hrp, 0) as hr, + COALESCE(c.gr, 0) as gr, + COALESCE(u.username, '') as username + FROM characters c + LEFT JOIN users u ON c.user_id = u.id + %s + ORDER BY c.last_login DESC NULLS LAST + LIMIT $2 + `, whereClause) + + rows, err := db.Query(query, "%"+*name+"%", *limit) + if err != nil { + fmt.Fprintf(os.Stderr, "Error querying database: %v\n", err) + os.Exit(1) + } + defer func() { _ = rows.Close() }() + + var history []LoginHistory + for rows.Next() { + var h LoginHistory + if err := rows.Scan(&h.CharID, &h.CharName, &h.LastLogin, &h.HR, &h.GR, &h.Username); err != nil { + continue + } + history = append(history, h) + } + + if len(history) == 0 { + fmt.Printf("No characters found matching '%s'\n", *name) + return + } + + fmt.Printf("=== Login History for '%s' (%d entries) ===\n\n", *name, len(history)) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "CHAR ID\tNAME\tHR\tGR\tUSERNAME\tLAST LOGIN\tONLINE") + _, _ = fmt.Fprintln(w, "-------\t----\t--\t--\t--------\t----------\t------") + + for _, h := range history { + lastLogin := "Never" + online := "No" + if h.LastLogin.Valid { + lastLogin = h.LastLogin.Time.Format("2006-01-02 15:04:05") + // Check if logged in within last hour (rough online indicator) + if time.Since(h.LastLogin.Time) < time.Hour { + online = "Possibly" + } + } + + // Check if actually online by checking sign_sessions + var isOnline bool + if err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM sign_sessions WHERE char_id = $1 AND server_id IS NOT NULL)", h.CharID).Scan(&isOnline); err == nil && isOnline { + online = "Yes" + } + + _, _ = fmt.Fprintf(w, "%d\t%s\t%d\t%d\t%s\t%s\t%s\n", + h.CharID, h.CharName, h.HR, h.GR, h.Username, lastLogin, online) + } + _ = w.Flush() + + // Show legend + fmt.Println() + fmt.Println("ONLINE column: Yes = Currently connected, Possibly = Last login within 1 hour, No = Not connected") +} diff --git a/tools/usercheck/db.go b/tools/usercheck/db.go new file mode 100644 index 000000000..0c826957c --- /dev/null +++ b/tools/usercheck/db.go @@ -0,0 +1,93 @@ +package main + +import ( + "database/sql" + "flag" + "fmt" + "os" + + _ "github.com/lib/pq" +) + +// DBConfig holds database connection configuration. +type DBConfig struct { + Host string + Port int + User string + Password string + DBName string +} + +// addDBFlags adds common database flags to a FlagSet. +func addDBFlags(fs *flag.FlagSet, cfg *DBConfig) { + fs.StringVar(&cfg.Host, "host", getEnvOrDefault("ERUPE_DB_HOST", "localhost"), "Database host") + fs.IntVar(&cfg.Port, "port", 5432, "Database port") + fs.StringVar(&cfg.User, "user", getEnvOrDefault("ERUPE_DB_USER", "postgres"), "Database user") + fs.StringVar(&cfg.Password, "password", os.Getenv("ERUPE_DB_PASSWORD"), "Database password") + fs.StringVar(&cfg.DBName, "dbname", getEnvOrDefault("ERUPE_DB_NAME", "erupe"), "Database name") +} + +// getEnvOrDefault returns the environment variable value or a default. +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// connectDB establishes a connection to the PostgreSQL database. +func connectDB(cfg *DBConfig) (*sql.DB, error) { + if cfg.Password == "" { + return nil, fmt.Errorf("database password is required (use -password flag or ERUPE_DB_PASSWORD env var)") + } + + connStr := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, + ) + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + return db, nil +} + +// ConnectedUser represents a user currently connected to the server. +type ConnectedUser struct { + CharID uint32 + CharName string + ServerID uint16 + ServerName string + UserID int + Username string + LastLogin sql.NullTime + HR int + GR int +} + +// ServerStatus represents the status of a channel server. +type ServerStatus struct { + ServerID uint16 + WorldName string + WorldDesc string + Land int + CurrentPlayers int + Season int +} + +// LoginHistory represents a player's login history entry. +type LoginHistory struct { + CharID uint32 + CharName string + LastLogin sql.NullTime + HR int + GR int + Username string +} diff --git a/tools/usercheck/main.go b/tools/usercheck/main.go new file mode 100644 index 000000000..8a0285347 --- /dev/null +++ b/tools/usercheck/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +// main is the entry point for the user check CLI tool. +// +// The tool provides commands to query connected users and server status +// by reading from the Erupe database. It's designed to be used while +// the server is running to monitor player activity. +// +// Usage: +// +// usercheck [options] +// usercheck list # List all connected users +// usercheck count # Count connected users +// usercheck search -name "player" # Search for a specific player +// usercheck servers # Show server/channel status +// usercheck history -name "player" # Show player login history +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Erupe User Check - Tool to query connected users and server status\n\n") + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, " %s [options]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Available commands:\n") + fmt.Fprintf(os.Stderr, " list List all currently connected users\n") + fmt.Fprintf(os.Stderr, " count Show count of connected users per server\n") + fmt.Fprintf(os.Stderr, " search Search for a specific connected user by name\n") + fmt.Fprintf(os.Stderr, " servers Show server/channel status and player counts\n") + fmt.Fprintf(os.Stderr, " history Show recent login history for a player\n") + fmt.Fprintf(os.Stderr, "\nDatabase connection options (apply to all commands):\n") + fmt.Fprintf(os.Stderr, " -host Database host (default: localhost)\n") + fmt.Fprintf(os.Stderr, " -port Database port (default: 5432)\n") + fmt.Fprintf(os.Stderr, " -user Database user (default: postgres)\n") + fmt.Fprintf(os.Stderr, " -password Database password (required)\n") + fmt.Fprintf(os.Stderr, " -dbname Database name (default: erupe)\n") + fmt.Fprintf(os.Stderr, "\nUse '%s -h' for more information about a command.\n", os.Args[0]) + } + + if len(os.Args) < 2 { + flag.Usage() + os.Exit(1) + } + + command := os.Args[1] + args := os.Args[2:] + + switch command { + case "list": + runList(args) + case "count": + runCount(args) + case "search": + runSearch(args) + case "servers": + runServers(args) + case "history": + runHistory(args) + case "-h", "--help", "help": + flag.Usage() + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", command) + flag.Usage() + os.Exit(1) + } +}