Веб-панель управления сервером 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>
This commit is contained in:
2026-09-03 22:41:11 +03:00
co-authored by Claude Opus 5
commit d0ff64c272
38 changed files with 6054 additions and 0 deletions
+347
View File
@@ -0,0 +1,347 @@
// 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])
}
+141
View File
@@ -0,0 +1,141 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"os"
"path/filepath"
"testing"
"github.com/mrleo1nid/pzmanager/internal/config"
)
func TestSafeJoinRejectsTraversal(t *testing.T) {
root := filepath.Join(os.TempDir(), "pzm-root")
for _, name := range []string{"../evil", "../../etc/passwd", "Saves/../../evil"} {
if _, err := safeJoin(root, name); err == nil {
t.Errorf("путь %q принят, ожидался отказ", name)
}
}
if _, err := safeJoin(root, "Saves/Multiplayer/map.bin"); err != nil {
t.Errorf("обычный путь отклонён: %v", err)
}
}
func TestSanitizeNote(t *testing.T) {
cases := map[string]string{
"перед вайпом": "",
"before wipe": "before_wipe",
"v1.2/../x": "v12x",
"": "",
"__trim-me__": "trim-me",
}
for input, want := range cases {
if got := sanitizeNote(input); got != want {
t.Errorf("sanitizeNote(%q) = %q, ожидалось %q", input, got, want)
}
}
}
func TestNameValidation(t *testing.T) {
m := New(&config.Config{})
if _, err := m.Path("../../etc/passwd"); err == nil {
t.Error("имя с переходом по каталогам принято")
}
if _, err := m.Path("pz-20250903-181500-auto.tar.gz"); err != nil {
t.Errorf("корректное имя отклонено: %v", err)
}
}
// TestCreateAndRestore проверяет полный цикл: архивируем дерево, портим файл,
// восстанавливаем и убеждаемся, что содержимое вернулось.
func TestCreateAndRestore(t *testing.T) {
root := t.TempDir()
zomboid := filepath.Join(root, "Zomboid")
savePath := filepath.Join(zomboid, "Saves", "Multiplayer", "world.bin")
if err := os.MkdirAll(filepath.Dir(savePath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(savePath, []byte("исходный мир"), 0o644); err != nil {
t.Fatal(err)
}
cfg := &config.Config{ZomboidDir: zomboid}
cfg.Backup.Dir = filepath.Join(root, "backups")
m := New(cfg)
archive, err := m.Create(context.Background(), func(string) {}, "test")
if err != nil {
t.Fatalf("Create: %v", err)
}
if archive.SizeBytes == 0 {
t.Error("архив пустой")
}
if err := os.WriteFile(savePath, []byte("испорчено"), 0o644); err != nil {
t.Fatal(err)
}
if err := m.Restore(context.Background(), func(string) {}, archive.Name); err != nil {
t.Fatalf("Restore: %v", err)
}
restored, err := os.ReadFile(savePath)
if err != nil {
t.Fatal(err)
}
if string(restored) != "исходный мир" {
t.Errorf("после восстановления содержимое = %q", restored)
}
}
func TestPruneKeepsNewest(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{ZomboidDir: filepath.Join(root, "Zomboid")}
cfg.Backup.Dir = filepath.Join(root, "backups")
m := New(cfg)
if err := os.MkdirAll(cfg.Backup.Dir, 0o755); err != nil {
t.Fatal(err)
}
names := []string{
"pz-20250101-000000.tar.gz",
"pz-20250102-000000.tar.gz",
"pz-20250103-000000.tar.gz",
}
for _, name := range names {
writeEmptyArchive(t, filepath.Join(cfg.Backup.Dir, name))
}
if err := m.Prune(2, func(string) {}); err != nil {
t.Fatal(err)
}
left, err := m.List()
if err != nil {
t.Fatal(err)
}
if len(left) != 2 {
t.Fatalf("осталось %d архивов, ожидалось 2: %v", len(left), left)
}
}
// writeEmptyArchive создаёт валидный пустой tar.gz — List проверяет имя, а не
// содержимое, но пусть файлы будут настоящими.
func writeEmptyArchive(t *testing.T, path string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
}