Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и лимит памяти JVM. Project Zomboid различает их по -servername, поэтому файлы профилей не пересекаются. Работает всегда один профиль: серверные файлы, игровые порты и файл запуска JVM общие, так что переключение разрешено только на остановленном сервере. - config: список профилей вместо одиночных server_name/admin_password/ java_memory; старые поля переносятся в первый профиль при загрузке; - supervisor: запускает активный профиль и выставляет его -Xmx на каждом старте, потому что файл запуска общий; - бэкапы: архив снимается с одного профиля (мир + его конфиги), имя профиля попадает в имя файла, ротация считается по профилям; - API профилей: список, создание с копированием настроек из другого профиля, правка, удаление и переключение активного; - панель: селектор профиля в шапке и вкладка со списком профилей. Удаление профиля убирает его только из панели: мир и конфиги остаются на диске, чтобы случайное нажатие не стоило сохранений. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
483 lines
16 KiB
Go
483 lines
16 KiB
Go
// Package pzserver запускает игровой сервер Project Zomboid как дочерний
|
|
// процесс, держит его консоль и следит за его состоянием.
|
|
package pzserver
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mrleo1nid/pzmanager/internal/config"
|
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
|
"github.com/mrleo1nid/pzmanager/internal/sysinfo"
|
|
)
|
|
|
|
// State — фаза жизненного цикла игрового сервера.
|
|
type State string
|
|
|
|
const (
|
|
StateStopped State = "stopped"
|
|
StateStarting State = "starting"
|
|
StateRunning State = "running"
|
|
StateStopping State = "stopping"
|
|
)
|
|
|
|
// Ошибки, на которые HTTP-слой отвечает осмысленным кодом.
|
|
var (
|
|
ErrAlreadyRunning = errors.New("сервер уже запущен")
|
|
ErrNotRunning = errors.New("сервер не запущен")
|
|
ErrNotInstalled = errors.New("серверные файлы не найдены — сначала выполните установку")
|
|
)
|
|
|
|
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
|
|
const playersPollInterval = 30 * time.Second
|
|
|
|
// restartDelay — пауза перед автоподъёмом упавшего сервера.
|
|
const restartDelay = 10 * time.Second
|
|
|
|
// Status — снимок состояния для веб-панели.
|
|
type Status struct {
|
|
State State `json:"state"`
|
|
// Profile и ProfileTitle описывают профиль, который сейчас работает
|
|
// (а если сервер остановлен — который будет запущен).
|
|
Profile string `json:"profile"`
|
|
ProfileTitle string `json:"profile_title"`
|
|
Installed bool `json:"installed"`
|
|
PID int `json:"pid"`
|
|
UptimeSec int64 `json:"uptime_sec"`
|
|
Players []string `json:"players"`
|
|
PlayersAt int64 `json:"players_at"`
|
|
LastExit string `json:"last_exit"`
|
|
AutoRestart bool `json:"auto_restart"`
|
|
Proc sysinfo.ProcStats `json:"proc"`
|
|
Host sysinfo.HostStats `json:"host"`
|
|
}
|
|
|
|
// Supervisor владеет процессом сервера: запускает, кормит командами консоли,
|
|
// собирает вывод и по желанию поднимает после падения.
|
|
type Supervisor struct {
|
|
cfg *config.Config
|
|
log *logBuffer
|
|
|
|
mu sync.Mutex
|
|
state State
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
pgid int
|
|
startedAt time.Time
|
|
lastExit string
|
|
players []string
|
|
playersAt time.Time
|
|
// profile — профиль, с которым сервер был запущен: активный профиль
|
|
// в конфиге может отличаться, если его успели переключить.
|
|
profile config.Profile
|
|
// intentionalStop отличает остановку по кнопке от падения: во втором
|
|
// случае включается автоперезапуск.
|
|
intentionalStop bool
|
|
exited chan struct{}
|
|
|
|
// Захват ответа на команду players: сервер печатает заголовок и список
|
|
// строк вида -Nick.
|
|
capturing bool
|
|
captured []string
|
|
autoQuery bool
|
|
|
|
sampler *sysinfo.Sampler
|
|
stopOnce sync.Once
|
|
quit chan struct{}
|
|
}
|
|
|
|
// New создаёт супервизор поверх конфига. Конфиг читается при каждом запуске,
|
|
// поэтому изменения настроек подхватываются следующим стартом сервера.
|
|
func New(cfg *config.Config) *Supervisor {
|
|
s := &Supervisor{
|
|
cfg: cfg,
|
|
log: newLogBuffer(cfg.LogBufferLines),
|
|
state: StateStopped,
|
|
sampler: sysinfo.NewSampler(),
|
|
quit: make(chan struct{}),
|
|
}
|
|
go s.pollPlayers()
|
|
return s
|
|
}
|
|
|
|
// Close останавливает фоновые задачи супервизора.
|
|
func (s *Supervisor) Close() {
|
|
s.stopOnce.Do(func() { close(s.quit) })
|
|
}
|
|
|
|
// Installed сообщает, лежат ли на диске файлы сервера.
|
|
func (s *Supervisor) Installed() bool {
|
|
_, err := os.Stat(s.startScript())
|
|
return err == nil
|
|
}
|
|
|
|
func (s *Supervisor) startScript() string {
|
|
return filepath.Join(s.cfg.ServerDir, "start-server.sh")
|
|
}
|
|
|
|
// AppendLog добавляет строку в консоль панели — им пользуются задачи
|
|
// установки и бэкапа, чтобы их вывод был виден там же, где логи сервера.
|
|
func (s *Supervisor) AppendLog(stream, text string) {
|
|
s.log.append(LogLine{Time: time.Now().UnixMilli(), Stream: stream, Text: text})
|
|
}
|
|
|
|
// Lines возвращает историю консоли начиная с номера seq.
|
|
func (s *Supervisor) Lines(seq int64) []LogLine { return s.log.since(seq) }
|
|
|
|
// Subscribe отдаёт канал новых строк консоли и функцию отписки.
|
|
func (s *Supervisor) Subscribe() (<-chan LogLine, func()) { return s.log.subscribe(256) }
|
|
|
|
// State возвращает текущую фазу без остальных метрик.
|
|
func (s *Supervisor) State() State {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.state
|
|
}
|
|
|
|
// Status собирает полный снимок состояния вместе с метриками процесса и хоста.
|
|
func (s *Supervisor) Status() Status {
|
|
profile := s.cfg.ActiveProfile()
|
|
|
|
s.mu.Lock()
|
|
if s.state != StateStopped {
|
|
profile = s.profile
|
|
}
|
|
st := Status{
|
|
State: s.state,
|
|
Profile: profile.ID,
|
|
ProfileTitle: profile.Name(),
|
|
Players: append([]string{}, s.players...),
|
|
LastExit: s.lastExit,
|
|
AutoRestart: s.cfg.AutoRestart,
|
|
}
|
|
if !s.playersAt.IsZero() {
|
|
st.PlayersAt = s.playersAt.Unix()
|
|
}
|
|
pgid := s.pgid
|
|
if s.cmd != nil && s.cmd.Process != nil {
|
|
st.PID = s.cmd.Process.Pid
|
|
}
|
|
if !s.startedAt.IsZero() && s.state != StateStopped {
|
|
st.UptimeSec = int64(time.Since(s.startedAt).Seconds())
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
st.Installed = s.Installed()
|
|
if st.State == StateRunning || st.State == StateStarting {
|
|
st.Proc = s.sampler.Sample(pgid)
|
|
} else {
|
|
s.sampler.Reset()
|
|
}
|
|
st.Host = sysinfo.Host(s.cfg.ZomboidDir)
|
|
return st
|
|
}
|
|
|
|
// Start поднимает игровой сервер. Возвращает ошибку, если он уже работает
|
|
// или файлы сервера ещё не установлены.
|
|
func (s *Supervisor) Start() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.startLocked()
|
|
}
|
|
|
|
func (s *Supervisor) startLocked() error {
|
|
if s.state != StateStopped {
|
|
return ErrAlreadyRunning
|
|
}
|
|
script := s.startScript()
|
|
if _, err := os.Stat(script); err != nil {
|
|
return ErrNotInstalled
|
|
}
|
|
// Права на исполнение теряются, например, после распаковки из архива.
|
|
if err := os.Chmod(script, 0o755); err != nil {
|
|
return fmt.Errorf("chmod %s: %w", script, err)
|
|
}
|
|
|
|
profile := s.cfg.ActiveProfile()
|
|
// Лимит памяти живёт в файле запуска, общем для всех профилей, поэтому
|
|
// выставляем его на каждом старте — иначе профиль унаследует чужой -Xmx.
|
|
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil {
|
|
s.AppendLog("manager", "Не удалось выставить лимит памяти JVM: "+err.Error())
|
|
}
|
|
|
|
args := []string{"-servername", profile.ID}
|
|
if profile.AdminPassword != "" {
|
|
args = append(args, "-adminpassword", profile.AdminPassword)
|
|
}
|
|
args = append(args, profile.ExtraArgs...)
|
|
|
|
cmd := exec.Command(script, args...)
|
|
cmd.Dir = s.cfg.ServerDir
|
|
// PZ ищет сохранения относительно HOME, а нативные библиотеки — рядом с
|
|
// собой; обе переменные задаём явно, чтобы не зависеть от окружения
|
|
// systemd-юнита.
|
|
cmd.Env = append(os.Environ(),
|
|
"HOME="+filepath.Dir(s.cfg.ZomboidDir),
|
|
"LD_LIBRARY_PATH="+s.cfg.ServerDir+"/linux64:"+s.cfg.ServerDir+"/natives:"+os.Getenv("LD_LIBRARY_PATH"),
|
|
)
|
|
setProcessGroup(cmd)
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stdin: %w", err)
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stdout: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stderr: %w", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("запустить %s: %w", script, err)
|
|
}
|
|
|
|
s.cmd = cmd
|
|
s.profile = profile
|
|
s.stdin = stdin
|
|
s.pgid = processGroupID(cmd.Process.Pid)
|
|
s.state = StateStarting
|
|
s.startedAt = time.Now()
|
|
s.lastExit = ""
|
|
s.intentionalStop = false
|
|
s.players = nil
|
|
s.exited = make(chan struct{})
|
|
s.sampler.Reset()
|
|
|
|
s.AppendLog("manager", fmt.Sprintf("Запуск профиля %q: %s %s (pid %d)",
|
|
profile.Name(), script, strings.Join(args, " "), cmd.Process.Pid))
|
|
|
|
go s.readPipe(stdout, "stdout")
|
|
go s.readPipe(stderr, "stderr")
|
|
go s.wait(cmd, s.exited)
|
|
return nil
|
|
}
|
|
|
|
// readPipe построчно читает вывод сервера. Буфер увеличен: PZ иногда печатает
|
|
// длинные стек-трейсы одной строкой.
|
|
func (s *Supervisor) readPipe(r io.Reader, stream string) {
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
s.handleLine(stream, scanner.Text())
|
|
}
|
|
}
|
|
|
|
// handleLine разбирает строку вывода: ловит момент готовности сервера и
|
|
// ответы на команду players, остальное складывает в консоль.
|
|
func (s *Supervisor) handleLine(stream, text string) {
|
|
trimmed := strings.TrimRight(text, "\r")
|
|
|
|
s.mu.Lock()
|
|
hide := false
|
|
|
|
switch {
|
|
case s.capturing:
|
|
if stripped := strings.TrimSpace(trimmed); strings.HasPrefix(stripped, "-") {
|
|
s.captured = append(s.captured, strings.TrimPrefix(stripped, "-"))
|
|
s.players = append([]string{}, s.captured...)
|
|
hide = s.autoQuery
|
|
} else {
|
|
s.capturing = false
|
|
s.autoQuery = false
|
|
}
|
|
case strings.Contains(trimmed, "Players connected"):
|
|
s.capturing = true
|
|
s.captured = nil
|
|
s.players = nil
|
|
s.playersAt = time.Now()
|
|
hide = s.autoQuery
|
|
}
|
|
|
|
if s.state == StateStarting && isServerReady(trimmed) {
|
|
s.state = StateRunning
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
// Автоопрос игроков не должен засорять консоль — команду отправили мы,
|
|
// а не пользователь.
|
|
if !hide {
|
|
s.AppendLog(stream, trimmed)
|
|
}
|
|
}
|
|
|
|
// isServerReady распознаёт строку, которой PZ сообщает о готовности принимать
|
|
// игроков.
|
|
func isServerReady(line string) bool {
|
|
return strings.Contains(line, "SERVER STARTED") ||
|
|
strings.Contains(line, "Server Steam ID")
|
|
}
|
|
|
|
// wait дожидается завершения процесса и решает, поднимать ли его заново.
|
|
func (s *Supervisor) wait(cmd *exec.Cmd, exited chan struct{}) {
|
|
err := cmd.Wait()
|
|
close(exited)
|
|
|
|
s.mu.Lock()
|
|
wasIntentional := s.intentionalStop
|
|
s.state = StateStopped
|
|
s.cmd = nil
|
|
s.stdin = nil
|
|
s.pgid = 0
|
|
s.players = nil
|
|
s.capturing = false
|
|
if err != nil {
|
|
s.lastExit = err.Error()
|
|
} else {
|
|
s.lastExit = "штатное завершение"
|
|
}
|
|
reason := s.lastExit
|
|
autoRestart := s.cfg.AutoRestart && !wasIntentional
|
|
s.mu.Unlock()
|
|
|
|
s.AppendLog("manager", "Сервер остановлен: "+reason)
|
|
if !autoRestart {
|
|
return
|
|
}
|
|
|
|
s.AppendLog("manager", fmt.Sprintf("Автоперезапуск через %s", restartDelay))
|
|
select {
|
|
case <-time.After(restartDelay):
|
|
case <-s.quit:
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
err = s.startLocked()
|
|
s.mu.Unlock()
|
|
if err != nil && !errors.Is(err, ErrAlreadyRunning) {
|
|
s.AppendLog("manager", "Автоперезапуск не удался: "+err.Error())
|
|
}
|
|
}
|
|
|
|
// Send отправляет команду в консоль сервера.
|
|
func (s *Supervisor) Send(command string) error {
|
|
command = strings.TrimSpace(command)
|
|
if command == "" {
|
|
return errors.New("пустая команда")
|
|
}
|
|
s.mu.Lock()
|
|
stdin, state := s.stdin, s.state
|
|
s.mu.Unlock()
|
|
|
|
if stdin == nil || state == StateStopped {
|
|
return ErrNotRunning
|
|
}
|
|
s.AppendLog("console", "> "+command)
|
|
if _, err := io.WriteString(stdin, command+"\n"); err != nil {
|
|
return fmt.Errorf("запись в консоль: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sendQuiet отправляет служебную команду, не показывая её в консоли панели.
|
|
func (s *Supervisor) sendQuiet(command string) error {
|
|
s.mu.Lock()
|
|
stdin := s.stdin
|
|
s.mu.Unlock()
|
|
if stdin == nil {
|
|
return ErrNotRunning
|
|
}
|
|
_, err := io.WriteString(stdin, command+"\n")
|
|
return err
|
|
}
|
|
|
|
// Stop корректно останавливает сервер: сначала команда quit (она сохраняет
|
|
// мир), затем SIGTERM группе процессов и в крайнем случае SIGKILL.
|
|
func (s *Supervisor) Stop() error {
|
|
s.mu.Lock()
|
|
if s.state == StateStopped || s.cmd == nil {
|
|
s.mu.Unlock()
|
|
return ErrNotRunning
|
|
}
|
|
s.state = StateStopping
|
|
s.intentionalStop = true
|
|
exited, pgid := s.exited, s.pgid
|
|
s.mu.Unlock()
|
|
|
|
s.AppendLog("manager", "Останавливаю сервер командой quit...")
|
|
if err := s.sendQuiet("quit"); err != nil {
|
|
s.AppendLog("manager", "Не удалось отправить quit: "+err.Error())
|
|
}
|
|
|
|
select {
|
|
case <-exited:
|
|
return nil
|
|
case <-time.After(s.cfg.StopTimeout.Duration()):
|
|
}
|
|
|
|
s.AppendLog("manager", "Сервер не ответил на quit — отправляю SIGTERM")
|
|
if err := signalGroup(pgid, sigTerm); err != nil {
|
|
s.AppendLog("manager", "SIGTERM не доставлен: "+err.Error())
|
|
}
|
|
select {
|
|
case <-exited:
|
|
return nil
|
|
case <-time.After(30 * time.Second):
|
|
}
|
|
|
|
s.AppendLog("manager", "Сервер завис — отправляю SIGKILL")
|
|
if err := signalGroup(pgid, sigKill); err != nil {
|
|
return fmt.Errorf("SIGKILL: %w", err)
|
|
}
|
|
<-exited
|
|
return nil
|
|
}
|
|
|
|
// Restart останавливает сервер и поднимает его заново.
|
|
func (s *Supervisor) Restart() error {
|
|
if s.State() != StateStopped {
|
|
if err := s.Stop(); err != nil && !errors.Is(err, ErrNotRunning) {
|
|
return err
|
|
}
|
|
// После Stop горутина wait ещё должна снять состояние.
|
|
s.WaitStopped(15 * time.Second)
|
|
}
|
|
return s.Start()
|
|
}
|
|
|
|
// WaitStopped ждёт, пока состояние станет stopped, но не дольше timeout.
|
|
// Возвращает true, если сервер действительно остановился.
|
|
func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if s.State() == StateStopped {
|
|
return true
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
return s.State() == StateStopped
|
|
}
|
|
|
|
// pollPlayers периодически спрашивает у работающего сервера, кто онлайн.
|
|
func (s *Supervisor) pollPlayers() {
|
|
ticker := time.NewTicker(playersPollInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-s.quit:
|
|
return
|
|
case <-ticker.C:
|
|
if s.State() != StateRunning {
|
|
continue
|
|
}
|
|
s.mu.Lock()
|
|
s.autoQuery = true
|
|
s.mu.Unlock()
|
|
if err := s.sendQuiet("players"); err != nil {
|
|
s.mu.Lock()
|
|
s.autoQuery = false
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
}
|