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