Веб-панель управления сервером 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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user