panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
|
||||
)
|
||||
|
||||
// Backup browse — list / read entries inside a backup tarball without
|
||||
// extracting it. The agent has direct host access to the .tar.gz under
|
||||
// $BACKUP_DIR, so we don't need a sidecar; standard library archive/tar
|
||||
// + compress/gzip handles both the list and the single-file extract.
|
||||
//
|
||||
// Sized for safety: BackupReadFile caps the in-memory buffer per-call
|
||||
// (default 8 MiB) so a malicious or corrupt tar entry can't OOM the
|
||||
// agent. Truncation is reported back to the caller so the UI can show
|
||||
// "(truncated, download to see full file)".
|
||||
|
||||
const (
|
||||
defaultBackupReadCap = 8 << 20 // 8 MiB
|
||||
maxBackupReadCap = 64 << 20 // 64 MiB hard ceiling regardless of caller request
|
||||
binarySniffLen = 8 << 10 // 8 KiB
|
||||
)
|
||||
|
||||
func (d *Dispatcher) handleBackupListEntries(corrID string, req *panelv1.BackupListEntriesRequest) {
|
||||
d.log.Info("backup list entries: start", "correlation_id", corrID, "backup_path", req.BackupPath, "instance_id", req.InstanceId)
|
||||
if req.BackupPath == "" {
|
||||
d.sendBackupListEntriesResult(corrID, nil, 0, "backup_path required")
|
||||
return
|
||||
}
|
||||
f, err := os.Open(req.BackupPath)
|
||||
if err != nil {
|
||||
d.sendBackupListEntriesResult(corrID, nil, 0, "open backup: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
d.sendBackupListEntriesResult(corrID, nil, 0, "gzip: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
|
||||
var entries []*panelv1.BackupTarEntry
|
||||
var totalBytes int64
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
d.sendBackupListEntriesResult(corrID, nil, 0, "tar header: "+err.Error())
|
||||
return
|
||||
}
|
||||
isDir := hdr.Typeflag == tar.TypeDir || strings.HasSuffix(hdr.Name, "/")
|
||||
clean := strings.TrimPrefix(hdr.Name, "./")
|
||||
clean = strings.TrimPrefix(clean, "/")
|
||||
entry := &panelv1.BackupTarEntry{
|
||||
Path: clean,
|
||||
Size: hdr.Size,
|
||||
IsDir: isDir,
|
||||
Mode: hdr.FileInfo().Mode().String(),
|
||||
ModTime: timestamppb.New(hdr.ModTime),
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
if !isDir {
|
||||
totalBytes += hdr.Size
|
||||
}
|
||||
}
|
||||
d.log.Info("backup list entries: ok", "correlation_id", corrID, "count", len(entries), "total_bytes", totalBytes)
|
||||
d.sendBackupListEntriesResult(corrID, entries, totalBytes, "")
|
||||
}
|
||||
|
||||
func (d *Dispatcher) handleBackupReadFile(corrID string, req *panelv1.BackupReadFileRequest) {
|
||||
if req.BackupPath == "" {
|
||||
d.sendBackupReadFileResult(corrID, nil, 0, false, false, "backup_path required")
|
||||
return
|
||||
}
|
||||
if req.EntryPath == "" {
|
||||
d.sendBackupReadFileResult(corrID, nil, 0, false, false, "entry_path required")
|
||||
return
|
||||
}
|
||||
maxRead := req.MaxBytes
|
||||
if maxRead <= 0 {
|
||||
maxRead = defaultBackupReadCap
|
||||
}
|
||||
if maxRead > maxBackupReadCap {
|
||||
maxRead = maxBackupReadCap
|
||||
}
|
||||
|
||||
f, err := os.Open(req.BackupPath)
|
||||
if err != nil {
|
||||
d.sendBackupReadFileResult(corrID, nil, 0, false, false, "open backup: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
d.sendBackupReadFileResult(corrID, nil, 0, false, false, "gzip: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
|
||||
// Match by exact path; tarballs sometimes prefix entries with "./" so
|
||||
// also accept that variant.
|
||||
want := strings.TrimPrefix(req.EntryPath, "/")
|
||||
wantAlt := "./" + want
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
d.sendBackupReadFileResult(corrID, nil, 0, false, false, fmt.Sprintf("entry %q not found in backup", req.EntryPath))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
d.sendBackupReadFileResult(corrID, nil, 0, false, false, "tar header: "+err.Error())
|
||||
return
|
||||
}
|
||||
if hdr.Name != want && hdr.Name != wantAlt {
|
||||
continue
|
||||
}
|
||||
if hdr.Typeflag == tar.TypeDir || strings.HasSuffix(hdr.Name, "/") {
|
||||
d.sendBackupReadFileResult(corrID, nil, hdr.Size, false, false, "entry is a directory")
|
||||
return
|
||||
}
|
||||
// Read up to maxRead+1 to detect truncation cleanly.
|
||||
buf := &bytes.Buffer{}
|
||||
_, err = io.Copy(buf, io.LimitReader(tr, maxRead+1))
|
||||
if err != nil {
|
||||
d.sendBackupReadFileResult(corrID, nil, hdr.Size, false, false, "read entry: "+err.Error())
|
||||
return
|
||||
}
|
||||
truncated := false
|
||||
content := buf.Bytes()
|
||||
if int64(len(content)) > maxRead {
|
||||
content = content[:maxRead]
|
||||
truncated = true
|
||||
}
|
||||
isBinary := looksBinary(content)
|
||||
d.sendBackupReadFileResult(corrID, content, hdr.Size, truncated, isBinary, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// looksBinary returns true if the first binarySniffLen bytes contain a
|
||||
// NUL byte. Standard heuristic — text files don't contain NULs, binaries
|
||||
// almost always do (executables, images, compressed save formats).
|
||||
func looksBinary(b []byte) bool {
|
||||
limit := binarySniffLen
|
||||
if len(b) < limit {
|
||||
limit = len(b)
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
if b[i] == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *Dispatcher) sendBackupListEntriesResult(corrID string, entries []*panelv1.BackupTarEntry, totalBytes int64, errMsg string) {
|
||||
d.sendEnv(&panelv1.AgentEnvelope{
|
||||
CorrelationId: corrID,
|
||||
SentAt: timestamppb.Now(),
|
||||
Payload: &panelv1.AgentEnvelope_BackupListEntriesResult{
|
||||
BackupListEntriesResult: &panelv1.BackupListEntriesResult{
|
||||
Entries: entries,
|
||||
TotalBytes: totalBytes,
|
||||
Error: errMsg,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Dispatcher) sendBackupReadFileResult(corrID string, content []byte, size int64, truncated, isBinary bool, errMsg string) {
|
||||
d.sendEnv(&panelv1.AgentEnvelope{
|
||||
CorrelationId: corrID,
|
||||
SentAt: timestamppb.Now(),
|
||||
Payload: &panelv1.AgentEnvelope_BackupReadFileResult{
|
||||
BackupReadFileResult: &panelv1.BackupReadFileResult{
|
||||
Content: content,
|
||||
Size: size,
|
||||
Truncated: truncated,
|
||||
IsBinary: isBinary,
|
||||
Error: errMsg,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user