Files
panel/agent/internal/dispatch/files_archive.go
T
dbledeez a00bd620a1 Panel — public source drop (v0.9.0)
Self-hostable game-server control panel: controller + agent + 26 game
modules. One-line install (prebuilt release, no Go required):

  curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:17:39 -07:00

512 lines
15 KiB
Go

package dispatch
// Archive operations for the file browser:
//
// - FsExtract : expand a zip / tar / tar.gz / tar.bz2 archive that
// already lives inside the instance's volume.
// - FsCompress : zip up one or more paths inside the instance into a
// destination .zip in the same volume.
// - FsRename : safe `mv` that respects the browseable_root sandbox.
//
// Every container-side path goes through the existing fs helper sidecar
// (see getFsTargetContainerID) so these all work on stopped instances.
//
// Implementation notes:
// - Extract reads the archive bytes via CopyFileFromContainer, walks
// the entries with the appropriate stdlib reader, and re-emits a
// single tar stream that's piped to CopyTarToContainer in one API
// call. That avoids N per-file writes.
// - Compress walks each source via CopyDirFromContainer (tar stream
// from Docker), and re-encodes the entries as zip. The final zip
// bytes go back via CopyFileToContainer.
// - We support zip + tar + tar.gz + tar.bz2 + tar.xz + 7z + rar.
// 7z and rar use pure-Go libraries (bodgit/sevenzip,
// nwaples/rardecode/v2) so no native deps. Encrypted rars and
// solid 7z volumes are surfaced as errors, not silently skipped.
import (
"archive/tar"
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/dbledeez/panel/agent/internal/archive"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// ---- FsExtract ----
func (d *Dispatcher) handleFsExtract(corrID string, req *panelv1.FsExtractRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
if req.ArchivePath == "" {
d.sendFsExtractResult(corrID, 0, 0, "archive_path is required")
return
}
if d.useContainerOps(rec) {
d.extractInContainer(corrID, rec, req)
return
}
d.extractOnHost(corrID, rec, req)
}
func (d *Dispatcher) extractInContainer(corrID string, rec *instanceRecord, req *panelv1.FsExtractRequest) {
archiveAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.ArchivePath)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
destRel := req.DestDir
if destRel == "" {
// Default: same dir as the archive.
destRel = path.Dir(req.ArchivePath)
}
destAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), destRel)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), fsCopyTimeout)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
// Pull the archive bytes back from the container. CopyFileFromContainer
// internally tar-walks Docker's response and returns just the file body.
archiveData, err := d.runtime.CopyFileFromContainer(ctx, targetID, archiveAbs)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, fmt.Errorf("read archive: %w", err).Error())
return
}
// Make sure the destination dir exists before we ship the tar stream.
_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mkdir -p -- " + shellQuote(destAbs)})
// Build a tar stream of the archive's contents and pipe it to
// CopyTarToContainer. Doing this in a single round-trip keeps the
// wall-time of "extract a 500 MB archive with 4000 entries" manageable.
pr, pw := io.Pipe()
emitErr := make(chan error, 1)
var entries, bytesOut int64
go func() {
n, b, err := archive.WriteAsTar(archiveData, req.ArchivePath, pw)
entries, bytesOut = n, b
_ = pw.CloseWithError(err)
emitErr <- err
}()
if err := d.runtime.CopyTarToContainer(ctx, targetID, destAbs, pr); err != nil {
// Pipe gets closed with our error from the goroutine; whichever
// side surfaces first wins. We prefer the goroutine error since
// it carries archive-format detail.
if encErr := <-emitErr; encErr != nil {
d.sendFsExtractResult(corrID, entries, bytesOut, encErr.Error())
return
}
d.sendFsExtractResult(corrID, entries, bytesOut, fmt.Errorf("copy to container: %w", err).Error())
return
}
if encErr := <-emitErr; encErr != nil {
d.sendFsExtractResult(corrID, entries, bytesOut, encErr.Error())
return
}
d.sendFsExtractResult(corrID, entries, bytesOut, "")
}
// extractOnHost handles the rare host-fallback case (no container, no
// helper) — uses os primitives. Same path-safety contract as everywhere
// else: must stay under the instance's data path.
func (d *Dispatcher) extractOnHost(corrID string, rec *instanceRecord, req *panelv1.FsExtractRequest) {
if rec.DataPath == "" {
d.sendFsExtractResult(corrID, 0, 0, "no file storage available")
return
}
archiveAbs, err := safeJoinHost(rec.DataPath, req.ArchivePath)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
destRel := req.DestDir
if destRel == "" {
destRel = filepath.Dir(req.ArchivePath)
}
destAbs, err := safeJoinHost(rec.DataPath, destRel)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
if err := os.MkdirAll(destAbs, 0o755); err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
data, err := os.ReadFile(archiveAbs)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
entries, bytesOut, err := archive.Walk(data, req.ArchivePath, func(name string, mode int64, isDir bool, body io.Reader) error {
safeName := archive.SafeEntryPath(name)
if safeName == "" {
return nil
}
out := filepath.Join(destAbs, filepath.FromSlash(safeName))
if isDir {
return os.MkdirAll(out, 0o755)
}
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(mode&0o777))
if err != nil {
return err
}
if _, err := io.Copy(f, body); err != nil {
_ = f.Close()
return err
}
return f.Close()
})
if err != nil {
d.sendFsExtractResult(corrID, entries, bytesOut, err.Error())
return
}
d.sendFsExtractResult(corrID, entries, bytesOut, "")
}
func (d *Dispatcher) sendFsExtractResult(corrID string, entries, bytesOut int64, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsExtractResult{
FsExtractResult: &panelv1.FsExtractResult{EntriesWritten: entries, BytesWritten: bytesOut, Error: errMsg},
},
})
}
// ---- FsCompress ----
func (d *Dispatcher) handleFsCompress(corrID string, req *panelv1.FsCompressRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
if len(req.Sources) == 0 {
d.sendFsCompressResult(corrID, 0, 0, "at least one source path is required")
return
}
if req.DestZipPath == "" {
d.sendFsCompressResult(corrID, 0, 0, "dest_zip_path is required")
return
}
if d.useContainerOps(rec) {
d.compressInContainer(corrID, rec, req)
return
}
d.compressOnHost(corrID, rec, req)
}
func (d *Dispatcher) compressInContainer(corrID string, rec *instanceRecord, req *panelv1.FsCompressRequest) {
destAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.DestZipPath)
if err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), fsCopyTimeout)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
var zipBuf bytes.Buffer
zw := zip.NewWriter(&zipBuf)
var entries, bytesOut int64
for _, src := range req.Sources {
srcAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), src)
if err != nil {
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
// CopyDirFromContainer returns Docker's tar stream of srcAbs.
// For files: a single-entry tar. For dirs: every file under it.
// The base name of srcAbs is the prefix Docker uses, which we
// keep in the zip so the user gets familiar names back.
rc, err := d.runtime.CopyDirFromContainer(ctx, targetID, srcAbs)
if err != nil {
d.sendFsCompressResult(corrID, entries, bytesOut, fmt.Errorf("copy from container %q: %w", src, err).Error())
return
}
n, b, err := pipeTarIntoZip(rc, zw)
_ = rc.Close()
if err != nil {
d.sendFsCompressResult(corrID, entries+n, bytesOut+b, fmt.Errorf("encode %q: %w", src, err).Error())
return
}
entries += n
bytesOut += b
}
if err := zw.Close(); err != nil {
d.sendFsCompressResult(corrID, entries, bytesOut, fmt.Errorf("zip close: %w", err).Error())
return
}
// Drop the resulting zip into the volume.
_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mkdir -p -- " + shellQuote(path.Dir(destAbs))})
if err := d.runtime.CopyFileToContainer(ctx, targetID, destAbs, zipBuf.Bytes()); err != nil {
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
d.sendFsCompressResult(corrID, entries, bytesOut, "")
}
func (d *Dispatcher) compressOnHost(corrID string, rec *instanceRecord, req *panelv1.FsCompressRequest) {
if rec.DataPath == "" {
d.sendFsCompressResult(corrID, 0, 0, "no file storage available")
return
}
destAbs, err := safeJoinHost(rec.DataPath, req.DestZipPath)
if err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
if err := os.MkdirAll(filepath.Dir(destAbs), 0o755); err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
f, err := os.Create(destAbs)
if err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
zw := zip.NewWriter(f)
var entries, bytesOut int64
for _, src := range req.Sources {
srcAbs, err := safeJoinHost(rec.DataPath, src)
if err != nil {
zw.Close()
f.Close()
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
base := filepath.Base(srcAbs)
err = filepath.Walk(srcAbs, func(p string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(srcAbs, p)
if err != nil {
return err
}
zipName := base
if rel != "." {
zipName = filepath.ToSlash(filepath.Join(base, rel))
}
if info.IsDir() {
if zipName == "" || zipName == "." {
return nil
}
_, err = zw.Create(zipName + "/")
return err
}
zf, err := zw.Create(zipName)
if err != nil {
return err
}
rf, err := os.Open(p)
if err != nil {
return err
}
defer rf.Close()
n, err := io.Copy(zf, rf)
if err != nil {
return err
}
entries++
bytesOut += n
return nil
})
if err != nil {
zw.Close()
f.Close()
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
}
if err := zw.Close(); err != nil {
f.Close()
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
if err := f.Close(); err != nil {
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
d.sendFsCompressResult(corrID, entries, bytesOut, "")
}
// pipeTarIntoZip walks a docker-style tar stream and writes each
// regular-file entry into the zip writer, preserving relative paths.
// Directory entries are written as zero-byte zip directory markers.
func pipeTarIntoZip(r io.Reader, zw *zip.Writer) (int64, int64, error) {
tr := tar.NewReader(r)
var entries, bytesOut, totalBytes int64
for {
hdr, err := tr.Next()
if err == io.EOF {
return entries, bytesOut, nil
}
if err != nil {
return entries, bytesOut, err
}
safe := archive.SafeEntryPath(hdr.Name)
if safe == "" {
continue
}
switch hdr.Typeflag {
case tar.TypeDir:
if _, err := zw.Create(safe + "/"); err != nil {
return entries, bytesOut, err
}
entries++
case tar.TypeReg, tar.TypeRegA:
zf, err := zw.Create(safe)
if err != nil {
return entries, bytesOut, err
}
n, err := io.Copy(zf, tr)
if err != nil {
return entries, bytesOut, err
}
entries++
bytesOut += n
totalBytes += n
if totalBytes > archive.MaxTotalBytes {
return entries, bytesOut, fmt.Errorf("compress: total uncompressed bytes exceed %d", archive.MaxTotalBytes)
}
}
}
}
func (d *Dispatcher) sendFsCompressResult(corrID string, entries, bytesOut int64, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsCompressResult{
FsCompressResult: &panelv1.FsCompressResult{EntriesWritten: entries, BytesWritten: bytesOut, Error: errMsg},
},
})
}
// ---- FsRename ----
func (d *Dispatcher) handleFsRename(corrID string, req *panelv1.FsRenameRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
if req.FromPath == "" || req.ToPath == "" {
d.sendFsRenameResult(corrID, "from_path and to_path are required")
return
}
if d.useContainerOps(rec) {
fromAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.FromPath)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
toAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.ToPath)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
if fromAbs == rec.BrowseableRoot {
d.sendFsRenameResult(corrID, "refusing to rename instance root")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
// mkdir parent so a "rename to subdir" implicitly creates it.
_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mkdir -p -- " + shellQuote(path.Dir(toAbs))})
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mv -- " + shellQuote(fromAbs) + " " + shellQuote(toAbs)})
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
if code != 0 {
msg := strings.TrimSpace(string(stderr))
if msg == "" {
msg = fmt.Sprintf("mv exited %d", code)
}
d.sendFsRenameResult(corrID, msg)
return
}
d.sendFsRenameResult(corrID, "")
return
}
// Host fallback.
if rec.DataPath == "" {
d.sendFsRenameResult(corrID, "no file storage available")
return
}
fromAbs, err := safeJoinHost(rec.DataPath, req.FromPath)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
toAbs, err := safeJoinHost(rec.DataPath, req.ToPath)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
if fromAbs == rec.DataPath {
d.sendFsRenameResult(corrID, "refusing to rename instance root")
return
}
if err := os.MkdirAll(filepath.Dir(toAbs), 0o755); err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
if err := os.Rename(fromAbs, toAbs); err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
d.sendFsRenameResult(corrID, "")
}
func (d *Dispatcher) sendFsRenameResult(corrID, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsRenameResult{
FsRenameResult: &panelv1.FsRenameResult{Error: errMsg},
},
})
}