Менеджер запускает сервер 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>
159 lines
4.8 KiB
Go
159 lines
4.8 KiB
Go
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)
|
|
}
|