// 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"` // ExtraArgs — дополнительные аргументы start-server.sh. ExtraArgs []string `yaml:"extra_args"` } // 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 } // 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 — путь к .ini. func (c *Config) ServerConfigPathFor(profileID string) string { return filepath.Join(c.ZomboidDir, "Server", profileID+".ini") } // SandboxConfigPathFor — путь к _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") }