Critical fixes: - Fix Dockerfile: reorder stages so frontend assets embed into Go binary - Fix Go version 1.25 (nonexistent) to 1.24 across Dockerfile, go.mod, CI - Add graceful game server shutdown on SIGTERM/SIGINT - Order startup tasks: updates complete before auto-start - Fix TOCTOU race in UpdateSettings with atomic Update() method Security: - Add optional AUTH_TOKEN bearer auth middleware on API/WS routes - Fix path traversal in DeleteMod using filepath.Rel instead of HasPrefix - Add input validation for IPPort, ServerParameters, ScheduledUpdate Memory safety: - Cap RPT buffer allocation to 64KB to prevent OOM on large logs - Cap GetLog file read to 10MB - Fix context cancel leak in SteamCmdManager.run() - Remove data-raced cancel field in steamcmd.go - Atomic file writes (write-temp-then-rename) across all managers Reliability: - Log save errors in ProcessManager.Stop() - Atomic file writes prevent corruption on crash Tests: - Add mod_manager_test.go (12 tests: ListWorkshopMods, ListLocalMods, BuildUsageMap, RemoveMod, dirSize) - Add scheduler_test.go (6 tests: Start/Stop, Refresh with empty, invalid, valid, and replaced cron expressions) - Add TestRestart to server_process_test.go CI/Docs: - Add -race flag to go test in CI and Makefile - Add npm lint step to CI - Add Go/npm module caching to CI - Update README: prerequisites, AUTH_TOKEN/GIN_MODE/SERVERS_DIR docs, fix manual quickstart to use make build
133 lines
2.9 KiB
Go
133 lines
2.9 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
func CheckWorkshopMod(serverfileDir, modID string) bool {
|
|
if modID == "" {
|
|
return false
|
|
}
|
|
p := filepath.Join(serverfileDir, "steamapps", "workshop", "content", "107410", modID)
|
|
fi, err := os.Stat(p)
|
|
return err == nil && fi.IsDir()
|
|
}
|
|
|
|
const arma3AppID = "233780"
|
|
|
|
type SteamCmdManager struct {
|
|
serverfileDir string
|
|
streamer *LogStreamer
|
|
running atomic.Bool
|
|
}
|
|
|
|
func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdManager {
|
|
return &SteamCmdManager{
|
|
serverfileDir: serverfileDir,
|
|
streamer: streamer,
|
|
}
|
|
}
|
|
|
|
func (s *SteamCmdManager) IsRunning() bool {
|
|
return s.running.Load()
|
|
}
|
|
|
|
func (s *SteamCmdManager) UpdateGame(branch, user string) error {
|
|
if !s.running.CompareAndSwap(false, true) {
|
|
return fmt.Errorf("steamcmd already running")
|
|
}
|
|
|
|
args := []string{
|
|
"+force_install_dir", s.serverfileDir,
|
|
"+login", user,
|
|
"+app_update", arma3AppID,
|
|
}
|
|
if branch != "" && branch != "stable" {
|
|
args = append(args, "-beta", branch)
|
|
}
|
|
args = append(args, "validate", "+quit")
|
|
|
|
return s.run("Game update", args)
|
|
}
|
|
|
|
func (s *SteamCmdManager) DownloadMod(modID string) error {
|
|
return s.DownloadMods([]string{modID})
|
|
}
|
|
|
|
func (s *SteamCmdManager) DownloadMods(modIDs []string) error {
|
|
if !s.running.CompareAndSwap(false, true) {
|
|
return fmt.Errorf("steamcmd already running")
|
|
}
|
|
|
|
if len(modIDs) == 0 {
|
|
return fmt.Errorf("no mod ids provided")
|
|
}
|
|
|
|
args := []string{
|
|
"+force_install_dir", s.serverfileDir,
|
|
"+login", "anonymous",
|
|
}
|
|
for _, id := range modIDs {
|
|
args = append(args, "+workshop_download_item", "107410", id)
|
|
}
|
|
args = append(args, "+quit")
|
|
|
|
label := fmt.Sprintf("Download %d mod(s)", len(modIDs))
|
|
return s.run(label, args)
|
|
}
|
|
|
|
const steamcmdTimeout = 10 * time.Minute
|
|
|
|
func (s *SteamCmdManager) run(label string, args []string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), steamcmdTimeout)
|
|
|
|
s.streamer.Broadcast("steamcmd", "[STEAMCMD] "+label+" starting...")
|
|
|
|
steamcmdPath := os.Getenv("STEAMCMD_PATH")
|
|
if steamcmdPath == "" {
|
|
steamcmdPath = "steamcmd"
|
|
}
|
|
cmd := exec.CommandContext(ctx, steamcmdPath, args...)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
s.running.Store(false)
|
|
return fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
cancel()
|
|
s.running.Store(false)
|
|
return fmt.Errorf("stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
s.running.Store(false)
|
|
return fmt.Errorf("start steamcmd: %w", err)
|
|
}
|
|
|
|
go s.streamer.Stream(ctx, "steamcmd", stdout, "")
|
|
go s.streamer.Stream(ctx, "steamcmd", stderr, "")
|
|
|
|
go func() {
|
|
err := cmd.Wait()
|
|
cancel()
|
|
s.running.Store(false)
|
|
if err == nil {
|
|
s.streamer.Broadcast("steamcmd", "[STEAMCMD] SUCCESS: "+label+" finished")
|
|
} else {
|
|
s.streamer.Broadcast("steamcmd", "[STEAMCMD] ERROR: "+label+" failed: "+err.Error())
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|