Профиль — это отдельный сервер: свой мир, .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>
476 lines
13 KiB
Go
476 lines
13 KiB
Go
// 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])
|
||
}
|