panel v0.9.2 — public release
Self-hostable game server control panel: Go controller + agent, 26 game modules, embedded web UI. One-line install via install.sh / install.ps1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// SteamCredentialRow is a single stored Steam account for SteamCMD
|
||||
// non-anonymous login. v1 is single-operator, so most panels will have
|
||||
// at most one row.
|
||||
type SteamCredentialRow struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordCiphertext []byte // AES-GCM ciphertext of the password
|
||||
PasswordNonce []byte // AES-GCM nonce (12 bytes)
|
||||
SentryFile []byte // SSFN device-auth blob; NULL until first success
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// GetSteamCredentialByUsername returns the stored credential for the given
|
||||
// username or pgx.ErrNoRows if none exists.
|
||||
func (db *DB) GetSteamCredentialByUsername(ctx context.Context, username string) (*SteamCredentialRow, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_ciphertext, password_nonce, sentry_file, last_used_at, created_at, updated_at
|
||||
FROM steam_credentials
|
||||
WHERE username = $1`, username)
|
||||
var r SteamCredentialRow
|
||||
if err := row.Scan(&r.ID, &r.Username, &r.PasswordCiphertext, &r.PasswordNonce, &r.SentryFile, &r.LastUsedAt, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// GetAnySteamCredential returns the most-recently-used Steam credential, or
|
||||
// pgx.ErrNoRows if none exist. Used when the caller doesn't care which
|
||||
// account (single-operator panels typically have one).
|
||||
func (db *DB) GetAnySteamCredential(ctx context.Context) (*SteamCredentialRow, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_ciphertext, password_nonce, sentry_file, last_used_at, created_at, updated_at
|
||||
FROM steam_credentials
|
||||
ORDER BY COALESCE(last_used_at, created_at) DESC
|
||||
LIMIT 1`)
|
||||
var r SteamCredentialRow
|
||||
if err := row.Scan(&r.ID, &r.Username, &r.PasswordCiphertext, &r.PasswordNonce, &r.SentryFile, &r.LastUsedAt, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// UpsertSteamCredential stores (or replaces) a Steam credential. Leaves
|
||||
// sentry_file untouched on update — that's populated separately by
|
||||
// UpdateSteamSentryFile once a SteamCMD login succeeds.
|
||||
func (db *DB) UpsertSteamCredential(ctx context.Context, username string, cipher, nonce []byte) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
INSERT INTO steam_credentials (username, password_ciphertext, password_nonce, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (username)
|
||||
DO UPDATE SET password_ciphertext = EXCLUDED.password_ciphertext,
|
||||
password_nonce = EXCLUDED.password_nonce,
|
||||
updated_at = NOW()`,
|
||||
username, cipher, nonce)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert steam credential: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSteamSentryFile stores the SSFN blob after a successful login so
|
||||
// subsequent SteamCMD invocations can skip the Steam Guard 2FA prompt.
|
||||
func (db *DB) UpdateSteamSentryFile(ctx context.Context, username string, sentry []byte) error {
|
||||
res, err := db.pool.Exec(ctx, `
|
||||
UPDATE steam_credentials
|
||||
SET sentry_file = $2, last_used_at = NOW(), updated_at = NOW()
|
||||
WHERE username = $1`,
|
||||
username, sentry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update sentry: %w", err)
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return errors.New("steam credential not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchSteamCredential updates last_used_at without writing a new sentry.
|
||||
func (db *DB) TouchSteamCredential(ctx context.Context, username string) error {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE steam_credentials SET last_used_at = NOW() WHERE username = $1`, username)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSteamCredential removes a cached credential (operator action).
|
||||
func (db *DB) DeleteSteamCredential(ctx context.Context, username string) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM steam_credentials WHERE username = $1`, username)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListSteamCredentialUsernames returns just the usernames (no password
|
||||
// material) so the UI can show "Steam accounts configured: X" without
|
||||
// touching the secrets.
|
||||
func (db *DB) ListSteamCredentialUsernames(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.pool.Query(ctx, `SELECT username FROM steam_credentials ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var u string
|
||||
if err := rows.Scan(&u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, u)
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
// avoid unused-import removal
|
||||
var _ = pgx.ErrNoRows
|
||||
Reference in New Issue
Block a user