panel public release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:26:41 -07:00
commit 295eb22826
2165 changed files with 301492 additions and 0 deletions
+198
View File
@@ -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,
},
},
})
}