zlh-agent/internal/backup/backup.go

433 lines
10 KiB
Go

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
}