Менеджер запускает сервер 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>
208 lines
6.2 KiB
Go
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
|
|
}
|