0a941f3ba6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
199 lines
5.7 KiB
Go
199 lines
5.7 KiB
Go
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,
|
|
},
|
|
},
|
|
})
|
|
}
|