Files
pzmanager/internal/backup/backup.go
T
mrleo1nidandClaude Opus 5 d0ff64c272 Веб-панель управления сервером Project Zomboid
Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout:
отсюда живая консоль без RCON, стриминг логов через SSE и корректная
остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов.
Сам менеджер работает под systemd.

Что входит:
- управление жизненным циклом сервера, автозапуск и автоподъём после падения;
- метрики процесса из /proc и состояние хоста, список игроков онлайн;
- редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное
  форматирование файлов;
- моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam;
- бэкапы мира по расписанию и вручную, с ротацией и восстановлением;
- установка и обновление сервера через SteamCMD;
- вход по логину и паролю, первичная настройка по одноразовому коду.

Веб-интерфейс без сборщика, вшит в бинарник через embed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:41:11 +03:00

348 lines
9.4 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"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt int64 `json:"created_at"`
}
// nameRe ограничивает имена архивов, которые принимает API: имя приходит от
// клиента и используется как путь.
var nameRe = regexp.MustCompile(`^pz-[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() || !nameRe.MatchString(e.Name()) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, Archive{
Name: e.Name(),
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
}
// 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
}
// Create собирает архив из Saves и серверных конфигов. Суффикс note попадает в
// имя файла, чтобы ручные бэкапы было легко отличить.
func (m *Manager) Create(ctx context.Context, log func(string), note string) (Archive, error) {
if err := os.MkdirAll(m.cfg.Backup.Dir, 0o755); err != nil {
return Archive{}, err
}
name := fmt.Sprintf("pz-%s", time.Now().Format("20060102-150405"))
if note = sanitizeNote(note); note != "" {
name += "-" + note
}
name += ".tar.gz"
target := filepath.Join(m.cfg.Backup.Dir, name)
log("Создаю архив " + name)
// Пишем во временный файл: недописанный архив не должен попасть в список.
tmp := target + ".part"
f, err := os.Create(tmp)
if err != nil {
return Archive{}, err
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
var written int64
sources := []string{m.cfg.SavesDir(), filepath.Join(m.cfg.ZomboidDir, "Server")}
for _, src := range sources {
if _, err := os.Stat(src); err != nil {
log("Пропускаю " + src + " — нет такой директории")
continue
}
n, err := addTree(ctx, tw, m.cfg.ZomboidDir, src, log)
written += n
if err != nil {
tw.Close()
gz.Close()
f.Close()
os.Remove(tmp)
return Archive{}, err
}
}
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(m.cfg.Backup.Keep, log); err != nil {
log("Ротация не удалась: " + err.Error())
}
return Archive{Name: name, SizeBytes: info.Size(), CreatedAt: info.ModTime().Unix()}, nil
}
// 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(path string, info os.FileInfo, err error) error {
if err != nil {
// Файл мог исчезнуть, пока идёт обход, — это не повод падать.
log("Пропускаю " + path + ": " + 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, path)
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(path)
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 {
path, err := m.Path(name)
if err != nil {
return err
}
f, err := os.Open(path)
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 {
path, err := m.Path(name)
if err != nil {
return err
}
return os.Remove(path)
}
// Prune оставляет только keep самых свежих архивов (keep <= 0 — не трогать).
func (m *Manager) Prune(keep int, log func(string)) error {
if keep <= 0 {
return nil
}
archives, err := m.List()
if err != nil {
return err
}
for _, a := range archives[min(keep, len(archives)):] {
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 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])
}