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