panel v0.9.1 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:25:11 -07:00
commit 03a281d009
2161 changed files with 300880 additions and 0 deletions
@@ -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,
},
},
})
}