Files
mrleo1nidandClaude Opus 5 d0ff64c272 Веб-панель управления сервером Project Zomboid
Менеджер запускает сервер 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>
2026-09-03 22:41:11 +03:00

208 lines
6.2 KiB
Go

// Package steamcmd ставит и обновляет серверные файлы Project Zomboid.
package steamcmd
import (
"archive/tar"
"bufio"
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
)
// AppID выделенного сервера Project Zomboid в Steam.
const AppID = "380870"
// downloadURL — официальная сборка SteamCMD для Linux.
const downloadURL = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz"
// Client знает, где лежит steamcmd и куда ставить сервер.
type Client struct {
// Path — путь к steamcmd.sh. Пустой означает "найти или скачать".
Path string
// InstallRoot — куда положить SteamCMD, если его нет в системе.
InstallRoot string
}
// Resolve находит рабочий steamcmd: сначала явно заданный путь, потом PATH,
// потом ранее скачанная копия. Если ничего нет — возвращает пустую строку.
func (c *Client) Resolve() string {
if c.Path != "" {
if _, err := os.Stat(c.Path); err == nil {
return c.Path
}
}
if p, err := exec.LookPath("steamcmd"); err == nil {
return p
}
local := filepath.Join(c.InstallRoot, "steamcmd", "steamcmd.sh")
if _, err := os.Stat(local); err == nil {
return local
}
return ""
}
// Ensure возвращает путь к steamcmd, при необходимости скачивая его.
func (c *Client) Ensure(ctx context.Context, log func(string)) (string, error) {
if p := c.Resolve(); p != "" {
log("SteamCMD найден: " + p)
return p, nil
}
dir := filepath.Join(c.InstallRoot, "steamcmd")
log("SteamCMD не найден, скачиваю в " + dir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
if err := downloadAndExtract(ctx, downloadURL, dir); err != nil {
return "", fmt.Errorf("скачать SteamCMD: %w", err)
}
script := filepath.Join(dir, "steamcmd.sh")
if err := os.Chmod(script, 0o755); err != nil {
return "", err
}
// Бинарь, который дёргает скрипт, тоже должен быть исполняемым.
if bin := filepath.Join(dir, "linux32", "steamcmd"); fileExists(bin) {
_ = os.Chmod(bin, 0o755)
}
log("SteamCMD установлен: " + script)
return script, nil
}
// InstallOrUpdate ставит или обновляет серверные файлы в targetDir. Вывод
// SteamCMD построчно уходит в log.
func (c *Client) InstallOrUpdate(ctx context.Context, log func(string), targetDir string) error {
script, err := c.Ensure(ctx, log)
if err != nil {
return err
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return err
}
// validate сверяет контрольные суммы: чуть дольше, но лечит битые файлы
// после падения диска или прерванной загрузки.
args := []string{
"+force_install_dir", targetDir,
"+login", "anonymous",
"+app_update", AppID, "validate",
"+quit",
}
log("Запускаю: " + script + " " + strings.Join(args, " "))
cmd := exec.CommandContext(ctx, script, args...)
cmd.Dir = filepath.Dir(script)
return runStreaming(cmd, log)
}
// runStreaming выполняет команду, отдавая её вывод построчно в log.
func runStreaming(cmd *exec.Cmd, log func(string)) error {
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
// Один канал на оба потока: SteamCMD пишет ошибки вперемешку с прогрессом,
// и разделять их в консоли панели незачем.
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
return err
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 32*1024), 512*1024)
for sc.Scan() {
// SteamCMD рисует прогресс через \r; разворачиваем в отдельные строки.
for _, part := range strings.Split(sc.Text(), "\r") {
if part = strings.TrimSpace(part); part != "" {
log(part)
}
}
}
return cmd.Wait()
}
// downloadAndExtract скачивает tar.gz и распаковывает его в dest.
func downloadAndExtract(ctx context.Context, url, dest string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %s", resp.Status)
}
gz, err := gzip.NewReader(resp.Body)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
target, err := safeJoin(dest, hdr.Name)
if err != nil {
return err
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode))
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return err
}
f.Close()
}
}
}
// safeJoin защищает от архивов с путями вида ../../etc/passwd. Сегменты
// проверяются до path.Clean: тот схлопнул бы выход за корень и скрыл его.
func safeJoin(root, name string) (string, error) {
slashed := filepath.ToSlash(name)
if path.IsAbs(slashed) {
return "", fmt.Errorf("абсолютный путь в архиве: %s", name)
}
for _, segment := range strings.Split(slashed, "/") {
if segment == ".." {
return "", fmt.Errorf("небезопасный путь в архиве: %s", name)
}
}
return filepath.Join(root, filepath.FromSlash(path.Clean(slashed))), nil
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}