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" ) type Manifest struct { ID string `json:"id"` CreatedAtUTC string `json:"created_at_utc"` 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"` } func Create(cfg *state.Config) (Manifest, error) { if err := requireMinecraft(cfg); err != nil { return Manifest{}, err } if err := os.MkdirAll(RootDir, 0o755); err != nil { return Manifest{}, err } id := time.Now().UTC().Format("20060102T150405Z") archiveName := id + ".tar.gz" archivePath := filepath.Join(RootDir, archiveName) serverRoot := provision.ServerDir(*cfg) paths := defaultPaths(cfg, serverRoot) _, running := system.GetServerPID() saveOff := false if running { state.SetOperationMessage("flushing minecraft saves") if err := system.RunMinecraftSaveOff(); err != nil { return Manifest{}, fmt.Errorf("disable minecraft saves: %w", err) } saveOff = true defer func() { if saveOff { _ = system.RunMinecraftSaveOn() } }() } state.SetOperationMessage("creating backup archive") manifest := Manifest{ ID: id, CreatedAtUTC: time.Now().UTC().Format(time.RFC3339), ContainerType: cfg.ContainerType, Game: cfg.Game, Variant: cfg.Variant, Version: cfg.Version, VMID: cfg.VMID, Archive: archiveName, Paths: paths, } if err := writeArchive(serverRoot, archivePath, &manifest); err != nil { return Manifest{}, err } if saveOff { if err := system.RunMinecraftSaveOn(); err != nil { return Manifest{}, fmt.Errorf("enable minecraft saves: %w", err) } saveOff = false } if err := writeManifestSidecar(manifest); err != nil { return Manifest{}, err } if err := prune(defaultMaxCount); err != nil { return Manifest{}, err } return manifest, 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 Restore(cfg *state.Config, id string) (Manifest, error) { if err := requireMinecraft(cfg); err != nil { return Manifest{}, err } id = strings.TrimSpace(id) if !safeID(id) { return Manifest{}, fmt.Errorf("invalid backup id") } manifest, err := readManifestSidecar(id) if err != nil { return Manifest{}, err } archivePath := filepath.Join(RootDir, manifest.Archive) if _, err := os.Stat(archivePath); err != nil { return Manifest{}, err } if _, running := system.GetServerPID(); running { state.SetOperationMessage("stopping server before restore") if err := system.StopServerAndWait(30 * time.Second); err != nil { return Manifest{}, err } } state.SetOperationMessage("restoring backup archive") if err := restoreArchive(provision.ServerDir(*cfg), archivePath, manifest.Paths); err != nil { return Manifest{}, err } state.SetOperationMessage("starting server after restore") state.SetState(state.StateStarting) state.SetReadyState(false, "", "") if err := system.StartServerReady(cfg); err != nil { return Manifest{}, err } return manifest, 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 { 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 { if err := addPath(tw, serverRoot, rel, manifest); err != nil { return err } } 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) 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" { 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() { 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 return nil }) } func restoreArchive(serverRoot, archivePath string, paths []string) error { for _, rel := range paths { if !safeRel(rel) { return fmt.Errorf("backup contains unsafe 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 { 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 } 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 } 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 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") } 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 }