Files
panel/agent/internal/dispatch/dispatch.go
T
2026-07-14 23:37:25 -07:00

2092 lines
83 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package dispatch ties together the Target-side components: it receives
// ControllerEnvelope messages off the gRPC stream, routes them to the
// module registry and the active Runtime, and emits AgentEnvelope state
// updates back to the Controller.
//
// Survivability:
//
// - On successful Create, dispatch writes a sidecar JSON in metaDir so
// the agent can re-attach to the running container after a restart.
// - On Stop, the sidecar is deleted.
// - On agent startup, Rehydrate() scans sidecars, verifies each
// container still exists, and rebuilds the in-memory instance map.
// - On every (re)connect, SetSender + Announce re-plumb the upward
// pipe and re-send current state so the controller's DB stays honest.
package dispatch
import (
"context"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
agentmodule "github.com/dbledeez/panel/agent/internal/module"
"github.com/dbledeez/panel/agent/internal/runtime"
"github.com/dbledeez/panel/agent/internal/rcon"
"github.com/dbledeez/panel/agent/internal/state"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// Sender is the subset of the gRPC stream we use to send events upstream.
type Sender interface {
Send(*panelv1.AgentEnvelope) error
}
// Dispatcher wires the gRPC stream to the runtime and module registry.
type Dispatcher struct {
log *slog.Logger
modules *modulepkg.Registry
runtime runtime.Runtime
dataRoot string
metaDir string
backupDir string
sendMu sync.Mutex
send Sender
// sendCh decouples envelope producers (handler goroutines, trackers,
// log streams) from the actual gRPC Send. A single drainer goroutine
// owns the stream's Send so concurrent producers never race on it
// (grpc ClientStream.Send is NOT safe for concurrent use) and never
// block the dispatch path when the controller is slow to drain. High-
// frequency telemetry (logs/stats/app-state/player/heartbeat) is
// DROPPED when the buffer is full rather than blocking — losing a log
// line is fine (the controller's durable state comes from Announce +
// InstanceState, not replayed history); freezing the agent is not.
// This is what stopped a chatty game (VEIN) from wedging the whole
// agent fleet-wide via a backpressured synchronous Send.
sendCh chan *panelv1.AgentEnvelope
sendClosed chan struct{}
mu sync.Mutex
instances map[string]*instanceRecord
// fsHelpers holds one long-lived alpine sidecar per stopped instance
// whose Files tab has been opened. Lets operators browse/edit files
// on a stopped container. Reaped after ~5 min idle; torn down when
// the main instance starts (frees up volumes for the game).
fsHelpers map[string]*fsHelper
// updaters tracks the active updater goroutine per instance so a
// stop / delete can cancel an in-flight steamcmd download. Without
// this the sidecar runs to completion (or its 30-min timeout)
// holding the volume mount, blocking the main delete from purging
// — which the operator sees as "stuck downloading" with the parent
// already gone. Each entry holds a unique pointer so the
// unregister path can compare against the slot it installed.
updaters map[string]*updaterSlot
// uploads holds in-progress chunked uploads keyed by upload_id.
// Each entry owns a temp file the chunk handler appends to; on the
// final chunk the file is shipped to the container, closed and
// unlinked. See files_chunked.go.
uploads map[string]*uploadSession
}
// updaterSlot pairs the cancel func with a unique pointer identity so
// concurrent unregisters from racing updater goroutines don't clobber
// each other. Comparing pointers is cheap and correct.
type updaterSlot struct {
cancel context.CancelFunc
}
// fsHelper tracks one file-ops sidecar: alpine mounting the instance's
// volumes read/write, running `sleep infinity`.
type fsHelper struct {
containerID string
lastAccess time.Time
}
type instanceRecord struct {
InstanceID string
ContainerID string
ModuleID string
// Branch is the normalized Steam branch this instance was installed from
// (e.g. "v2.6", "latest_experimental", "public"), derived at Create from
// the chosen update_provider. Used by warm-seed so a new instance only
// reuses an install from a sibling on the SAME branch — copying a 3.0
// (experimental) install into a server meant to run 2.6 would corrupt it.
// Empty on instances created before this feature (treated as the module
// default branch by branchOrDefault).
Branch string
DataPath string
// BrowseableRoot is the default (first) file-manager root —
// container-absolute. BrowseableRoots is the full list; when the
// module exposes more than one view (7DTD: saves + game files),
// safeJoinContainer accepts any of them. Always contains at least
// BrowseableRoot.
BrowseableRoot string
BrowseableRoots []modulepkg.BrowseableRoot
LogCancel context.CancelFunc
// ExitWatchCancel single-flights the watchExit goroutine per container
// lifetime. Before this guard, every activate() call — handleStart,
// Announce on EVERY controller reconnect, the post-warm-seed autostart —
// spawned a fresh watchExit, each parked in a ctx-unbounded
// ContainerWait: leaked goroutines + leaked docker connections (the same
// failure shape as the ExecCapture leak) + duplicate crashed/exited
// emissions when the container finally died. Guarded by d.mu like the
// other cancel fields.
ExitWatchCancel context.CancelFunc
// RCON wire-up, resolved at Create time, consumed at Start time.
RCONAddr string
RCONPassword string
// State tracker and its cancel, populated on Start.
Tracker *state.Tracker
TrackerCancel context.CancelFunc
TrackerDone chan struct{}
// stopping suppresses watchExit's CRASHED emission during graceful stop.
stopping atomic.Bool
// memGuardWarned latches true the first time the 45 GB warn path
// fires on this container. Both paths (warn + hard) check this to
// avoid stacking SIGKILL on top of an in-flight graceful save.
memGuardWarned atomic.Bool
// lastStatus tracks the most recent InstanceStatus the agent sent
// upstream for this instance. Read by EmitAppState to promote a stale
// CRASHED status back to RUNNING when state-source polls succeed —
// the docker-restart-policy auto-recovery flip in watchExit doesn't
// fire when a server segfaults during world-gen but stabilizes on
// retry, leaving the panel stuck on CRASHED until manual stop+start.
lastStatus atomic.Int32
// hangGuard is the per-instance state for the ark-sa hang-detection
// guardrail. Embedded by value so its zero value is usable. Methods
// on Dispatcher (arkHangGuardOn{LogLine,RconResult}, arkHangGuardRestart)
// own the field; nothing outside the guard touches it. See hangguard.go.
hangGuard hangGuardState
// provisionGuard single-flights the 7dtd cluster auto-provision watcher
// (one per instance at a time). See decoremap_provision.go.
provisionGuard atomic.Bool
}
// New constructs a dispatcher. The caller sets the sender via SetSender
// after the initial handshake (and on each reconnect).
func New(log *slog.Logger, modules *modulepkg.Registry, rt runtime.Runtime, dataRoot, metaDir, backupDir string) *Dispatcher {
d := &Dispatcher{
log: log,
modules: modules,
runtime: rt,
dataRoot: dataRoot,
metaDir: metaDir,
backupDir: backupDir,
instances: map[string]*instanceRecord{},
fsHelpers: map[string]*fsHelper{},
updaters: map[string]*updaterSlot{},
uploads: map[string]*uploadSession{},
// Buffer sized to absorb a burst of telemetry from a chatty game
// while the controller catches up. Beyond this, droppable envelopes
// are discarded (see sendEnv). 4096 is generous for ~dozens of
// instances; a full buffer means the controller is badly behind and
// dropping stale logs/stats is the right call.
sendCh: make(chan *panelv1.AgentEnvelope, 4096),
}
go d.fsHelperReaper()
go d.uploadReaper()
go d.sendLoop()
return d
}
// sendLoop is the SOLE owner of stream.Send. It drains sendCh and writes each
// envelope to the current sender. Running on one goroutine guarantees Sends
// are serialized (grpc ClientStream.Send is not safe for concurrent use) and
// that a slow/backpressured controller can never block a producer past the
// buffer. Envelopes enqueued while disconnected (send == nil) are dropped —
// the controller rebuilds durable state from Announce on reconnect.
func (d *Dispatcher) sendLoop() {
for env := range d.sendCh {
d.sendMu.Lock()
s := d.send
d.sendMu.Unlock()
if s == nil {
continue
}
_ = s.Send(env)
}
}
// Modules exposes the registry so main() can summarize it into the
// AgentHello envelope. Read-only from the caller's perspective.
func (d *Dispatcher) Modules() *modulepkg.Registry { return d.modules }
// SetSender installs (or replaces) the upstream sender after a
// (re)connect. Safe to call from any goroutine.
func (d *Dispatcher) SetSender(s Sender) {
d.sendMu.Lock()
d.send = s
d.sendMu.Unlock()
}
// sendEnv enqueues an envelope for the sendLoop drainer instead of calling
// stream.Send inline. This keeps producers (handler goroutines, trackers, log
// streams) off the gRPC send path entirely: they never race on Send and never
// block when the controller is slow to drain.
//
// Backpressure policy: high-frequency telemetry (logs / stats / app-state /
// player / heartbeat) is DROPPED if the buffer is full — losing a log line is
// harmless, wedging the agent is not. Low-frequency CRITICAL envelopes
// (command replies, instance-state, FS/backup/etc. results) are never dropped:
// if the buffer is somehow full they block briefly until space frees. Because
// critical envelopes are rare, this can't cause the flood-driven wedge that a
// chatty game's telemetry would.
//
// Messages emitted while disconnected (send == nil) are still effectively
// dropped by sendLoop — intentional, the controller rebuilds durable state
// from Announce() on reconnect, not replayed history.
func (d *Dispatcher) sendEnv(env *panelv1.AgentEnvelope) {
if isDroppableEnvelope(env) {
select {
case d.sendCh <- env:
default:
// Buffer full — drop this telemetry frame rather than block.
}
return
}
// Critical envelope: enqueue, blocking only if the buffer is full (rare).
d.sendCh <- env
}
// SendHeartbeat enqueues a heartbeat envelope on the droppable send queue.
// Routing heartbeats through sendLoop keeps it the SOLE owner of stream.Send:
// previously main.go's session ticker called stream.Send directly, racing the
// drainer goroutine (grpc ClientStream.Send is NOT safe for concurrent use).
// Heartbeats are droppable telemetry — a skipped beat under backpressure is
// harmless. Note the tradeoff: a broken stream is now detected by the recv
// loop (stream.Recv errors when the transport dies), not by a failed
// heartbeat send.
func (d *Dispatcher) SendHeartbeat(t time.Time) {
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.New(t),
Payload: &panelv1.AgentEnvelope_Heartbeat{
Heartbeat: &panelv1.Heartbeat{At: timestamppb.New(t)},
},
})
}
// isDroppableEnvelope reports whether an envelope is high-frequency telemetry
// that's safe to discard under backpressure. Everything not listed here is
// treated as critical (command replies, instance state, FS/backup results,
// hello/announce) and is never dropped.
func isDroppableEnvelope(env *panelv1.AgentEnvelope) bool {
switch env.GetPayload().(type) {
case *panelv1.AgentEnvelope_Log,
*panelv1.AgentEnvelope_InstanceStats,
*panelv1.AgentEnvelope_AppState,
*panelv1.AgentEnvelope_Player,
*panelv1.AgentEnvelope_Heartbeat:
return true
default:
return false
}
}
// Handle routes a single inbound envelope.
func (d *Dispatcher) Handle(ctx context.Context, env *panelv1.ControllerEnvelope) {
switch p := env.Payload.(type) {
case *panelv1.ControllerEnvelope_Ping:
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: env.CorrelationId,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Pong{Pong: &panelv1.Pong{Nonce: p.Ping.Nonce}},
})
case *panelv1.ControllerEnvelope_InstanceCreate:
d.handleCreate(ctx, env.CorrelationId, p.InstanceCreate)
case *panelv1.ControllerEnvelope_InstanceStart:
d.handleStart(ctx, env.CorrelationId, p.InstanceStart)
case *panelv1.ControllerEnvelope_InstanceStop:
d.handleStop(ctx, env.CorrelationId, p.InstanceStop)
case *panelv1.ControllerEnvelope_InstanceDelete:
d.handleDelete(ctx, env.CorrelationId, p.InstanceDelete)
case *panelv1.ControllerEnvelope_Backup:
d.handleBackup(ctx, env.CorrelationId, p.Backup)
case *panelv1.ControllerEnvelope_Restore:
d.handleRestore(ctx, env.CorrelationId, p.Restore)
case *panelv1.ControllerEnvelope_BackupListEntries:
d.handleBackupListEntries(env.CorrelationId, p.BackupListEntries)
case *panelv1.ControllerEnvelope_BackupReadFile:
d.handleBackupReadFile(env.CorrelationId, p.BackupReadFile)
case *panelv1.ControllerEnvelope_Exec:
// No exec implementation yet — tell the caller instead of logging
// and silently succeeding (the controller's Await would otherwise
// hang until timeout with no signal why).
d.log.Warn("exec requested but not implemented", "instance_id", p.Exec.InstanceId)
d.replyError(env.CorrelationId, p.Exec.InstanceId, "exec_not_implemented", "exec is not implemented on this agent")
case *panelv1.ControllerEnvelope_Rcon:
d.handleRCON(ctx, env.CorrelationId, p.Rcon)
case *panelv1.ControllerEnvelope_FsList:
d.handleFsList(env.CorrelationId, p.FsList)
case *panelv1.ControllerEnvelope_FsRead:
d.handleFsRead(env.CorrelationId, p.FsRead)
case *panelv1.ControllerEnvelope_FsWrite:
d.handleFsWrite(env.CorrelationId, p.FsWrite)
case *panelv1.ControllerEnvelope_FsDelete:
d.handleFsDelete(env.CorrelationId, p.FsDelete)
case *panelv1.ControllerEnvelope_FsSymlink:
d.handleFsSymlink(env.CorrelationId, p.FsSymlink)
case *panelv1.ControllerEnvelope_FsExtract:
d.handleFsExtract(env.CorrelationId, p.FsExtract)
case *panelv1.ControllerEnvelope_FsCompress:
d.handleFsCompress(env.CorrelationId, p.FsCompress)
case *panelv1.ControllerEnvelope_FsRename:
d.handleFsRename(env.CorrelationId, p.FsRename)
case *panelv1.ControllerEnvelope_FsWriteChunk:
d.handleFsWriteChunk(env.CorrelationId, p.FsWriteChunk)
case *panelv1.ControllerEnvelope_ArkSaveRestore:
d.handleArkSaveRestore(env.CorrelationId, p.ArkSaveRestore)
case *panelv1.ControllerEnvelope_DayzModInstall:
d.handleDayzModInstall(env.CorrelationId, p.DayzModInstall)
case *panelv1.ControllerEnvelope_DayzModUninstall:
d.handleDayzModUninstall(env.CorrelationId, p.DayzModUninstall)
case *panelv1.ControllerEnvelope_Update:
d.handleUpdate(env.CorrelationId, p.Update)
case *panelv1.ControllerEnvelope_EmpyrionScenarioInstall:
d.handleEmpyrionScenarioInstall(env.CorrelationId, p.EmpyrionScenarioInstall)
case *panelv1.ControllerEnvelope_EmpyrionDiscoveries:
d.handleEmpyrionDiscoveries(env.CorrelationId, p.EmpyrionDiscoveries)
case *panelv1.ControllerEnvelope_EmpyrionPlayerSummary:
d.handleEmpyrionPlayerSummary(env.CorrelationId, p.EmpyrionPlayerSummary)
case *panelv1.ControllerEnvelope_RegionScan:
d.handleRegionScan(ctx, env.CorrelationId, p.RegionScan)
case *panelv1.ControllerEnvelope_RegionHeal:
d.handleRegionHeal(ctx, env.CorrelationId, p.RegionHeal)
case *panelv1.ControllerEnvelope_InstanceRenderConfig:
d.handleRenderConfig(ctx, env.CorrelationId, p.InstanceRenderConfig)
case *panelv1.ControllerEnvelope_SeedMods:
d.handleSeedMods(ctx, env.CorrelationId, p.SeedMods)
case *panelv1.ControllerEnvelope_UpdateCheck:
d.handleUpdateCheck(env.CorrelationId, p.UpdateCheck)
default:
d.log.Debug("unhandled controller message", "type", fmt.Sprintf("%T", env.Payload))
}
}
// Shutdown winds down agent-internal goroutines on SIGINT/SIGTERM but
// LEAVES the game containers running. A `systemctl restart panel-agent`
// shouldn't stop every ARK / 7DTD / V Rising the operator has up — the
// agent is a control-plane process; the game containers are workloads
// that should survive control-plane restarts the same way they survive
// gRPC blips. Earlier behaviour (Stop every instance) caused half a
// dozen game containers to drop out from under players any time the
// agent binary was redeployed.
//
// What we still tear down: state trackers + log-stream pumps, since
// those are agent-side goroutines holding open RCON dials and Docker
// log subscriptions that the next agent process will re-establish on
// rehydrate. The Docker container itself stays untouched.
func (d *Dispatcher) Shutdown(_ context.Context) {
// Snapshot the cancel funcs under d.mu (they're mutated under the same
// mutex by activate/handleStop), then invoke them outside the lock.
d.mu.Lock()
cancels := make([]context.CancelFunc, 0, len(d.instances)*3)
n := 0
for _, r := range d.instances {
n++
for _, c := range []context.CancelFunc{r.TrackerCancel, r.LogCancel, r.ExitWatchCancel} {
if c != nil {
cancels = append(cancels, c)
}
}
r.TrackerCancel = nil
r.LogCancel = nil
r.ExitWatchCancel = nil
}
d.mu.Unlock()
for _, c := range cancels {
c()
}
d.log.Info("agent shutdown — game containers left running for next agent rehydrate", "instance_count", n)
}
// Rehydrate loads each sidecar in metaDir, verifies the underlying
// container still exists, and rebuilds the in-memory instance record.
// Stale sidecars (container gone) are deleted. Intended for agent cold
// start. Activation (log stream + tracker) is deferred to Announce so
// the sender is in place before we start emitting events.
func (d *Dispatcher) Rehydrate(ctx context.Context) error {
metas, err := d.loadAllMeta()
if err != nil {
return fmt.Errorf("load meta: %w", err)
}
haveMeta := make(map[string]bool, len(metas))
for _, m := range metas {
haveMeta[m.InstanceID] = true
}
for _, m := range metas {
state, err := d.runtime.InspectByName(ctx, "panel-"+m.InstanceID)
if err != nil {
d.log.Warn("rehydrate: container gone, removing sidecar", "instance_id", m.InstanceID, "err", err)
d.deleteMeta(m.InstanceID)
continue
}
// If the sidecar predates this feature, BrowseableRoot is empty.
// Fill it from the current manifest so rehydrated instances gain
// container-path file ops without needing recreate.
browseable := m.BrowseableRoot
var roots []modulepkg.BrowseableRoot
if mf, ok := d.modules.Get(m.ModuleID); ok {
// Always re-read roots from current manifest — lets operators
// add new browseable_roots entries and have existing instances
// pick them up on next agent restart without a container rebuild.
roots = resolveBrowseableRoots(mf)
if browseable == "" {
browseable = resolveBrowseableRoot(mf)
}
}
d.mu.Lock()
d.instances[m.InstanceID] = &instanceRecord{
InstanceID: m.InstanceID,
ContainerID: state.ContainerID,
ModuleID: m.ModuleID,
Branch: m.Branch,
DataPath: m.DataPath,
BrowseableRoot: browseable,
BrowseableRoots: roots,
RCONAddr: m.RCONAddr,
RCONPassword: m.RCONPassword,
}
d.mu.Unlock()
d.log.Info("rehydrated instance",
"instance_id", m.InstanceID,
"module_id", m.ModuleID,
"container_status", state.Status,
)
// Reap orphan sidecar containers from a previous agent crash.
// steamcmd/backup/wipe sidecars are usually torn down by their
// goroutine's defer — but if the agent dies mid-run, the defer
// never fires and the sidecar lingers. It then holds the
// instance's volumes "in use," blocking future delete-with-purge.
//
// We only sweep EXITED containers here; running ones might be
// legitimately in use (e.g. an fs-helper sidecar an operator
// kept open). Force-removed exited orphans are safe — they
// already finished whatever they were doing.
if dr, ok := d.runtime.(*runtime.DockerRuntime); ok {
holders, err := dr.ListContainersHoldingInstanceVolumes(ctx, m.InstanceID)
if err == nil {
for _, hid := range holders {
if hid == state.ContainerID {
continue // the main instance container itself
}
// InspectByName accepts an ID too — Docker's ContainerInspect
// resolves either form. Status tells us if this is an
// exited orphan vs a still-running fs-helper.
hstate, ierr := dr.InspectByName(ctx, hid)
if ierr != nil {
continue
}
if hstate.Status == "exited" || hstate.Status == "dead" {
if err := dr.ForceRemoveContainer(ctx, hid); err == nil {
d.log.Info("rehydrate: reaped orphan sidecar", "instance_id", m.InstanceID, "container_id", short(hid))
}
}
}
}
}
}
// Orphan detection: a main game-server container (panel.role=instance)
// that exists on the runtime but has NO sidecar metadata. This is the
// fingerprint of an interrupted delete — e.g. a delete racing with
// agent shutdown, where the sidecar got removed but the container
// remove failed because the Docker connection died mid-call. Such an
// instance is fully invisible to the agent (every file/RCON/stop op
// returns "not on this target") even though the controller still
// routes to it and the world keeps running. Surface it loudly so it
// can be recovered instead of silently lost.
if dr, ok := d.runtime.(*runtime.DockerRuntime); ok {
names, lerr := dr.ListMainInstanceNames(ctx)
if lerr != nil {
d.log.Warn("rehydrate: orphan scan failed", "err", lerr)
} else {
for _, id := range names {
if haveMeta[id] {
continue
}
d.mu.Lock()
_, tracked := d.instances[id]
d.mu.Unlock()
if tracked {
continue
}
d.log.Error("rehydrate: ORPHAN container has no sidecar metadata — instance is invisible to the agent (likely an interrupted delete). Recover by recreating its sidecar or re-issuing create/delete from the controller.",
"instance_id", id, "container", "panel-"+id)
}
}
}
return nil
}
// Announce re-sends the current state of every tracked instance to the
// controller. For instances whose containers are currently running, it
// also (re)activates log streaming + the state tracker. Called after
// each (re)connect.
func (d *Dispatcher) Announce(ctx context.Context) {
d.mu.Lock()
recs := make([]*instanceRecord, 0, len(d.instances))
for _, r := range d.instances {
recs = append(recs, r)
}
d.mu.Unlock()
for _, rec := range recs {
rtState, err := d.runtime.InspectByName(ctx, "panel-"+rec.InstanceID)
if err != nil {
d.log.Warn("announce: inspect failed", "instance_id", rec.InstanceID, "err", err)
continue
}
status := rtStatusToProto(rtState.Status)
d.sendInstanceState(rec.InstanceID, status, rtState.ExitCode, "announced:"+rtState.Status)
if rtState.Status == "running" {
// On every reconnect, tear down and rebuild the log + stats
// streams. The reason: the controller's in-memory log ring buffer
// is wiped on its restart, so when the user opens the Console tab
// after a controller restart, history is blank. Docker's
// ContainerLogs API replays Tail=400 lines on every fresh attach,
// so rebuilding the streams here repopulates the controller's
// ring buffer with recent history (including the per-module
// "ready" log line, which lets the dashboard's auto-scan clear
// the "starting" overlay without needing the operator to open
// the console). Old streams keep flowing in the background until
// their goroutines notice the cancelled context and exit.
d.mu.Lock()
prevLogCancel := rec.LogCancel
rec.LogCancel = nil
d.mu.Unlock()
if prevLogCancel != nil {
prevLogCancel()
}
d.activate(rec)
}
}
}
// ---- Emitter (used by state.Tracker) ----
// EmitAppState forwards an AppStateUpdate upstream.
//
// Side effect: a successful state-source poll (the only thing that
// produces AppStateUpdate) implies the server is responsive. If the
// last status we sent was CRASHED, promote to RUNNING — handles the
// "segfault on first world-gen, healthy on retry" pattern where docker
// restart-policy auto-recovery in watchExit doesn't fire because the
// container's restart was driven by a different cause.
func (d *Dispatcher) EmitAppState(a *panelv1.AppStateUpdate) {
if a != nil && a.InstanceId != "" {
d.mu.Lock()
rec := d.instances[a.InstanceId]
d.mu.Unlock()
if rec != nil {
last := panelv1.InstanceStatus(rec.lastStatus.Load())
if last == panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED {
d.sendInstanceState(a.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING, 0,
"running (recovered — state source healthy)")
}
}
}
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_AppState{AppState: a},
})
}
// EmitPlayerEvent forwards a PlayerEvent upstream.
func (d *Dispatcher) EmitPlayerEvent(p *panelv1.PlayerEvent) {
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Player{Player: p},
})
}
// ---- Lifecycle handlers ----
// handleCreate resolves the manifest, creates the container, and records it.
// The container is NOT started here — that's InstanceStart's job.
func (d *Dispatcher) handleCreate(ctx context.Context, corrID string, req *panelv1.InstanceCreate) {
log := d.log.With("instance_id", req.InstanceId, "module_id", req.ModuleId, "correlation_id", corrID)
manifest, ok := d.modules.Get(req.ModuleId)
if !ok {
d.replyError(corrID, req.InstanceId, "module_not_found", fmt.Sprintf("module %q not in registry", req.ModuleId))
log.Warn("module not found")
return
}
if !manifest.HasMode("docker") {
d.replyError(corrID, req.InstanceId, "mode_unsupported", "module does not support docker mode")
return
}
if req.DataPath == "" {
req.DataPath = filepath.Join(d.dataRoot, req.InstanceId)
}
if err := os.MkdirAll(req.DataPath, 0o755); err != nil {
d.replyError(corrID, req.InstanceId, "data_path_mkdir", err.Error())
log.Error("mkdir data_path", "err", err)
return
}
// Expand templated paths in any mount_overrides the controller sent,
// then mkdir the resolved host path so Docker's bind-mount won't
// silently create an empty root-owned dir elsewhere.
//
// Templates understood (both are absolute after expansion):
// $AGENT_DATA_ROOT → agent's --data-root flag (where per-instance
// data lives; default ./data/instances)
// $PANEL_DATA_ROOT → parent of AGENT_DATA_ROOT — the panel's data
// root (./data on the default setup). This is
// where cross-instance shared dirs live (like
// ARK cluster transfer dirs, alongside
// instances/ and backups/).
if len(req.MountOverrides) > 0 {
panelRoot := filepath.Dir(d.dataRoot)
for k, v := range req.MountOverrides {
exp := v
exp = strings.ReplaceAll(exp, "$PANEL_DATA_ROOT", panelRoot)
exp = strings.ReplaceAll(exp, "$AGENT_DATA_ROOT", d.dataRoot)
req.MountOverrides[k] = exp
if filepath.IsAbs(exp) {
if err := os.MkdirAll(exp, 0o755); err != nil {
d.replyError(corrID, req.InstanceId, "mount_mkdir", err.Error())
log.Error("mkdir mount override", "path", exp, "err", err)
return
}
}
}
}
values, err := mergeValuesAndSecrets(manifest, req.ConfigValues)
if err != nil {
d.replyError(corrID, req.InstanceId, "secrets", err.Error())
log.Error("generate secrets", "err", err)
return
}
if err := modulepkg.RenderForBranch(manifest, req.DataPath, values, resolveCreateBranch(manifest, values)); err != nil {
d.replyError(corrID, req.InstanceId, "render", err.Error())
log.Error("render config files", "err", err)
return
}
req.ConfigValues = values
// _PANEL_SUPPRESS_AUTOSTART sentinel — controller-set marker for
// recreate dances (env-config / change-ports / cluster / mods) that
// preserve the prior running state. When true, skip the agent's
// post-create auto-start path so a stopped instance stays stopped
// after the recreate. The controller will issue an explicit Start
// itself when the prior state was "running".
suppressAutoStart := false
if v := req.ConfigValues["_PANEL_SUPPRESS_AUTOSTART"]; v == "true" {
suppressAutoStart = true
delete(req.ConfigValues, "_PANEL_SUPPRESS_AUTOSTART")
}
// _PANEL_SKIP_REINSTALL sentinel — controller-set marker for recreate
// dances that PRESERVE the install volume (env-config / change-ports /
// cluster / mods). On a recreate the game files + the operator's custom
// Mods are already on the volume, so the create-time warm-seed +
// first-update must NOT run: warm-seed does `find /game -delete` and
// re-copies from an ARBITRARY sibling, which silently drops a 7DTD
// server's custom Mods (the whole point of preserving the volume is to
// keep them). A first-ever create never carries this flag, so brand-new
// installs still warm-seed / download normally. Invariant for callers:
// any path that sets this must also set _PANEL_SUPPRESS_AUTOSTART when the
// prior state was stopped AND issue its own Start when it was running —
// the agent does neither for a skip-reinstall recreate.
skipReinstall := false
if v := req.ConfigValues["_PANEL_SKIP_REINSTALL"]; v == "true" {
skipReinstall = true
delete(req.ConfigValues, "_PANEL_SKIP_REINSTALL")
}
// _PANEL_SEED_MODS_FROM sentinel — controller-set marker for a cluster
// join that should clone the cluster master's Mods set onto this server.
// Value is the master's instance id. Handled in the skip-reinstall branch
// below (a cluster join is always a volume-preserving recreate): we run a
// Mods-only sidecar copy before the controller starts the server.
seedModsFrom := ""
if v := req.ConfigValues["_PANEL_SEED_MODS_FROM"]; v != "" {
seedModsFrom = v
delete(req.ConfigValues, "_PANEL_SEED_MODS_FROM")
}
// _PANEL_WIPE_PATHS sentinel — comma-separated list of paths
// (relative to BrowseableRoot) the controller wants nuked before
// the new container starts. Used by the ARK map-switch flow when
// the operator checks "wipe existing world data" — clean slate
// for the new map without keeping the old map's saves on disk.
//
// Strip the key so it doesn't pollute the container env. Wipe runs
// AFTER the previous container was deleted (volumes preserved) and
// BEFORE we create the new one, in an alpine sidecar that mounts
// the same volumes we're about to mount.
if wipeCSV := req.ConfigValues["_PANEL_WIPE_PATHS"]; wipeCSV != "" {
delete(req.ConfigValues, "_PANEL_WIPE_PATHS")
paths := []string{}
for _, p := range strings.Split(wipeCSV, ",") {
p = strings.TrimSpace(p)
if p != "" {
paths = append(paths, p)
}
}
if len(paths) > 0 {
if err := d.runWipeSidecar(ctx, manifest, req.InstanceId, req.DataPath, paths); err != nil {
d.replyError(corrID, req.InstanceId, "wipe", err.Error())
log.Error("wipe sidecar", "err", err)
return
}
log.Info("wiped paths before create", "instance_id", req.InstanceId, "paths", paths)
}
}
// Auto-bump host ports that collide with already-bound ports on the
// host. Without this, two ARK SA instances both try to bind 7777/UDP
// + 27020/TCP — the second container's port mapping silently drops
// (Docker creates the container without published ports), the agent's
// RCON tracker still dials the manifest default (27020), and ends up
// talking to whichever container won the binding. Result: both
// "running" servers share state from one container's RCON.
//
// We mutate req.Ports in place so both ResolveDocker (below) and
// resolveRCONAddress (after Create) see the bumped ports — keeping
// the RCON dial address aligned with the actual host binding.
if err := assignNonCollidingHostPorts(manifest, req); err != nil {
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, "port_alloc_failed: "+err.Error())
d.replyError(corrID, req.InstanceId, "port_alloc", err.Error())
log.Error("port allocation", "err", err)
return
}
// Mirror the assigned ports into ConfigValues for any port that
// declares an `env:` mapping. This is what makes a non-default
// host port reach the game binary — without it, two ARK instances
// pick distinct host ports but both ARKs internally bind 7777/27015
// because their ASA_PORT/QUERY_PORT envs are still the manifest
// defaults. Steam server browser would then list only one of them.
//
// Also force ContainerPort = HostPort for ALL ports. The game's
// env var tells it which port to bind INSIDE the container; that
// port has to match what Docker forwards from the outside, otherwise
// the agent's RCON dial (or a player's join attempt) hits an empty
// container port. Was previously a 1:1 mapping for non-internal
// ports only — but RCON's env var (e.g. RCON_PORT) also had to
// follow the host port to avoid the same mismatch internally.
if req.ConfigValues == nil {
req.ConfigValues = map[string]string{}
}
for i, p := range req.Ports {
if p == nil {
continue
}
decl := manifest.Port(p.Name)
if decl == nil {
continue
}
if decl.Env != "" {
req.ConfigValues[decl.Env] = fmt.Sprintf("%d", p.HostPort)
}
req.Ports[i].ContainerPort = p.HostPort
}
spec, err := agentmodule.ResolveDocker(manifest, req)
if err != nil {
d.replyError(corrID, req.InstanceId, "resolve", err.Error())
log.Error("resolve docker spec", "err", err)
return
}
// Forward image-acquisition progress (auto-build of `panel-*` images
// on first use, or registry pull progress) to the controller as log
// lines on the regular stdout stream — operator sees them in the
// Console tab while waiting on the create. Without this, first-time
// builds run silently for 30s3min and the UI just sits on
// "installing".
spec.LogSink = func(line string) {
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: req.InstanceId,
Stream: "stdout",
At: timestamppb.Now(),
Line: line,
}},
})
}
log.Info("pulling image and creating container", "image", spec.Image)
containerID, err := d.runtime.Create(ctx, spec)
if err != nil {
// Emit a terminal state so the controller's DB row moves out of
// "creating" and the operator can see what went wrong.
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, "create_failed: "+err.Error())
d.replyError(corrID, req.InstanceId, "create", err.Error())
log.Error("runtime create", "err", err)
return
}
rconAddr, rconPassword := resolveRCONAddress(manifest, req)
browseable := resolveBrowseableRoot(manifest)
roots := resolveBrowseableRoots(manifest)
rec := &instanceRecord{
InstanceID: req.InstanceId,
ContainerID: containerID,
ModuleID: req.ModuleId,
Branch: resolveCreateBranch(manifest, req.ConfigValues),
DataPath: req.DataPath,
BrowseableRoot: browseable,
BrowseableRoots: roots,
RCONAddr: rconAddr,
RCONPassword: rconPassword,
}
d.mu.Lock()
d.instances[req.InstanceId] = rec
d.mu.Unlock()
if err := d.writeMeta(rec); err != nil {
log.Warn("write sidecar meta failed (instance will not survive agent restart)", "err", err)
}
log.Info("instance created", "container_id", short(containerID), "rcon_addr", rconAddr)
// Detail string is parsed by the controller's UI to drive banners; for
// modules whose first update_provider needs Steam creds we tag the
// state so the dashboard can show a "Sign in + Update to install"
// banner directly on the card. Otherwise stay with the generic
// "created" detail.
createdDetail := "created"
if len(manifest.UpdateProviders) > 0 && manifest.UpdateProviders[0].RequiresSteamLogin {
createdDetail = "needs_steam_login"
}
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, createdDetail)
d.replyOK(corrID)
// Shared auto-start helper — used from both the update-success path and
// the "skip update but still auto-start" path so the Create flow lands
// on a running server regardless of which install model the module uses.
autoStart := func() {
startCorr := fmt.Sprintf("autostart-%d", time.Now().UnixNano())
d.log.Info("auto-starting after create", "instance_id", req.InstanceId, "correlation_id", startCorr)
startCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
d.handleStart(startCtx, startCorr, &panelv1.InstanceStart{InstanceId: req.InstanceId})
}
// Auto-fire the primary update provider so the operator doesn't have to
// click Update as a separate step. Opt-out via `auto_update_on_create:
// false` in module.yaml for modules whose image handles its own
// downloads (e.g. ark-sa's community image).
//
// Recreate-dance creates carry _PANEL_SUPPRESS_AUTOSTART=true; for those
// we skip BOTH the update kick AND the auto-start so a previously
// stopped instance stays stopped after an env-config edit. Volumes
// already hold the game files (recreate preserves them), so re-running
// the updater would just waste minutes and then auto-start at the end
// via onUpdateComplete. Controller will issue an explicit Start when
// the prior state was running.
if suppressAutoStart {
log.Info("create: auto-start + auto-update suppressed (recreate dance, prior state was stopped)", "instance_id", req.InstanceId)
} else if skipReinstall {
// Recreate that PRESERVES a populated install volume (env-config /
// cluster / ports / mods). Skip the create-time warm-seed +
// first-update — they wipe /game and re-copy from a sibling, which
// silently drops a 7DTD server's custom Mods. The controller drives
// the post-recreate start itself (explicit Start when the prior state
// was running; stays stopped otherwise via _PANEL_SUPPRESS_AUTOSTART),
// so the agent does nothing further here — EXCEPT an optional mod-seed.
if seedModsFrom != "" {
// Cluster join with "seed mods from master": clone the master's
// Mods set onto this server before the controller starts it.
// Synchronous so the seeded mods are in place by the time the
// create reply returns and the controller issues Start (~5-10s for
// a typical ~170 MB 7DTD mod set — well inside the 90s create
// budget). Best-effort: a failure leaves the existing mods intact.
seedCtx, cancel := context.WithTimeout(context.Background(), 75*time.Second)
if _, serr := d.seedModsFromInstance(seedCtx, seedModsFrom, req.InstanceId, req.DataPath, manifest); serr != nil {
log.Warn("mod-seed failed — server keeps its existing mods", "instance_id", req.InstanceId, "src", seedModsFrom, "err", serr)
}
cancel()
}
log.Info("create: install preserved (recreate dance) — skipping warm-seed + update", "instance_id", req.InstanceId)
} else if shouldAutoUpdate(manifest) {
// Default to the first declared provider, but allow a config_value
// to override it at create time. This is what lets Conan Exiles
// pick `enhanced` (UE5) vs `legacy` (UE4) from the create wizard's
// EDITION dropdown — the matching update_provider id fires on
// first install. Generic by design: any module can expose a
// `provider_id` config_value and have it auto-honored.
providerID := manifest.UpdateProviders[0].ID
if pref := pickProviderFromConfigValues(req.ConfigValues, manifest); pref != "" {
providerID = pref
}
log.Info("auto-triggering first update", "provider_id", providerID,
"chosen_via_config_values", providerID != manifest.UpdateProviders[0].ID)
autoCorr := fmt.Sprintf("autoupdate-%d", time.Now().UnixNano())
instanceID := req.InstanceId
// Surface the install as a server-authoritative state so any operator
// viewing the dashboard (not just the one who clicked Create) sees
// the "updating" pulse. The client was previously only flipping
// is-installing locally on its own Create button press — refreshing
// or joining a session mid-install showed "stopped" incorrectly.
// handleUpdateWithHook emits UPDATING again immediately below, but
// firing it here too closes the UI race between create-completion
// and the goroutine starting.
d.sendInstanceState(instanceID, panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING, 0, "installing")
// When the first-time download finishes, auto-start the server. That
// way Create → "installing" → "starting" → "running" happens as one
// operator gesture instead of requiring a separate Start click. Mirrors
// AMP-style "create and run" behaviour. Guarded by the same flag as
// auto-update itself — a module that opts out of auto-update stays
// stopped after create as before.
onUpdateComplete := func(err error) {
if err != nil {
// Clear the "installing" pulse so the UI stops pretending the
// download is in flight. Detail carries the error for anyone
// inspecting the DB / logs.
d.sendInstanceState(instanceID, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, "install_failed: "+err.Error())
return
}
// Cluster join "seed mods from master" requested at CREATE time: on a
// FRESH create the install ran through this warm-seed/update path (not
// the skip-reinstall branch that also honors seedModsFrom), so clone
// the master's Mods set HERE — after the base install, before the
// server is startable. Best-effort: a failure keeps the base mods.
if seedModsFrom != "" {
d.sendInstanceState(instanceID, panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING, 0, fmt.Sprintf("seeding mods from cluster master (%s)", seedModsFrom))
seedCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
if _, serr := d.seedModsFromInstance(seedCtx, seedModsFrom, instanceID, req.DataPath, manifest); serr != nil {
log.Warn("create mod-seed failed — server keeps its base mods", "instance_id", instanceID, "src", seedModsFrom, "err", serr)
}
cancel()
}
// Drop the installing marker before kicking Start — handleStart
// sends its own STARTING event which will supersede, but emitting
// the transition explicitly avoids an awkward "installing" flash
// if Start races the next poll tick.
d.sendInstanceState(instanceID, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, "installed")
// Auto-start ONLY if the module opts in. 7DTD sets
// auto_start_on_create=false so the operator finishes world/RWG +
// cluster setup and starts manually (and to avoid the warm-seed →
// immediate-start memory race that locked up the agent host).
if shouldAutoStart(manifest) {
autoStart()
} else {
log.Info("create: install complete — auto-start suppressed (auto_start_on_create=false); operator starts manually", "instance_id", instanceID)
}
}
// Warm-seed shortcut: if a sibling instance of the same module
// already has the install volume populated, copy it instead of
// running the updater. Saves the operator a 30-90 min SteamCMD
// round-trip on the second/third/Nth instance of a game. If no
// sibling or the seed fails, fall through to the normal updater.
// The seed itself runs in a debian sidecar and takes ~2-5 min
// for ~16 GB on local SSD.
dataPath := req.DataPath
go func() {
seedCtx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
seeded, err := d.tryWarmSeedFromSibling(seedCtx, instanceID, manifest, dataPath)
if err != nil {
log.Warn("warm-seed failed, falling through to updater", "instance_id", instanceID, "err", err)
} else if seeded {
log.Info("warm-seed succeeded — skipping initial updater run", "instance_id", instanceID, "module_id", manifest.ID)
onUpdateComplete(nil)
return
}
d.handleUpdateWithHook(autoCorr, &panelv1.UpdateRequest{
InstanceId: instanceID,
ProviderId: providerID,
}, onUpdateComplete)
}()
} else if shouldAutoStart(manifest) {
// Image-managed install path (ark-sa et al): no external updater but
// we still want Create → running in one gesture. The container's
// entrypoint handles first-boot SteamCMD itself, so all we do here
// is kick Start. UI sees STARTING (yellow pulse) until the game's
// ready-regex fires. Slight delay gives the agent's replyOK envelope
// time to reach the controller before the next state update.
log.Info("auto-starting (module handles install via container entrypoint)")
go func() {
time.Sleep(250 * time.Millisecond)
autoStart()
}()
}
}
// shouldAutoStart decides whether a just-created instance should boot
// automatically after the create (and first-time update, if any) lands.
// Default: yes. Modules that legitimately want "create then wait for the
// operator to configure before starting" opt out with
// `auto_start_on_create: false`. Independent of shouldAutoUpdate so that
// image-managed modules (ark-sa: no external updater, in-container
// SteamCMD) still end the Create flow on a running server.
//
// EXCEPTION — Steam-login-required modules: when the first update_provider
// has requires_steam_login, the create-time auto-update is skipped (no
// creds to use), so the game files were never downloaded. Auto-starting
// in that state would just bounce the container with an "executable not
// found" error and leave the instance crashed. We hold off until the
// operator has signed in to Steam + clicked Update; the update path's
// onUpdateComplete hook fires the start at the right moment.
func shouldAutoStart(m *modulepkg.Manifest) bool {
if m == nil {
return false
}
if m.AutoStartOnCreate != nil {
return *m.AutoStartOnCreate
}
// Don't auto-start before the Steam-gated install has run.
if len(m.UpdateProviders) > 0 && m.UpdateProviders[0].RequiresSteamLogin {
return false
}
return true
}
// shouldAutoUpdate decides whether a just-created instance should kick off
// its first update provider automatically. Default: yes, if there's any
// provider declared. Modules whose container image handles its own
// downloads (ark-sa via UPDATE_SERVER=TRUE) should set
// `auto_update_on_create: false` in their manifest.
func shouldAutoUpdate(m *modulepkg.Manifest) bool {
if m == nil || len(m.UpdateProviders) == 0 {
return false
}
if m.AutoUpdateOnCreate != nil {
return *m.AutoUpdateOnCreate
}
// Modules whose first provider requires a Steam login can't auto-update:
// the agent doesn't have credentials on the auto-create path (the
// controller-side Steam-login gate doesn't intercept create events).
// The operator clicks Update manually once creds are in place; that
// path flows through the controller and picks up the cached login.
if m.UpdateProviders[0].RequiresSteamLogin {
return false
}
return true
}
// pickProviderFromConfigValues lets a module expose a config_value that
// overrides which update_provider auto-fires at create time. Two keys are
// honored, in order of preference:
//
// 1. config_values["provider_id"] — explicit, generic. Matches an
// update_provider's `id` directly.
// 2. config_values["EDITION"] — module-specific convention used by
// Conan Exiles (enhanced/legacy → matching provider id).
//
// If neither is set, or the named provider doesn't exist, returns "" so
// the caller falls back to UpdateProviders[0]. Case-insensitive match
// against the provider id; trim whitespace defensively because
// `runtime.docker.env` defaults can sneak through with stray spaces.
func pickProviderFromConfigValues(cv map[string]string, m *modulepkg.Manifest) string {
if cv == nil || m == nil || len(m.UpdateProviders) == 0 {
return ""
}
candidates := []string{cv["provider_id"], cv["EDITION"]}
for _, want := range candidates {
want = strings.TrimSpace(strings.ToLower(want))
if want == "" {
continue
}
for _, p := range m.UpdateProviders {
if strings.ToLower(p.ID) == want {
return p.ID
}
}
}
return ""
}
// resolveCreateBranch returns the normalized Steam branch an instance is being
// installed from, derived from the update_provider it will use (the same
// selection logic as the create-time auto-update: config_values.provider_id
// via pickProviderFromConfigValues, else UpdateProviders[0]). The value tags
// the instance record so warm-seed only reuses an install from a sibling on
// the SAME branch. See normalizeBranch for the public-equivalent collapsing.
func resolveCreateBranch(m *modulepkg.Manifest, cv map[string]string) string {
if m == nil || len(m.UpdateProviders) == 0 {
return ""
}
id := m.UpdateProviders[0].ID
if pref := pickProviderFromConfigValues(cv, m); pref != "" {
id = pref
}
for i := range m.UpdateProviders {
if m.UpdateProviders[i].ID == id {
return normalizeBranch(m.UpdateProviders[i].Beta)
}
}
return ""
}
// handleStart starts a previously-created instance and begins log streaming.
func (d *Dispatcher) handleStart(ctx context.Context, corrID string, req *panelv1.InstanceStart) {
log := d.log.With("instance_id", req.InstanceId, "correlation_id", corrID)
d.mu.Lock()
rec, ok := d.instances[req.InstanceId]
d.mu.Unlock()
if !ok {
d.replyError(corrID, req.InstanceId, "not_found", "instance not created on this target")
return
}
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STARTING, 0, "starting")
// A Start racing an in-flight graceful stop is a trap: docker start on a
// container that's still shutting down is a no-op "success", and when the
// stop's SIGTERM grace finally lands it kills the instance the operator
// just asked to start (observed live: a UI restart left the server
// stranded stopped). Wait for the stop to settle before proceeding —
// bounded so a wedged stop can't park Start forever.
if rec.stopping.Load() {
log.Info("start racing an in-flight stop — waiting for it to settle")
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STARTING, 0, "waiting for in-flight stop to finish…")
deadline := time.Now().Add(90 * time.Second)
for rec.stopping.Load() && time.Now().Before(deadline) {
select {
case <-ctx.Done():
d.replyError(corrID, req.InstanceId, "cancelled", ctx.Err().Error())
return
case <-time.After(time.Second):
}
}
}
// Region Medic — for 7DTD, validate + heal the active world's regions while
// the container is still STOPPED, snapshot a fresh rolling backup, and ping
// Discord on heal. The CLI reads its per-server config (enable/keep/channel)
// from region-medic.json in the saves volume. Best-effort: never blocks start.
if rec.ModuleID == "7dtd" {
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STARTING, 0, "region medic: validating world…")
d.runRegionMedicOnStart(ctx, rec)
// Snapshot the shared cluster Player/ dir (character .ttp saves) to a
// rolling slot before boot. No-op for standalone servers. The panel
// tar backup can't capture this — the active Player dir is a symlink
// out to the /cluster bind — so this is the only restore path for
// per-character data on a clustered server.
d.snapshotClusterPlayerOnStart(ctx, rec)
}
// Retry start on transient port-bind races. When a container exits the
// kernel can hold its UDP bindings for a few seconds; the next Start
// sees "ports are not available" until the OS releases them. A short
// backoff is much friendlier than making the user click Start twice.
const maxAttempts = 4
var err error
for attempt := 1; attempt <= maxAttempts; attempt++ {
err = d.runtime.Start(ctx, rec.ContainerID)
if err == nil {
break
}
if !isTransientStartError(err) || attempt == maxAttempts {
break
}
wait := time.Duration(attempt*2) * time.Second
log.Warn("transient start error, retrying", "err", err, "attempt", attempt, "wait", wait)
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STARTING, 0,
fmt.Sprintf("retrying (port still held): attempt %d/%d", attempt+1, maxAttempts))
select {
case <-ctx.Done():
d.replyError(corrID, req.InstanceId, "cancelled", ctx.Err().Error())
return
case <-time.After(wait):
}
}
if err != nil {
d.replyError(corrID, req.InstanceId, "start", err.Error())
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, err.Error())
log.Error("runtime start", "err", err)
return
}
d.activate(rec)
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING, 0, "running")
d.replyOK(corrID)
log.Info("instance started")
// Auto-provision a freshly-generated clustered 7DTD world: watch (best-effort,
// off-thread) for the gen boot to bake native-banded decorations and bounce the
// container ONCE so the entrypoint aligns them to the cluster canonical. Strict
// no-op for already-canon worlds (season10/insane/pvp/creative) and non-cluster
// servers; single-flight. See decoremap_provision.go.
if rec.ModuleID == "7dtd" {
d.autoProvisionClusterWorldOnStart(rec)
}
// post_start lifecycle hooks — best-effort shell commands that run
// INSIDE the container after it's up. Used to patch image-generated
// config files that need a panel-side fixup (e.g. ark-sa's RCON port
// needs to be duplicated into [SessionSettings] of GameUserSettings.ini
// because ASA ignores AUTH responses when it's only under
// [ServerSettings]). Failures are logged but don't fail the Start —
// a broken hook shouldn't block an otherwise-healthy server.
if manifest, ok := d.modules.Get(rec.ModuleID); ok && manifest.Lifecycle != nil && len(manifest.Lifecycle.PostStart) > 0 {
go d.runPostStartHooks(rec, manifest.Lifecycle.PostStart)
}
}
// runPostStartHooks executes each post_start command inside the running
// container, via docker exec. Runs async so a slow or blocking hook (e.g.
// one that waits for a config file to be written by the entrypoint) never
// holds up the Start reply or the dashboard state transition to RUNNING.
func (d *Dispatcher) runPostStartHooks(rec *instanceRecord, commands []string) {
log := d.log.With("instance_id", rec.InstanceID, "phase", "post_start")
// Bound the whole hook suite at 10 minutes — ASA's ini fixup waits
// for the image's entrypoint to write the file on first boot, which
// on a cold SteamCMD install can take several minutes.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
for i, cmd := range commands {
if cmd == "" {
continue
}
log.Info("running hook", "index", i, "cmd_preview", previewCmd(cmd))
stdout, stderr, code, err := d.runtime.ExecCapture(ctx, rec.ContainerID, []string{"sh", "-c", cmd})
if err != nil {
log.Warn("hook exec failed", "index", i, "err", err)
continue
}
if code != 0 {
log.Warn("hook non-zero exit", "index", i, "exit_code", code,
"stdout", trimForLog(stdout), "stderr", trimForLog(stderr))
continue
}
log.Info("hook ok", "index", i, "exit_code", code, "stdout", trimForLog(stdout))
}
}
func previewCmd(s string) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
if len(s) > 80 {
return s[:80] + "…"
}
return s
}
func trimForLog(b []byte) string {
s := strings.TrimSpace(string(b))
if len(s) > 400 {
s = s[:400] + "…"
}
return s
}
// extractINIValue pulls a single key from a specific section of an INI
// file. If section is empty the first matching key anywhere in the file
// wins. Lines with `;` or `#` as the first non-space character are
// comments. Whitespace around the value is trimmed.
//
// Used to read live RCON passwords out of game config files (ARK's
// [ServerSettings]ServerAdminPassword) so operator edits through the
// Config tab take effect without panel manual intervention.
func extractINIValue(content, section, key string) (string, error) {
inSection := section == ""
scanner := strings.Split(content, "\n")
for _, raw := range scanner {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
sec := line[1 : len(line)-1]
if section == "" {
// No section filter — keep scanning across sections.
continue
}
inSection = sec == section
continue
}
if !inSection {
continue
}
eq := strings.IndexByte(line, '=')
if eq <= 0 {
continue
}
k := strings.TrimSpace(line[:eq])
if k == key {
return strings.TrimSpace(line[eq+1:]), nil
}
}
return "", fmt.Errorf("ini key [%s]%s not found", section, key)
}
// isTransientStartError detects Docker errors that typically resolve within
// a few seconds (port bind still held by kernel after previous container
// exit, daemon momentarily busy). We treat these as retryable so the user
// doesn't see a stale "ports unavailable" the first time they click Start.
func isTransientStartError(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "ports are not available") ||
strings.Contains(s, "port is already allocated") ||
strings.Contains(s, "address already in use") ||
strings.Contains(s, "bind: Only one usage")
}
// activate starts log streaming + state tracker for a running instance.
// Idempotent: if LogCancel is already set, this is a no-op.
func (d *Dispatcher) activate(rec *instanceRecord) {
d.mu.Lock()
alreadyActive := rec.LogCancel != nil
d.mu.Unlock()
if alreadyActive {
return
}
// Free the shared volumes for the game by tearing down the fs helper.
// Both containers on the same volume is technically allowed, but we
// prefer the game own it exclusively while it's running.
d.teardownFsHelper(rec.InstanceID)
manifest, ok := d.modules.Get(rec.ModuleID)
if !ok {
d.log.Warn("activate: module vanished", "instance_id", rec.InstanceID, "module_id", rec.ModuleID)
return
}
// Tear down any tracker still attached to this rec from a previous
// activation BEFORE building a new one. activate() is called from
// handleStart (which doesn't pre-cancel), Announce on every controller
// reconnect (definitely doesn't pre-cancel), and the post-warm-seed
// autostart in handleCreate. Without this guard each call leaves the
// previous goroutine alive with its frozen cfg.RCONAddr — visible as
// "rcon dial failed ... 127.0.0.1:<old_port>" forever after a recreate
// (the old tracker still references the old container's port allocation;
// only an agent restart cleared it). Wait briefly for the goroutine to
// exit so the fresh dial doesn't race the stale one.
d.mu.Lock()
prevCancel := rec.TrackerCancel
prevDone := rec.TrackerDone
rec.TrackerCancel = nil
rec.Tracker = nil
rec.TrackerDone = nil
d.mu.Unlock()
if prevCancel != nil {
prevCancel()
if prevDone != nil {
select {
case <-prevDone:
case <-time.After(5 * time.Second):
d.log.Warn("activate: prior tracker did not exit within 5s",
"instance_id", rec.InstanceID)
}
}
}
// On rehydrate, sidecar may have an empty RCONPassword (older agent
// versions didn't persist secrets, or the password lookup hadn't
// been wired for this module yet). Re-resolve from the live manifest
// so a manifest update (e.g. adding password_secret to ark-sa) takes
// effect on the next start without forcing a delete + recreate.
if rec.RCONPassword == "" && manifest.RCON != nil {
_, pw := resolveRCONAddress(manifest, &panelv1.InstanceCreate{InstanceId: rec.InstanceID})
if pw != "" {
rec.RCONPassword = pw
_ = d.writeMeta(rec) // best-effort persist
}
}
// If the module declares a way to source the RCON password at runtime
// (from a file or from a specific INI key), wire a PasswordFunc that
// re-reads on each dial attempt. Lets modules whose entrypoint
// generates secrets at first boot (7DTD's TelnetPassword via
// /game-saves/.panel-telnet-password) or whose config file is the
// source of truth (ARK: SA's [ServerSettings]ServerAdminPassword)
// work without manual wiring.
var passwordFunc func(context.Context) (string, error)
if manifest.RCON != nil {
if iniSpec := manifest.RCON.PasswordFromINI; iniSpec != nil && iniSpec.File != "" && iniSpec.Key != "" {
path := iniSpec.File
section := iniSpec.Section
key := iniSpec.Key
containerID := rec.ContainerID
rt := d.runtime
passwordFunc = func(ctx context.Context) (string, error) {
data, err := rt.CopyFileFromContainer(ctx, containerID, path)
if err != nil {
return "", err
}
return extractINIValue(string(data), section, key)
}
} else if manifest.RCON.PasswordFromFile != "" {
path := manifest.RCON.PasswordFromFile
containerID := rec.ContainerID
rt := d.runtime
passwordFunc = func(ctx context.Context) (string, error) {
data, err := rt.CopyFileFromContainer(ctx, containerID, path)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
}
}
tracker, err := state.New(d.log, state.Config{
InstanceID: rec.InstanceID,
Manifest: manifest,
RCONAddr: rec.RCONAddr,
RCONPassword: rec.RCONPassword,
PasswordFunc: passwordFunc,
Emitter: d,
// stdio adapter needs a handle to the runtime so it can attach to
// the container's stdin. Irrelevant for tcp adapters — tracker
// ignores these fields unless manifest.RCON.Adapter == "stdio".
ContainerID: "panel-" + rec.InstanceID,
Stdio: d.runtime.(rcon.StdioBackend),
// arkHangGuard hook — counts consecutive RCON failures per
// instance and triggers an auto-restart at threshold. No-op for
// non-ark-sa modules (the guard short-circuits inside).
OnRconResult: func(success bool) { d.arkHangGuardOnRconResult(rec, success) },
})
if err != nil {
d.log.Warn("state tracker init failed (continuing without it)", "instance_id", rec.InstanceID, "err", err)
} else {
trackerCtx, trackerCancel := context.WithCancel(context.Background())
done := make(chan struct{})
d.mu.Lock()
rec.Tracker = tracker
rec.TrackerCancel = trackerCancel
rec.TrackerDone = done
d.mu.Unlock()
go func() {
defer close(done)
tracker.Run(trackerCtx)
}()
}
// Same teardown discipline for the log + stats streams: every caller of
// activate (Announce, handleStart, post-warm-seed autostart) reaches
// here, and most pre-cancel LogCancel — but not all. Cancel anything
// lingering before installing fresh streams so we don't end up with
// two streamLogs goroutines duplicating every log line into the
// emitter.
d.mu.Lock()
prevLogCancel := rec.LogCancel
rec.LogCancel = nil
d.mu.Unlock()
if prevLogCancel != nil {
prevLogCancel()
}
logCtx, logCancel := context.WithCancel(context.Background())
d.mu.Lock()
rec.LogCancel = logCancel
d.mu.Unlock()
go d.streamLogs(logCtx, rec)
d.startExitWatch(rec)
// Stats poller shares the log-lifetime context: cancelled on stop,
// auto-closed if the container exits.
go d.streamStats(logCtx, rec)
}
// startExitWatch (re)arms the exit watcher for a record, single-flight:
// any watcher from a previous activation is cancelled first, so re-Announce
// on controller reconnects can't stack N watchExit goroutines each holding
// its own ContainerWait docker connection.
func (d *Dispatcher) startExitWatch(rec *instanceRecord) {
ctx, cancel := context.WithCancel(context.Background())
d.mu.Lock()
prev := rec.ExitWatchCancel
rec.ExitWatchCancel = cancel
d.mu.Unlock()
if prev != nil {
prev()
}
go d.watchExit(ctx, rec)
}
// handleStop stops the container gracefully and leaves everything else
// intact — the in-memory record, the sidecar JSON, the Docker container
// (in Exited state), the volumes, and the DB row all persist so the
// instance can be re-started, backed up, or restored. Full teardown is
// InstanceDelete's job.
func (d *Dispatcher) handleStop(ctx context.Context, corrID string, req *panelv1.InstanceStop) {
log := d.log.With("instance_id", req.InstanceId, "correlation_id", corrID)
d.mu.Lock()
rec, ok := d.instances[req.InstanceId]
d.mu.Unlock()
if !ok {
d.replyError(corrID, req.InstanceId, "not_found", "instance not on this target")
return
}
rec.stopping.Store(true)
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPING, 0, "stopping")
grace := time.Duration(req.GraceSeconds) * time.Second
if grace == 0 {
grace = 30 * time.Second
}
// Cancel any in-flight updater (steamcmd download, github fetch).
// Otherwise Stop kills the main container but the sidecar keeps
// running, which the operator perceives as "stop didn't work — it's
// still downloading."
d.cancelUpdater(req.InstanceId)
// Graceful in-game save BEFORE signalling the container. A bare docker
// stop (SIGTERM→SIGKILL after grace) can tear a region file mid-write —
// the root cause of 7DTD .7rg "Wrong chunk header!" corruption that
// Region Medic exists to repair. If the module declares an RCON `save`
// command and the tracker is live, flush the world first. Best-effort:
// short timeout, and fall through to the normal stop on any error.
d.mu.Lock()
tracker := rec.Tracker
d.mu.Unlock()
if manifest, ok := d.modules.Get(rec.ModuleID); !req.Force && tracker != nil && ok && manifest.RCON != nil {
if saveCmd := manifest.RCON.Commands["save"]; saveCmd != "" {
saveCtx, cancelSave := context.WithTimeout(ctx, 15*time.Second)
if out, err := tracker.Exec(saveCtx, saveCmd); err != nil {
log.Warn("graceful save before stop failed (continuing to stop)", "cmd", saveCmd, "err", err)
} else {
log.Info("graceful save before stop", "cmd", saveCmd, "out", strings.TrimSpace(out))
time.Sleep(2 * time.Second) // let the engine flush region files to disk
}
cancelSave()
}
}
// Tear down the tracker, log/stats streams, and exit watcher. Mutations
// go under d.mu — activate/Announce/Shutdown touch the same fields from
// other goroutines; the cancel calls themselves happen outside the lock.
d.mu.Lock()
trackerCancel := rec.TrackerCancel
logCancel := rec.LogCancel
exitWatchCancel := rec.ExitWatchCancel
rec.TrackerCancel = nil
rec.Tracker = nil
rec.LogCancel = nil
rec.ExitWatchCancel = nil
d.mu.Unlock()
for _, c := range []context.CancelFunc{trackerCancel, logCancel, exitWatchCancel} {
if c != nil {
c()
}
}
if err := d.runtime.Stop(ctx, rec.ContainerID, grace); err != nil {
// Clear the flag on failure too — the container is still running, so
// a future exit is NOT part of a graceful stop, and handleStart's
// race guard must not wait on a stop that already gave up.
rec.stopping.Store(false)
d.replyError(corrID, req.InstanceId, "stop", err.Error())
log.Error("runtime stop", "err", err)
return
}
// Reset stopping so the exit watcher (if it fires again on a future
// restart) treats future exits normally.
rec.stopping.Store(false)
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, "stopped")
d.replyOK(corrID)
log.Info("instance stopped (container retained for restart/backup)")
}
// handleRCON runs one operator-supplied RCON command against the instance's
// tracker client and returns the result via an RCONResult envelope carrying
// the same correlation_id so the controller can match it to the original
// ExecRCON request.
func (d *Dispatcher) handleRCON(ctx context.Context, corrID string, req *panelv1.RCONCommand) {
d.mu.Lock()
rec, ok := d.instances[req.InstanceId]
var tracker *state.Tracker
if ok {
tracker = rec.Tracker
}
d.mu.Unlock()
if !ok {
d.sendRCONResult(corrID, "", "not_found", "instance not on this target")
return
}
if tracker == nil {
d.sendRCONResult(corrID, "", "no_tracker", "instance has no RCON tracker (module may not declare rcon)")
return
}
// Use tracker.Exec so we transparently redial on dead connections —
// Palworld's RCON closes sockets after ~60s idle, so the first operator
// command after a quiet period would otherwise always fail with EOF.
out, err := tracker.Exec(ctx, req.Command)
if err != nil {
d.sendRCONResult(corrID, out, "exec", err.Error())
return
}
d.sendRCONResult(corrID, out, "", "")
}
func (d *Dispatcher) sendRCONResult(corrID, output, _code, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_RconResult{
RconResult: &panelv1.RCONResult{Output: output, Error: errMsg},
},
})
}
func (d *Dispatcher) streamLogs(ctx context.Context, rec *instanceRecord) {
// Coalesce progress-style bursts (SteamCMD download ticks, preallocation,
// LGSM step banners) into at most one update per 200ms per "key". Non-
// progress lines go through immediately so errors and unique content are
// never delayed. The Tracker still sees every line for event matching.
//
// The whole thing runs in a restart loop. Reason: docker's
// ContainerLogs(--follow) can return cleanly (EOF / pipe closed) for
// reasons that have nothing to do with the container exiting — daemon
// hiccups, log driver buffer flushes, occasional plain weirdness. Before
// this loop, when StreamLogs returned without ctx.Err(), the goroutine
// just exited and the instance went silent until the next agent restart.
// Symptom in the wild: a server's pill stuck on "starting" because no
// log lines were reaching the controller's ring buffer, even though the
// container itself was fine. Live-debugged on V Rising 2026-04-29.
for {
if ctx.Err() != nil {
return
}
// Skip if the container is no longer running — watchExit owns the
// terminal "stopped" / "crashed" transition, no point chasing the
// stream of a dead container.
if rec.ContainerID == "" {
return
}
co := newLogCoalescer(200*time.Millisecond, func(stream runtime.LogStream, line string, at time.Time) {
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID,
Stream: string(stream),
At: timestamppb.New(at),
Line: line,
}},
})
})
err := d.runtime.StreamLogs(ctx, rec.ContainerID, func(stream runtime.LogStream, line string, at time.Time) {
co.offer(stream, line, at)
// Snapshot the tracker under d.mu — handleStop/activate swap it
// concurrently. Uncontended mutex cost is noise next to the
// regex matching OnLogLine does.
d.mu.Lock()
tracker := rec.Tracker
d.mu.Unlock()
if tracker != nil {
tracker.OnLogLine(line)
}
// ark-sa hang-detection guardrail: fires on the engine's
// "!!!HANG DETECTED!!!" self-detection token. No-op for
// other modules. See hangguard.go.
d.arkHangGuardOnLogLine(rec, line)
})
co.stop()
if ctx.Err() != nil {
return
}
if err != nil {
d.log.Warn("log stream ended with error, will restart",
"instance_id", rec.InstanceID, "err", err)
} else {
d.log.Info("log stream returned cleanly, restarting",
"instance_id", rec.InstanceID)
}
// Brief backoff so a wedged docker daemon doesn't peg CPU on tight
// retry. 2s is plenty quick for "the operator is watching the
// pill," far short of human-perceptible silence.
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
}
}
}
// watchExit blocks on ContainerWait and reports the container's exit (or
// crash/auto-restart cycle) upstream. ctx-bound: cancelled by startExitWatch
// on re-activation, by handleStop, and by Shutdown, so a stale watcher can't
// hold a docker connection past its container lifetime.
func (d *Dispatcher) watchExit(ctx context.Context, rec *instanceRecord) {
for {
code, err := d.runtime.Wait(ctx, rec.ContainerID)
if ctx.Err() != nil {
return
}
if err != nil {
if strings.Contains(err.Error(), "context") {
return
}
d.log.Warn("wait error", "instance_id", rec.InstanceID, "err", err)
}
if rec.stopping.Load() {
return
}
// Docker's restart policy (unless-stopped / on-failure / always) may
// relaunch the container process after the previous one dies. If the
// container is back in "running" state by the time Wait returns, this
// is a crash-then-auto-restart cycle, not a terminal exit — report the
// crash + recovery to the UI and re-arm our streams against the new
// process. Otherwise the Console goes dead and the status pill gets
// stuck on "stopped" even though the server is live again.
state, ierr := d.runtime.InspectByName(ctx, "panel-"+rec.InstanceID)
if ierr == nil && state.Status == "running" {
d.log.Warn("container auto-restarted by docker restart policy",
"instance_id", rec.InstanceID, "prior_exit_code", code)
d.sendInstanceState(rec.InstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, int32(code),
fmt.Sprintf("crashed (exit %d) — docker auto-restarted", code))
d.sendInstanceState(rec.InstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING, 0,
"running (recovered after crash)")
d.rearmStreamsAfterAutoRestart(rec)
continue
}
// Terminal exit — no auto-restart.
status := panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED
if code != 0 {
status = panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED
}
d.sendInstanceState(rec.InstanceID, status, int32(code), "exited")
return
}
}
// rearmStreamsAfterAutoRestart cancels the log + stats streams left pointing
// at the dead process and spawns fresh ones against the newly-restarted
// container. Called from watchExit on a crash-recovery cycle so the Console
// tab and stats graph don't go dark between the docker `die` and the next
// `start`. The RCON tracker (if any) is left to reconnect on its next poll —
// no explicit rearm needed.
func (d *Dispatcher) rearmStreamsAfterAutoRestart(rec *instanceRecord) {
d.mu.Lock()
if rec.LogCancel != nil {
rec.LogCancel()
}
logCtx, logCancel := context.WithCancel(context.Background())
rec.LogCancel = logCancel
d.mu.Unlock()
go d.streamLogs(logCtx, rec)
go d.streamStats(logCtx, rec)
}
func (d *Dispatcher) sendInstanceState(instanceID string, status panelv1.InstanceStatus, exitCode int32, detail string) {
// Record the last-sent status on the instance record (when one exists)
// so EmitAppState can detect a stale CRASHED that needs promotion.
d.mu.Lock()
rec := d.instances[instanceID]
d.mu.Unlock()
if rec != nil {
rec.lastStatus.Store(int32(status))
}
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_InstanceState{
InstanceState: &panelv1.InstanceStateUpdate{
InstanceId: instanceID,
Status: status,
ExitCode: exitCode,
At: timestamppb.Now(),
Detail: detail,
},
},
})
}
func (d *Dispatcher) replyOK(corrID string) {
if corrID == "" {
return
}
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Result{
Result: &panelv1.CommandResult{ExitCode: 0},
},
})
}
func (d *Dispatcher) replyError(corrID, instanceID, code, msg string) {
if corrID == "" && instanceID == "" {
return
}
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Result{
Result: &panelv1.CommandResult{
ExitCode: 1,
Error: &panelv1.Error{
Code: code,
Message: msg,
At: timestamppb.Now(),
},
},
},
})
}
// assignNonCollidingHostPorts walks the manifest's ports list and, for
// each declared port, picks an actually-available host port — auto-
// bumping if the manifest default (or operator-supplied override) is
// already in use by another process or container. Mutates req.Ports
// in place: every declared manifest port ends up with an explicit
// HostPort entry, so downstream code (ResolveDocker, resolveRCONAddress)
// sees the same chosen port.
//
// Probe strategy: bind a real socket on 0.0.0.0:port (or 127.0.0.1:port
// for internal ports) for ~50ms; if the bind succeeds, the port is
// available — Docker's published-port machinery will succeed there too.
// Avoids guessing from /proc/net or shelling to ss/lsof.
//
// Bump direction: increment by 1 starting from the requested port up
// to +200. We intentionally don't pick from a far-flung high range —
// keeping ports clustered makes operator firewall configs easier and
// the typical "default + N siblings" workload fits in <50.
func assignNonCollidingHostPorts(manifest *modulepkg.Manifest, req *panelv1.InstanceCreate) error {
if manifest == nil || manifest.Runtime.Docker == nil {
return nil
}
// Build by-name lookup of any existing operator overrides so we can
// preserve them and only mutate when they collide.
overrideByName := map[string]*panelv1.PortMap{}
for i, ov := range req.Ports {
if ov != nil && ov.Name != "" {
overrideByName[ov.Name] = req.Ports[i]
}
}
// Track ports we've claimed during THIS create — sibling ports in the
// same module (e.g. ark-sa game/raw/rcon) all bind on this same call,
// so the probe needs to know about ports we're about to take.
claimed := map[string]bool{} // key: "tcp:7777" / "udp:7777"
// Reserve list for predictable rollback if something goes wrong mid-call.
for _, p := range manifest.Ports {
if p.Name == "" || p.Default == 0 {
continue
}
proto := strings.ToLower(p.Proto)
if proto != "tcp" && proto != "udp" {
proto = "tcp"
}
// Operator-supplied override wins as the starting point.
startHost := uint16(p.Default)
if ov, ok := overrideByName[p.Name]; ok && ov.HostPort != 0 {
startHost = uint16(ov.HostPort)
}
chosen, err := findFreePort(startHost, proto, p.Internal, claimed)
if err != nil {
return fmt.Errorf("port %q: %w", p.Name, err)
}
claimed[fmt.Sprintf("%s:%d", proto, chosen)] = true
// Update or insert the PortMap entry. ContainerPort stays at the
// manifest default (or operator override) — only HostPort is
// adjusted to dodge the collision.
containerPort := uint32(p.Default)
if ov, ok := overrideByName[p.Name]; ok && ov.ContainerPort != 0 {
containerPort = ov.ContainerPort
}
entry := &panelv1.PortMap{
Name: p.Name,
Proto: proto,
ContainerPort: containerPort,
HostPort: uint32(chosen),
Internal: p.Internal,
}
if existing, ok := overrideByName[p.Name]; ok {
*existing = *entry
} else {
req.Ports = append(req.Ports, entry)
overrideByName[p.Name] = req.Ports[len(req.Ports)-1]
}
}
return nil
}
// findFreePort scans upward from start, returning the first port that
// can be bound on the appropriate interface. claimed tracks ports we've
// already chosen earlier in the same call. Caps at +200 — keeps panel-
// assigned ports in a tight cluster for operator firewall sanity.
func findFreePort(start uint16, proto string, internal bool, claimed map[string]bool) (uint16, error) {
host := "0.0.0.0"
if internal {
host = "127.0.0.1"
}
for offset := 0; offset < 200; offset++ {
port := uint32(start) + uint32(offset)
if port > 65535 {
break
}
key := fmt.Sprintf("%s:%d", proto, port)
if claimed[key] {
continue
}
addr := fmt.Sprintf("%s:%d", host, port)
if proto == "udp" {
l, err := net.ListenPacket("udp", addr)
if err != nil {
continue
}
_ = l.Close()
return uint16(port), nil
}
l, err := net.Listen("tcp", addr)
if err != nil {
continue
}
_ = l.Close()
return uint16(port), nil
}
return 0, fmt.Errorf("no free %s port in range %d-%d", proto, start, uint32(start)+200)
}
// resolveRCONAddress determines the 127.0.0.1:port the Agent will use to
// reach the instance's RCON and pulls the password from config_values.
// Returns ("", "") if the manifest declares no RCON.
//
// Password source precedence:
// 1. config_values["rcon_password"] — explicit override
// 2. config_values[manifest.RCON.PasswordSecret] — module-declared mapping
// (operator-set value via the Config tab)
// 3. manifest.Runtime.Docker.Env[manifest.RCON.PasswordSecret] —
// module manifest's own default for the secret-named env var (this
// is the line that fixes ARK SA: SERVER_ADMIN_PASSWORD lives in
// env defaults, never gets seeded into ConfigValues, and the
// acekorneya image doesn't write it back to GameUserSettings.ini)
// 4. manifest.RCON.PasswordLiteral — explicit literal in manifest
// 5. "" (tracker will still attempt connect; auth may fail)
func resolveRCONAddress(manifest *modulepkg.Manifest, req *panelv1.InstanceCreate) (addr, password string) {
if manifest.RCON == nil || manifest.RCON.HostPort == "" {
return "", ""
}
portDecl := manifest.Port(manifest.RCON.HostPort)
if portDecl == nil {
return "", ""
}
hostPort := uint16(portDecl.Default)
for _, ov := range req.Ports {
if ov != nil && ov.Name == manifest.RCON.HostPort && ov.HostPort != 0 {
hostPort = uint16(ov.HostPort)
break
}
}
if req.ConfigValues != nil {
if p := req.ConfigValues["rcon_password"]; p != "" {
password = p
} else if key := manifest.RCON.PasswordSecret; key != "" {
password = req.ConfigValues[key]
}
}
// Fall back to the manifest's env-defaults map when the secret
// hasn't been overridden via ConfigValues. The agent merges env
// defaults into the container's env at create time, but it does
// NOT seed ConfigValues with them — so without this, RCON sees
// an empty password and dials get RST'd at auth.
if password == "" && manifest.RCON.PasswordSecret != "" && manifest.Runtime.Docker != nil {
if v, ok := manifest.Runtime.Docker.Env[manifest.RCON.PasswordSecret]; ok {
password = v
}
}
// Last resort: explicit literal in the manifest.
if password == "" {
password = manifest.RCON.PasswordLiteral
}
return fmt.Sprintf("127.0.0.1:%d", hostPort), password
}
// resolveBrowseableRoot returns the container-absolute path the file
// manager should treat as the DEFAULT root (first view). Precedence:
// browseable_roots[0], explicit browseable_root, first declared volume's
// container path, or "" to fall back to bind mount.
func resolveBrowseableRoot(manifest *modulepkg.Manifest) string {
if manifest.Runtime.Docker == nil {
return ""
}
d := manifest.Runtime.Docker
if len(d.BrowseableRoots) > 0 && d.BrowseableRoots[0].Path != "" {
return d.BrowseableRoots[0].Path
}
if d.BrowseableRoot != "" {
return d.BrowseableRoot
}
if len(d.Volumes) > 0 {
return d.Volumes[0].Container
}
return ""
}
// resolveBrowseableRoots returns every root the operator is allowed to
// browse. Guarantees at least one entry (synthesized from browseable_root
// if the module only declares the singular form).
func resolveBrowseableRoots(manifest *modulepkg.Manifest) []modulepkg.BrowseableRoot {
if manifest.Runtime.Docker == nil {
return nil
}
d := manifest.Runtime.Docker
if len(d.BrowseableRoots) > 0 {
return d.BrowseableRoots
}
if d.BrowseableRoot != "" {
return []modulepkg.BrowseableRoot{{Name: "default", Path: d.BrowseableRoot}}
}
if len(d.Volumes) > 0 {
return []modulepkg.BrowseableRoot{{Name: "default", Path: d.Volumes[0].Container}}
}
return nil
}
// mergeValuesAndSecrets generates any module-declared secrets that are
// flagged Generated, then layers user-supplied config_values on top
// (user wins). Returns a fresh map suitable for template rendering.
func mergeValuesAndSecrets(manifest *modulepkg.Manifest, userValues map[string]string) (map[string]string, error) {
secrets, err := modulepkg.GenerateSecrets(manifest)
if err != nil {
return nil, err
}
out := make(map[string]string, len(secrets)+len(userValues))
for k, v := range secrets {
out[k] = v
}
for k, v := range userValues {
if v != "" {
out[k] = v
}
}
return out, nil
}
// rtStatusToProto converts a Docker container State.Status to our
// InstanceStatus enum. Unknown states map to UNSPECIFIED.
func rtStatusToProto(s string) panelv1.InstanceStatus {
switch s {
case "running":
return panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING
case "restarting":
return panelv1.InstanceStatus_INSTANCE_STATUS_STARTING
case "created":
return panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED
case "paused":
return panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING
case "exited":
return panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED
case "dead":
return panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED
default:
return panelv1.InstanceStatus_INSTANCE_STATUS_UNSPECIFIED
}
}
func short(id string) string {
if len(id) > 12 {
return id[:12]
}
return id
}