Веб-панель управления сервером 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
+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)
}
}