Files
pzmanager/internal/backup/backup.go
T
mrleo1nidandClaude Opus 5 277ab7af4a Профили серверов: несколько миров с раздельными настройками
Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и
лимит памяти JVM. Project Zomboid различает их по -servername, поэтому
файлы профилей не пересекаются. Работает всегда один профиль: серверные
файлы, игровые порты и файл запуска JVM общие, так что переключение
разрешено только на остановленном сервере.

- config: список профилей вместо одиночных server_name/admin_password/
  java_memory; старые поля переносятся в первый профиль при загрузке;
- supervisor: запускает активный профиль и выставляет его -Xmx на каждом
  старте, потому что файл запуска общий;
- бэкапы: архив снимается с одного профиля (мир + его конфиги), имя
  профиля попадает в имя файла, ротация считается по профилям;
- API профилей: список, создание с копированием настроек из другого
  профиля, правка, удаление и переключение активного;
- панель: селектор профиля в шапке и вкладка со списком профилей.

Удаление профиля убирает его только из панели: мир и конфиги остаются на
диске, чтобы случайное нажатие не стоило сохранений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:27:12 +03:00

476 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package backup архивирует миры и конфиги Project Zomboid и восстанавливает
// их обратно. Каждый архив относится к одному профилю сервера.
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/mrleo1nid/pzmanager/internal/config"
)
// Archive — один файл бэкапа на диске.
type Archive struct {
Name string `json:"name"`
Profile string `json:"profile"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt int64 `json:"created_at"`
}
// nameRe разбирает имена архивов: имя приходит от клиента и используется как
// путь, поэтому набор символов жёстко ограничен. Часть с профилем
// необязательна — так читаются архивы, снятые до появления профилей.
var nameRe = regexp.MustCompile(`^pz-(?:([A-Za-z0-9_-]+)-)?[0-9]{8}-[0-9]{6}(?:-[a-zA-Z0-9_-]+)?\.tar\.gz$`)
// Manager владеет директорией с бэкапами.
type Manager struct {
cfg *config.Config
}
// New создаёт менеджер бэкапов.
func New(cfg *config.Config) *Manager { return &Manager{cfg: cfg} }
// Dir возвращает директорию с архивами.
func (m *Manager) Dir() string { return m.cfg.Backup.Dir }
// List возвращает архивы, свежие в начале списка.
func (m *Manager) List() ([]Archive, error) {
entries, err := os.ReadDir(m.cfg.Backup.Dir)
if os.IsNotExist(err) {
return []Archive{}, nil
}
if err != nil {
return nil, err
}
out := make([]Archive, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
match := nameRe.FindStringSubmatch(e.Name())
if match == nil {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, Archive{
Name: e.Name(),
Profile: match[1],
SizeBytes: info.Size(),
CreatedAt: info.ModTime().Unix(),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
return out, nil
}
// ListForProfile отбирает архивы одного профиля.
func (m *Manager) ListForProfile(profileID string) ([]Archive, error) {
all, err := m.List()
if err != nil {
return nil, err
}
out := make([]Archive, 0, len(all))
for _, a := range all {
if a.Profile == profileID {
out = append(out, a)
}
}
return out, nil
}
// Path возвращает полный путь к архиву, проверив имя.
func (m *Manager) Path(name string) (string, error) {
if !nameRe.MatchString(name) {
return "", fmt.Errorf("недопустимое имя архива: %q", name)
}
return filepath.Join(m.cfg.Backup.Dir, name), nil
}
// ProfileOf возвращает профиль, которому принадлежит архив. Для архивов,
// снятых до появления профилей, возвращает пустую строку.
func (m *Manager) ProfileOf(name string) string {
match := nameRe.FindStringSubmatch(name)
if match == nil {
return ""
}
return match[1]
}
// Create собирает архив мира профиля и его серверных конфигов. Суффикс note
// попадает в имя файла, чтобы ручные бэкапы было легко отличить.
func (m *Manager) Create(ctx context.Context, log func(string), profileID, note string) (Archive, error) {
if err := config.ValidateProfileID(profileID); err != nil {
return Archive{}, err
}
if err := os.MkdirAll(m.cfg.Backup.Dir, 0o755); err != nil {
return Archive{}, err
}
name := fmt.Sprintf("pz-%s-%s", profileID, time.Now().Format("20060102-150405"))
if note = sanitizeNote(note); note != "" {
name += "-" + note
}
name += ".tar.gz"
target := filepath.Join(m.cfg.Backup.Dir, name)
log(fmt.Sprintf("Создаю архив %s (профиль %s)", name, profileID))
// Пишем во временный файл: недописанный архив не должен попасть в список.
tmp := target + ".part"
f, err := os.Create(tmp)
if err != nil {
return Archive{}, err
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
// abort закрывает всё начатое и убирает недописанный файл.
abort := func(err error) (Archive, error) {
tw.Close()
gz.Close()
f.Close()
os.Remove(tmp)
return Archive{}, err
}
var written int64
if world := m.cfg.WorldDirFor(profileID); dirExists(world) {
n, err := addTree(ctx, tw, m.cfg.ZomboidDir, world, log)
written += n
if err != nil {
return abort(err)
}
} else {
log("Мир ещё не создан — архивирую только конфиги")
}
configs, err := profileConfigFiles(m.cfg.ServerDirPath(), profileID)
if err != nil {
return abort(err)
}
for _, file := range configs {
if err := addFile(tw, m.cfg.ZomboidDir, file); err != nil {
return abort(err)
}
written++
}
if err := tw.Close(); err != nil {
gz.Close()
f.Close()
os.Remove(tmp)
return Archive{}, err
}
if err := gz.Close(); err != nil {
f.Close()
os.Remove(tmp)
return Archive{}, err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return Archive{}, err
}
if err := os.Rename(tmp, target); err != nil {
return Archive{}, err
}
info, err := os.Stat(target)
if err != nil {
return Archive{}, err
}
log(fmt.Sprintf("Архив готов: %s (%s, файлов: %d)", name, humanSize(info.Size()), written))
if err := m.Prune(profileID, m.cfg.Backup.Keep, log); err != nil {
log("Ротация не удалась: " + err.Error())
}
return Archive{
Name: name,
Profile: profileID,
SizeBytes: info.Size(),
CreatedAt: info.ModTime().Unix(),
}, nil
}
// profileConfigFiles находит серверные файлы профиля: <id>.ini,
// <id>_SandboxVars.lua, <id>_spawnpoints.lua и прочие, которые создаёт игра.
// Разделитель после префикса проверяется явно: иначе профиль "test" утащил бы
// файлы профиля "test2".
func profileConfigFiles(serverDir, profileID string) ([]string, error) {
entries, err := os.ReadDir(serverDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, ".bak") || strings.HasSuffix(name, ".tmp") {
continue
}
rest, ok := strings.CutPrefix(name, profileID)
if !ok || rest == "" {
continue
}
if rest[0] != '.' && rest[0] != '_' {
continue
}
out = append(out, filepath.Join(serverDir, name))
}
return out, nil
}
// addFile кладёт один файл в архив, сохраняя путь относительно root.
func addFile(tw *tar.Writer, root, filePath string) error {
info, err := os.Stat(filePath)
if err != nil {
return err
}
rel, err := filepath.Rel(root, filePath)
if err != nil {
return err
}
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
hdr.Name = filepath.ToSlash(rel)
if err := tw.WriteHeader(hdr); err != nil {
return err
}
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
}
// addTree кладёт директорию в архив, сохраняя пути относительно root.
func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(string)) (int64, error) {
var count int64
err := filepath.Walk(dir, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
// Файл мог исчезнуть, пока идёт обход, — это не повод падать.
log("Пропускаю " + filePath + ": " + err.Error())
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if !info.Mode().IsRegular() && !info.IsDir() {
return nil
}
rel, err := filepath.Rel(root, filePath)
if err != nil {
return err
}
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
hdr.Name = filepath.ToSlash(rel)
if info.IsDir() {
hdr.Name += "/"
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(filePath)
if err != nil {
// Блокировка файла сервером не должна ронять весь бэкап.
log("Не прочитан " + rel + ": " + err.Error())
return nil
}
defer f.Close()
if _, err := io.Copy(tw, f); err != nil {
return err
}
count++
return nil
})
return count, err
}
// Restore распаковывает архив поверх ~/Zomboid. Пути внутри архива уже
// содержат имя профиля, поэтому восстановление не зависит от того, какой
// профиль сейчас активен. Сервер должен быть остановлен — это проверяет
// вызывающий код.
func (m *Manager) Restore(ctx context.Context, log func(string), name string) error {
archivePath, err := m.Path(name)
if err != nil {
return err
}
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("архив повреждён: %w", err)
}
defer gz.Close()
log("Восстанавливаю " + name + " в " + m.cfg.ZomboidDir)
tr := tar.NewReader(gz)
var restored int
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target, err := safeJoin(m.cfg.ZomboidDir, hdr.Name)
if err != nil {
return err
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); 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(hdr.Mode))
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
out.Close()
restored++
}
}
log(fmt.Sprintf("Восстановлено файлов: %d", restored))
return nil
}
// Delete удаляет архив.
func (m *Manager) Delete(name string) error {
archivePath, err := m.Path(name)
if err != nil {
return err
}
return os.Remove(archivePath)
}
// Prune оставляет только keep самых свежих архивов профиля (keep <= 0 — не
// трогать). Ротация идёт по профилям отдельно: иначе частые бэкапы активного
// сервера вытеснили бы архивы остальных.
func (m *Manager) Prune(profileID string, keep int, log func(string)) error {
if keep <= 0 {
return nil
}
archives, err := m.ListForProfile(profileID)
if err != nil {
return err
}
if len(archives) <= keep {
return nil
}
for _, a := range archives[keep:] {
if err := m.Delete(a.Name); err != nil {
return err
}
log("Удалён старый архив " + a.Name)
}
return nil
}
// safeJoin не даёт архиву с путями вида ../.. вылезти за пределы root.
// Сегменты проверяются до нормализации: path.Clean схлопнул бы "a/../../b" в
// безобидный на вид путь и скрыл попытку выхода. Такие пути отвергаются, а не
// подгоняются под корень — они означают либо испорченный, либо специально
// собранный архив.
func safeJoin(root, name string) (string, error) {
slashed := filepath.ToSlash(name)
if path.IsAbs(slashed) {
return "", fmt.Errorf("абсолютный путь в архиве: %s", name)
}
for _, segment := range strings.Split(slashed, "/") {
if segment == ".." {
return "", fmt.Errorf("небезопасный путь в архиве: %s", name)
}
}
return filepath.Join(root, filepath.FromSlash(path.Clean(slashed))), nil
}
// sanitizeNote оставляет от пометки только безопасные для имени файла символы.
func sanitizeNote(note string) string {
var b strings.Builder
for _, r := range note {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
case r == ' ':
b.WriteRune('_')
}
if b.Len() >= 32 {
break
}
}
return strings.Trim(b.String(), "_-")
}
func dirExists(p string) bool {
info, err := os.Stat(p)
return err == nil && info.IsDir()
}
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d Б", n)
}
prefixes := []string{"К", "М", "Г", "Т"}
div, exp := int64(unit), 0
for size := n / unit; size >= unit && exp < len(prefixes)-1; size /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %sБ", float64(n)/float64(div), prefixes[exp])
}