Веб-панель управления сервером 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
+151
View File
@@ -0,0 +1,151 @@
// Package pzconfig читает и правит конфиги Project Zomboid: servertest.ini и
// SandboxVars.lua.
package pzconfig
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
)
// iniLine — одна строка файла. Пустой Key означает комментарий или пустую
// строку, которую нужно сохранить как есть.
type iniLine struct {
Key string
Value string
Raw string
}
// INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок
// строк и комментарии, чтобы правка одного параметра не переписывала файл.
type INI struct {
lines []iniLine
index map[string]int
}
// ParseINI разбирает содержимое конфига.
func ParseINI(data []byte) *INI {
ini := &INI{index: make(map[string]int)}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
for _, raw := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(raw)
key, value, found := strings.Cut(raw, "=")
if !found || trimmed == "" || strings.HasPrefix(trimmed, "#") {
ini.lines = append(ini.lines, iniLine{Raw: raw})
continue
}
key = strings.TrimSpace(key)
ini.index[key] = len(ini.lines)
ini.lines = append(ini.lines, iniLine{Key: key, Value: strings.TrimSpace(value)})
}
return ini
}
// LoadINI читает конфиг с диска.
func LoadINI(path string) (*INI, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseINI(data), nil
}
// Get возвращает значение параметра.
func (i *INI) Get(key string) string {
idx, ok := i.index[key]
if !ok {
return ""
}
return i.lines[idx].Value
}
// Has сообщает, есть ли такой параметр в файле.
func (i *INI) Has(key string) bool {
_, ok := i.index[key]
return ok
}
// Set меняет значение, добавляя параметр в конец, если его ещё нет.
func (i *INI) Set(key, value string) {
// Перевод строки в значении сломал бы формат файла.
value = strings.NewReplacer("\n", " ", "\r", " ").Replace(value)
if idx, ok := i.index[key]; ok {
i.lines[idx].Value = value
return
}
i.index[key] = len(i.lines)
i.lines = append(i.lines, iniLine{Key: key, Value: value})
}
// Keys возвращает имена параметров в порядке их появления в файле.
func (i *INI) Keys() []string {
out := make([]string, 0, len(i.index))
for _, l := range i.lines {
if l.Key != "" {
out = append(out, l.Key)
}
}
return out
}
// Map отдаёт все параметры в виде словаря.
func (i *INI) Map() map[string]string {
out := make(map[string]string, len(i.index))
for _, l := range i.lines {
if l.Key != "" {
out[l.Key] = l.Value
}
}
return out
}
// Render собирает файл обратно в текст.
func (i *INI) Render() []byte {
var buf bytes.Buffer
for n, l := range i.lines {
if l.Key == "" {
buf.WriteString(l.Raw)
} else {
buf.WriteString(l.Key)
buf.WriteByte('=')
buf.WriteString(l.Value)
}
if n < len(i.lines)-1 {
buf.WriteByte('\n')
}
}
return buf.Bytes()
}
// SaveINI записывает конфиг атомарно, предварительно сняв копию прежней
// версии рядом (.bak): испорченный конфиг остановит сервер.
func SaveINI(path string, ini *INI) error {
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, ini.Render(), 0o644)
}
func backupFile(path string) error {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
return os.WriteFile(path+".bak", data, 0o644)
}
func writeAtomic(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
return fmt.Errorf("записать %s: %w", tmp, err)
}
return os.Rename(tmp, path)
}
+62
View File
@@ -0,0 +1,62 @@
package pzconfig
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// javaLauncherFile — файл, из которого сервер берёт аргументы JVM.
const javaLauncherFile = "ProjectZomboid64.json"
var xmxRe = regexp.MustCompile(`^-Xm[sx]`)
// SetJavaMemory прописывает лимит памяти JVM в ProjectZomboid64.json. Именно
// оттуда start-server.sh берёт -Xmx, поэтому менять размер памяти в аргументах
// запуска бесполезно.
func SetJavaMemory(serverDir, memory string) error {
memory = strings.TrimSpace(memory)
if memory == "" {
return nil
}
if !regexp.MustCompile(`^[0-9]+[kKmMgG]$`).MatchString(memory) {
return fmt.Errorf("объём памяти должен выглядеть как 4g или 8192m, получено %q", memory)
}
path := filepath.Join(serverDir, javaLauncherFile)
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("прочитать %s: %w", path, err)
}
// Разбираем в map, чтобы не потерять поля, о которых мы не знаем.
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
return fmt.Errorf("разобрать %s: %w", path, err)
}
args, _ := doc["vmArgs"].([]any)
updated := make([]any, 0, len(args)+2)
for _, a := range args {
if str, ok := a.(string); ok && xmxRe.MatchString(str) {
continue
}
updated = append(updated, a)
}
// -Xms равным -Xmx: JVM сразу забирает память и не тратит время на рост
// кучи в момент, когда на сервере много игроков.
updated = append(updated, "-Xms"+memory, "-Xmx"+memory)
doc["vmArgs"] = updated
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, out, 0o644)
}
+52
View File
@@ -0,0 +1,52 @@
package pzconfig
import "strings"
// Ключи servertest.ini, в которых живут моды.
const (
KeyMods = "Mods"
KeyWorkshopItems = "WorkshopItems"
KeyMap = "Map"
)
// SplitList разбирает список PZ вида "a;b;c" в срез без пустых элементов.
func SplitList(value string) []string {
if strings.TrimSpace(value) == "" {
return []string{}
}
parts := strings.Split(value, ";")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// JoinList собирает список обратно в формат PZ, убирая дубликаты и сохраняя
// порядок: он важен, моды грузятся именно в нём.
func JoinList(items []string) string {
seen := make(map[string]bool, len(items))
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return strings.Join(out, ";")
}
// Mods возвращает текущие списки модов сервера.
func (i *INI) Mods() (modIDs, workshopIDs []string) {
return SplitList(i.Get(KeyMods)), SplitList(i.Get(KeyWorkshopItems))
}
// SetMods записывает списки модов обратно в конфиг.
func (i *INI) SetMods(modIDs, workshopIDs []string) {
i.Set(KeyMods, JoinList(modIDs))
i.Set(KeyWorkshopItems, JoinList(workshopIDs))
}
+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)
}
}
}
+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)
}
+153
View File
@@ -0,0 +1,153 @@
package pzconfig
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// workshopAPI — публичный метод Steam, ключ доступа не требуется.
const workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
// WorkshopItem — то, что панель показывает про мод из мастерской.
type WorkshopItem struct {
ID string `json:"id"`
Title string `json:"title"`
Preview string `json:"preview"`
Description string `json:"description"`
Error string `json:"error,omitempty"`
}
// WorkshopClient запрашивает названия модов у Steam и кеширует их: список
// модов на странице обновляется часто, а названия не меняются.
type WorkshopClient struct {
HTTP *http.Client
mu sync.Mutex
cache map[string]cachedItem
}
type cachedItem struct {
item WorkshopItem
fetched time.Time
}
// cacheTTL — как долго доверять закешированному названию мода.
const cacheTTL = 12 * time.Hour
// NewWorkshopClient создаёт клиент со своим HTTP-таймаутом.
func NewWorkshopClient() *WorkshopClient {
return &WorkshopClient{
HTTP: &http.Client{Timeout: 15 * time.Second},
cache: make(map[string]cachedItem),
}
}
// Details запрашивает описание модов по их Workshop ID. Если Steam недоступен,
// возвращает записи с заполненным полем Error — панель всё равно покажет
// список, просто без названий.
func (c *WorkshopClient) Details(ctx context.Context, ids []string) []WorkshopItem {
out := make([]WorkshopItem, 0, len(ids))
missing := make([]string, 0, len(ids))
c.mu.Lock()
for _, id := range ids {
if entry, ok := c.cache[id]; ok && time.Since(entry.fetched) < cacheTTL {
out = append(out, entry.item)
continue
}
missing = append(missing, id)
}
c.mu.Unlock()
if len(missing) > 0 {
fetched, err := c.fetch(ctx, missing)
if err != nil {
for _, id := range missing {
out = append(out, WorkshopItem{ID: id, Error: err.Error()})
}
} else {
c.mu.Lock()
for _, item := range fetched {
c.cache[item.ID] = cachedItem{item: item, fetched: time.Now()}
}
c.mu.Unlock()
out = append(out, fetched...)
}
}
// Возвращаем в том же порядке, в каком просили.
byID := make(map[string]WorkshopItem, len(out))
for _, item := range out {
byID[item.ID] = item
}
ordered := make([]WorkshopItem, 0, len(ids))
for _, id := range ids {
if item, ok := byID[id]; ok {
ordered = append(ordered, item)
continue
}
ordered = append(ordered, WorkshopItem{ID: id, Error: "нет данных"})
}
return ordered
}
func (c *WorkshopClient) fetch(ctx context.Context, ids []string) ([]WorkshopItem, error) {
form := url.Values{}
form.Set("itemcount", strconv.Itoa(len(ids)))
for n, id := range ids {
form.Set(fmt.Sprintf("publishedfileids[%d]", n), id)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, workshopAPI,
strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("Steam недоступен: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Steam ответил %s", resp.Status)
}
var payload struct {
Response struct {
PublishedFileDetails []struct {
PublishedFileID string `json:"publishedfileid"`
Title string `json:"title"`
Description string `json:"description"`
PreviewURL string `json:"preview_url"`
Result int `json:"result"`
} `json:"publishedfiledetails"`
} `json:"response"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("ответ Steam не разобран: %w", err)
}
items := make([]WorkshopItem, 0, len(payload.Response.PublishedFileDetails))
for _, d := range payload.Response.PublishedFileDetails {
item := WorkshopItem{ID: d.PublishedFileID, Title: d.Title, Preview: d.PreviewURL}
// result == 1 означает "найдено"; всё остальное — удалён или скрыт.
if d.Result != 1 {
item.Error = "мод не найден в мастерской"
}
if len(d.Description) > 400 {
d.Description = d.Description[:400] + "…"
}
item.Description = d.Description
items = append(items, item)
}
return items, nil
}