Веб-панель управления сервером 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
+156
View File
@@ -0,0 +1,156 @@
package pzconfig
import (
"strings"
"testing"
)
const sampleINI = `# Comment line
PublicName=My Server
Public=true
MaxPlayers=32
Mods=modA;modB
WorkshopItems=111;222
`
func TestINIPreservesLayout(t *testing.T) {
ini := ParseINI([]byte(sampleINI))
if got := ini.Get("PublicName"); got != "My Server" {
t.Fatalf("PublicName = %q, ожидалось %q", got, "My Server")
}
// Правка одного ключа не должна трогать комментарии и пустые строки.
ini.Set("MaxPlayers", "64")
got := string(ini.Render())
if !strings.HasPrefix(got, "# Comment line\n") {
t.Errorf("комментарий потерян:\n%s", got)
}
if !strings.Contains(got, "\n\nMaxPlayers=64\n") {
t.Errorf("пустая строка или новое значение потеряны:\n%s", got)
}
if strings.Contains(got, "MaxPlayers=32") {
t.Errorf("старое значение осталось:\n%s", got)
}
}
func TestINIAddsMissingKey(t *testing.T) {
ini := ParseINI([]byte("A=1"))
ini.Set("B", "2")
if got := ini.Get("B"); got != "2" {
t.Fatalf("B = %q", got)
}
if got := string(ini.Render()); got != "A=1\nB=2" {
t.Fatalf("render = %q", got)
}
}
func TestINIRejectsNewlineInValue(t *testing.T) {
ini := ParseINI([]byte("A=1"))
ini.Set("A", "нет\nпереносам")
if strings.Contains(string(ini.Render()), "\nп") {
t.Fatalf("перенос строки попал в значение: %q", ini.Get("A"))
}
}
func TestModsRoundTrip(t *testing.T) {
ini := ParseINI([]byte(sampleINI))
mods, workshop := ini.Mods()
if len(mods) != 2 || mods[0] != "modA" {
t.Fatalf("mods = %v", mods)
}
if len(workshop) != 2 || workshop[1] != "222" {
t.Fatalf("workshop = %v", workshop)
}
// Дубликаты отбрасываются, порядок сохраняется.
ini.SetMods([]string{"modB", "modA", "modB"}, []string{"222"})
if got := ini.Get(KeyMods); got != "modB;modA" {
t.Fatalf("Mods = %q", got)
}
if got := ini.Get(KeyWorkshopItems); got != "222" {
t.Fatalf("WorkshopItems = %q", got)
}
}
func TestSplitListIgnoresBlanks(t *testing.T) {
if got := SplitList(" a ; ; b;"); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("SplitList = %v", got)
}
if got := SplitList(" "); len(got) != 0 {
t.Fatalf("пустая строка дала %v", got)
}
}
const sampleSandbox = `SandboxVars = {
VERSION = 5,
Zombies = 3,
Speed = 2,
ZombieLore = {
Speed = 2,
Strength = 2,
},
WaterShutModifier = 14,
}
`
func TestSandboxParsesNestedPaths(t *testing.T) {
sb := ParseSandbox([]byte(sampleSandbox))
if v, ok := sb.Get("Zombies"); !ok || v != "3" {
t.Fatalf("Zombies = %q (найдено: %v)", v, ok)
}
// Одноимённые ключи на разных уровнях не должны перетирать друг друга.
if v, ok := sb.Get("ZombieLore.Speed"); !ok || v != "2" {
t.Fatalf("ZombieLore.Speed = %q (найдено: %v)", v, ok)
}
if v, ok := sb.Get("Speed"); !ok || v != "2" {
t.Fatalf("Speed верхнего уровня = %q (найдено: %v)", v, ok)
}
if v, ok := sb.Get("WaterShutModifier"); !ok || v != "14" {
t.Fatalf("WaterShutModifier после закрытия таблицы = %q (найдено: %v)", v, ok)
}
}
func TestSandboxSetKeepsFormatting(t *testing.T) {
sb := ParseSandbox([]byte(sampleSandbox))
if err := sb.Set("ZombieLore.Speed", "1"); err != nil {
t.Fatal(err)
}
got := string(sb.Render())
if !strings.Contains(got, " Speed = 1,") {
t.Errorf("отступ или запятая потеряны:\n%s", got)
}
if !strings.Contains(got, " Speed = 2,") {
t.Errorf("параметр верхнего уровня изменён по ошибке:\n%s", got)
}
if !strings.HasSuffix(got, "}\n") {
t.Errorf("хвост файла изменён:\n%s", got)
}
}
func TestSandboxRejectsUnknownAndBrokenValues(t *testing.T) {
sb := ParseSandbox([]byte(sampleSandbox))
if err := sb.Set("НетТакого", "1"); err == nil {
t.Error("ожидалась ошибка для неизвестного параметра")
}
if err := sb.Set("Zombies", "{ 1, 2 }"); err == nil {
t.Error("ожидалась ошибка для значения с фигурными скобками")
}
}
func TestSandboxEntriesFollowFileOrder(t *testing.T) {
entries := ParseSandbox([]byte(sampleSandbox)).Entries()
want := []string{"VERSION", "Zombies", "Speed", "ZombieLore.Speed", "ZombieLore.Strength", "WaterShutModifier"}
if len(entries) != len(want) {
t.Fatalf("получено %d параметров, ожидалось %d: %v", len(entries), len(want), entries)
}
for i, path := range want {
if entries[i].Path != path {
t.Errorf("entries[%d] = %q, ожидалось %q", i, entries[i].Path, path)
}
}
}