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 ` 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}, }, }) }