433 lines
10 KiB
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
|
|
}
|