panel v0.9.2 — public release
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>
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
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},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user