4cf3471398
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
512 lines
15 KiB
Go
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},
|
|
},
|
|
})
|
|
}
|