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 +login // anonymous +app_update [-beta ] 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 ` uses the cached refresh token from config.vdf // (persisted in panel-steamcmd-auth) — no Steam Guard push, no // password round-trip. Passing `+login ` 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

` stages downloads // at

/steamapps/downloading// and atomically renames into //

/ 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 /steamapps/downloading// and, if present and // non-empty, moves its contents into /. 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 ` 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 }