v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
5d2be4abb2
commit
d0326b059e
66 files changed
+4016
-2433
No files matched your search
+174
-220
@@ -18,6 +18,8 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const dsnEnv = "CHESS_DSN"
|
||||
|
||||
// Run is the entry point for the CLI mini-app
|
||||
func Run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
@@ -41,74 +43,94 @@ func Run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func runInit(args []string) error {
|
||||
fs := flag.NewFlagSet("init", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
// newFlagSet returns a flag set with the shared -dsn flag. An empty -dsn falls
|
||||
// back to $CHESS_DSN (in openStore, so usage output never prints it), which
|
||||
// keeps a password-bearing DSN out of process arguments.
|
||||
func newFlagSet(name string) (*flag.FlagSet, *string) {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
dsn := fs.String("dsn", "", "PostgreSQL connection string (default $"+dsnEnv+")")
|
||||
return fs, dsn
|
||||
}
|
||||
|
||||
// openStore connects and, unless migrating, verifies the schema is current.
|
||||
func openStore(dsn string, requireSchema bool) (*storage.Store, error) {
|
||||
if dsn == "" {
|
||||
dsn = os.Getenv(dsnEnv)
|
||||
}
|
||||
if dsn == "" {
|
||||
return nil, fmt.Errorf("database connection required: use -dsn or %s", dsnEnv)
|
||||
}
|
||||
store, err := storage.NewStore(dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requireSchema {
|
||||
if err := store.CheckSchema(); err != nil {
|
||||
store.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func runInit(args []string) error {
|
||||
fs, dsn := newFlagSet("init")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.InitDB(); err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Database initialized at: %s\n", *path)
|
||||
version, err := store.SchemaVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Database schema ready (version %d)\n", version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDelete(args []string) error {
|
||||
fs := flag.NewFlagSet("delete", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
|
||||
fs, dsn := newFlagSet("delete")
|
||||
confirm := fs.Bool("confirm", false, "Confirm dropping all chess tables and data")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
if !*confirm {
|
||||
return fmt.Errorf("refusing to drop all chess tables without -confirm")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.DeleteDB(); err != nil {
|
||||
return fmt.Errorf("failed to delete database: %w", err)
|
||||
// ☣ DESTRUCTIVE: drops every chess table in the connection's search_path
|
||||
if err := store.DropSchema(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Database deleted: %s\n", *path)
|
||||
fmt.Println("Chess tables dropped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runQuery(args []string) error {
|
||||
fs := flag.NewFlagSet("query", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("query")
|
||||
gameID := fs.String("gameId", "", "Game ID to filter (optional, * for all)")
|
||||
playerID := fs.String("playerId", "", "Player ID to filter (optional, * for all)")
|
||||
|
||||
playerID := fs.String("playerId", "", "Player or user ID to filter (optional, * for all)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
@@ -189,107 +211,86 @@ func runUser(subcommand string, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// readPassword prompts on the terminal without echo.
|
||||
func readPassword(prompt string) (string, error) {
|
||||
fmt.Print(prompt)
|
||||
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
return string(pwBytes), nil
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
if len(password) < 8 {
|
||||
return "", fmt.Errorf("password must be at least 8 characters")
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func runUserAdd(args []string) error {
|
||||
fs := flag.NewFlagSet("user add", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user add")
|
||||
username := fs.String("username", "", "Username (required)")
|
||||
email := fs.String("email", "", "Email address (optional)")
|
||||
password := fs.String("password", "", "Password (optional, will prompt if not provided)")
|
||||
hash := fs.String("hash", "", "Pre-computed password hash (optional)")
|
||||
interactive := fs.Bool("interactive", false, "Interactive password prompt")
|
||||
temp := fs.Bool("temp", false, "Create as temporary user (24h TTL, default: permanent)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *username == "" {
|
||||
return fmt.Errorf("username required")
|
||||
}
|
||||
|
||||
// Validate password/hash options
|
||||
if *password != "" && *hash != "" {
|
||||
return fmt.Errorf("cannot specify both -password and -hash")
|
||||
}
|
||||
|
||||
var passwordHash string
|
||||
|
||||
if *interactive {
|
||||
switch {
|
||||
case *interactive:
|
||||
if *password != "" || *hash != "" {
|
||||
return fmt.Errorf("cannot use -interactive with -password or -hash")
|
||||
}
|
||||
fmt.Print("Enter password: ")
|
||||
pwBytes, err := term.ReadPassword(syscall.Stdin)
|
||||
fmt.Println()
|
||||
entered, err := readPassword("Enter password: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
return err
|
||||
}
|
||||
if len(pwBytes) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters")
|
||||
if passwordHash, err = hashPassword(entered); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hash password (Argon2)
|
||||
passwordHash, err = auth.HashPassword(string(pwBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
case *hash != "":
|
||||
if err := auth.ValidatePHCHashFormat(*hash); err != nil {
|
||||
return fmt.Errorf("invalid hash format: %w", err)
|
||||
}
|
||||
} else if *hash != "" {
|
||||
passwordHash = *hash
|
||||
} else if *password != "" {
|
||||
if len(*password) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters")
|
||||
}
|
||||
// Hash password (Argon2)
|
||||
case *password != "":
|
||||
var err error
|
||||
passwordHash, err = auth.HashPassword(*password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
if passwordHash, err = hashPassword(*password); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
default:
|
||||
return fmt.Errorf("password required: use -password, -hash, or -interactive")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Generate user ID with conflict check
|
||||
var userID string
|
||||
for attempts := 0; attempts < 10; attempts++ {
|
||||
userID = uuid.New().String()
|
||||
if _, err := store.GetUserByID(userID); errors.Is(err, sql.ErrNoRows) {
|
||||
// User doesn't exist, ID is unique
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to check generated user ID: %w", err)
|
||||
}
|
||||
if attempts == 9 {
|
||||
return fmt.Errorf("failed to generate unique user ID after 10 attempts")
|
||||
}
|
||||
}
|
||||
|
||||
// Determine account type (CLI default = permanent)
|
||||
accountType := "permanent"
|
||||
var expiresAt *time.Time
|
||||
if *temp {
|
||||
accountType = "temp"
|
||||
expiry := time.Now().UTC().Add(24 * time.Hour)
|
||||
expiresAt = &expiry
|
||||
}
|
||||
|
||||
userID := uuid.NewString()
|
||||
record := storage.UserRecord{
|
||||
UserID: userID,
|
||||
Username: strings.ToLower(*username),
|
||||
Email: strings.ToLower(*email),
|
||||
PasswordHash: passwordHash,
|
||||
AccountType: accountType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err := store.CreateUser(record); err != nil {
|
||||
@@ -298,50 +299,59 @@ func runUserAdd(args []string) error {
|
||||
|
||||
fmt.Printf("User created successfully:\n")
|
||||
fmt.Printf(" ID: %s\n", userID)
|
||||
fmt.Printf(" Username: %s\n", *username)
|
||||
if *email != "" {
|
||||
fmt.Printf(" Email: %s\n", *email)
|
||||
fmt.Printf(" Username: %s\n", record.Username)
|
||||
if record.Email != "" {
|
||||
fmt.Printf(" Email: %s\n", record.Email)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupUser resolves -username or -id to a user ID.
|
||||
func lookupUser(store *storage.Store, username, userID string) (string, error) {
|
||||
switch {
|
||||
case username == "" && userID == "":
|
||||
return "", fmt.Errorf("either -username or -id required")
|
||||
case username != "" && userID != "":
|
||||
return "", fmt.Errorf("specify either -username or -id, not both")
|
||||
case userID != "":
|
||||
if _, err := store.GetUserByID(userID); err != nil {
|
||||
return "", userLookupError(userID, err)
|
||||
}
|
||||
return userID, nil
|
||||
default:
|
||||
user, err := store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
return "", userLookupError(username, err)
|
||||
}
|
||||
return user.UserID, nil
|
||||
}
|
||||
}
|
||||
|
||||
func userLookupError(identifier string, err error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("user not found: %s", identifier)
|
||||
}
|
||||
return fmt.Errorf("failed to look up user %s: %w", identifier, err)
|
||||
}
|
||||
|
||||
func runUserDelete(args []string) error {
|
||||
fs := flag.NewFlagSet("user delete", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user delete")
|
||||
username := fs.String("username", "", "Username to delete")
|
||||
userID := fs.String("id", "", "User ID to delete")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *username == "" && *userID == "" {
|
||||
return fmt.Errorf("either -username or -id required")
|
||||
}
|
||||
if *username != "" && *userID != "" {
|
||||
return fmt.Errorf("specify either -username or -id, not both")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
var targetID string
|
||||
if *userID != "" {
|
||||
targetID = *userID
|
||||
} else {
|
||||
user, err := store.GetUserByUsername(*username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", *username)
|
||||
}
|
||||
targetID = user.UserID
|
||||
targetID, err := lookupUser(store, *username, *userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := store.DeleteUser(targetID); err != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
@@ -351,65 +361,49 @@ func runUserDelete(args []string) error {
|
||||
}
|
||||
|
||||
func runUserSetPassword(args []string) error {
|
||||
fs := flag.NewFlagSet("user set-password", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user set-password")
|
||||
username := fs.String("username", "", "Username (required)")
|
||||
password := fs.String("password", "", "New password")
|
||||
interactive := fs.Bool("interactive", false, "Interactive password prompt")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *username == "" {
|
||||
return fmt.Errorf("username required")
|
||||
}
|
||||
|
||||
var newPassword string
|
||||
if *interactive {
|
||||
switch {
|
||||
case *interactive:
|
||||
if *password != "" {
|
||||
return fmt.Errorf("cannot use -interactive with -password")
|
||||
}
|
||||
fmt.Print("Enter new password: ")
|
||||
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
entered, err := readPassword("Enter new password: ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
return err
|
||||
}
|
||||
newPassword = string(pwBytes)
|
||||
} else if *password != "" {
|
||||
newPassword = entered
|
||||
case *password != "":
|
||||
newPassword = *password
|
||||
} else {
|
||||
default:
|
||||
return fmt.Errorf("password required: use -password or -interactive")
|
||||
}
|
||||
|
||||
if len(newPassword) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters")
|
||||
passwordHash, err := hashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Get user
|
||||
user, err := store.GetUserByUsername(*username)
|
||||
targetID, err := lookupUser(store, *username, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", *username)
|
||||
return err
|
||||
}
|
||||
|
||||
// Hash password (Argon2)
|
||||
passwordHash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Update password
|
||||
if err := store.UpdateUserPassword(user.UserID, passwordHash); err != nil {
|
||||
if err := store.UpdateUserPassword(targetID, passwordHash); err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
@@ -418,43 +412,33 @@ func runUserSetPassword(args []string) error {
|
||||
}
|
||||
|
||||
func runUserSetHash(args []string) error {
|
||||
fs := flag.NewFlagSet("user set-hash", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user set-hash")
|
||||
username := fs.String("username", "", "Username (required)")
|
||||
hash := fs.String("hash", "", "Password hash (required)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *username == "" {
|
||||
return fmt.Errorf("username required")
|
||||
}
|
||||
if *hash == "" {
|
||||
return fmt.Errorf("password hash required")
|
||||
}
|
||||
|
||||
if err := auth.ValidatePHCHashFormat(*hash); err != nil {
|
||||
return fmt.Errorf("invalid hash format: %w", err)
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Get user
|
||||
user, err := store.GetUserByUsername(*username)
|
||||
targetID, err := lookupUser(store, *username, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", *username)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update password hash directly
|
||||
if err := store.UpdateUserPassword(user.UserID, *hash); err != nil {
|
||||
if err := store.UpdateUserPassword(targetID, *hash); err != nil {
|
||||
return fmt.Errorf("failed to update password hash: %w", err)
|
||||
}
|
||||
|
||||
@@ -463,18 +447,12 @@ func runUserSetHash(args []string) error {
|
||||
}
|
||||
|
||||
func runUserSetEmail(args []string) error {
|
||||
fs := flag.NewFlagSet("user set-email", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user set-email")
|
||||
username := fs.String("username", "", "Username (required)")
|
||||
email := fs.String("email", "", "New email address (required)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *username == "" {
|
||||
return fmt.Errorf("username required")
|
||||
}
|
||||
@@ -482,20 +460,17 @@ func runUserSetEmail(args []string) error {
|
||||
return fmt.Errorf("email required")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Get user
|
||||
user, err := store.GetUserByUsername(*username)
|
||||
targetID, err := lookupUser(store, *username, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", *username)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update email
|
||||
if err := store.UpdateUserEmail(user.UserID, strings.ToLower(*email)); err != nil {
|
||||
if err := store.UpdateUserEmail(targetID, *email); err != nil {
|
||||
return fmt.Errorf("failed to update email: %w", err)
|
||||
}
|
||||
|
||||
@@ -504,61 +479,46 @@ func runUserSetEmail(args []string) error {
|
||||
}
|
||||
|
||||
func runUserSetUsername(args []string) error {
|
||||
fs := flag.NewFlagSet("user set-username", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
fs, dsn := newFlagSet("user set-username")
|
||||
current := fs.String("current", "", "Current username (required)")
|
||||
new := fs.String("new", "", "New username (required)")
|
||||
|
||||
newName := fs.String("new", "", "New username (required)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
if *current == "" {
|
||||
return fmt.Errorf("current username required")
|
||||
}
|
||||
if *new == "" {
|
||||
if *newName == "" {
|
||||
return fmt.Errorf("new username required")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Get user
|
||||
user, err := store.GetUserByUsername(*current)
|
||||
targetID, err := lookupUser(store, *current, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %s", *current)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update username
|
||||
if err := store.UpdateUserUsername(user.UserID, strings.ToLower(*new)); err != nil {
|
||||
if err := store.UpdateUserUsername(targetID, *newName); err != nil {
|
||||
return fmt.Errorf("failed to update username: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Username updated: %s -> %s\n", *current, *new)
|
||||
fmt.Printf("Username updated: %s -> %s\n", *current, strings.ToLower(*newName))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runUserList(args []string) error {
|
||||
fs := flag.NewFlagSet("user list", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
|
||||
fs, dsn := newFlagSet("user list")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *path == "" {
|
||||
return fmt.Errorf("database path required")
|
||||
}
|
||||
|
||||
store, err := storage.NewStore(*path, false)
|
||||
store, err := openStore(*dsn, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open store: %w", err)
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
@@ -574,8 +534,8 @@ func runUserList(args []string) error {
|
||||
|
||||
// Print results in tabular format
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "User ID\tUsername\tType\tEmail\tCreated\tExpires\tLast Login")
|
||||
fmt.Fprintln(w, strings.Repeat("-", 120))
|
||||
fmt.Fprintln(w, "User ID\tUsername\tEmail\tCreated\tLast Login")
|
||||
fmt.Fprintln(w, strings.Repeat("-", 100))
|
||||
|
||||
for _, u := range users {
|
||||
lastLogin := "never"
|
||||
@@ -586,17 +546,11 @@ func runUserList(args []string) error {
|
||||
if email == "" {
|
||||
email = "(none)"
|
||||
}
|
||||
expires := "never"
|
||||
if u.ExpiresAt != nil {
|
||||
expires = u.ExpiresAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
abbreviateID(u.UserID),
|
||||
u.Username,
|
||||
u.AccountType,
|
||||
email,
|
||||
u.CreatedAt.Format("2006-01-02 15:04"),
|
||||
expires,
|
||||
lastLogin,
|
||||
)
|
||||
}
|
||||
|
||||
+58
-19
@@ -41,12 +41,17 @@ func main() {
|
||||
apiHost = flag.String("api-host", "localhost", "API server host")
|
||||
apiPort = flag.Int("api-port", 8080, "API server port")
|
||||
dev = flag.Bool("dev", false, "Development mode (relaxed rate limits)")
|
||||
storagePath = flag.String("storage-path", "", "Path to SQLite database file (disables persistence if empty)")
|
||||
dsn = flag.String("dsn", "", "PostgreSQL connection string (default $CHESS_DSN; persistence is disabled if empty)")
|
||||
pidPath = flag.String("pid", "", "Optional path to write PID file")
|
||||
pidLock = flag.Bool("pid-lock", false, "Lock PID file to allow only one instance (requires -pid)")
|
||||
logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, or error")
|
||||
logHTTP = flag.Bool("log-http", true, "Log HTTP requests")
|
||||
proxies = flag.String("trusted-proxies", "", "Comma-separated reverse-proxy IPs/CIDRs whose -proxy-header is trusted for client IPs")
|
||||
proxyHeader = flag.String("proxy-header", "X-Real-IP", "Header carrying the client IP from a trusted proxy")
|
||||
finishedTTL = flag.Duration("finished-game-ttl", service.FinishedGameTTL, "How long completed games remain in memory (0 disables eviction)")
|
||||
anonTTL = flag.Duration("anonymous-game-ttl", service.AnonymousGameTTL, "How long games without a registered player survive after their last activity, in memory and in the database (0 keeps them)")
|
||||
maxUsers = flag.Int("max-users", service.DefaultMaxUsers, "Accounts at which public registration closes (0 = no limit; CLI-created accounts are not limited)")
|
||||
jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)")
|
||||
|
||||
// Web UI server flags
|
||||
serve = flag.Bool("serve", false, "Enable web UI server")
|
||||
@@ -55,6 +60,14 @@ func main() {
|
||||
webAPIURL = flag.String("web-api-url", "", "Browser-visible API base URL (defaults to the API listen address)")
|
||||
)
|
||||
flag.Parse()
|
||||
// Environment fallbacks are applied after parsing so -h never prints a
|
||||
// DSN, which may contain a password.
|
||||
if *dsn == "" {
|
||||
*dsn = os.Getenv("CHESS_DSN")
|
||||
}
|
||||
if *jwtFile == "" {
|
||||
*jwtFile = os.Getenv("CHESS_JWT_SECRET_FILE")
|
||||
}
|
||||
|
||||
var level slog.Level
|
||||
if err := level.UnmarshalText([]byte(*logLevel)); err != nil {
|
||||
@@ -73,6 +86,11 @@ func main() {
|
||||
// handler. Avoid embedding a second timestamp inside its message.
|
||||
log.SetFlags(0)
|
||||
|
||||
trustedProxies, err := parseTrustedProxies(*proxies)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid -trusted-proxies: %v", err)
|
||||
}
|
||||
|
||||
// Validate PID flags
|
||||
if *pidLock && *pidPath == "" {
|
||||
log.Fatal("Error: -pid-lock flag requires the -pid flag to be set")
|
||||
@@ -88,12 +106,13 @@ func main() {
|
||||
log.Printf("PID file created at: %s (lock: %v)", *pidPath, *pidLock)
|
||||
}
|
||||
|
||||
// 1. Initialize Storage (optional)
|
||||
// 1. Initialize Storage (optional). The DSN is never logged: it may carry a
|
||||
// password when peer or .pgpass authentication is not used.
|
||||
var store *storage.Store
|
||||
if *storagePath != "" {
|
||||
log.Printf("Initializing persistent storage at: %s", *storagePath)
|
||||
if *dsn != "" {
|
||||
log.Printf("Initializing PostgreSQL storage")
|
||||
var err error
|
||||
store, err = storage.NewStore(*storagePath, *dev)
|
||||
store, err = storage.NewStore(*dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
}
|
||||
@@ -101,27 +120,37 @@ func main() {
|
||||
log.Fatalf("Failed to initialize schema: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Persistent storage disabled (use -storage-path to enable)")
|
||||
log.Printf("Persistent storage disabled (use -dsn or CHESS_DSN to enable)")
|
||||
}
|
||||
|
||||
// JWT secret management
|
||||
// JWT signing key: a key file keeps sessions valid across restarts; without
|
||||
// one, dev mode uses a fixed key and production generates a per-process key.
|
||||
var jwtSecret []byte
|
||||
if *dev {
|
||||
// Fixed secret in dev mode for testing consistency
|
||||
switch {
|
||||
case *jwtFile != "":
|
||||
var err error
|
||||
if jwtSecret, err = loadJWTSecret(*jwtFile); err != nil {
|
||||
log.Fatalf("Failed to load JWT secret: %v", err)
|
||||
}
|
||||
log.Printf("JWT secret loaded from file (sessions survive restarts)")
|
||||
case *dev:
|
||||
jwtSecret = []byte("dev-secret-minimum-32-characters-long")
|
||||
log.Printf("Using fixed JWT secret (dev mode)")
|
||||
} else {
|
||||
// Generate cryptographically secure secret
|
||||
default:
|
||||
jwtSecret = make([]byte, 32)
|
||||
if _, err := rand.Read(jwtSecret); err != nil {
|
||||
log.Fatalf("Failed to generate JWT secret: %v", err)
|
||||
}
|
||||
log.Printf("JWT secret generated (sessions valid until restart)")
|
||||
rand.Read(jwtSecret)
|
||||
log.Printf("JWT secret generated (sessions valid until restart; use -jwt-secret-file to persist)")
|
||||
}
|
||||
|
||||
// 2. Initialize the Service with optional storage and auth
|
||||
svc := service.New(store, jwtSecret)
|
||||
svc, err := service.New(store, jwtSecret)
|
||||
clear(jwtSecret) // the JWT manager keeps its own copy
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize service: %v", err)
|
||||
}
|
||||
svc.SetFinishedGameTTL(*finishedTTL)
|
||||
svc.SetAnonymousGameTTL(*anonTTL)
|
||||
svc.SetMaxUsers(*maxUsers)
|
||||
|
||||
// Start cleanup job for expired users/sessions
|
||||
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
|
||||
@@ -141,7 +170,12 @@ func main() {
|
||||
}
|
||||
|
||||
// 4. Initialize the Fiber App/HTTP Handler, injecting processor and service
|
||||
app := http.NewFiberApp(proc, svc, *dev, *logHTTP)
|
||||
app := http.NewFiberApp(proc, svc, http.Options{
|
||||
DevMode: *dev,
|
||||
LogRequests: *logHTTP,
|
||||
TrustedProxies: trustedProxies,
|
||||
ProxyHeader: *proxyHeader,
|
||||
})
|
||||
|
||||
// API Server configuration
|
||||
apiAddr := fmt.Sprintf("%s:%d", *apiHost, *apiPort)
|
||||
@@ -157,8 +191,13 @@ func main() {
|
||||
} else {
|
||||
log.Printf("Rate Limit: 10 requests/second per IP")
|
||||
}
|
||||
if *storagePath != "" {
|
||||
log.Printf("Storage: Enabled (%s)", *storagePath)
|
||||
if len(trustedProxies) > 0 {
|
||||
log.Printf("Client IP: %s from trusted proxies %v", *proxyHeader, trustedProxies)
|
||||
} else {
|
||||
log.Printf("Client IP: TCP peer (behind a reverse proxy, set -trusted-proxies or all clients share one rate limit)")
|
||||
}
|
||||
if store != nil {
|
||||
log.Printf("Storage: Enabled (PostgreSQL)")
|
||||
} else {
|
||||
log.Printf("Storage: Disabled (auth features unavailable)")
|
||||
}
|
||||
|
||||
@@ -102,4 +102,4 @@ func checkStalePID(path string) error {
|
||||
|
||||
// Process is running
|
||||
return fmt.Errorf("stale PID file: process %d is running but not holding lock", pid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseTrustedProxies validates a comma-separated list of IPs and CIDRs.
|
||||
// Fiber skips entries it cannot parse, which would silently disable trust.
|
||||
func parseTrustedProxies(value string) ([]string, error) {
|
||||
var proxies []string
|
||||
for _, entry := range strings.Split(value, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entry, "/") {
|
||||
if _, err := netip.ParsePrefix(entry); err != nil {
|
||||
return nil, fmt.Errorf("%q is not a valid CIDR", entry)
|
||||
}
|
||||
} else if _, err := netip.ParseAddr(entry); err != nil {
|
||||
return nil, fmt.Errorf("%q is not a valid IP address", entry)
|
||||
}
|
||||
proxies = append(proxies, entry)
|
||||
}
|
||||
return proxies, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTrustedProxies(t *testing.T) {
|
||||
got, err := parseTrustedProxies(" 10.0.0.1, 10.1.0.0/16,,::1 ")
|
||||
if err != nil || !slices.Equal(got, []string{"10.0.0.1", "10.1.0.0/16", "::1"}) {
|
||||
t.Fatalf("parse = %v, %v", got, err)
|
||||
}
|
||||
if got, err := parseTrustedProxies(""); err != nil || len(got) != 0 {
|
||||
t.Fatalf("empty = %v, %v", got, err)
|
||||
}
|
||||
for _, invalid := range []string{"10.0.0", "10.0.0.0/33", "proxy.local"} {
|
||||
if _, err := parseTrustedProxies(invalid); err == nil {
|
||||
t.Errorf("%q accepted", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
minJWTSecretLen = 32
|
||||
maxJWTSecretLen = 4096
|
||||
)
|
||||
|
||||
// loadJWTSecret reads a signing key file. The file must be a regular file
|
||||
// readable only by its owner, like an SSH private key; trailing line endings
|
||||
// are ignored so `openssl rand -base64 48 > file` works as-is.
|
||||
func loadJWTSecret(path string) ([]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open JWT secret file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat JWT secret file: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("JWT secret file %s is not a regular file", path)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm&0o077 != 0 {
|
||||
return nil, fmt.Errorf("JWT secret file %s has mode %04o; it must not be accessible by group or others (chmod 600)",
|
||||
path, perm)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxJWTSecretLen+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read JWT secret file: %w", err)
|
||||
}
|
||||
if len(data) > maxJWTSecretLen {
|
||||
return nil, fmt.Errorf("JWT secret file %s exceeds %d bytes", path, maxJWTSecretLen)
|
||||
}
|
||||
secret := bytes.TrimRight(data, "\r\n")
|
||||
if len(secret) < minJWTSecretLen {
|
||||
return nil, fmt.Errorf("JWT secret file %s must contain at least %d bytes", path, minJWTSecretLen)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadJWTSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, content string, mode os.FileMode) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil { // umask-independent
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
key := strings.Repeat("k", 48)
|
||||
|
||||
secret, err := loadJWTSecret(write("ok", key+"\n", 0o600))
|
||||
if err != nil || string(secret) != key {
|
||||
t.Fatalf("valid secret = %q, %v", secret, err)
|
||||
}
|
||||
for name, test := range map[string]struct {
|
||||
content string
|
||||
mode os.FileMode
|
||||
want string
|
||||
}{
|
||||
"group readable": {key, 0o640, "chmod 600"},
|
||||
"short": {strings.Repeat("k", 31) + "\n", 0o600, "at least 32 bytes"},
|
||||
"oversized": {strings.Repeat("k", maxJWTSecretLen+1), 0o600, "exceeds"},
|
||||
} {
|
||||
if _, err := loadJWTSecret(write(name, test.content, test.mode)); err == nil ||
|
||||
!strings.Contains(err.Error(), test.want) {
|
||||
t.Errorf("%s: error = %v, want %q", name, err, test.want)
|
||||
}
|
||||
}
|
||||
if _, err := loadJWTSecret(dir); err == nil {
|
||||
t.Error("directory accepted as secret file")
|
||||
}
|
||||
if _, err := loadJWTSecret(filepath.Join(dir, "missing")); err == nil {
|
||||
t.Error("missing file accepted")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user