295eb22826
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
333 lines
13 KiB
Go
333 lines
13 KiB
Go
package updater
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
agentmodule "github.com/dbledeez/panel/agent/internal/module"
|
|
"github.com/dbledeez/panel/agent/internal/runtime"
|
|
modulepkg "github.com/dbledeez/panel/pkg/module"
|
|
)
|
|
|
|
// SteamCMDProvider runs `steamcmd +force_install_dir <path> +login
|
|
// anonymous +app_update <appid> [-beta <branch>] validate +quit` in a
|
|
// one-shot sidecar container that mounts the target instance's volumes.
|
|
// The sidecar writes game files into the same Docker volumes the main
|
|
// container reads from, so "update then start" just works.
|
|
//
|
|
// Preconditions:
|
|
// - Target instance's container must NOT be running (Steam holds file
|
|
// locks that conflict with a live server). Error clearly otherwise.
|
|
// - Manifest must declare at least one volume; install_path resolves
|
|
// to spec.InstallPath → BrowseableRoot → first volume's container path.
|
|
type SteamCMDProvider struct{}
|
|
|
|
// Name identifies this provider kind in logs / manifest.
|
|
func (SteamCMDProvider) Name() string { return "steamcmd" }
|
|
|
|
// sidecarImage is the official Valve-maintained SteamCMD image. Its
|
|
// ENTRYPOINT is `steamcmd`, so we pass steamcmd's `+...` flags as Cmd.
|
|
const sidecarImage = "steamcmd/steamcmd:latest"
|
|
|
|
// redactSteamPassword replaces a password in a cmdline arg slice with
|
|
// "[REDACTED]" so the Console tab never echoes it. Used before logging
|
|
// steamcmd argv.
|
|
func redactSteamPassword(args []string, password string) []string {
|
|
if password == "" {
|
|
return args
|
|
}
|
|
out := make([]string, len(args))
|
|
for i, a := range args {
|
|
if a == password {
|
|
out[i] = "[REDACTED]"
|
|
continue
|
|
}
|
|
out[i] = a
|
|
}
|
|
return out
|
|
}
|
|
|
|
// redactPasswordLine strips the password substring from a log line if it
|
|
// ever appears (e.g. SteamCMD echoing `+login user PASS` back). Returns
|
|
// the original string when password is empty so anonymous/public flows
|
|
// are zero-cost.
|
|
func redactPasswordLine(line, password string) string {
|
|
if password == "" || len(password) < 4 {
|
|
return line
|
|
}
|
|
if !strings.Contains(line, password) {
|
|
return line
|
|
}
|
|
return strings.ReplaceAll(line, password, "[REDACTED]")
|
|
}
|
|
|
|
func (SteamCMDProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
|
|
if spec.AppID == "" {
|
|
return fmt.Errorf("steamcmd provider: app_id is required")
|
|
}
|
|
|
|
// Refuse if the main container is still running — Steam locks files.
|
|
mainName := "panel-" + uc.InstanceID
|
|
if state, err := uc.Runtime.InspectByName(ctx, mainName); err == nil && state.Status == "running" {
|
|
return fmt.Errorf("stop the instance first — container %q is running; SteamCMD conflicts with a live server", mainName)
|
|
}
|
|
|
|
installPath := spec.InstallPath
|
|
if installPath == "" {
|
|
installPath = uc.BrowseableRoot
|
|
}
|
|
if installPath == "" {
|
|
return fmt.Errorf("steamcmd provider: install_path missing and module has no browseable_root/volumes to default from")
|
|
}
|
|
|
|
// Mount the same volumes as the target instance so SteamCMD writes
|
|
// where the main container will later read.
|
|
volumes := agentmodule.ResolveVolumes(uc.Manifest, uc.InstanceID, uc.DataPath)
|
|
if len(volumes) == 0 {
|
|
return fmt.Errorf("steamcmd provider: module declares no volumes to mount")
|
|
}
|
|
|
|
// Platform override MUST come before +login — SteamCMD evaluates its
|
|
// forced-platform state at login time, not at app_update time. Used for
|
|
// apps that ship only a Windows depot (run via Wine on Linux hosts).
|
|
args := []string{}
|
|
if spec.Platform != "" {
|
|
args = append(args, "+@sSteamCmdForcePlatformType", spec.Platform)
|
|
}
|
|
args = append(args, "+force_install_dir", installPath)
|
|
|
|
// Non-anonymous Steam login for paid apps (DayZ, Arma). If the module
|
|
// declared requires_steam_login and the controller forwarded creds,
|
|
// use them. Otherwise fall back to anonymous (most free-to-download
|
|
// dedicated servers work anonymously).
|
|
//
|
|
// CRITICAL for UX: pass ONLY the username when possible. SteamCMD's
|
|
// `+login <user>` uses the cached refresh token from config.vdf
|
|
// (persisted in panel-steamcmd-auth) — no Steam Guard push, no
|
|
// password round-trip. Passing `+login <user> <password>` forces a
|
|
// fresh password auth every time, which triggers a 2FA push on every
|
|
// call even when a perfectly valid cached token is already on disk.
|
|
//
|
|
// First-ever login ceremony happens in the controller (steamauth.go)
|
|
// with the full password+guard flow to prime the cache. After that,
|
|
// this path rides the cache. If the cache is stale/expired, SteamCMD
|
|
// exits non-zero — the retry loop below re-runs which will hit the
|
|
// same error; the operator then clicks Update, which the controller
|
|
// can short-circuit to re-run the login modal (the `steam_login_required`
|
|
// response path).
|
|
needsLogin := spec.RequiresSteamLogin && uc.SteamUsername != ""
|
|
if needsLogin {
|
|
args = append(args, "+login", uc.SteamUsername)
|
|
} else {
|
|
args = append(args, "+login", "anonymous")
|
|
}
|
|
updateArgs := []string{"+app_update", spec.AppID}
|
|
if spec.Beta != "" {
|
|
updateArgs = append(updateArgs, "-beta", spec.Beta)
|
|
}
|
|
if !spec.SkipValidate {
|
|
updateArgs = append(updateArgs, "validate")
|
|
}
|
|
args = append(args, updateArgs...)
|
|
args = append(args, "+quit")
|
|
|
|
uc.Log(fmt.Sprintf("steamcmd: image=%s install_path=%s app_id=%s beta=%s", sidecarImage, installPath, spec.AppID, spec.Beta))
|
|
uc.Log(fmt.Sprintf("steamcmd argv: %v", redactSteamPassword(args, uc.SteamPassword)))
|
|
|
|
// SteamCMD is notoriously flaky on first invocation — exit 8 ("missing
|
|
// configuration"), exit 2 (generic "retry later"), and Steam auth races
|
|
// are all recoverable by simply running the same command again. AMP,
|
|
// LinuxGSM, and Valve's own Steam client all retry on these codes. We
|
|
// mirror that behaviour so operators don't have to click Update twice.
|
|
const maxAttempts = 4
|
|
var lastExit int
|
|
var ranSuccessfully bool
|
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
|
exitCode, err := runSteamCMDOnce(ctx, uc, sidecarImage, args, volumes, attempt)
|
|
lastExit = exitCode
|
|
if err == nil && exitCode == 0 {
|
|
uc.Log(fmt.Sprintf("steamcmd: completed successfully (app_id=%s, attempts=%d)", spec.AppID, attempt))
|
|
ranSuccessfully = true
|
|
break
|
|
}
|
|
// Transient exit codes SteamCMD is known to recover from. Anything
|
|
// else we surface immediately (missing disk space, bad app id, etc.).
|
|
transient := err == nil && (exitCode == 2 || exitCode == 5 || exitCode == 8)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !transient || attempt == maxAttempts {
|
|
break
|
|
}
|
|
backoff := time.Duration(attempt*attempt*3) * time.Second // 3s, 12s, 27s
|
|
uc.Log(fmt.Sprintf("steamcmd: exit %d — transient, retrying in %s (attempt %d/%d)", exitCode, backoff, attempt+1, maxAttempts))
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
}
|
|
|
|
// Finalize: SteamCMD with `+force_install_dir <p>` stages downloads
|
|
// at <p>/steamapps/downloading/<appid>/ and atomically renames into
|
|
// <p>/ on success. When SteamCMD exits with state 0x602 (often after
|
|
// concurrent runs interrupt finalization, or a `validate` pass races
|
|
// the move) the bytes are on disk but never get renamed — operator
|
|
// sees an empty install dir and an exit-8 error from the panel.
|
|
//
|
|
// This step runs unconditionally after the retry loop. If the staging
|
|
// dir is empty or absent (the happy path), it's a cheap no-op. If it
|
|
// has content, we cp -a it into place and the install completes.
|
|
finalized, ferr := finalizeStagingDir(ctx, uc, installPath, spec.AppID, volumes)
|
|
if ferr != nil {
|
|
uc.Log(fmt.Sprintf("steamcmd: finalize check failed: %s (continuing anyway)", ferr.Error()))
|
|
} else if finalized {
|
|
uc.Log("steamcmd: recovered staged install (state 0x602 workaround)")
|
|
return nil
|
|
}
|
|
|
|
if ranSuccessfully {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("steamcmd exited %d after %d attempt(s)", lastExit, maxAttempts)
|
|
}
|
|
|
|
// finalizeStagingDir checks for a stranded SteamCMD staging directory
|
|
// at <installPath>/steamapps/downloading/<appid>/ and, if present and
|
|
// non-empty, moves its contents into <installPath>/. Runs as a one-shot
|
|
// alpine sidecar with the same volume mounts as the SteamCMD run so
|
|
// it has access to the install path. Returns true if a recovery move
|
|
// actually happened, false if there was nothing to do.
|
|
//
|
|
// The script is intentionally defensive:
|
|
// - mv is preferred over cp+rm because it's atomic on the same FS.
|
|
// - If both source and destination have entries with the same name,
|
|
// mv -f overwrites (rare; happens when steamcmd partially renamed
|
|
// before dying).
|
|
// - We exit 0 on "no staging dir" so this is a no-op for the happy
|
|
// path.
|
|
func finalizeStagingDir(ctx context.Context, uc *Context, installPath, appID string, volumes []runtime.VolumeSpec) (bool, error) {
|
|
const stagingMarker = "/__staging__"
|
|
script := fmt.Sprintf(`set -e
|
|
DL=%q/steamapps/downloading/%s
|
|
DEST=%q
|
|
if [ ! -d "$DL" ] || [ -z "$(ls -A "$DL" 2>/dev/null)" ]; then
|
|
echo "no-staging"
|
|
exit 0
|
|
fi
|
|
echo "finalizing $DL -> $DEST"
|
|
mkdir -p "$DEST"
|
|
# mv every top-level entry; -f overwrites partial renames.
|
|
for f in "$DL"/* "$DL"/.[!.]* "$DL"/..?*; do
|
|
[ -e "$f" ] || continue
|
|
mv -f "$f" "$DEST/" 2>&1 || cp -a "$f" "$DEST/" && rm -rf "$f"
|
|
done
|
|
rm -rf "$DL"
|
|
touch %q
|
|
echo "finalized"
|
|
`, installPath, appID, installPath, installPath+stagingMarker)
|
|
|
|
sidecarSpec := runtime.InstanceSpec{
|
|
InstanceID: fmt.Sprintf("%s-steamcmd-finalize", uc.InstanceID),
|
|
IsSidecar: true,
|
|
Image: "alpine:latest",
|
|
Command: []string{"sh", "-c", script},
|
|
Volumes: volumes,
|
|
RestartPolicy: "no",
|
|
}
|
|
contID, err := uc.Runtime.Create(ctx, sidecarSpec)
|
|
if err != nil {
|
|
return false, fmt.Errorf("create finalize sidecar: %w", err)
|
|
}
|
|
defer func() {
|
|
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
_ = uc.Runtime.Remove(rmCtx, contID)
|
|
}()
|
|
if err := uc.Runtime.Start(ctx, contID); err != nil {
|
|
return false, fmt.Errorf("start finalize sidecar: %w", err)
|
|
}
|
|
logCtx, cancelLogs := context.WithCancel(ctx)
|
|
defer cancelLogs()
|
|
logDone := make(chan struct{})
|
|
var lastLine string
|
|
go func() {
|
|
defer close(logDone)
|
|
_ = uc.Runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
|
|
lastLine = strings.TrimSpace(line)
|
|
uc.Log("finalize: " + lastLine)
|
|
})
|
|
}()
|
|
exitCode, err := uc.Runtime.Wait(ctx, contID)
|
|
cancelLogs()
|
|
<-logDone
|
|
if err != nil {
|
|
return false, fmt.Errorf("wait finalize sidecar: %w", err)
|
|
}
|
|
if exitCode != 0 {
|
|
return false, fmt.Errorf("finalize sidecar exit %d", exitCode)
|
|
}
|
|
return lastLine == "finalized", nil
|
|
}
|
|
|
|
// runSteamCMDOnce is a single run of the steamcmd sidecar; called in a loop
|
|
// by the retry wrapper. Returns the container's exit code on clean
|
|
// lifecycle (even non-zero) or an error on infrastructure failure.
|
|
func runSteamCMDOnce(ctx context.Context, uc *Context, image string, args []string, volumes []runtime.VolumeSpec, attempt int) (int, error) {
|
|
sidecarID := fmt.Sprintf("%s-steamcmd-%d", uc.InstanceID, attempt)
|
|
// Mount the panel-wide SteamCMD auth volume at /root/.local/share/Steam
|
|
// (the real data dir of the steamcmd:latest image — NOT /root/Steam).
|
|
// This persists config/config.vdf (login refresh token) + ssfn sentry,
|
|
// which lets subsequent `+login <user>` runs skip the 2FA push. Anon
|
|
// logins ignore this dir — no harm.
|
|
volumesWithAuth := append([]runtime.VolumeSpec(nil), volumes...)
|
|
volumesWithAuth = append(volumesWithAuth, runtime.VolumeSpec{
|
|
VolumeName: "panel-steamcmd-auth",
|
|
ContainerPath: "/root/.local/share/Steam",
|
|
Type: "volume",
|
|
})
|
|
sidecarSpec := runtime.InstanceSpec{
|
|
InstanceID: sidecarID,
|
|
IsSidecar: true,
|
|
Image: image,
|
|
Command: args,
|
|
Volumes: volumesWithAuth,
|
|
RestartPolicy: "no",
|
|
// Docker 29.4+ default seccomp ENOSYS's a syscall the 32-bit
|
|
// Steam runtime needs ("CreateBoundSocket error 38"). Older
|
|
// Docker (29.1.x on princess/ivy) doesn't filter it. Unconfine
|
|
// for the sidecar so install/validate works across hosts.
|
|
SecurityOpts: []string{"seccomp=unconfined"},
|
|
}
|
|
contID, err := uc.Runtime.Create(ctx, sidecarSpec)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create sidecar: %w", err)
|
|
}
|
|
defer func() {
|
|
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
_ = uc.Runtime.Remove(rmCtx, contID)
|
|
}()
|
|
if err := uc.Runtime.Start(ctx, contID); err != nil {
|
|
return 0, fmt.Errorf("start sidecar: %w", err)
|
|
}
|
|
logCtx, cancelLogs := context.WithCancel(ctx)
|
|
defer cancelLogs()
|
|
logDone := make(chan struct{})
|
|
go func() {
|
|
defer close(logDone)
|
|
_ = uc.Runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
|
|
uc.Log(redactPasswordLine(line, uc.SteamPassword))
|
|
})
|
|
}()
|
|
exitCode, err := uc.Runtime.Wait(ctx, contID)
|
|
cancelLogs()
|
|
<-logDone
|
|
if err != nil {
|
|
return 0, fmt.Errorf("wait sidecar: %w", err)
|
|
}
|
|
return exitCode, nil
|
|
}
|