panel — open-source game server manager (public release)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:19:43 -07:00
commit 5232609719
2160 changed files with 300415 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
package dispatch
import (
"context"
"errors"
"fmt"
"time"
agentmodule "github.com/dbledeez/panel/agent/internal/module"
"github.com/dbledeez/panel/agent/internal/runtime"
"github.com/dbledeez/panel/agent/internal/updater"
modulepkg "github.com/dbledeez/panel/pkg/module"
)
// installBaseMods runs a SteamCMD validate (if the module has a steamcmd
// update provider) followed by an alpine sidecar that downloads + installs
// the third-party 7DTD base-mod set:
//
// - Allocs Server Fixes — tarball from illy.bz (canonical upstream).
// Contains Allocs_CommandExtensions + Allocs_WebAndMapRendering +
// Alloc_XmlPatch, all under a Mods/ prefix when extracted to /game.
// - PrismaCore — latest GitHub release .zip from Prisma501/PrismaCore.
//
// SteamCMD validate step (new):
// - If the module declares at least one update provider with kind="steamcmd",
// the first such provider is used to run `app_update <app_id> validate`
// with anonymous login BEFORE the alpine sidecar runs. This re-downloads
// or restores stock TFP mods (TFP_*, 0_TFP_*, xMarkers) that ship with
// the dedicated server install.
// - If the SteamCMD validate fails, the function returns an error and does
// NOT proceed to the alpine step.
// - If there is no steamcmd provider (e.g., Conan Exiles), the validate is
// skipped and the function goes straight to the alpine path.
//
// What the alpine step does NOT touch (and does not need to):
// - TFP_* / 0_TFP_* — shipped by SteamCMD as part of the dedicated
// server install. TFP_Harmony is the runtime patcher; wiping it
// would brick the engine. Always present in /game/Mods/ after a
// fresh `app_update 294420`.
// - xMarkers — also ships with the SteamCMD 7DTD install per the
// operator (2026-05-04). Already in /game/Mods/ after install.
// - Custom mods (RefugeBot, AGF-VP-*, AsylumRoboticInbox, anything not
// in the recognized base-mod name set). Those are operator territory.
//
// Behavior on existing installs: any directory with a name that matches
// the incoming mod gets renamed to `<name>.bak-<UTCstamp>` first so a
// re-run is reversible. Direct port of the safety pattern in
// refugebotserver's DependencyInstaller.cs.
//
// Caller responsibility: the instance container MUST be stopped before
// calling this — the game holds DLLs in /game/Mods open while running,
// and rename-on-extract will fail or corrupt under that.
func (d *Dispatcher) installBaseMods(ctx context.Context, instanceID string, manifest *modulepkg.Manifest, dataPath string) error {
if manifest == nil || len(manifest.UpdateProviders) == 0 {
return errors.New("install-base-mods: module has no update provider")
}
installPath := manifest.UpdateProviders[0].InstallPath
if installPath == "" {
installPath = manifest.Runtime.Docker.BrowseableRoot
}
if installPath == "" {
return errors.New("install-base-mods: install_path missing on module")
}
volumes := agentmodule.ResolveVolumes(manifest, instanceID, dataPath)
gameVol := volumeNameForPath(volumes, installPath)
if gameVol == "" {
return fmt.Errorf("install-base-mods: install_path %q not in module volumes", installPath)
}
// ── SteamCMD validate step ──────────────────────────────────────────
// If the module has a steamcmd update provider, run app_update validate
// with anonymous login FIRST to re-download/restore stock TFP mods.
// This ensures TFP_*, 0_TFP_*, and xMarkers are present before the
// alpine sidecar installs third-party base mods.
var steamcmdSpec *modulepkg.UpdateProvider
for i := range manifest.UpdateProviders {
if manifest.UpdateProviders[i].Kind == "steamcmd" {
steamcmdSpec = &manifest.UpdateProviders[i]
break
}
}
if steamcmdSpec != nil {
d.log.Info("install-base-mods: steamcmd validate starting", "instance_id", instanceID, "app_id", steamcmdSpec.AppID)
prov, err := updater.Get("steamcmd")
if err != nil {
return fmt.Errorf("install-base-mods: get steamcmd provider: %w", err)
}
// Build a log sink that streams through the same "install-base-mods log" logger.
sink := func(line string) {
d.log.Info("install-base-mods log", "instance_id", instanceID, "line", line)
}
uc := &updater.Context{
InstanceID: instanceID,
Manifest: manifest,
BrowseableRoot: installPath,
DataPath: dataPath,
Runtime: d.runtime,
Log: sink,
SteamUsername: "", // anonymous login
SteamPassword: "", // anonymous login
}
if err := prov.Update(ctx, uc, steamcmdSpec); err != nil {
return fmt.Errorf("basemods: steamcmd validate failed: %w", err)
}
d.log.Info("install-base-mods: steamcmd validate succeeded", "instance_id", instanceID)
}
// alpine:3.19 is a 7 MB image with apk available. apk add fetches
// curl + unzip + tar + jq in ~5s on a warm cache. Cheaper than
// shipping our own base image and good enough for occasional runs.
script := `set -e
apk add --no-cache --quiet curl tar unzip jq ca-certificates >/dev/null
STAMP=$(date -u +%Y%m%d%H%M%S)
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
backup_if_exists() {
for d in "$@"; do
if [ -d "$d" ] && [ ! -L "$d" ]; then
mv "$d" "$d.bak-$STAMP" 2>/dev/null || true
fi
done
}
# ── Allocs Server Fixes ───────────────────────────────────────────────
echo "[basemods] downloading Allocs from illy.bz"
curl -fsSL -o "$TMP/allocs.tgz" "https://illy.bz/fi/7dtd/server_fixes.tar.gz"
# Magic-byte check: gzip starts with 0x1f 0x8b — protects against
# CDN-served HTML 404 pages landing as a "valid" archive.
head -c 2 "$TMP/allocs.tgz" | od -An -tx1 | tr -d ' \n' | grep -q '^1f8b' \
|| { echo "[basemods] Allocs download wasn't gzip — aborting"; exit 1; }
backup_if_exists /game/Mods/Allocs_CommandExtensions /game/Mods/Allocs_WebAndMapRendering /game/Mods/Alloc_XmlPatch
tar -xzf "$TMP/allocs.tgz" -C /game
echo "[basemods] Allocs installed"
# ── PrismaCore (latest GitHub release zip) ─────────────────────────────
echo "[basemods] resolving latest PrismaCore release"
PRISMA_JSON="$TMP/prisma.json"
curl -fsSL -A "panel-basemods/1.0" -H "Accept: application/vnd.github+json" \
-o "$PRISMA_JSON" "https://api.github.com/repos/Prisma501/PrismaCore/releases/latest"
PRISMA_TAG=$(jq -r '.tag_name // empty' "$PRISMA_JSON")
PRISMA_URL=$(jq -r '.assets[]? | select(.name | test("\\.zip$"; "i")) | .browser_download_url' "$PRISMA_JSON" | head -1)
if [ -z "$PRISMA_URL" ]; then
echo "[basemods] no PrismaCore .zip asset — skipping"
else
echo "[basemods] downloading PrismaCore $PRISMA_TAG from $PRISMA_URL"
curl -fsSL -A "panel-basemods/1.0" -o "$TMP/prisma.zip" "$PRISMA_URL"
head -c 2 "$TMP/prisma.zip" | od -An -tx1 | tr -d ' \n' | grep -q '^504b' \
|| { echo "[basemods] PrismaCore download wasn't a zip — aborting"; exit 1; }
for d in /game/Mods/Prisma*; do backup_if_exists "$d"; done
unzip -o -q "$TMP/prisma.zip" -d /game/Mods/
echo "[basemods] PrismaCore installed"
fi
# Note: TFP_* and xMarkers are shipped as part of the SteamCMD dedicated
# server install (app 294420). They're already in /game/Mods/ after a
# fresh install and we don't touch them here — wiping TFP_Harmony would
# brick the engine.
echo BASEMODS_OK
`
sidecarID := fmt.Sprintf("%s-basemods", instanceID)
spec := runtime.InstanceSpec{
InstanceID: sidecarID,
IsSidecar: true,
Image: "alpine:3.19",
Command: []string{"sh", "-c", script},
Volumes: []runtime.VolumeSpec{
{Type: "volume", VolumeName: gameVol, ContainerPath: installPath},
},
RestartPolicy: "no",
}
d.log.Info("install-base-mods: starting sidecar", "instance_id", instanceID, "vol", gameVol)
contID, err := d.runtime.Create(ctx, spec)
if err != nil {
return fmt.Errorf("install-base-mods: create: %w", err)
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = d.runtime.Remove(rmCtx, contID)
}()
if err := d.runtime.Start(ctx, contID); err != nil {
return fmt.Errorf("install-base-mods: start: %w", err)
}
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
d.log.Info("install-base-mods log", "instance_id", instanceID, "line", line)
})
}()
exitCode, err := d.runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
return fmt.Errorf("install-base-mods: wait: %w", err)
}
if exitCode != 0 {
return fmt.Errorf("install-base-mods: sidecar exited %d", exitCode)
}
d.log.Info("install-base-mods: success", "instance_id", instanceID)
return nil
}