Игра приводит путь к вложенным файлам анимаций в нижний регистр целиком, вместе с именем папки мода. На Linux такие файлы не находятся, и сервер сыплет в лог сотнями FileNotFoundException. Кнопка на вкладке «Моды» раскладывает рядом строчные симлинки: файлы модов не меняются, повторный запуск после докачки безопасен, а на файловых системах без различия регистра кнопка честно говорит, что делать нечего. Ожидание строки о готовности стало настройкой профиля со значением по умолчанию 30 минут вместо общих 15. Сборке на три сотни модов пятнадцати не хватает, и панель объявляла бы работающим сервер, который ещё никого не пустит. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
458 lines
18 KiB
Go
458 lines
18 KiB
Go
// Package config загружает и сохраняет настройки самого менеджера.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// profileIDRe ограничивает идентификатор профиля: он становится именем файлов
|
|
// PZ (servertest.ini, servertest_SandboxVars.lua) и частью пути к миру.
|
|
var profileIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,32}$`)
|
|
|
|
// Profile — один игровой сервер: собственный мир, конфиг, набор модов и
|
|
// параметры запуска. ID профиля — это -servername для Project Zomboid.
|
|
type Profile struct {
|
|
// ID — имя набора конфигов PZ: servertest -> servertest.ini, мир лежит в
|
|
// Saves/Multiplayer/servertest.
|
|
ID string `yaml:"id"`
|
|
|
|
// Title — понятное человеку название для веб-панели.
|
|
Title string `yaml:"title"`
|
|
|
|
// AdminPassword — пароль админа игрового сервера, нужен при первом
|
|
// запуске PZ. Хранится здесь, чтобы не вводить его в консоли.
|
|
AdminPassword string `yaml:"admin_password"`
|
|
|
|
// JavaMemory — -Xmx для JVM, например "8g". Применяется при каждом
|
|
// запуске: файл запуска сервера общий для всех профилей.
|
|
JavaMemory string `yaml:"java_memory"`
|
|
|
|
// JavaMemoryMin — -Xms. Пусто (по умолчанию) означает, что JVM занимает
|
|
// память по мере надобности: равные -Xms и -Xmx заставляют её забрать всё
|
|
// сразу, и на машине с небольшой ОЗУ сервер не поднимется.
|
|
JavaMemoryMin string `yaml:"java_memory_min"`
|
|
|
|
// ExtraArgs — дополнительные аргументы start-server.sh.
|
|
ExtraArgs []string `yaml:"extra_args"`
|
|
|
|
// StartupTimeout — сколько ждать строку о готовности сервера, прежде чем
|
|
// считать его работающим. Сборка на три сотни модов грузит мир десятками
|
|
// минут, и общего значения на все профили не подобрать: слишком малое
|
|
// объявит рабочим сервер, который ещё никого не пустит. Ноль означает
|
|
// DefaultStartupTimeout.
|
|
StartupTimeout Duration `yaml:"startup_timeout"`
|
|
}
|
|
|
|
// DefaultStartupTimeout — запас на загрузку мира, когда профиль своего не
|
|
// задал. Ванильный сервер поднимается за минуту, сборка с сотнями модов —
|
|
// заметно дольше, поэтому запас с большим запасом.
|
|
const DefaultStartupTimeout = 30 * time.Minute
|
|
|
|
// StartupWait возвращает время ожидания готовности с учётом значения по
|
|
// умолчанию.
|
|
func (p Profile) StartupWait() time.Duration {
|
|
if p.StartupTimeout <= 0 {
|
|
return DefaultStartupTimeout
|
|
}
|
|
return p.StartupTimeout.Duration()
|
|
}
|
|
|
|
// Name возвращает название для интерфейса: заголовок, а если его нет — ID.
|
|
func (p Profile) Name() string {
|
|
if p.Title != "" {
|
|
return p.Title
|
|
}
|
|
return p.ID
|
|
}
|
|
|
|
// ValidateProfileID проверяет идентификатор, приходящий из веб-панели.
|
|
func ValidateProfileID(id string) error {
|
|
if !profileIDRe.MatchString(id) {
|
|
return fmt.Errorf("идентификатор %q недопустим: разрешены латинские буквы, цифры, дефис и подчёркивание (до 32 символов)", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SanitizeProfileID превращает имя файла из чужого пакета настроек в годный
|
|
// идентификатор профиля: пробелы и точки в именах пресетов не редкость, а
|
|
// идентификатор становится именем конфига и папки мира.
|
|
func SanitizeProfileID(name string) string {
|
|
var b strings.Builder
|
|
for _, r := range name {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('-')
|
|
}
|
|
if b.Len() >= 32 {
|
|
break
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), "-")
|
|
}
|
|
|
|
// Config — всё, что менеджеру нужно знать о хосте и о серверах PZ.
|
|
type Config struct {
|
|
// Listen — адрес веб-панели. Для доступа только через SSH-туннель
|
|
// используйте 127.0.0.1:8080.
|
|
Listen string `yaml:"listen"`
|
|
|
|
// DataDir — рабочая директория менеджера (пользователи, сессии, бэкапы).
|
|
DataDir string `yaml:"data_dir"`
|
|
|
|
// ServerDir — куда SteamCMD ставит серверные файлы PZ. Общий для всех
|
|
// профилей: сама игра одна, различаются только конфиги и миры.
|
|
ServerDir string `yaml:"server_dir"`
|
|
|
|
// ZomboidDir — ~/Zomboid: сохранения, конфиги, логи.
|
|
ZomboidDir string `yaml:"zomboid_dir"`
|
|
|
|
// SteamCMDPath — путь к бинарю steamcmd.sh (пустой = ищем в PATH).
|
|
SteamCMDPath string `yaml:"steamcmd_path"`
|
|
|
|
// Profiles — сконфигурированные серверы. Одновременно работает только
|
|
// один: они делят серверные файлы и игровые порты.
|
|
Profiles []Profile `yaml:"profiles"`
|
|
|
|
// Active — ID профиля, который запускается и настраивается сейчас.
|
|
Active string `yaml:"active_profile"`
|
|
|
|
// AutoStart — поднимать игровой сервер сразу при старте менеджера.
|
|
AutoStart bool `yaml:"autostart"`
|
|
|
|
// AutoRestart — перезапускать сервер, если он упал сам.
|
|
AutoRestart bool `yaml:"autorestart"`
|
|
|
|
// StopTimeout — сколько ждать корректного завершения после "quit".
|
|
StopTimeout Duration `yaml:"stop_timeout"`
|
|
|
|
// LogBufferLines — сколько строк консоли держать в памяти для веб-панели.
|
|
LogBufferLines int `yaml:"log_buffer_lines"`
|
|
|
|
// Backup — настройки резервного копирования.
|
|
Backup BackupConfig `yaml:"backup"`
|
|
|
|
// Поля ниже остались от версии с единственным сервером. При загрузке они
|
|
// превращаются в первый профиль и больше не записываются в файл.
|
|
LegacyServerName string `yaml:"server_name,omitempty"`
|
|
LegacyAdminPassword string `yaml:"admin_password,omitempty"`
|
|
LegacyJavaMemory string `yaml:"java_memory,omitempty"`
|
|
LegacyExtraArgs []string `yaml:"extra_args,omitempty"`
|
|
|
|
path string
|
|
}
|
|
|
|
// BackupConfig описывает расписание и ротацию бэкапов мира.
|
|
type BackupConfig struct {
|
|
// Dir — куда складывать архивы.
|
|
Dir string `yaml:"dir"`
|
|
// Schedule — интервал автобэкапа; 0 отключает автоматику.
|
|
Schedule Duration `yaml:"schedule"`
|
|
// Keep — сколько последних архивов хранить для каждого профиля
|
|
// (0 = не удалять).
|
|
Keep int `yaml:"keep"`
|
|
// StopServer — останавливать сервер на время бэкапа. Безопаснее, но
|
|
// игроков выкидывает; по умолчанию выключено.
|
|
StopServer bool `yaml:"stop_server"`
|
|
}
|
|
|
|
// DefaultProfile — профиль, который создаётся на чистой установке.
|
|
func DefaultProfile() Profile {
|
|
return Profile{
|
|
ID: "servertest",
|
|
Title: "Основной сервер",
|
|
JavaMemory: "4g",
|
|
}
|
|
}
|
|
|
|
// Default возвращает конфиг с разумными значениями для Ubuntu.
|
|
func Default() *Config {
|
|
home, _ := os.UserHomeDir()
|
|
return &Config{
|
|
Listen: "127.0.0.1:8080",
|
|
DataDir: filepath.Join(home, ".local", "share", "pzmanager"),
|
|
ServerDir: filepath.Join(home, "pzserver"),
|
|
ZomboidDir: filepath.Join(home, "Zomboid"),
|
|
Profiles: []Profile{DefaultProfile()},
|
|
Active: DefaultProfile().ID,
|
|
AutoRestart: true,
|
|
StopTimeout: Duration(90 * time.Second),
|
|
LogBufferLines: 5000,
|
|
Backup: BackupConfig{
|
|
Dir: filepath.Join(home, "pzbackups"),
|
|
Schedule: Duration(6 * time.Hour),
|
|
Keep: 20,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Load читает конфиг с диска, дополняя пропущенные поля значениями по
|
|
// умолчанию. Если файла нет — создаёт его.
|
|
func Load(path string) (*Config, error) {
|
|
cfg := Default()
|
|
cfg.path = path
|
|
|
|
raw, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
if err := cfg.Save(); err != nil {
|
|
return nil, fmt.Errorf("создать конфиг %s: %w", path, err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("прочитать конфиг %s: %w", path, err)
|
|
}
|
|
|
|
// Профили из файла должны заменить умолчания, а не дополнить их.
|
|
cfg.Profiles = nil
|
|
if err := yaml.Unmarshal(raw, cfg); err != nil {
|
|
return nil, fmt.Errorf("разобрать конфиг %s: %w", path, err)
|
|
}
|
|
|
|
migrated := cfg.migrateLegacy()
|
|
cfg.applyFallbacks()
|
|
if migrated {
|
|
// Сразу переписываем файл, чтобы старые поля не остались висеть
|
|
// рядом с новым списком профилей и не путали при правке руками.
|
|
if err := cfg.Save(); err != nil {
|
|
return nil, fmt.Errorf("обновить конфиг %s: %w", path, err)
|
|
}
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// migrateLegacy превращает настройки единственного сервера в первый профиль.
|
|
// Возвращает true, если конфиг пришлось поменять.
|
|
func (c *Config) migrateLegacy() bool {
|
|
hasLegacy := c.LegacyServerName != "" || c.LegacyAdminPassword != "" ||
|
|
c.LegacyJavaMemory != "" || len(c.LegacyExtraArgs) > 0
|
|
if !hasLegacy {
|
|
return false
|
|
}
|
|
|
|
profile := Profile{
|
|
ID: c.LegacyServerName,
|
|
AdminPassword: c.LegacyAdminPassword,
|
|
JavaMemory: c.LegacyJavaMemory,
|
|
ExtraArgs: c.LegacyExtraArgs,
|
|
}
|
|
if profile.ID == "" {
|
|
profile.ID = DefaultProfile().ID
|
|
}
|
|
profile.Title = "Основной сервер"
|
|
|
|
// Если профили уже описаны, старые поля просто отбрасываем: список
|
|
// профилей — источник истины.
|
|
if len(c.Profiles) == 0 {
|
|
c.Profiles = []Profile{profile}
|
|
c.Active = profile.ID
|
|
}
|
|
c.LegacyServerName = ""
|
|
c.LegacyAdminPassword = ""
|
|
c.LegacyJavaMemory = ""
|
|
c.LegacyExtraArgs = nil
|
|
return true
|
|
}
|
|
|
|
// applyFallbacks подставляет умолчания вместо пустых значений: конфиг,
|
|
// написанный руками, редко заполнен целиком.
|
|
func (c *Config) applyFallbacks() {
|
|
d := Default()
|
|
if c.Listen == "" {
|
|
c.Listen = d.Listen
|
|
}
|
|
if c.DataDir == "" {
|
|
c.DataDir = d.DataDir
|
|
}
|
|
if c.ServerDir == "" {
|
|
c.ServerDir = d.ServerDir
|
|
}
|
|
if c.ZomboidDir == "" {
|
|
c.ZomboidDir = d.ZomboidDir
|
|
}
|
|
if c.StopTimeout <= 0 {
|
|
c.StopTimeout = d.StopTimeout
|
|
}
|
|
if c.LogBufferLines <= 0 {
|
|
c.LogBufferLines = d.LogBufferLines
|
|
}
|
|
if c.Backup.Dir == "" {
|
|
c.Backup.Dir = d.Backup.Dir
|
|
}
|
|
if len(c.Profiles) == 0 {
|
|
c.Profiles = []Profile{DefaultProfile()}
|
|
}
|
|
for i := range c.Profiles {
|
|
if c.Profiles[i].JavaMemory == "" {
|
|
c.Profiles[i].JavaMemory = DefaultProfile().JavaMemory
|
|
}
|
|
}
|
|
// Активный профиль мог быть удалён из файла руками.
|
|
if _, ok := c.Profile(c.Active); !ok {
|
|
c.Active = c.Profiles[0].ID
|
|
}
|
|
}
|
|
|
|
// Profile возвращает профиль по идентификатору.
|
|
func (c *Config) Profile(id string) (Profile, bool) {
|
|
for _, p := range c.Profiles {
|
|
if p.ID == id {
|
|
return p, true
|
|
}
|
|
}
|
|
return Profile{}, false
|
|
}
|
|
|
|
// ActiveProfile возвращает профиль, который сейчас запускается и правится.
|
|
func (c *Config) ActiveProfile() Profile {
|
|
if p, ok := c.Profile(c.Active); ok {
|
|
return p
|
|
}
|
|
// До сюда доходить не должно: applyFallbacks чинит ссылку на активный
|
|
// профиль, а список никогда не бывает пустым.
|
|
if len(c.Profiles) > 0 {
|
|
return c.Profiles[0]
|
|
}
|
|
return DefaultProfile()
|
|
}
|
|
|
|
// AddProfile добавляет новый профиль.
|
|
func (c *Config) AddProfile(p Profile) error {
|
|
if err := ValidateProfileID(p.ID); err != nil {
|
|
return err
|
|
}
|
|
if _, exists := c.Profile(p.ID); exists {
|
|
return fmt.Errorf("профиль %q уже существует", p.ID)
|
|
}
|
|
if p.JavaMemory == "" {
|
|
p.JavaMemory = DefaultProfile().JavaMemory
|
|
}
|
|
p.Title = strings.TrimSpace(p.Title)
|
|
c.Profiles = append(c.Profiles, p)
|
|
return nil
|
|
}
|
|
|
|
// UpdateProfile заменяет настройки существующего профиля. Идентификатор
|
|
// профиля неизменен: от него зависят имена файлов конфигов и путь к миру.
|
|
func (c *Config) UpdateProfile(id string, p Profile) error {
|
|
for i := range c.Profiles {
|
|
if c.Profiles[i].ID != id {
|
|
continue
|
|
}
|
|
p.ID = id
|
|
if p.JavaMemory == "" {
|
|
p.JavaMemory = c.Profiles[i].JavaMemory
|
|
}
|
|
p.Title = strings.TrimSpace(p.Title)
|
|
c.Profiles[i] = p
|
|
return nil
|
|
}
|
|
return fmt.Errorf("профиль %q не найден", id)
|
|
}
|
|
|
|
// DeleteProfile убирает профиль из конфига. Файлы мира при этом остаются на
|
|
// диске: удалять чужие сохранения по нажатию кнопки в панели слишком опасно.
|
|
func (c *Config) DeleteProfile(id string) error {
|
|
if len(c.Profiles) == 1 {
|
|
return fmt.Errorf("нельзя удалить единственный профиль")
|
|
}
|
|
if id == c.Active {
|
|
return fmt.Errorf("нельзя удалить активный профиль — сначала переключитесь на другой")
|
|
}
|
|
for i, p := range c.Profiles {
|
|
if p.ID == id {
|
|
c.Profiles = append(c.Profiles[:i], c.Profiles[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("профиль %q не найден", id)
|
|
}
|
|
|
|
// SetActive переключает активный профиль.
|
|
func (c *Config) SetActive(id string) error {
|
|
if _, ok := c.Profile(id); !ok {
|
|
return fmt.Errorf("профиль %q не найден", id)
|
|
}
|
|
c.Active = id
|
|
return nil
|
|
}
|
|
|
|
// Save записывает конфиг атомарно: сначала во временный файл рядом, затем
|
|
// переименование, чтобы падение посреди записи не оставило огрызок.
|
|
func (c *Config) Save() error {
|
|
if c.path == "" {
|
|
return fmt.Errorf("путь к конфигу неизвестен")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
raw, err := yaml.Marshal(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := c.path + ".tmp"
|
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, c.path)
|
|
}
|
|
|
|
// Path возвращает путь, откуда конфиг был загружен.
|
|
func (c *Config) Path() string { return c.path }
|
|
|
|
// SetPath задаёт файл для последующего Save.
|
|
func (c *Config) SetPath(p string) { c.path = p }
|
|
|
|
// EnsureDirs создаёт директории, без которых менеджер не сможет работать.
|
|
func (c *Config) EnsureDirs() error {
|
|
for _, dir := range []string{c.DataDir, c.ServerDir, c.Backup.Dir} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("создать %s: %w", dir, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ServerConfigPathFor — путь к <profile>.ini.
|
|
func (c *Config) ServerConfigPathFor(profileID string) string {
|
|
return filepath.Join(c.ZomboidDir, "Server", profileID+".ini")
|
|
}
|
|
|
|
// SandboxConfigPathFor — путь к <profile>_SandboxVars.lua.
|
|
func (c *Config) SandboxConfigPathFor(profileID string) string {
|
|
return filepath.Join(c.ZomboidDir, "Server", profileID+"_SandboxVars.lua")
|
|
}
|
|
|
|
// WorldDirFor — директория мира профиля.
|
|
func (c *Config) WorldDirFor(profileID string) string {
|
|
return filepath.Join(c.ZomboidDir, "Saves", "Multiplayer", profileID)
|
|
}
|
|
|
|
// ServerConfigPath — конфиг активного профиля.
|
|
func (c *Config) ServerConfigPath() string {
|
|
return c.ServerConfigPathFor(c.ActiveProfile().ID)
|
|
}
|
|
|
|
// SandboxConfigPath — настройки песочницы активного профиля.
|
|
func (c *Config) SandboxConfigPath() string {
|
|
return c.SandboxConfigPathFor(c.ActiveProfile().ID)
|
|
}
|
|
|
|
// ServerDirPath — директория с серверными конфигами всех профилей.
|
|
func (c *Config) ServerDirPath() string {
|
|
return filepath.Join(c.ZomboidDir, "Server")
|
|
}
|
|
|
|
// SavesDir — директория со всеми мирами.
|
|
func (c *Config) SavesDir() string {
|
|
return filepath.Join(c.ZomboidDir, "Saves")
|
|
}
|