295eb22826
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
125 lines
4.5 KiB
Go
125 lines
4.5 KiB
Go
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
|