Менеджер запускает сервер 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>
281 lines
7.7 KiB
Go
281 lines
7.7 KiB
Go
// Команда pzmanager — веб-панель управления сервером Project Zomboid.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/term"
|
|
|
|
"github.com/mrleo1nid/pzmanager/internal/api"
|
|
"github.com/mrleo1nid/pzmanager/internal/auth"
|
|
"github.com/mrleo1nid/pzmanager/internal/backup"
|
|
"github.com/mrleo1nid/pzmanager/internal/config"
|
|
"github.com/mrleo1nid/pzmanager/internal/job"
|
|
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
|
"github.com/mrleo1nid/pzmanager/internal/steamcmd"
|
|
"github.com/mrleo1nid/pzmanager/web"
|
|
)
|
|
|
|
const usage = `pzmanager — панель управления сервером Project Zomboid.
|
|
|
|
Использование:
|
|
pzmanager [serve] запустить панель (команда по умолчанию)
|
|
pzmanager useradd <логин> создать пользователя панели
|
|
pzmanager passwd <логин> сменить пароль
|
|
pzmanager users показать список пользователей
|
|
|
|
Флаги:
|
|
`
|
|
|
|
func main() {
|
|
fs := flag.NewFlagSet("pzmanager", flag.ExitOnError)
|
|
configPath := fs.String("config", defaultConfigPath(), "путь к config.yaml")
|
|
listen := fs.String("listen", "", "адрес прослушивания (перекрывает конфиг)")
|
|
secureCookies := fs.Bool("secure-cookies", false,
|
|
"ставить флаг Secure у cookie сессии (панель работает по HTTPS)")
|
|
fs.Usage = func() {
|
|
fmt.Fprint(os.Stderr, usage)
|
|
fs.PrintDefaults()
|
|
}
|
|
|
|
command := "serve"
|
|
args := os.Args[1:]
|
|
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
|
command, args = args[0], args[1:]
|
|
}
|
|
if err := fs.Parse(args); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
|
|
cfg, err := config.Load(*configPath)
|
|
if err != nil {
|
|
fatal(err)
|
|
}
|
|
if *listen != "" {
|
|
cfg.Listen = *listen
|
|
}
|
|
if err := cfg.EnsureDirs(); err != nil {
|
|
fatal(err)
|
|
}
|
|
|
|
users, err := auth.NewStore(filepath.Join(cfg.DataDir, "users.json"), 12*time.Hour)
|
|
if err != nil {
|
|
fatal(err)
|
|
}
|
|
|
|
switch command {
|
|
case "serve":
|
|
if err := serve(cfg, users, *secureCookies); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "useradd":
|
|
if err := cmdUserAdd(users, fs.Args()); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "passwd":
|
|
if err := cmdPasswd(users, fs.Args()); err != nil {
|
|
fatal(err)
|
|
}
|
|
case "users":
|
|
for _, login := range users.Logins() {
|
|
fmt.Println(login)
|
|
}
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "неизвестная команда %q\n\n", command)
|
|
fs.Usage()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error {
|
|
static, err := web.Static()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
supervisor := pzserver.New(cfg)
|
|
defer supervisor.Close()
|
|
|
|
jobs := job.New(supervisor.AppendLog)
|
|
backups := backup.New(cfg)
|
|
steam := &steamcmd.Client{Path: cfg.SteamCMDPath, InstallRoot: cfg.DataDir}
|
|
|
|
// Пока учёток нет, создать первого администратора можно только с этим
|
|
// кодом — иначе панель захватит любой, кто откроет её первым.
|
|
setupToken := ""
|
|
if users.IsEmpty() {
|
|
setupToken = randomToken()
|
|
fmt.Printf("\n Панель ещё не настроена.\n"+
|
|
" Откройте её в браузере и введите код первичной настройки:\n\n %s\n\n", setupToken)
|
|
}
|
|
|
|
apiServer := api.New(api.Options{
|
|
Config: cfg,
|
|
Users: users,
|
|
Supervisor: supervisor,
|
|
Jobs: jobs,
|
|
Backups: backups,
|
|
Steam: steam,
|
|
Static: static,
|
|
SecureCookies: secureCookies,
|
|
SetupToken: setupToken,
|
|
})
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
api.StartBackupScheduler(ctx, apiServer)
|
|
go sessionGC(ctx, users)
|
|
|
|
httpServer := &http.Server{
|
|
Addr: cfg.Listen,
|
|
Handler: apiServer.Handler(),
|
|
// Записи без таймаута: SSE-поток логов живёт, пока открыта вкладка.
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
if cfg.AutoStart {
|
|
if err := supervisor.Start(); err != nil {
|
|
supervisor.AppendLog("manager", "Автозапуск не удался: "+err.Error())
|
|
}
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
fmt.Printf("pzmanager слушает http://%s\n", cfg.Listen)
|
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
errCh <- err
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
return err
|
|
case <-ctx.Done():
|
|
}
|
|
|
|
fmt.Println("\nЗавершение работы…")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
|
fmt.Fprintln(os.Stderr, "HTTP-сервер закрыт принудительно:", err)
|
|
}
|
|
|
|
// Игровой сервер останавливаем последним: это самая долгая часть, зато
|
|
// мир гарантированно сохранён.
|
|
if supervisor.State() != pzserver.StateStopped {
|
|
fmt.Println("Останавливаю игровой сервер (это может занять пару минут)…")
|
|
if err := supervisor.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sessionGC периодически чистит протухшие сессии.
|
|
func sessionGC(ctx context.Context, users *auth.Store) {
|
|
ticker := time.NewTicker(30 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
users.GC()
|
|
}
|
|
}
|
|
}
|
|
|
|
func cmdUserAdd(users *auth.Store, args []string) error {
|
|
if len(args) != 1 {
|
|
return errors.New("укажите логин: pzmanager useradd <логин>")
|
|
}
|
|
password, err := readPassword("Пароль: ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
repeat, err := readPassword("Ещё раз: ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if password != repeat {
|
|
return errors.New("пароли не совпадают")
|
|
}
|
|
if err := users.AddUser(args[0], password); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("Пользователь создан:", args[0])
|
|
return nil
|
|
}
|
|
|
|
func cmdPasswd(users *auth.Store, args []string) error {
|
|
if len(args) != 1 {
|
|
return errors.New("укажите логин: pzmanager passwd <логин>")
|
|
}
|
|
password, err := readPassword("Новый пароль: ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := users.SetPassword(args[0], password); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("Пароль изменён:", args[0])
|
|
return nil
|
|
}
|
|
|
|
// readPassword читает пароль без эха, если запущено в терминале.
|
|
func readPassword(prompt string) (string, error) {
|
|
fmt.Print(prompt)
|
|
fd := int(syscall.Stdin)
|
|
if !term.IsTerminal(fd) {
|
|
var line string
|
|
_, err := fmt.Scanln(&line)
|
|
return line, err
|
|
}
|
|
raw, err := term.ReadPassword(fd)
|
|
fmt.Println()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(raw), nil
|
|
}
|
|
|
|
func randomToken() string {
|
|
buf := make([]byte, 12)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
// Практически недостижимо; лучше упасть, чем выдать предсказуемый код.
|
|
fatal(err)
|
|
}
|
|
return hex.EncodeToString(buf)
|
|
}
|
|
|
|
// defaultConfigPath — ~/.config/pzmanager/config.yaml.
|
|
func defaultConfigPath() string {
|
|
if env := os.Getenv("PZMANAGER_CONFIG"); env != "" {
|
|
return env
|
|
}
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return "config.yaml"
|
|
}
|
|
return filepath.Join(dir, "pzmanager", "config.yaml")
|
|
}
|
|
|
|
func fatal(err error) {
|
|
fmt.Fprintln(os.Stderr, "pzmanager:", err)
|
|
os.Exit(1)
|
|
}
|