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