658bda1d24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
456 lines
16 KiB
Go
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
|
|
}
|
|
|