Files
ArmA-3-web-server/backend/internal/services/steamcmd.go
T
MrFastwind 4d6f162b8f fix: code quality, memory safety, and install improvements
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
2026-07-25 02:47:04 +02:00

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
}