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:
2026-07-15 00:43:35 -07:00
commit 4ccccc6fe2
2164 changed files with 301480 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
package updater
import (
"context"
"fmt"
"strings"
"time"
"github.com/dbledeez/panel/agent/internal/runtime"
"github.com/dbledeez/panel/pkg/steamvdf"
)
// FetchAppInfoBranches runs a one-shot SteamCMD sidecar with
//
// +login anonymous +app_info_update 1 +app_info_print <appID> +quit
//
// captures its stdout, and parses the depots.branches map (branch name →
// latest buildid). CHECK-ONLY: no game volumes are mounted, so this can
// never touch an install. Only the shared panel-steamcmd-auth volume is
// attached (same as update runs) so Steam's client config cache persists
// across calls.
//
// tag disambiguates the sidecar container name per caller (usually the
// instance id); appID appears too so parallel checks for different apps
// can't collide.
func FetchAppInfoBranches(ctx context.Context, rt runtime.Runtime, tag, appID string, log func(string)) (map[string]string, error) {
if appID == "" {
return nil, fmt.Errorf("app_info: app_id is required")
}
args := []string{
"+login", "anonymous",
"+app_info_update", "1",
"+app_info_print", appID,
"+quit",
}
spec := runtime.InstanceSpec{
InstanceID: fmt.Sprintf("%s-appinfo-%s", tag, appID),
IsSidecar: true,
Image: sidecarImage,
Command: args,
Volumes: []runtime.VolumeSpec{{
VolumeName: "panel-steamcmd-auth",
ContainerPath: "/root/.local/share/Steam",
Type: "volume",
}},
RestartPolicy: "no",
// Same seccomp note as the update sidecar (steamcmd.go): newer
// Docker default profiles ENOSYS a syscall the 32-bit Steam
// runtime needs.
SecurityOpts: []string{"seccomp=unconfined"},
}
contID, err := rt.Create(ctx, spec)
if err != nil {
return nil, fmt.Errorf("create app_info sidecar: %w", err)
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = rt.Remove(rmCtx, contID)
}()
if err := rt.Start(ctx, contID); err != nil {
return nil, fmt.Errorf("start app_info sidecar: %w", err)
}
var out strings.Builder
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = rt.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
out.WriteString(line)
out.WriteByte('\n')
})
}()
exitCode, err := rt.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
return nil, fmt.Errorf("wait app_info sidecar: %w", err)
}
if log != nil {
log(fmt.Sprintf("app_info: steamcmd exit %d, %d bytes of output", exitCode, out.Len()))
}
// SteamCMD sometimes exits non-zero even after printing valid app
// info — parse first, only surface the exit code when parsing fails.
branches, perr := steamvdf.ParseAppInfoBranches(out.String(), appID)
if perr != nil {
if exitCode != 0 {
return nil, fmt.Errorf("app_info: steamcmd exit %d (%v)", exitCode, perr)
}
return nil, perr
}
return branches, nil
}
+207
View File
@@ -0,0 +1,207 @@
package updater
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
archivepkg "github.com/dbledeez/panel/agent/internal/archive"
modulepkg "github.com/dbledeez/panel/pkg/module"
"github.com/dbledeez/panel/pkg/version"
)
// DirectProvider fetches a URL and writes the response body to a target
// path under the instance. Used for vanilla Minecraft jars, custom mod
// packs hosted on any HTTP server, etc.
type DirectProvider struct{}
// Name identifies this provider kind in logs / manifest.
func (DirectProvider) Name() string { return "direct" }
const maxDirectDownloadBytes = 1 << 30 // 1 GiB sanity cap
// Update downloads spec.URL and writes it to spec.TargetPath relative
// to the instance. Optional SHA256 verification is reserved for a future
// `sha256:` field on the spec.
func (DirectProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
if spec.URL == "" {
return fmt.Errorf("direct provider: url is required")
}
if spec.TargetPath == "" {
return fmt.Errorf("direct provider: target_path is required")
}
req, err := http.NewRequestWithContext(ctx, "GET", spec.URL, nil)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("User-Agent", version.UserAgent())
client := &http.Client{Timeout: 5 * time.Minute}
uc.Log(fmt.Sprintf("GET %s", spec.URL))
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("http status %d", resp.StatusCode)
}
if resp.ContentLength > 0 {
uc.Log(fmt.Sprintf("Content-Length: %d bytes", resp.ContentLength))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDirectDownloadBytes+1))
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if int64(len(data)) > maxDirectDownloadBytes {
return fmt.Errorf("response exceeds %d-byte cap", maxDirectDownloadBytes)
}
sum := sha256.Sum256(data)
uc.Log(fmt.Sprintf("downloaded %d bytes (sha256:%s)", len(data), hex.EncodeToString(sum[:])))
// Archive handling. Some direct URLs serve a whole game tree as an
// archive (factorio's headless .tar.xz) — dumping those bytes as a
// single file at TargetPath is never what the module wants. Extract
// when the module opted in (spec.Extract) or when the content is a
// recognized archive AND TargetPath looks like a directory (no file
// extension). A TargetPath with an extension is always written
// verbatim: minecraft server.jar IS a zip by magic bytes, and
// terraria's entrypoint expects its .zip left on disk.
format := archivepkg.DetectFormat(data, spec.URL)
if spec.Extract && format == "" {
return fmt.Errorf("extract requested but content is not a recognized archive (url %s)", spec.URL)
}
if format != "" && (spec.Extract || path.Ext(path.Base(spec.TargetPath)) == "") {
uc.Log(fmt.Sprintf("content is a %s archive — extracting into %s", format, spec.TargetPath))
entries, bytesOut, err := extractToInstance(ctx, uc, spec.TargetPath, data, spec.URL)
if err != nil {
return fmt.Errorf("extract into %s: %w", spec.TargetPath, err)
}
uc.Log(fmt.Sprintf("extracted %d entries (%d bytes) into %s", entries, bytesOut, spec.TargetPath))
return nil
}
if err := writeToInstance(ctx, uc, spec.TargetPath, data); err != nil {
return fmt.Errorf("write target: %w", err)
}
uc.Log(fmt.Sprintf("wrote %s", spec.TargetPath))
return nil
}
// containerTargetAbs resolves a provider target_path to a container-
// absolute path. An absolute target that is equal to or under one of
// the module's declared browseable roots or volume mount points is
// used as-is (multi-root modules: factorio's /game volume sits BESIDE
// its /game-saves default root). Anything else keeps the historical
// behavior of joining under BrowseableRoot — terraria's entrypoint,
// for one, expects /terraria-server.zip to land at
// /game-saves/terraria-server.zip via exactly that join.
func containerTargetAbs(uc *Context, targetPath string) string {
if strings.HasPrefix(targetPath, "/") && uc.Manifest != nil {
cleaned := path.Clean(targetPath)
var roots []string
if d := uc.Manifest.Runtime.Docker; d != nil {
for _, r := range d.BrowseableRoots {
if r.Path != "" {
roots = append(roots, r.Path)
}
}
for _, v := range d.Volumes {
if v.Container != "" {
roots = append(roots, v.Container)
}
}
}
for _, r := range roots {
if cleaned == r || strings.HasPrefix(cleaned+"/", r+"/") {
return cleaned
}
}
}
return path.Join(uc.BrowseableRoot, targetPath)
}
// extractToInstance expands archive `data` into the instance-relative
// directory targetPath. Mirrors writeToInstance's storage preference:
// container-path ops when the instance has a BrowseableRoot and a
// container (re-encode the archive as one tar stream and let Docker
// extract it in place — same pattern as the file manager's FsExtract),
// falling back to plain filesystem extraction under DataPath.
func extractToInstance(ctx context.Context, uc *Context, targetPath string, data []byte, originalName string) (int64, int64, error) {
if targetPath == "" {
return 0, 0, fmt.Errorf("target_path is required")
}
if uc.BrowseableRoot != "" && uc.ContainerID != "" {
destAbs := containerTargetAbs(uc, targetPath)
// Best-effort mkdir; only possible on a running container, and
// the usual target is a volume mount point that already exists.
_, _, _, _ = uc.Runtime.ExecCapture(ctx, uc.ContainerID, []string{"sh", "-c", "mkdir -p -- '" + destAbs + "'"})
pr, pw := io.Pipe()
emitErr := make(chan error, 1)
var entries, bytesOut int64
go func() {
n, b, err := archivepkg.WriteAsTar(data, originalName, pw)
entries, bytesOut = n, b
_ = pw.CloseWithError(err)
emitErr <- err
}()
if err := uc.Runtime.CopyTarToContainer(ctx, uc.ContainerID, destAbs, pr); err != nil {
// Prefer the encoder's error when it carries archive-format
// detail — but NOT when it's just the pipe slamming shut
// because Docker aborted the copy (that would mask the real
// error, e.g. "destination directory does not exist").
if encErr := <-emitErr; encErr != nil && !errors.Is(encErr, io.ErrClosedPipe) {
return entries, bytesOut, encErr
}
return entries, bytesOut, fmt.Errorf("copy to container %s: %w", destAbs, err)
}
if encErr := <-emitErr; encErr != nil {
return entries, bytesOut, encErr
}
return entries, bytesOut, nil
}
if uc.DataPath == "" {
return 0, 0, fmt.Errorf("no storage available: container has no BrowseableRoot and instance has no DataPath")
}
destAbs := filepath.Join(uc.DataPath, filepath.FromSlash(targetPath))
if err := os.MkdirAll(destAbs, 0o755); err != nil {
return 0, 0, fmt.Errorf("mkdir %s: %w", destAbs, err)
}
return archivepkg.Walk(data, originalName, func(name string, mode int64, isDir bool, body io.Reader) error {
safe := archivepkg.SafeEntryPath(name)
if safe == "" {
return nil
}
out := filepath.Join(destAbs, filepath.FromSlash(safe))
if isDir {
return os.MkdirAll(out, 0o755)
}
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
perm := os.FileMode(mode & 0o777)
if perm == 0 {
perm = 0o644
}
f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
if err != nil {
return err
}
if _, err := io.Copy(f, body); err != nil {
_ = f.Close()
return err
}
return f.Close()
})
}
+194
View File
@@ -0,0 +1,194 @@
package updater
import (
"archive/tar"
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/ulikunitz/xz"
modulepkg "github.com/dbledeez/panel/pkg/module"
)
// makeFactorioTarXz builds a tiny tar.xz shaped like factorio's
// headless tarball (factorio/bin/x64/factorio).
func makeFactorioTarXz(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
xw, err := xz.NewWriter(&buf)
if err != nil {
t.Fatal(err)
}
tw := tar.NewWriter(xw)
body := []byte("ELF-not-really")
if err := tw.WriteHeader(&tar.Header{Name: "factorio/bin/x64/factorio", Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(body); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := xw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func testCtx(t *testing.T, dataPath string) *Context {
t.Helper()
return &Context{
InstanceID: "test",
DataPath: dataPath,
Log: func(string) {},
}
}
// The factorio case: archive content + extensionless URL + extract:true
// must land the extracted tree under TargetPath, not a raw blob.
func TestDirectExtractsArchiveIntoTargetDir(t *testing.T) {
blob := makeFactorioTarXz(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(blob)
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/get-download/stable/headless/linux64", TargetPath: "/game", Extract: true}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "game", "factorio", "bin", "x64", "factorio")
data, err := os.ReadFile(bin)
if err != nil {
t.Fatalf("expected extracted binary at %s: %v", bin, err)
}
if string(data) != "ELF-not-really" {
t.Fatalf("binary content mismatch: %q", data)
}
// The old bug: raw blob written AS the target path.
if fi, err := os.Stat(filepath.Join(dir, "game")); err != nil || !fi.IsDir() {
t.Fatalf("target path should be a directory, got err=%v", err)
}
}
// Same archive, no explicit extract flag: extensionless TargetPath
// still triggers extraction (directory heuristic).
func TestDirectHeuristicExtractsWithoutFlag(t *testing.T) {
blob := makeFactorioTarXz(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(blob)
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/linux64", TargetPath: "/game"}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "game", "factorio", "bin", "x64", "factorio")); err != nil {
t.Fatalf("expected extraction: %v", err)
}
}
// The terraria/minecraft guard: a TargetPath WITH a file extension is
// written verbatim even when the content has archive magic (jars are
// zips; terraria's entrypoint unzips its own .zip).
func TestDirectWritesArchiveVerbatimWhenTargetHasExtension(t *testing.T) {
// A zip blob (like a .jar or terraria-server zip).
var zipBuf bytes.Buffer
zipBuf.Write([]byte{0x50, 0x4b, 0x03, 0x04}) // zip local-header magic
zipBuf.WriteString("rest-of-zip-not-parsed")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(zipBuf.Bytes())
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/terraria-server-1449.zip", TargetPath: "/terraria-server.zip"}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "terraria-server.zip"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, zipBuf.Bytes()) {
t.Fatal("zip should have been written verbatim")
}
}
// extract:true on non-archive content is a hard error, not a silent
// verbatim write.
func TestDirectExtractFlagOnNonArchiveErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("just text"))
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/blob", TargetPath: "/game", Extract: true}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err == nil {
t.Fatal("expected error for extract on non-archive content")
}
}
// containerTargetAbs: absolute targets matching a declared root or
// volume mount stay container-absolute (factorio's /game volume);
// anything else keeps the historical BrowseableRoot join (terraria's
// /terraria-server.zip → /game-saves/terraria-server.zip).
func TestContainerTargetAbs(t *testing.T) {
uc := &Context{
BrowseableRoot: "/game-saves",
Manifest: &modulepkg.Manifest{
Runtime: modulepkg.Runtime{Docker: &modulepkg.DockerSpec{
BrowseableRoots: []modulepkg.BrowseableRoot{{Name: "Saves", Path: "/game-saves"}},
Volumes: []modulepkg.Volume{
{Type: "volume", Name: "panel-$INSTANCE_ID-saves", Container: "/game-saves"},
{Type: "volume", Name: "panel-$INSTANCE_ID-game", Container: "/game"},
},
}},
},
}
cases := map[string]string{
"/game": "/game",
"/game/sub": "/game/sub",
"/game-saves/mods": "/game-saves/mods",
"/terraria-server.zip": "/game-saves/terraria-server.zip",
"relative/file": "/game-saves/relative/file",
}
for in, want := range cases {
if got := containerTargetAbs(uc, in); got != want {
t.Errorf("containerTargetAbs(%q) = %q, want %q", in, got, want)
}
}
}
// Non-archive content with an extensionless target keeps the old
// single-file write behavior.
func TestDirectNonArchiveStillWritesFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("binary-ish payload"))
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/blob", TargetPath: "/some/file"}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "some", "file"))
if err != nil {
t.Fatal(err)
}
if string(data) != "binary-ish payload" {
t.Fatalf("content mismatch: %q", data)
}
}
+108
View File
@@ -0,0 +1,108 @@
package updater
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"time"
modulepkg "github.com/dbledeez/panel/pkg/module"
"github.com/dbledeez/panel/pkg/version"
)
// GitHubProvider queries a repo's latest release, finds an asset whose
// name matches the provider's AssetRegex, and writes it to TargetPath.
type GitHubProvider struct{}
// Name identifies this provider kind in logs / manifest.
func (GitHubProvider) Name() string { return "github" }
type githubAsset struct {
Name string `json:"name"`
DownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
}
type githubRelease struct {
Name string `json:"name"`
TagName string `json:"tag_name"`
Assets []githubAsset `json:"assets"`
}
// Update fetches the latest release of spec.Repo, picks the first asset
// matching spec.AssetRegex, and writes its bytes to spec.TargetPath.
func (GitHubProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
if spec.Repo == "" {
return fmt.Errorf("github provider: repo is required (owner/name)")
}
if spec.AssetRegex == "" {
return fmt.Errorf("github provider: asset_regex is required")
}
if spec.TargetPath == "" {
return fmt.Errorf("github provider: target_path is required")
}
re, err := regexp.Compile(spec.AssetRegex)
if err != nil {
return fmt.Errorf("asset_regex: %w", err)
}
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", spec.Repo)
uc.Log(fmt.Sprintf("querying GitHub: %s", apiURL))
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", version.UserAgent())
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("github api %d: %s", resp.StatusCode, string(body))
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return fmt.Errorf("decode release: %w", err)
}
uc.Log(fmt.Sprintf("latest release: %s (%s), %d assets", release.Name, release.TagName, len(release.Assets)))
var chosen *githubAsset
for i := range release.Assets {
if re.MatchString(release.Assets[i].Name) {
chosen = &release.Assets[i]
break
}
}
if chosen == nil {
return fmt.Errorf("no asset matches %q (saw: %s)", spec.AssetRegex, assetNames(release.Assets))
}
uc.Log(fmt.Sprintf("downloading asset %s (%d bytes)", chosen.Name, chosen.Size))
direct := &modulepkg.UpdateProvider{
Kind: "direct",
URL: chosen.DownloadURL,
TargetPath: spec.TargetPath,
}
return DirectProvider{}.Update(ctx, uc, direct)
}
func assetNames(as []githubAsset) string {
out := ""
for _, a := range as {
if out != "" {
out += ", "
}
out += a.Name
}
if out == "" {
out = "(none)"
}
return out
}
+90
View File
@@ -0,0 +1,90 @@
// Package updater implements the Generic Update system: pluggable
// providers that fetch new versions of game files from SteamCMD,
// GitHub releases, or a direct URL, then lay them down under the
// instance's data path (bind mount) or container volume.
//
// The panel's manifest declares one or more update_providers per module;
// the operator picks one to trigger. Each provider runs on the *Agent*
// (close to the data) and streams progress back as LogLine envelopes
// so the dashboard renders updates in the same events pane as normal
// server output.
package updater
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"github.com/dbledeez/panel/agent/internal/runtime"
modulepkg "github.com/dbledeez/panel/pkg/module"
)
// Context is the environment an update runs in.
type Context struct {
InstanceID string
Manifest *modulepkg.Manifest
ContainerID string // empty if container doesn't exist; present for running + stopped
BrowseableRoot string // container-absolute install root, when applicable
DataPath string // host bind-mount root, fallback
Runtime runtime.Runtime // for container file ops + sidecar launches (future)
Log func(line string) // streams a progress line upstream via LogLine
// Steam login credentials for SteamCMD apps that refuse +login
// anonymous (DayZ, Arma, etc.). Populated by the controller from its
// encrypted steam_credentials store, forwarded via the mTLS-protected
// gRPC stream. The agent redacts SteamPassword from log output before
// it ever hits the bus; see steamcmd.go.
SteamUsername string
SteamPassword string
}
// Provider performs an update according to its kind.
type Provider interface {
Name() string
Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error
}
// Get returns the Provider implementation for the given kind, or an
// error if the kind is unknown.
func Get(kind string) (Provider, error) {
switch kind {
case "direct":
return &DirectProvider{}, nil
case "github":
return &GitHubProvider{}, nil
case "steamcmd":
return &SteamCMDProvider{}, nil
default:
return nil, fmt.Errorf("unknown update provider kind %q", kind)
}
}
// writeToInstance puts data at the instance-relative path. Prefers
// container-path ops when the instance has a BrowseableRoot and a live
// container; otherwise falls back to writing under DataPath.
func writeToInstance(ctx context.Context, uc *Context, targetPath string, data []byte) error {
if targetPath == "" {
return fmt.Errorf("target_path is required")
}
if uc.BrowseableRoot != "" && uc.ContainerID != "" {
abs := path.Join(uc.BrowseableRoot, targetPath)
// Ensure parent dir exists inside the container via runtime exec.
// We don't have a helper in the interface; we just try to write
// and rely on CopyFileToContainer returning a clear error if the
// parent is missing. (itzg and most images pre-create /data.)
return uc.Runtime.CopyFileToContainer(ctx, uc.ContainerID, abs, data)
}
if uc.DataPath == "" {
return fmt.Errorf("no storage available: container has no BrowseableRoot and instance has no DataPath")
}
abs := filepath.Join(uc.DataPath, filepath.FromSlash(targetPath))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(abs), err)
}
if err := os.WriteFile(abs, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", abs, err)
}
return nil
}
+332
View File
@@ -0,0 +1,332 @@
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
}