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