Files
panel/controller/cmd/controller/steamauth.go
T
2026-07-14 19:22:07 -07:00

456 lines
16 KiB
Go

package main
// Steam credential storage + test-login flow.
// Handlers are hung off httpServer (the REST surface) via h.handleSteam*
// after registration in httpapi.go's handler() routing table.
//
// Some SteamCMD apps (DayZ app 223350, Arma 3, a handful of paid servers)
// refuse `+login anonymous` with "No subscription". The panel needs to
// supply real Steam credentials from an account that owns the target app.
// We do this with a one-time login ceremony:
//
// 1. Operator triggers Create/Update on a module whose provider declares
// `requires_steam_login: true`.
// 2. Controller checks for a cached credential. None → returns 200 with
// {"steam_login_required": true, "reason": "..."} — frontend pops a modal.
// 3. Operator enters Steam username + password (+ optional Steam Guard code).
// 4. POST /api/steam/login runs a one-shot SteamCMD sidecar with those
// creds. Outcomes:
// - success → persist password ciphertext + sentry/SSFN blob, return 200
// - Steam Guard required → return {"need_guard": true}; modal re-prompts
// - invalid password → 401
// 5. Controller re-enqueues the original update request with the now-known
// credentials.
//
// Secrets handling:
// - Password is encrypted at rest with AES-GCM, key HKDF-derived from the
// CA private key. Never logged, never sent back to the UI.
// - Sentry file (SSFN) is stored separately and mounted into every
// SteamCMD sidecar so 2FA isn't re-prompted every run.
// - Database column is BYTEA; transport is over HTTPS (dashboard) → the
// password crosses the wire once per login ceremony.
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"time"
"golang.org/x/crypto/hkdf"
)
// steamKEK derives a 32-byte key from the controller's CA private key.
// The CA key is unique per panel install, never leaves disk, and is
// already backed up/restored as a unit with the rest of data/ca/ — so
// tying our encryption key to it means "restore data/ca + postgres dump
// → everything still decrypts" without a separate key file to manage.
//
// We pass the PKCS1-marshalled private key bytes into HKDF. The marshal
// output is deterministic for a given key so the KEK is stable across
// restarts.
func steamKEK(privKey *rsa.PrivateKey) ([]byte, error) {
if privKey == nil {
return nil, errors.New("ca key is nil")
}
seed := x509.MarshalPKCS1PrivateKey(privKey)
h := hkdf.New(sha256.New, seed, []byte("panel.steam.credentials.v1"), []byte("aes-256-gcm"))
out := make([]byte, 32)
if _, err := io.ReadFull(h, out); err != nil {
return nil, fmt.Errorf("hkdf: %w", err)
}
return out, nil
}
// encryptSteamPassword returns (ciphertext, nonce). AES-GCM provides both
// confidentiality and integrity.
func encryptSteamPassword(kek []byte, password string) ([]byte, []byte, error) {
block, err := aes.NewCipher(kek)
if err != nil {
return nil, nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, nil, err
}
ct := gcm.Seal(nil, nonce, []byte(password), nil)
return ct, nonce, nil
}
// decryptSteamPassword is the inverse of encryptSteamPassword.
func decryptSteamPassword(kek, ciphertext, nonce []byte) (string, error) {
block, err := aes.NewCipher(kek)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
pt, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(pt), nil
}
// ---- HTTP handlers ----
type steamLoginRequest struct {
Username string `json:"username"`
Password string `json:"password,omitempty"` // required on first login, absent on guard-retry
GuardCode string `json:"guard_code,omitempty"` // Steam Guard code — empty unless server asked for it
}
type steamLoginResponse struct {
OK bool `json:"ok"`
Username string `json:"username,omitempty"`
NeedGuard bool `json:"need_guard,omitempty"`
Error string `json:"error,omitempty"`
}
func (h *httpServer) handleSteamLogin(w http.ResponseWriter, r *http.Request) {
var req steamLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" {
writeError(w, http.StatusBadRequest, "bad_request", "username required")
return
}
// If no password was supplied AND we don't have one cached, reject.
// (UI sends password on the initial call but not on guard-retries;
// on guard-retry we reuse the cached-plaintext path via the new creds
// we just stored a moment ago.)
var password string
if req.Password != "" {
password = req.Password
} else {
kek, err := steamKEK(h.ca.RootKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "kek derive: "+err.Error())
return
}
existing, err := h.db.GetSteamCredentialByUsername(r.Context(), req.Username)
if err != nil {
writeError(w, http.StatusUnauthorized, "no_cached_password", "provide a password for first login")
return
}
pw, err := decryptSteamPassword(kek, existing.PasswordCiphertext, existing.PasswordNonce)
if err != nil {
writeError(w, http.StatusInternalServerError, "decrypt", err.Error())
return
}
password = pw
}
// Run SteamCMD test-login. Mounts the panel-wide SteamCMD auth volume
// so a successful login's sentry/SSFN blob persists for subsequent
// update sidecars.
result, err := runSteamCMDTestLogin(r.Context(), req.Username, password, req.GuardCode)
if err != nil {
h.log.Error("steam test-login infra error", "username", req.Username, "err", err)
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
h.log.Info("steam test-login",
"username", req.Username,
"outcome", result.outcome,
"guard_supplied", req.GuardCode != "",
"message_head", firstLine(result.message),
)
// On unclassified outcomes, dump the tail of SteamCMD's output so we
// can grow the classifier. Password is never in this message (we
// redact via redactSteamOutput before it reaches here, and SteamCMD
// doesn't echo +login args back on stdout).
if result.outcome == steamLoginUnknown {
h.log.Warn("steam test-login: unclassified output (please copy to a dev issue)",
"tail", result.message,
)
}
switch result.outcome {
case steamLoginSuccess:
kek, err := steamKEK(h.ca.RootKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "kek derive: "+err.Error())
return
}
ct, nonce, err := encryptSteamPassword(kek, password)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "encrypt: "+err.Error())
return
}
if err := h.db.UpsertSteamCredential(r.Context(), req.Username, ct, nonce); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "persist: "+err.Error())
return
}
if err := h.db.TouchSteamCredential(r.Context(), req.Username); err != nil {
h.log.Warn("touch steam credential", "err", err)
}
writeJSON(w, http.StatusOK, steamLoginResponse{OK: true, Username: req.Username})
case steamLoginGuardRequired:
writeJSON(w, http.StatusOK, steamLoginResponse{OK: false, NeedGuard: true, Username: req.Username})
case steamLoginInvalidPassword:
writeJSON(w, http.StatusUnauthorized, steamLoginResponse{OK: false, Error: "invalid Steam password"})
case steamLoginRateLimited:
writeJSON(w, http.StatusTooManyRequests, steamLoginResponse{OK: false, Error: "Steam rate-limited the login. Wait a few minutes."})
default:
writeJSON(w, http.StatusInternalServerError, steamLoginResponse{OK: false, Error: result.message})
}
}
type steamLoginOutcome int
const (
steamLoginUnknown steamLoginOutcome = iota
steamLoginSuccess
steamLoginGuardRequired
steamLoginInvalidPassword
steamLoginRateLimited
)
type steamLoginResult struct {
outcome steamLoginOutcome
message string
}
// runSteamCMDTestLogin runs `docker run --rm steamcmd/steamcmd +set_steam_guard_code <g> +login <u> <p> +quit`
// and classifies the result. The panel-wide auth volume is mounted so the
// sentry blob persists across sidecars.
func runSteamCMDTestLogin(ctx context.Context, username, password, guardCode string) (steamLoginResult, error) {
const authVolume = "panel-steamcmd-auth"
const image = "steamcmd/steamcmd:latest"
// The steamcmd:latest image runs as root with HOME=/root; its data dir
// is /root/.local/share/Steam (config.vdf login cache + ssfn sentry +
// workshop cache all live under here). Mounting the auth volume at
// /root/Steam (as earlier versions did) catches nothing — fresh 2FA
// prompt on EVERY login. Fixed: mount at the real data dir.
args := []string{
"run", "--rm",
"-v", authVolume + ":/root/.local/share/Steam",
image,
}
if guardCode != "" {
args = append(args, "+set_steam_guard_code", guardCode)
}
args = append(args, "+login", username, password, "+quit")
// Allow 90 s for login (Steam Guard email delivery + user typing takes
// real time — but here we're handed the code already). Rate-limiting
// is the slow case.
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
// Locate docker. Try the standard Windows install path first (that's
// where Claude Code sessions + the panel's dev loop always run), then
// fall back to $PATH + $DOCKER_BIN. Using os.Stat with a native-style
// path — Unix-style `/c/Program Files/...` fails on Windows.
dockerCandidates := []string{
os.Getenv("DOCKER_BIN"),
`C:\Program Files\Docker\Docker\resources\bin\docker.exe`,
"/usr/bin/docker",
"/usr/local/bin/docker",
}
dockerBin := "docker"
for _, c := range dockerCandidates {
if c == "" {
continue
}
if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
dockerBin = c
break
}
}
cmd := exec.CommandContext(cctx, dockerBin, args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
runErr := cmd.Run() // classify via stdout regardless of exit code
rawOut := out.String()
// SteamCMD's stdout is laced with ANSI color escapes. They routinely
// split the literal strings we classify on ("Waiting for user info..."
// then ESC[0m then "OK"), so normalize before matching. We also case-
// fold to tolerate minor casing drift between SteamCMD versions.
outStr := strings.ToLower(ansiEscapeRE.ReplaceAllString(rawOut, ""))
if runErr != nil && outStr == "" {
// exec itself failed (docker not found, bad args, etc.). Return a
// real error so the caller can show "Failed to run SteamCMD: ...".
return steamLoginResult{outcome: steamLoginUnknown, message: runErr.Error()}, nil
}
// Classify by the tell-tale SteamCMD phrases. Order matters — guard-
// required and invalid-password need to win over a bare "OK" earlier
// in the stream that's from the Steam-API probe, not the real login.
hasAny := func(needles ...string) bool {
for _, n := range needles {
if strings.Contains(outStr, n) {
return true
}
}
return false
}
switch {
// Hard-stop failure markers win first.
case hasAny("invalid password", "invalidpassword", "failed (invalid password)",
"password is incorrect", "failed to login"):
return steamLoginResult{outcome: steamLoginInvalidPassword, message: redactSteamOutput(rawOut)}, nil
case hasAny("ratelimitexceeded", "rate limit", "too many login failures"):
return steamLoginResult{outcome: steamLoginRateLimited, message: redactSteamOutput(rawOut)}, nil
case hasAny("two-factor code", "steam guard code",
"accountlogondenied", "accountlogondeniedneedtwofactorcode",
"requires a steam guard", "please check your email"):
return steamLoginResult{outcome: steamLoginGuardRequired, message: redactSteamOutput(rawOut)}, nil
// Success markers — any of these in the stream means we got past auth.
// The most reliable is "logging in user '<name>' ... to steam public" + "ok"
// later in the stream. We also accept the older "logged in ok" phrasing.
case hasAny("logged in ok",
"logging in user", // immediately followed by success in practice
"waiting for user info", // only reached after a clean login
"waiting for client config"): // same — follows successful auth
return steamLoginResult{outcome: steamLoginSuccess}, nil
}
// Unknown outcome — dump a redacted snippet.
return steamLoginResult{outcome: steamLoginUnknown, message: redactSteamOutput(rawOut)}, nil
}
// ansiEscapeRE matches the common ANSI CSI color/style escapes SteamCMD
// emits (e.g. \x1b[0m, \x1b[1m, \x1b[33;1m). We strip these before
// classifying stdout so color codes don't fragment our search strings.
var ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
// firstLine returns the first non-empty line of s, for log-friendly
// one-liners derived from multi-line SteamCMD output.
func firstLine(s string) string {
for _, ln := range strings.Split(s, "\n") {
ln = strings.TrimSpace(ln)
if ln != "" {
if len(ln) > 120 {
ln = ln[:120] + "…"
}
return ln
}
}
return ""
}
// redactSteamOutput strips anything that looks like a credential before
// we return it as an error message.
func redactSteamOutput(s string) string {
// Trim to the last ~20 lines and mask obvious credentials.
lines := strings.Split(s, "\n")
if len(lines) > 25 {
lines = lines[len(lines)-25:]
}
for i, ln := range lines {
if strings.Contains(ln, "+login") {
lines[i] = "[+login redacted]"
}
}
return strings.Join(lines, "\n")
}
// ---- helpers used from handlers ----
func (h *httpServer) handleSteamList(w http.ResponseWriter, r *http.Request) {
names, err := h.db.ListSteamCredentialUsernames(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"usernames": names})
}
func (h *httpServer) handleSteamDelete(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.URL.Query().Get("username"))
if username == "" {
writeError(w, http.StatusBadRequest, "bad_request", "username query param required")
return
}
if err := h.db.DeleteSteamCredential(r.Context(), username); err != nil {
writeError(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// providerNeedsSteamLogin inspects the module-summary advertisement the
// agent sent at handshake and returns (true, providerID) if the update
// provider the caller picked (or the module's first provider) declares
// requires_steam_login. Returns (false, "") when credentials aren't
// needed — the update path then proceeds with the normal +login anonymous.
func (h *httpServer) providerNeedsSteamLogin(ctx context.Context, agentID, instanceID, providerID string) (bool, string) {
rows, err := h.db.ListInstances(ctx, "")
if err != nil {
return false, ""
}
var moduleID string
for _, r := range rows {
if r.InstanceID == instanceID {
moduleID = r.ModuleID
break
}
}
if moduleID == "" {
return false, ""
}
mod := h.registry.moduleByID(agentID, moduleID)
if mod == nil {
return false, ""
}
for _, p := range mod.UpdateProviders {
if providerID == "" || p.Id == providerID {
return p.RequiresSteamLogin, p.Id
}
}
return false, ""
}
// getSteamLoginForUpdate returns the cached Steam username + plaintext
// password to use for the next SteamCMD sidecar. The agent receives these
// over the normal gRPC control envelope (mTLS-encrypted channel), used as
// `+login <user> <pass>` args, and they never appear in the Console log
// stream (the dispatch-side log pump redacts them; see steamcmd.go).
// Returns (_, _, false) if no credential is cached.
func (h *httpServer) getSteamLoginForUpdate(ctx context.Context) (username, password string, ok bool) {
row, err := h.db.GetAnySteamCredential(ctx)
if err != nil {
return "", "", false
}
kek, err := steamKEK(h.ca.RootKey)
if err != nil {
return "", "", false
}
pw, err := decryptSteamPassword(kek, row.PasswordCiphertext, row.PasswordNonce)
if err != nil {
return "", "", false
}
return row.Username, pw, true
}