Веб-панель управления сервером 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
+158
View File
@@ -0,0 +1,158 @@
package pzconfig
import (
"bytes"
"fmt"
"os"
"regexp"
"strings"
)
// SandboxEntry — один параметр песочницы. Path включает вложенные таблицы
// (например ZombieLore.Speed), Value хранится как в файле.
type SandboxEntry struct {
Path string `json:"path"`
Value string `json:"value"`
}
// Sandbox — разбор <server>_SandboxVars.lua. Файл генерируется игрой и имеет
// предсказуемый вид (одна пара ключ-значение на строку), поэтому вместо
// полноценного интерпретатора Lua достаточно построчного разбора: он
// гарантирует, что при сохранении файл останется байт-в-байт прежним, кроме
// изменённых значений.
type Sandbox struct {
lines []string
// index сопоставляет путь параметра с номером строки.
index map[string]int
}
var (
sandboxAssignRe = regexp.MustCompile(`^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)(,?)\s*$`)
sandboxCloseRe = regexp.MustCompile(`^\s*\}`)
)
// ParseSandbox разбирает содержимое SandboxVars.lua.
func ParseSandbox(data []byte) *Sandbox {
s := &Sandbox{index: make(map[string]int)}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
s.lines = strings.Split(text, "\n")
var stack []string
for n, line := range s.lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
continue
}
if sandboxCloseRe.MatchString(line) {
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
continue
}
m := sandboxAssignRe.FindStringSubmatch(line)
if m == nil {
continue
}
name, value := m[2], strings.TrimSpace(m[3])
if strings.HasPrefix(value, "{") {
// Начало вложенной таблицы: сам SandboxVars = { тоже сюда попадёт,
// но его имя в путях не нужно.
if name != "SandboxVars" {
stack = append(stack, name)
} else {
stack = stack[:0]
}
continue
}
path := name
if len(stack) > 0 {
path = strings.Join(stack, ".") + "." + name
}
s.index[path] = n
}
return s
}
// LoadSandbox читает файл песочницы с диска.
func LoadSandbox(path string) (*Sandbox, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseSandbox(data), nil
}
// Entries возвращает все параметры в порядке следования в файле.
func (s *Sandbox) Entries() []SandboxEntry {
out := make([]SandboxEntry, 0, len(s.index))
// Обходим строки, а не карту: порядок в файле осмысленный.
byLine := make(map[int]string, len(s.index))
for path, line := range s.index {
byLine[line] = path
}
for n := range s.lines {
path, ok := byLine[n]
if !ok {
continue
}
out = append(out, SandboxEntry{Path: path, Value: s.valueAt(n)})
}
return out
}
func (s *Sandbox) valueAt(line int) string {
m := sandboxAssignRe.FindStringSubmatch(s.lines[line])
if m == nil {
return ""
}
return strings.TrimSpace(m[3])
}
// Get возвращает значение параметра по пути.
func (s *Sandbox) Get(path string) (string, bool) {
n, ok := s.index[path]
if !ok {
return "", false
}
return s.valueAt(n), true
}
// Set меняет значение существующего параметра. Новые параметры не добавляются:
// игра всё равно игнорирует незнакомые ключи.
func (s *Sandbox) Set(path, value string) error {
n, ok := s.index[path]
if !ok {
return fmt.Errorf("параметр %q не найден в SandboxVars", path)
}
value = strings.TrimSpace(value)
if strings.ContainsAny(value, "\n\r{}") {
return fmt.Errorf("недопустимое значение для %q", path)
}
m := sandboxAssignRe.FindStringSubmatch(s.lines[n])
if m == nil {
return fmt.Errorf("не удалось разобрать строку параметра %q", path)
}
indent, name, comma := m[1], m[2], m[4]
s.lines[n] = fmt.Sprintf("%s%s = %s%s", indent, name, value, comma)
return nil
}
// Render собирает файл обратно.
func (s *Sandbox) Render() []byte {
var buf bytes.Buffer
for n, line := range s.lines {
buf.WriteString(line)
if n < len(s.lines)-1 {
buf.WriteByte('\n')
}
}
return buf.Bytes()
}
// SaveSandbox пишет файл песочницы, сохранив предыдущую версию в .bak.
func SaveSandbox(path string, s *Sandbox) error {
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, s.Render(), 0o644)
}