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 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 `.bak-` 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 }