// 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() } } } }