panel v0.9.2 — public release
Self-hostable game server control panel: Go controller + agent, 26 game modules, embedded web UI. One-line install via install.sh / install.ps1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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-<INSTANCE>-game` volume and copies the scenario files into
|
||||
// Content/Scenarios/<scenario_name>/.
|
||||
// 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/<scenario_name>/. 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/<folder> 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user