4ccccc6fe2
Self-hostable game server control panel: Go controller + agent, 26 game modules, embedded web UI. One-line install via install.sh / install.ps1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
663 lines
21 KiB
Go
663 lines
21 KiB
Go
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},
|
|
},
|
|
})
|
|
}
|