a00bd620a1
Self-hostable game-server control panel: controller + agent + 26 game modules. One-line install (prebuilt release, no Go required): curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
192 lines
6.5 KiB
Go
192 lines
6.5 KiB
Go
package regionmedic
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// BackupRef identifies one panel backup tarball for an instance.
|
|
type BackupRef struct {
|
|
Path string `json:"path"`
|
|
ID string `json:"id,omitempty"` // "bkp_<hex>" parsed from the name
|
|
CreatedAt time.Time `json:"created_at"` // parsed from the filename (UTC)
|
|
}
|
|
|
|
// backupTimeLayout matches the panel backup filename timestamp
|
|
// (time.Now().UTC().Format) — see agent/internal/dispatch/backup.go.
|
|
const backupTimeLayout = "20060102-150405"
|
|
|
|
// ListPanelBackups reads an instance's backup directory
|
|
// (<agent --backup-dir>/<instance_id>/) and returns the backups sorted newest
|
|
// first, parsing the UTC timestamp + bkp id out of each filename:
|
|
//
|
|
// 20060102-150405-bkp_<hex>.tar.gz
|
|
//
|
|
// Files that don't match the pattern are skipped.
|
|
func ListPanelBackups(instanceBackupDir string) ([]BackupRef, error) {
|
|
ents, err := os.ReadDir(instanceBackupDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var refs []BackupRef
|
|
for _, e := range ents {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if !strings.HasSuffix(name, ".tar.gz") {
|
|
continue
|
|
}
|
|
ref, ok := parseBackupName(name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
ref.Path = filepath.Join(instanceBackupDir, name)
|
|
refs = append(refs, ref)
|
|
}
|
|
sort.Slice(refs, func(i, j int) bool { return refs[i].CreatedAt.After(refs[j].CreatedAt) })
|
|
return refs, nil
|
|
}
|
|
|
|
// parseBackupName parses "20060102-150405-bkp_<hex>.tar.gz".
|
|
func parseBackupName(name string) (BackupRef, bool) {
|
|
base := strings.TrimSuffix(name, ".tar.gz")
|
|
// timestamp is the first 15 chars: YYYYMMDD-HHMMSS
|
|
if len(base) < len(backupTimeLayout) {
|
|
return BackupRef{}, false
|
|
}
|
|
tsPart := base[:len(backupTimeLayout)]
|
|
ts, err := time.Parse(backupTimeLayout, tsPart)
|
|
if err != nil {
|
|
return BackupRef{}, false
|
|
}
|
|
ref := BackupRef{CreatedAt: ts.UTC()}
|
|
if i := strings.Index(base, "bkp_"); i >= 0 {
|
|
ref.ID = base[i:]
|
|
}
|
|
return ref, true
|
|
}
|
|
|
|
// ExtractRegionFromBackup pulls one region file out of a panel backup tarball.
|
|
// The tar stores paths relative to the backup root, and a single backup may
|
|
// contain SEVERAL worlds (e.g. ".../Saves/Navezgane/MyGame/Region/r.0.0.7rg"
|
|
// AND ".../Saves/West Apeeni Mountains/MapRender/Region/r.0.0.7rg"). The caller
|
|
// MUST therefore pass a world-qualified entry suffix — e.g.
|
|
// "Saves/Navezgane/MyGame/Region/r.0.0.7rg" — so the correct world's region is
|
|
// returned; a bare "r.0.0.7rg" would be ambiguous and could heal from the wrong
|
|
// world. An entry matches when its cleaned path equals entry or ends with
|
|
// "/"+entry. Returns ErrRegionNotInBackup if no entry matches.
|
|
func ExtractRegionFromBackup(backupPath, entry string) ([]byte, error) {
|
|
f, err := os.Open(backupPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
gz, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gzip: %w", err)
|
|
}
|
|
defer gz.Close()
|
|
want := strings.TrimPrefix(entry, "/")
|
|
tr := tar.NewReader(gz)
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tar: %w", err)
|
|
}
|
|
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
|
continue
|
|
}
|
|
clean := strings.TrimPrefix(path.Clean(hdr.Name), "./")
|
|
if clean == want || strings.HasSuffix(clean, "/"+want) {
|
|
b, err := io.ReadAll(tr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read entry: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("%w: %s", ErrRegionNotInBackup, entry)
|
|
}
|
|
|
|
// SaveRelEntry converts an absolute Region directory and a region filename into
|
|
// the world-qualified backup entry suffix used by ExtractRegionFromBackup. A
|
|
// regionDir of ".../Saves/Navezgane/MyGame/Region" + "r.0.0.7rg" yields
|
|
// "Saves/Navezgane/MyGame/Region/r.0.0.7rg". If regionDir contains no "/Saves/"
|
|
// segment it falls back to the (ambiguous) "Region/<name>".
|
|
func SaveRelEntry(regionDir, regionFileName string) string {
|
|
slashed := filepath.ToSlash(regionDir)
|
|
if i := strings.Index(slashed, "/Saves/"); i >= 0 {
|
|
return slashed[i+1:] + "/" + regionFileName // from "Saves/" onward
|
|
}
|
|
if strings.HasPrefix(slashed, "Saves/") {
|
|
return slashed + "/" + regionFileName
|
|
}
|
|
return "Region/" + regionFileName
|
|
}
|
|
|
|
// ErrRegionNotInBackup is returned when a backup doesn't contain the region.
|
|
var ErrRegionNotInBackup = fmt.Errorf("region not found in backup")
|
|
|
|
// Candidate is a backup evaluated as a heal source for one region.
|
|
type Candidate struct {
|
|
Backup BackupRef `json:"backup"`
|
|
Found bool `json:"found"` // region present in this backup
|
|
Report RegionReport `json:"report"` // validation of the extracted region
|
|
Bytes []byte `json:"-"` // extracted region bytes (best only)
|
|
Note string `json:"note,omitempty"` // why skipped, if applicable
|
|
}
|
|
|
|
// Clean reports whether this candidate's region image validated healthy.
|
|
func (c Candidate) Clean() bool { return c.Found && c.Report.Healthy() }
|
|
|
|
// FindLastGoodRegion walks backups newest→oldest, extracts the region (using a
|
|
// world-qualified entry suffix — see SaveRelEntry) from each and validates it,
|
|
// and returns the newest CLEAN candidate (best.Found && best.Clean()). The full
|
|
// evaluated list is returned for logging/auditing. Only the chosen best carries
|
|
// Bytes (others are dropped to save memory). If no clean copy is found,
|
|
// best.Found is false.
|
|
//
|
|
// backups should already be sorted newest-first (ListPanelBackups does this);
|
|
// FindLastGoodRegion re-sorts defensively. An optional notAfter bound skips
|
|
// backups created at/after it (use the corruption time to avoid trusting a
|
|
// snapshot that already captured the damage); pass the zero time to disable.
|
|
func FindLastGoodRegion(backups []BackupRef, entry string, deep bool, notAfter time.Time) (best Candidate, evaluated []Candidate, err error) {
|
|
sorted := append([]BackupRef(nil), backups...)
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].CreatedAt.After(sorted[j].CreatedAt) })
|
|
|
|
for _, b := range sorted {
|
|
c := Candidate{Backup: b}
|
|
if !notAfter.IsZero() && !b.CreatedAt.Before(notAfter) {
|
|
c.Note = "skipped: at/after corruption time"
|
|
evaluated = append(evaluated, c)
|
|
continue
|
|
}
|
|
data, exErr := ExtractRegionFromBackup(b.Path, entry)
|
|
if exErr != nil {
|
|
c.Note = exErr.Error()
|
|
evaluated = append(evaluated, c)
|
|
continue
|
|
}
|
|
c.Found = true
|
|
c.Report = ValidateRegionBytes(data, deep)
|
|
if c.Report.Healthy() && !best.Found {
|
|
c.Bytes = data
|
|
best = c
|
|
}
|
|
evaluated = append(evaluated, c)
|
|
}
|
|
return best, evaluated, nil
|
|
}
|