panel v0.9.1 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:25:11 -07:00
commit 03a281d009
2161 changed files with 300880 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
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
}