feat(tools): adds a small tools to monitor players.

This commit is contained in:
Houmgaor
2025-11-24 01:10:20 +01:00
parent 67dad15204
commit e722533fdb
6 changed files with 665 additions and 0 deletions

View File

@@ -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

2
tools/usercheck/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
usercheck
usercheck.exe

54
tools/usercheck/README.md Normal file
View File

@@ -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 <command> [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 |

438
tools/usercheck/commands.go Normal file
View File

@@ -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, &currentPlayers, &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")
}

93
tools/usercheck/db.go Normal file
View File

@@ -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
}

69
tools/usercheck/main.go Normal file
View File

@@ -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 <command> [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 <command> [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 <command> -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)
}
}