package dispatch // Agent-side Empyrion workshop-scenario installer. Runs entirely on the // local Docker daemon, so a multi-agent panel can install Reforged Eden 2 // (and any other workshop scenario) into an Empyrion instance regardless // of which agent owns it. // // Flow: // 1. SteamCMD sidecar mounts panel-empyrion-workshop (an agent-local // cache volume, lazily created on first install) and downloads the // workshop item. // 2. Alpine helper sidecar mounts the workshop volume + the instance's // `panel--game` volume and copies the scenario files into // Content/Scenarios//. // 3. A marker file (.panel-installed) is touched so the empyrion // entrypoint can confirm the install. // // Progress is streamed back to the controller as LogLine envelopes with // stream="scenario" so the panel UI's scenario job log shows what's // happening live. import ( "context" "fmt" "os/exec" "strings" "time" "google.golang.org/protobuf/types/known/timestamppb" panelv1 "github.com/dbledeez/panel/proto/panel/v1" ) func (d *Dispatcher) handleEmpyrionScenarioInstall(corrID string, req *panelv1.EmpyrionScenarioInstallRequest) { if req == nil || req.JobId == "" || req.InstanceId == "" || req.WorkshopId == "" || req.ScenarioName == "" { d.sendEmpyrionScenarioResult(corrID, req.GetJobId(), false, "missing required fields", req.GetScenarioName()) return } appID := req.AppId if appID == "" { appID = "530870" } go d.runEmpyrionScenarioJob(corrID, req, appID) } func (d *Dispatcher) runEmpyrionScenarioJob(corrID string, req *panelv1.EmpyrionScenarioInstallRequest, appID string) { // Redact the password from any line we forward upstream, since // SteamCMD CAN echo `+login user PASS` back in error paths. redact := func(s string) string { if req.SteamPassword == "" || len(req.SteamPassword) < 4 { return s } return strings.ReplaceAll(s, req.SteamPassword, "[REDACTED]") } emit := func(line string) { d.sendEnv(&panelv1.AgentEnvelope{ SentAt: timestamppb.Now(), Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{ InstanceId: req.InstanceId, Stream: "scenario", At: timestamppb.Now(), Line: redact(line), }}, }) } finish := func(ok bool, msg string) { d.sendEmpyrionScenarioResult(corrID, req.JobId, ok, msg, req.ScenarioName) } emit(fmt.Sprintf("[scenario] starting workshop download: app=%s item=%s scenario=%q", appID, req.WorkshopId, req.ScenarioName)) dockerBin, err := exec.LookPath("docker") if err != nil { finish(false, "docker binary not found on agent host") return } // Phase 1 — workshop download. The volume name is per-agent (Docker // daemons don't share volumes); it's created lazily by the first run. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() // Build the SteamCMD argv for an attempt — login portion is the only // variable. `+app_license_request` refreshes Steam's entitlement // cache before the workshop download (cheap fix for stale-license // "Access Denied" on owned games). buildArgs := func(loginArgs []string) []string { base := []string{ "run", "--rm", "-v", "panel-steamcmd-auth:/root/.local/share/Steam", "-v", "panel-empyrion-workshop:/empyrion-workshop/steamapps/workshop", "steamcmd/steamcmd:latest", "+@sSteamCmdForcePlatformType", "windows", "+force_install_dir", "/empyrion-workshop", } base = append(base, loginArgs...) base = append(base, "+app_license_request", appID, "+workshop_download_item", appID, req.WorkshopId, "validate", "+quit", ) return base } var out string switch { case req.SteamUsername == "": emit("[scenario] no Steam credentials provided — trying anonymous (will fail for app workshops that require ownership)") out, err = runWithLineEmit(ctx, dockerBin, buildArgs([]string{"+login", "anonymous"}), emit) default: // Try cached refresh-token login first (no password). If the // agent's panel-steamcmd-auth volume has a valid token from a // prior install, this skips Steam Guard entirely — no push // notification fires. Only when the cache is missing or expired // (Logon failure / no cached credentials in the SteamCMD output) // do we fall back to full password auth, which DOES require // approving a Steam Guard push but then re-primes the cache for // future runs. emit(fmt.Sprintf("[scenario] login attempt 1: %q via cached refresh token (no Steam Guard push if cache valid)", req.SteamUsername)) cachedOut, cachedErr := runWithLineEmit(ctx, dockerBin, buildArgs([]string{"+login", req.SteamUsername}), emit) needsPwd := cachedErr != nil || strings.Contains(cachedOut, "Logon failure") || strings.Contains(cachedOut, "No cached credentials") || strings.Contains(cachedOut, "FAILED login") || strings.Contains(cachedOut, "Login failure") if !needsPwd { out, err = cachedOut, cachedErr break } if req.SteamPassword == "" { finish(false, "Steam refresh-token cache is missing/expired on this agent and no password is available to re-prime it. Re-enter credentials via the Steam login modal.") return } emit(fmt.Sprintf("[scenario] login attempt 2: %q with password (Steam Guard push will fire — approve on phone)", req.SteamUsername)) out, err = runWithLineEmit(ctx, dockerBin, buildArgs([]string{"+login", req.SteamUsername, req.SteamPassword}), emit) } out = redact(out) if err != nil { finish(false, "steamcmd: "+err.Error()) return } // Steam reports "Access Denied" when the logged-in account can't // pull this workshop item. Common causes (in order of likelihood): // 1. Account doesn't own the app — most common // 2. License cache stale — we already issued +app_license_request // above, so if it still errors that's not it // 3. Workshop item is private / friends-only / restricted by author // 4. Workshop item requires the operator to "subscribe" to it via // the desktop Steam client at least once before SteamCMD can // pull it (some apps gate this) // 5. Family Library Sharing — the account borrows Empyrion via // sharing instead of owning a license; SteamCMD requires real // ownership, not borrowed licenses if strings.Contains(out, "ERROR! Download item") && strings.Contains(out, "Access Denied") { // Account confirmed-owner of the app but still denied — this is // almost always a publisher-side restriction on workshop_download_item // (Empyrion / Eleon is the canonical example: the desktop Steam // client uses a different API path that's allowed, but SteamCMD's // path is denied for this app's workshop). NOT a panel bug, NOT an // account bug. Manual upload is the only reliable path. ownsApp := strings.Contains(out, "already owned") var msg string if ownsApp { msg = fmt.Sprintf( "Workshop item %s denied even though account %q owns app %s (Steam confirmed: \"AppID %s already owned\"). "+ "This is a publisher-side restriction on SteamCMD's workshop API — common for Empyrion (Eleon disables CLI workshop downloads; the desktop Steam client uses a different allowed path). "+ "Workaround: subscribe to the item in the desktop Steam client (Workshop → Subscribe), let it download to %%STEAM%%/steamapps/workshop/content/%s/%s/, then upload that folder into the instance's panel-empyrion-game volume at Content/Scenarios//. The panel can't bypass this — Steam policy enforced server-side.", req.WorkshopId, req.SteamUsername, appID, appID, appID, req.WorkshopId, ) } else { msg = fmt.Sprintf( "Steam returned Access Denied for workshop item %s after logging in as %q. "+ "Likely causes: (a) account doesn't actually own app %s (Family Library Sharing borrows don't count — needs a purchase license); "+ "(b) workshop item is private/friends-only/restricted by author; "+ "(c) item requires desktop-Steam-client subscription first. "+ "Workaround: download via desktop Steam client and drop Content/Scenarios/ into the instance volume manually.", req.WorkshopId, req.SteamUsername, appID, ) } finish(false, msg) return } if !strings.Contains(out, "Success. Downloaded item") { // Don't fail outright on a missing success marker — some // SteamCMD versions return without that exact string when the // item is already cached. Sanity-check the file actually // exists in the volume before deciding. probe := []string{"run", "--rm", "-v", "panel-empyrion-workshop:/ws", "alpine:3.20", "sh", "-c", fmt.Sprintf("test -d /ws/content/%s/%s && echo cached", appID, req.WorkshopId)} probeOut, _ := exec.CommandContext(ctx, dockerBin, probe...).CombinedOutput() if !strings.Contains(string(probeOut), "cached") { finish(false, "steamcmd did not download item — see log for details") return } emit("[scenario] workshop item already cached on this agent — skipping re-download") } // Phase 2 — copy into instance volume. emit("[scenario] copying scenario files into instance volume…") gameVol := "panel-" + req.InstanceId + "-game" dst := "/dst/Content/Scenarios/" + req.ScenarioName // Verify the instance volume exists; if not, the instance was // never created on this agent. probe := []string{"volume", "inspect", gameVol} if err := exec.CommandContext(ctx, dockerBin, probe...).Run(); err != nil { finish(false, fmt.Sprintf("instance volume %q not found on this agent — is the empyrion instance created here?", gameVol)) return } helperArgs := []string{ "run", "--rm", "-v", "panel-empyrion-workshop:/src", "-v", gameVol + ":/dst", "alpine:3.20", "sh", "-c", fmt.Sprintf( "mkdir -p %s && rm -rf %s/* %s/.* 2>/dev/null; "+ "cp -a /src/content/%s/%s/. %s/ && date > %s/.panel-installed && echo OK", shQuote(dst), shQuote(dst), shQuote(dst), appID, req.WorkshopId, shQuote(dst), shQuote(dst), ), } out, err = runWithLineEmit(ctx, dockerBin, helperArgs, emit) if err != nil { finish(false, "scenario copy failed: "+err.Error()) return } if !strings.Contains(out, "OK") { finish(false, "scenario copy did not confirm OK — see log for details") return } emit(fmt.Sprintf("[scenario] %q installed into %s", req.ScenarioName, req.InstanceId)) finish(true, "") } // shQuote single-quotes a path for safe inclusion in a `sh -c` script. // Embedded single quotes are escaped via the standard '\'' trick. Used // for scenario_name which is operator input — even though the controller // already rejects path-traversal characters, defense-in-depth at the // agent layer is cheap. func shQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // runWithLineEmit spawns docker, captures combined stdout/stderr, // emits each line as it arrives, and returns the full combined output // for post-mortem checks (success markers etc.) when the process exits. func runWithLineEmit(ctx context.Context, bin string, args []string, emit func(string)) (string, error) { cmd := exec.CommandContext(ctx, bin, args...) pipe, err := cmd.StdoutPipe() if err != nil { return "", err } cmd.Stderr = cmd.Stdout if err := cmd.Start(); err != nil { return "", err } var buf strings.Builder scanCh := make(chan struct{}) go func() { defer close(scanCh) readBuf := make([]byte, 4096) var line strings.Builder for { n, err := pipe.Read(readBuf) if n > 0 { chunk := readBuf[:n] buf.Write(chunk) for _, b := range chunk { if b == '\n' { emit(strings.TrimRight(line.String(), "\r")) line.Reset() } else { line.WriteByte(b) } } } if err != nil { if line.Len() > 0 { emit(strings.TrimRight(line.String(), "\r")) } return } } }() waitErr := cmd.Wait() <-scanCh if waitErr != nil { return buf.String(), waitErr } return buf.String(), nil } func (d *Dispatcher) sendEmpyrionScenarioResult(corrID, jobID string, ok bool, errMsg, scenarioName string) { d.sendEnv(&panelv1.AgentEnvelope{ CorrelationId: corrID, SentAt: timestamppb.Now(), Payload: &panelv1.AgentEnvelope_EmpyrionScenarioInstallResult{ EmpyrionScenarioInstallResult: &panelv1.EmpyrionScenarioInstallResult{ JobId: jobID, Ok: ok, Error: errMsg, ScenarioName: scenarioName, }, }, }) }