package backup import ( "archive/tar" "compress/gzip" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" "zlh-agent/internal/provision" "zlh-agent/internal/state" "zlh-agent/internal/system" ) const ( RootDir = "/opt/zlh-agent/backups" defaultMaxCount = 10 manifestName = "backup_manifest.json" BackupTypeManual = "manual" BackupTypeCheckpoint = "checkpoint" BackupReasonPreRestore = "pre_restore" ) type Manifest struct { ID string `json:"id"` CreatedAtUTC string `json:"created_at_utc"` Type string `json:"type"` Reason string `json:"reason,omitempty"` ContainerType string `json:"container_type"` Game string `json:"game"` Variant string `json:"variant"` Version string `json:"version"` VMID int `json:"vmid"` Archive string `json:"archive"` Paths []string `json:"paths"` FileCount int `json:"file_count"` TotalBytes int64 `json:"total_bytes"` } type RestoreResult struct { Backup Manifest `json:"backup"` Checkpoint Manifest `json:"checkpoint"` } type createOptions struct { backupType string reason string prune bool } var ( rootDir = RootDir serverDir = provision.ServerDir getServerPID = system.GetServerPID runSaveOff = system.RunMinecraftSaveOff runSaveOn = system.RunMinecraftSaveOn stopServerAndWait = system.StopServerAndWait startServerReady = system.StartServerReady ) func Create(cfg *state.Config) (Manifest, error) { return create(cfg, createOptions{ backupType: BackupTypeManual, prune: true, }) } func createCheckpoint(cfg *state.Config) (Manifest, error) { return create(cfg, createOptions{ backupType: BackupTypeCheckpoint, reason: BackupReasonPreRestore, prune: false, }) } func create(cfg *state.Config, opts createOptions) (manifest Manifest, err error) { started := time.Now() backupLogf(cfg, "action=backup_create status=begin requested_type=%s reason=%s prune=%t", opts.backupType, opts.reason, opts.prune) defer func() { if err != nil { backupLogf(cfg, "action=backup_create status=failed elapsed_ms=%d err=%v", time.Since(started).Milliseconds(), err) return } backupLogf(cfg, "action=backup_create status=complete id=%s archive=%s paths=%d files=%d bytes=%d elapsed_ms=%d", manifest.ID, manifest.Archive, len(manifest.Paths), manifest.FileCount, manifest.TotalBytes, time.Since(started).Milliseconds()) }() if err := requireMinecraft(cfg); err != nil { return Manifest{}, err } if err := os.MkdirAll(rootDir, 0o755); err != nil { return Manifest{}, err } id, err := nextBackupID(time.Now().UTC()) if err != nil { return Manifest{}, err } archiveName := id + ".tar.gz" archivePath := filepath.Join(rootDir, archiveName) serverRoot := serverDir(*cfg) paths := defaultPaths(cfg, serverRoot) backupLogf(cfg, "action=backup_create step=metadata id=%s archive=%s root=%s server_root=%s paths=%q", id, archiveName, rootDir, serverRoot, paths) backupType := strings.TrimSpace(opts.backupType) if backupType == "" { backupType = BackupTypeManual } _, running := getServerPID() saveOff := false if running { state.SetOperationMessage("flushing minecraft saves") backupLogf(cfg, "action=backup_create step=save_off status=begin id=%s", id) if err := runSaveOff(); err != nil { return Manifest{}, fmt.Errorf("disable minecraft saves: %w", err) } backupLogf(cfg, "action=backup_create step=save_off status=complete id=%s", id) saveOff = true defer func() { if saveOff { backupLogf(cfg, "action=backup_create step=save_on status=deferred id=%s", id) _ = runSaveOn() } }() } else { backupLogf(cfg, "action=backup_create step=save_off status=skipped id=%s reason=server_not_running", id) } state.SetOperationMessage("creating backup archive") manifest = Manifest{ ID: id, CreatedAtUTC: time.Now().UTC().Format(time.RFC3339), Type: backupType, Reason: strings.TrimSpace(opts.reason), ContainerType: cfg.ContainerType, Game: cfg.Game, Variant: cfg.Variant, Version: cfg.Version, VMID: cfg.VMID, Archive: archiveName, Paths: paths, } backupLogf(cfg, "action=backup_create step=archive_write status=begin id=%s archive_path=%s", id, archivePath) if err := writeArchive(serverRoot, archivePath, &manifest); err != nil { return Manifest{}, err } backupLogf(cfg, "action=backup_create step=archive_write status=complete id=%s files=%d bytes=%d", id, manifest.FileCount, manifest.TotalBytes) if saveOff { backupLogf(cfg, "action=backup_create step=save_on status=begin id=%s", id) if err := runSaveOn(); err != nil { return Manifest{}, fmt.Errorf("enable minecraft saves: %w", err) } saveOff = false backupLogf(cfg, "action=backup_create step=save_on status=complete id=%s", id) } backupLogf(cfg, "action=backup_create step=manifest_sidecar status=begin id=%s", id) if err := writeManifestSidecar(manifest); err != nil { return Manifest{}, err } backupLogf(cfg, "action=backup_create step=manifest_sidecar status=complete id=%s", id) if opts.prune { backupLogf(cfg, "action=backup_create step=prune status=begin id=%s max_count=%d", id, defaultMaxCount) if err := prune(defaultMaxCount); err != nil { return Manifest{}, err } backupLogf(cfg, "action=backup_create step=prune status=complete id=%s", id) } return manifest, nil } func Restore(cfg *state.Config, id string) (result RestoreResult, err error) { started := time.Now() backupLogf(cfg, "action=backup_restore status=begin requested_id=%s", strings.TrimSpace(id)) defer func() { if err != nil { backupLogf(cfg, "action=backup_restore status=failed requested_id=%s elapsed_ms=%d err=%v", strings.TrimSpace(id), time.Since(started).Milliseconds(), err) return } backupLogf(cfg, "action=backup_restore status=complete backup_id=%s checkpoint_id=%s elapsed_ms=%d", result.Backup.ID, result.Checkpoint.ID, time.Since(started).Milliseconds()) }() if err := requireMinecraft(cfg); err != nil { return RestoreResult{}, err } id = strings.TrimSpace(id) if !safeID(id) { return RestoreResult{}, fmt.Errorf("invalid backup id") } manifest, err := readManifestSidecar(id) if err != nil { return RestoreResult{}, err } archivePath := filepath.Join(rootDir, manifest.Archive) backupLogf(cfg, "action=backup_restore step=manifest status=loaded id=%s archive=%s paths=%q files=%d bytes=%d", id, manifest.Archive, manifest.Paths, manifest.FileCount, manifest.TotalBytes) if _, err := os.Stat(archivePath); err != nil { return RestoreResult{}, err } backupLogf(cfg, "action=backup_restore step=archive_check status=ok id=%s archive_path=%s", id, archivePath) state.SetOperationMessage("creating pre-restore checkpoint") backupLogf(cfg, "action=backup_restore step=checkpoint status=begin target_id=%s", id) checkpoint, err := createCheckpoint(cfg) if err != nil { return RestoreResult{}, fmt.Errorf("create pre-restore checkpoint: %w", err) } backupLogf(cfg, "action=backup_restore step=checkpoint status=complete target_id=%s checkpoint_id=%s archive=%s", id, checkpoint.ID, checkpoint.Archive) if _, running := getServerPID(); running { state.SetOperationMessage("stopping server before restore") backupLogf(cfg, "action=backup_restore step=stop_server status=begin id=%s timeout=%s", id, 30*time.Second) if err := stopServerAndWait(30 * time.Second); err != nil { return RestoreResult{}, err } backupLogf(cfg, "action=backup_restore step=stop_server status=complete id=%s", id) } else { backupLogf(cfg, "action=backup_restore step=stop_server status=skipped id=%s reason=server_not_running", id) } state.SetOperationMessage("restoring backup archive") backupLogf(cfg, "action=backup_restore step=archive_restore status=begin id=%s archive_path=%s", id, archivePath) if err := restoreArchive(serverDir(*cfg), archivePath, manifest.Paths); err != nil { return RestoreResult{}, err } backupLogf(cfg, "action=backup_restore step=archive_restore status=complete id=%s", id) state.SetOperationMessage("starting server after restore") state.SetState(state.StateStarting) state.SetReadyState(false, "", "") backupLogf(cfg, "action=backup_restore step=start_server status=begin id=%s", id) if err := startServerReady(cfg); err != nil { return RestoreResult{}, err } backupLogf(cfg, "action=backup_restore step=start_server status=complete id=%s", id) result = RestoreResult{Backup: manifest, Checkpoint: checkpoint} return result, nil } func List() ([]Manifest, error) { entries, err := os.ReadDir(rootDir) if err != nil { if errors.Is(err, os.ErrNotExist) { return []Manifest{}, nil } return nil, err } out := make([]Manifest, 0, len(entries)) for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } manifest, err := readManifestSidecar(strings.TrimSuffix(entry.Name(), ".json")) if err == nil { out = append(out, manifest) } } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAtUTC > out[j].CreatedAtUTC }) return out, nil } func Delete(id string) error { id = strings.TrimSpace(id) if !safeID(id) { return fmt.Errorf("invalid backup id") } archiveName := id + ".tar.gz" if manifest, err := readManifestSidecar(id); err == nil && strings.TrimSpace(manifest.Archive) != "" { archiveName = filepath.Base(manifest.Archive) } archivePath := filepath.Join(rootDir, archiveName) manifestPath := filepath.Join(rootDir, id+".json") archiveErr := os.Remove(archivePath) if archiveErr != nil && !errors.Is(archiveErr, os.ErrNotExist) { return archiveErr } manifestErr := os.Remove(manifestPath) if manifestErr != nil && !errors.Is(manifestErr, os.ErrNotExist) { return manifestErr } if errors.Is(archiveErr, os.ErrNotExist) && errors.Is(manifestErr, os.ErrNotExist) { return os.ErrNotExist } return nil } func requireMinecraft(cfg *state.Config) error { if cfg == nil { return fmt.Errorf("config required") } if !strings.EqualFold(cfg.ContainerType, "game") { return fmt.Errorf("backups are only available for game containers") } if !strings.EqualFold(cfg.Game, "minecraft") { return fmt.Errorf("backups are only implemented for minecraft") } return nil } func defaultPaths(cfg *state.Config, serverRoot string) []string { candidates := []string{} world := strings.TrimSpace(cfg.World) if world == "" { world = "world" } candidates = append(candidates, world) candidates = append(candidates, "server.properties", "whitelist.json", "ops.json", "banned-players.json", "banned-ips.json", "config", ) paths := make([]string, 0, len(candidates)) seen := map[string]struct{}{} for _, rel := range candidates { rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel))) if rel == "." || rel == "" || strings.HasPrefix(rel, "../") || filepath.IsAbs(rel) { continue } if _, ok := seen[rel]; ok { continue } if _, err := os.Stat(filepath.Join(serverRoot, filepath.FromSlash(rel))); err == nil { paths = append(paths, rel) seen[rel] = struct{}{} } } return paths } func writeArchive(serverRoot, archivePath string, manifest *Manifest) error { backupLogf(nil, "action=archive_write status=begin backup_id=%s archive_path=%s server_root=%s paths=%q", manifest.ID, archivePath, serverRoot, manifest.Paths) file, err := os.Create(archivePath) if err != nil { return err } defer file.Close() gz := gzip.NewWriter(file) defer gz.Close() tw := tar.NewWriter(gz) defer tw.Close() for _, rel := range manifest.Paths { backupLogf(nil, "action=archive_write step=add_path status=begin backup_id=%s path=%s", manifest.ID, rel) if err := addPath(tw, serverRoot, rel, manifest); err != nil { return err } backupLogf(nil, "action=archive_write step=add_path status=complete backup_id=%s path=%s files=%d bytes=%d", manifest.ID, rel, manifest.FileCount, manifest.TotalBytes) } data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return err } data = append(data, '\n') header := &tar.Header{ Name: manifestName, Mode: 0o644, Size: int64(len(data)), ModTime: time.Now(), } if err := tw.WriteHeader(header); err != nil { return err } _, err = tw.Write(data) if err == nil { backupLogf(nil, "action=archive_write status=complete backup_id=%s files=%d bytes=%d", manifest.ID, manifest.FileCount, manifest.TotalBytes) } return err } func addPath(tw *tar.Writer, serverRoot, rel string, manifest *Manifest) error { abs := filepath.Join(serverRoot, filepath.FromSlash(rel)) return filepath.WalkDir(abs, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } info, err := d.Info() if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { return nil } name, err := filepath.Rel(serverRoot, path) if err != nil { return err } name = filepath.ToSlash(name) if name == "." || strings.HasPrefix(name, ".zlh-shadow/") || name == ".zlh-shadow" { backupLogf(nil, "action=archive_write step=skip_internal backup_id=%s path=%s", manifest.ID, name) return nil } header, err := tar.FileInfoHeader(info, "") if err != nil { return err } header.Name = name if err := tw.WriteHeader(header); err != nil { return err } if info.IsDir() { backupLogf(nil, "action=archive_write step=entry backup_id=%s type=dir path=%s", manifest.ID, name) return nil } f, err := os.Open(path) if err != nil { return err } n, err := io.Copy(tw, f) closeErr := f.Close() if err != nil { return err } if closeErr != nil { return closeErr } manifest.FileCount++ manifest.TotalBytes += n backupLogf(nil, "action=archive_write step=entry backup_id=%s type=file path=%s bytes=%d total_files=%d total_bytes=%d", manifest.ID, name, n, manifest.FileCount, manifest.TotalBytes) return nil }) } func restoreArchive(serverRoot, archivePath string, paths []string) error { backupLogf(nil, "action=archive_restore status=begin archive_path=%s server_root=%s paths=%q", archivePath, serverRoot, paths) for _, rel := range paths { if !safeRel(rel) { return fmt.Errorf("backup contains unsafe path: %s", rel) } backupLogf(nil, "action=archive_restore step=remove_existing path=%s", rel) if err := os.RemoveAll(filepath.Join(serverRoot, filepath.FromSlash(rel))); err != nil { return err } } file, err := os.Open(archivePath) if err != nil { return err } defer file.Close() gz, err := gzip.NewReader(file) if err != nil { return err } defer gz.Close() tr := tar.NewReader(gz) for { header, err := tr.Next() if errors.Is(err, io.EOF) { return nil } if err != nil { return err } if header.Name == manifestName { backupLogf(nil, "action=archive_restore step=skip_manifest path=%s", header.Name) continue } if !safeRel(header.Name) { return fmt.Errorf("archive contains unsafe path: %s", header.Name) } if !selectedPath(header.Name, paths) { return fmt.Errorf("archive contains unexpected path: %s", header.Name) } target := filepath.Join(serverRoot, filepath.FromSlash(header.Name)) switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, os.FileMode(header.Mode)&0o777); err != nil { return err } backupLogf(nil, "action=archive_restore step=entry type=dir path=%s mode=%#o", header.Name, os.FileMode(header.Mode)&0o777) case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)&0o777) if err != nil { return err } if _, err := io.Copy(out, tr); err != nil { out.Close() return err } if err := out.Close(); err != nil { return err } backupLogf(nil, "action=archive_restore step=entry type=file path=%s bytes=%d mode=%#o", header.Name, header.Size, os.FileMode(header.Mode)&0o777) default: return fmt.Errorf("unsupported archive entry type for %s", header.Name) } } } func writeManifestSidecar(manifest Manifest) error { data, err := json.MarshalIndent(manifest, "", " ") if err != nil { return err } data = append(data, '\n') return os.WriteFile(filepath.Join(rootDir, manifest.ID+".json"), data, 0o644) } func nextBackupID(now time.Time) (string, error) { base := now.UTC().Format("20060102T150405Z") for i := 0; i < 1000; i++ { id := base if i > 0 { id = fmt.Sprintf("%s_%03d", base, i) } if _, err := os.Stat(filepath.Join(rootDir, id+".json")); err == nil { continue } else if !errors.Is(err, os.ErrNotExist) { return "", err } if _, err := os.Stat(filepath.Join(rootDir, id+".tar.gz")); err == nil { continue } else if !errors.Is(err, os.ErrNotExist) { return "", err } return id, nil } return "", fmt.Errorf("unable to allocate backup id") } func readManifestSidecar(id string) (Manifest, error) { if !safeID(id) { return Manifest{}, fmt.Errorf("invalid backup id") } data, err := os.ReadFile(filepath.Join(rootDir, id+".json")) if err != nil { return Manifest{}, err } var manifest Manifest if err := json.Unmarshal(data, &manifest); err != nil { return Manifest{}, err } if manifest.ID != id { return Manifest{}, fmt.Errorf("backup manifest id mismatch") } if strings.TrimSpace(manifest.Type) == "" { manifest.Type = BackupTypeManual } return manifest, nil } func prune(maxCount int) error { if maxCount <= 0 { return nil } backups, err := List() if err != nil { return err } for i := maxCount; i < len(backups); i++ { _ = os.Remove(filepath.Join(rootDir, backups[i].Archive)) _ = os.Remove(filepath.Join(rootDir, backups[i].ID+".json")) } return nil } func safeID(id string) bool { if id == "" { return false } for _, r := range id { if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || r == 'T' || r == 'Z' || r == '-' || r == '_' { continue } return false } return true } func safeRel(rel string) bool { rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel))) return rel != "" && rel != "." && !filepath.IsAbs(rel) && rel != ".." && !strings.HasPrefix(rel, "../") } func selectedPath(name string, roots []string) bool { name = filepath.ToSlash(filepath.Clean(name)) for _, root := range roots { root = filepath.ToSlash(filepath.Clean(root)) if name == root || strings.HasPrefix(name, root+"/") { return true } } return false }