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
+662
View File
@@ -0,0 +1,662 @@
package dispatch
import (
"context"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
agentmodule "github.com/dbledeez/panel/agent/internal/module"
"github.com/dbledeez/panel/agent/internal/runtime"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// File management: operations are rooted at each instance's declared
// BrowseableRoot inside the container when the container is running +
// the runtime supports container-path ops (Docker). Otherwise we fall
// back to the host-side DataPath bind mount. Either path is safe-joined
// to prevent escape.
const (
// fsMaxReadBytes caps a single file-read RPC. Big enough for full
// world saves and most ARK/Empyrion scenario zips (a few-hundred MB
// is common). True streaming would remove the cap entirely; until
// then 4 GiB matches the controller-side upload cap.
fsMaxReadBytes = 4 * 1024 * 1024 * 1024
fsMaxListSize = 5000
// fsCopyTimeout bounds a single CopyToContainer / CopyFromContainer
// call. A multi-GB upload over the loopback Docker socket can take
// 30+ seconds, and an extract that ships the whole archive's
// contents back to the container can be larger still — give it
// real headroom so we don't surface spurious timeouts.
fsCopyTimeout = 10 * time.Minute
)
// ---- Path safety ----
// safeJoinHost joins rel onto root using OS path semantics, rejecting
// anything that canonicalizes outside of root.
func safeJoinHost(root, rel string) (string, error) {
rel = strings.TrimPrefix(rel, "/")
rel = strings.TrimPrefix(rel, "\\")
if rel == "" {
return root, nil
}
joined := filepath.Join(root, rel)
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
absJoined, err := filepath.Abs(joined)
if err != nil {
return "", err
}
sep := string(filepath.Separator)
if absJoined != absRoot && !strings.HasPrefix(absJoined+sep, absRoot+sep) {
return "", fmt.Errorf("path escapes instance data directory")
}
return absJoined, nil
}
// safeJoinContainer joins rel onto root using Linux container semantics
// (always forward-slash). Returns the cleaned absolute path.
func safeJoinContainer(root, rel string) (string, error) {
rel = strings.TrimPrefix(rel, "/")
if rel == "" {
return root, nil
}
joined := path.Clean(path.Join(root, rel))
if joined != root && !strings.HasPrefix(joined+"/", root+"/") {
return "", fmt.Errorf("path escapes instance root")
}
return joined, nil
}
// rootPaths returns the full list of allowed root paths for a record
// (flattens BrowseableRoots → container paths, or falls back to the
// singular BrowseableRoot). Always returns at least one entry.
func rootPaths(rec *instanceRecord) []string {
if len(rec.BrowseableRoots) > 0 {
out := make([]string, 0, len(rec.BrowseableRoots))
for _, r := range rec.BrowseableRoots {
if r.Path != "" {
out = append(out, r.Path)
}
}
if len(out) > 0 {
return out
}
}
if rec.BrowseableRoot != "" {
return []string{rec.BrowseableRoot}
}
return nil
}
// safeJoinAny resolves a caller-supplied path against ANY of the module's
// declared roots. Semantics:
//
// - If p starts with "/", it must be EQUAL TO or UNDER one of the
// roots; we return it cleaned as-is. Lets the UI send container-
// absolute paths when it knows the full layout (multi-root modules).
// - Otherwise p is relative, resolved against `defaultRoot`. Same
// safeJoinContainer behaviour as before.
//
// Returns an error if the path can't be mapped to any root or tries to
// escape via `..`.
func safeJoinAny(defaultRoot string, roots []string, p string) (string, error) {
if strings.HasPrefix(p, "/") {
cleaned := path.Clean(p)
for _, r := range roots {
if cleaned == r || strings.HasPrefix(cleaned+"/", r+"/") {
return cleaned, nil
}
}
return "", fmt.Errorf("path %q is not under any declared root", cleaned)
}
return safeJoinContainer(defaultRoot, p)
}
func (d *Dispatcher) lookupRecord(instanceID string) (*instanceRecord, error) {
d.mu.Lock()
defer d.mu.Unlock()
rec, ok := d.instances[instanceID]
if !ok {
return nil, fmt.Errorf("instance %q not on this target", instanceID)
}
return rec, nil
}
// useContainerOps decides whether to go through the container runtime
// (true) or fall back to the host bind mount (false). Any module that
// declares a browseable root uses container ops — if the main container
// isn't running at the moment, we spin up a helper sidecar (see
// getFsTargetContainerID). This lets operators browse/edit files on
// stopped instances.
func (d *Dispatcher) useContainerOps(rec *instanceRecord) bool {
return rec.BrowseableRoot != ""
}
// ---- FS helper sidecar: file ops on stopped containers ----
// fsHelperImage is the base for the helper container. We use debian-slim
// rather than alpine because our `ls --time-style=+%s` call relies on GNU
// coreutils — busybox's ls doesn't accept --time-style. debian:12-slim is
// already on disk for every panel-native game module, so no extra pull.
const fsHelperImage = "debian:12-slim"
// fsHelperIdleTTL is how long a helper container is kept alive after its
// last file-op access. Balance: long enough that click-around browsing
// reuses the same helper without respawning; short enough that a forgotten
// Files tab doesn't pin volumes forever.
const fsHelperIdleTTL = 5 * time.Minute
// getFsTargetContainerID picks the container to route a file op through:
// the main instance container if it's running, or a helper sidecar
// (lazy-created + started on demand) if it isn't. The helper reuses the
// same named volumes as the main container, so the operator sees the
// same files they would see with the game running.
func (d *Dispatcher) getFsTargetContainerID(ctx context.Context, rec *instanceRecord) (string, error) {
mainName := "panel-" + rec.InstanceID
st, err := d.runtime.InspectByName(ctx, mainName)
if err == nil && st.Status == "running" {
// Main is alive — tear down any lingering helper so volumes
// aren't held by two containers simultaneously during live play.
d.teardownFsHelper(rec.InstanceID)
return st.ContainerID, nil
}
return d.ensureFsHelper(ctx, rec)
}
func (d *Dispatcher) ensureFsHelper(ctx context.Context, rec *instanceRecord) (string, error) {
helperName := "panel-" + rec.InstanceID + "-fs"
// Reuse existing helper if it's still healthy.
d.mu.Lock()
h, ok := d.fsHelpers[rec.InstanceID]
d.mu.Unlock()
if ok {
st, err := d.runtime.InspectByName(ctx, helperName)
if err == nil && st.Status == "running" {
d.mu.Lock()
h.lastAccess = time.Now()
d.mu.Unlock()
return h.containerID, nil
}
// Stale record — container gone or in a bad state. Drop + rebuild.
d.teardownFsHelper(rec.InstanceID)
}
// Check for an orphaned helper container on Docker's side from a
// previous agent run. When the agent restarts, in-memory fsHelpers
// empties but the container persists. Adopt it if it's running,
// remove it if not (Create will then succeed).
if st, err := d.runtime.InspectByName(ctx, helperName); err == nil {
if st.Status == "running" {
d.log.Info("fs helper adopted from prior agent run",
"instance_id", rec.InstanceID, "container_id", st.ContainerID)
d.mu.Lock()
d.fsHelpers[rec.InstanceID] = &fsHelper{containerID: st.ContainerID, lastAccess: time.Now()}
d.mu.Unlock()
return st.ContainerID, nil
}
d.log.Info("removing stale fs helper before recreate",
"instance_id", rec.InstanceID, "container_id", st.ContainerID, "status", st.Status)
rmCtx, rmCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = d.runtime.Remove(rmCtx, st.ContainerID)
rmCancel()
}
// Resolve the instance's volumes from its manifest so the helper sees
// exactly what the game would.
manifest, ok := d.modules.Get(rec.ModuleID)
if !ok {
return "", fmt.Errorf("module %q not loaded; can't mount helper volumes", rec.ModuleID)
}
volumes := agentmodule.ResolveVolumes(manifest, rec.InstanceID, rec.DataPath)
spec := runtime.InstanceSpec{
InstanceID: rec.InstanceID + "-fs",
IsSidecar: true,
Image: fsHelperImage,
Command: []string{"sleep", "infinity"},
Volumes: volumes,
RestartPolicy: "no",
}
containerID, err := d.runtime.Create(ctx, spec)
if err != nil {
return "", fmt.Errorf("create fs helper: %w", err)
}
if err := d.runtime.Start(ctx, containerID); err != nil {
// Clean up the half-created container so next call doesn't see a
// zombie from `Created` state.
rmCtx, rmCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer rmCancel()
_ = d.runtime.Remove(rmCtx, containerID)
return "", fmt.Errorf("start fs helper: %w", err)
}
d.mu.Lock()
d.fsHelpers[rec.InstanceID] = &fsHelper{containerID: containerID, lastAccess: time.Now()}
d.mu.Unlock()
d.log.Info("fs helper started", "instance_id", rec.InstanceID, "container_id", containerID)
return containerID, nil
}
// teardownFsHelper stops and removes the helper sidecar for an instance,
// if one exists. Safe to call at any time — no-ops on absent helpers.
// Called on main-container start (to free volumes) and on instance
// delete (cleanup).
func (d *Dispatcher) teardownFsHelper(instanceID string) {
d.mu.Lock()
h, ok := d.fsHelpers[instanceID]
if ok {
delete(d.fsHelpers, instanceID)
}
d.mu.Unlock()
if !ok {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = d.runtime.Stop(ctx, h.containerID, 2*time.Second)
_ = d.runtime.Remove(ctx, h.containerID)
d.log.Info("fs helper torn down", "instance_id", instanceID)
}
// fsHelperReaper runs for the lifetime of the dispatcher and tears down
// any helper that hasn't been touched in fsHelperIdleTTL. Scans every
// minute — granular enough that abandoned Files tabs don't pin resources
// for long, coarse enough that the scan itself is cheap.
func (d *Dispatcher) fsHelperReaper() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
d.mu.Lock()
stale := make([]string, 0)
for id, h := range d.fsHelpers {
if now.Sub(h.lastAccess) > fsHelperIdleTTL {
stale = append(stale, id)
}
}
d.mu.Unlock()
for _, id := range stale {
d.teardownFsHelper(id)
}
}
}
// ---- FsList ----
func (d *Dispatcher) handleFsList(corrID string, req *panelv1.FsListRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsListResult(corrID, nil, "", err.Error())
return
}
if d.useContainerOps(rec) {
entries, err := d.listInContainer(rec, req.Path)
if err != nil {
d.sendFsListResult(corrID, nil, req.Path, err.Error())
return
}
d.sendFsListResult(corrID, entries, req.Path, "")
return
}
// Host fallback — bind mount.
if rec.DataPath == "" {
d.sendFsListResult(corrID, nil, req.Path, "no file storage available (container not running and no host data path)")
return
}
absPath, err := safeJoinHost(rec.DataPath, req.Path)
if err != nil {
d.sendFsListResult(corrID, nil, req.Path, err.Error())
return
}
entries, err := os.ReadDir(absPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
d.sendFsListResult(corrID, nil, req.Path, "not found")
return
}
d.sendFsListResult(corrID, nil, req.Path, err.Error())
return
}
out := make([]*panelv1.FsEntry, 0, len(entries))
for i, e := range entries {
if i >= fsMaxListSize {
break
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, &panelv1.FsEntry{
Name: e.Name(),
Path: filepath.ToSlash(filepath.Join(req.Path, e.Name())),
IsDir: e.IsDir(),
Size: info.Size(),
ModTime: timestamppb.New(info.ModTime()),
})
}
d.sendFsListResult(corrID, out, filepath.ToSlash(req.Path), "")
}
// listInContainer shells out to `ls -la --time-style=+%s <root/rel>` and
// parses the output into FsEntry records.
func (d *Dispatcher) listInContainer(rec *instanceRecord, rel string) ([]*panelv1.FsEntry, error) {
abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), rel)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
return nil, err
}
// -L dereferences symlinks so that listing a `@ModName` link (which
// points into the shared workshop tree) shows the mod's contents, not
// the symlink itself. Without this, `ls -l` on a symlinked dir prints
// the symlink as a single entry and the UI looks empty.
stdout, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{
"sh", "-c",
"ls -lAL --time-style=+%s -- " + shellQuote(abs),
})
if err != nil {
return nil, fmt.Errorf("exec ls: %w", err)
}
if code != 0 {
msg := strings.TrimSpace(string(stderr))
if msg == "" {
msg = fmt.Sprintf("ls exited %d", code)
}
return nil, fmt.Errorf("%s", msg)
}
return parseLsOutput(string(stdout), rel)
}
// parseLsOutput parses `ls -lA --time-style=+%s` output. Each line:
//
// drwxr-xr-x 2 root root 4096 1735000000 somefile
//
// We split on any whitespace and take fields. The date field is a unix
// timestamp (because of --time-style=+%s).
func parseLsOutput(out, relRoot string) ([]*panelv1.FsEntry, error) {
lines := strings.Split(strings.TrimSpace(out), "\n")
entries := make([]*panelv1.FsEntry, 0, len(lines))
for i, line := range lines {
if i == 0 && strings.HasPrefix(line, "total ") {
continue
}
// Split into at most 7 fields so filenames with spaces survive.
fields := strings.Fields(line)
if len(fields) < 7 {
continue
}
// fields[0]=mode 1=nlink 2=user 3=group 4=size 5=unixts 6+=name
size, _ := strconv.ParseInt(fields[4], 10, 64)
tsUnix, _ := strconv.ParseInt(fields[5], 10, 64)
name := strings.Join(fields[6:], " ")
// For symlinks "name -> target", keep just the name.
if strings.Contains(name, " -> ") {
name = strings.SplitN(name, " -> ", 2)[0]
}
if name == "" || name == "." || name == ".." {
continue
}
isDir := strings.HasPrefix(fields[0], "d")
entries = append(entries, &panelv1.FsEntry{
Name: name,
Path: path.Join(relRoot, name),
IsDir: isDir,
Size: size,
ModTime: timestamppb.New(time.Unix(tsUnix, 0)),
})
}
return entries, nil
}
// shellQuote wraps s in single quotes, escaping embedded quotes for
// interpolation into `sh -c`.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func (d *Dispatcher) sendFsListResult(corrID string, entries []*panelv1.FsEntry, p, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsListResult{
FsListResult: &panelv1.FsListResult{Entries: entries, Path: p, Error: errMsg},
},
})
}
// ---- FsRead ----
func (d *Dispatcher) handleFsRead(corrID string, req *panelv1.FsReadRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
if d.useContainerOps(rec) {
abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.Path)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
data, err := d.runtime.CopyFileFromContainer(ctx, targetID, abs)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
if len(data) > fsMaxReadBytes {
d.sendFsReadResult(corrID, req.Path, nil, fmt.Sprintf("file too large (%d bytes; max %d)", len(data), fsMaxReadBytes))
return
}
d.sendFsReadResult(corrID, req.Path, data, "")
return
}
// Host fallback.
if rec.DataPath == "" {
d.sendFsReadResult(corrID, req.Path, nil, "no file storage available")
return
}
abs, err := safeJoinHost(rec.DataPath, req.Path)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
info, err := os.Stat(abs)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
if info.IsDir() {
d.sendFsReadResult(corrID, req.Path, nil, "is a directory")
return
}
if info.Size() > fsMaxReadBytes {
d.sendFsReadResult(corrID, req.Path, nil, fmt.Sprintf("file too large (%d bytes; max %d)", info.Size(), fsMaxReadBytes))
return
}
data, err := os.ReadFile(abs)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
d.sendFsReadResult(corrID, req.Path, data, "")
}
func (d *Dispatcher) sendFsReadResult(corrID, p string, content []byte, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsReadResult{
FsReadResult: &panelv1.FsReadResult{Path: p, Content: content, Error: errMsg},
},
})
}
// ---- FsWrite ----
func (d *Dispatcher) handleFsWrite(corrID string, req *panelv1.FsWriteRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
if d.useContainerOps(rec) {
abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.Path)
if err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
// Make sure the parent dir exists. `mkdir -p` inside container.
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
targetID, terr := d.getFsTargetContainerID(ctx, rec)
if terr != nil {
cancel()
d.sendFsWriteResult(corrID, 0, terr.Error())
return
}
_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mkdir -p -- " + shellQuote(path.Dir(abs))})
cancel()
ctx2, cancel2 := context.WithTimeout(context.Background(), fsCopyTimeout)
defer cancel2()
if err := d.runtime.CopyFileToContainer(ctx2, targetID, abs, req.Content); err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
d.sendFsWriteResult(corrID, int64(len(req.Content)), "")
return
}
// Host fallback.
if rec.DataPath == "" {
d.sendFsWriteResult(corrID, 0, "no file storage available")
return
}
abs, err := safeJoinHost(rec.DataPath, req.Path)
if err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
if err := os.WriteFile(abs, req.Content, 0o644); err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
d.sendFsWriteResult(corrID, int64(len(req.Content)), "")
}
func (d *Dispatcher) sendFsWriteResult(corrID string, bytesWritten int64, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsWriteResult{
FsWriteResult: &panelv1.FsWriteResult{BytesWritten: bytesWritten, Error: errMsg},
},
})
}
// ---- FsDelete ----
func (d *Dispatcher) handleFsDelete(corrID string, req *panelv1.FsDeleteRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsDeleteResult(corrID, err.Error())
return
}
if d.useContainerOps(rec) {
abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.Path)
if err != nil {
d.sendFsDeleteResult(corrID, err.Error())
return
}
if abs == rec.BrowseableRoot {
d.sendFsDeleteResult(corrID, "refusing to delete instance root")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsDeleteResult(corrID, err.Error())
return
}
flag := ""
if req.Recursive {
flag = "-rf"
} else {
flag = "-f"
}
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"rm " + flag + " -- " + shellQuote(abs)})
if err != nil {
d.sendFsDeleteResult(corrID, err.Error())
return
}
if code != 0 {
d.sendFsDeleteResult(corrID, strings.TrimSpace(string(stderr)))
return
}
d.sendFsDeleteResult(corrID, "")
return
}
// Host fallback.
if rec.DataPath == "" {
d.sendFsDeleteResult(corrID, "no file storage available")
return
}
abs, err := safeJoinHost(rec.DataPath, req.Path)
if err != nil {
d.sendFsDeleteResult(corrID, err.Error())
return
}
if abs == rec.DataPath {
d.sendFsDeleteResult(corrID, "refusing to delete instance data root")
return
}
var delErr error
if req.Recursive {
delErr = os.RemoveAll(abs)
} else {
delErr = os.Remove(abs)
}
if delErr != nil {
d.sendFsDeleteResult(corrID, delErr.Error())
return
}
d.sendFsDeleteResult(corrID, "")
}
func (d *Dispatcher) sendFsDeleteResult(corrID, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsDeleteResult{
FsDeleteResult: &panelv1.FsDeleteResult{Error: errMsg},
},
})
}