Files
ArmA-3-web-server/backend/cmd/server/main.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

192 lines
4.9 KiB
Go

package main
import (
"context"
"io/fs"
"log"
"mime"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"arma3-web-server/embed"
"arma3-web-server/internal/api"
"arma3-web-server/internal/services"
"github.com/gin-gonic/gin"
)
func main() {
serverfileDir := mustAbs(getEnv("SERVERFILE_DIR", "./serverfiles"))
dataDir := mustAbs(getEnv("DATA_DIR", "./data"))
modsDir := mustAbs(getEnv("MODS_DIR", filepath.Join(serverfileDir, "mods")))
cfgDir := mustAbs(getEnv("CFG_DIR", filepath.Join(serverfileDir, "cfg")))
profilesDir := mustAbs(getEnv("PROFILES_DIR", filepath.Join(serverfileDir, "profiles")))
listen := getEnv("LISTEN", ":8080")
log.Printf("data dir: %s", dataDir)
log.Printf("serverfile dir: %s", serverfileDir)
log.Printf("mods dir: %s", modsDir)
log.Printf("cfg dir: %s", cfgDir)
log.Printf("profiles dir: %s", profilesDir)
log.Printf("listening on %s", listen)
dirs := []string{
dataDir, filepath.Join(dataDir, "modlists"),
serverfileDir, modsDir, cfgDir, profilesDir,
filepath.Join(serverfileDir, "steamapps", "workshop", "content", "107410"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
log.Fatalf("mkdir %s: %v", dir, err)
}
}
settings := services.NewSettingsManager(dataDir)
streamer := services.NewLogStreamer()
configMgr := services.NewConfigManager(cfgDir)
modlistMgr := services.NewModlistManager(dataDir)
process := services.NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, settings, modlistMgr, configMgr, streamer)
steamcmd := services.NewSteamCmdManager(serverfileDir, streamer)
scheduler := services.NewScheduler(settings, modlistMgr, steamcmd)
r := gin.Default()
frontendServed := false
subFS, subErr := fs.Sub(embed.Frontend, "dist")
if subErr == nil {
if _, err := subFS.Open("index.html"); err == nil {
frontendServed = true
r.NoRoute(func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path == "" {
path = "index.html"
}
data, err := fs.ReadFile(subFS, path)
if err == nil {
ct := mime.TypeByExtension(filepath.Ext(path))
if ct == "" {
ct = "text/html; charset=utf-8"
}
c.Data(http.StatusOK, ct, data)
return
}
data, _ = fs.ReadFile(subFS, "index.html")
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
})
log.Print("serving embedded frontend")
}
}
handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, scheduler, streamer, dataDir, serverfileDir, modsDir, cfgDir, profilesDir, frontendServed)
handler.SetupRoutes(r)
// Startup auto-tasks
s, err := settings.Load()
if err != nil {
log.Printf("startup: load settings: %v", err)
} else {
var updatesWg sync.WaitGroup
if s.AutoUpdateOnStartup && s.SteamUser != "" {
updatesWg.Add(1)
go func() {
defer updatesWg.Done()
log.Print("startup: auto-updating gameserver")
if err := steamcmd.UpdateGame(s.SteamBranch, s.SteamUser); err != nil {
log.Printf("startup: auto-update game failed: %v", err)
}
}()
}
if s.AutoUpdateModsOnStartup && s.ActiveModlist != "" {
updatesWg.Add(1)
go func() {
defer updatesWg.Done()
log.Print("startup: auto-updating mods")
ml, err := modlistMgr.Get(s.ActiveModlist)
if err != nil {
log.Printf("startup: load modlist for auto-update: %v", err)
return
}
var modIDs []string
for _, m := range ml.Mods {
if m.Enabled {
modIDs = append(modIDs, m.ID)
}
}
if len(modIDs) > 0 {
if err := steamcmd.DownloadMods(modIDs); err != nil {
log.Printf("startup: auto-update mods failed: %v", err)
}
}
}()
}
if s.AutoStartOnStartup && s.WasRunning {
go func() {
updatesWg.Wait()
log.Print("startup: auto-restarting server (was running before)")
if err := process.Start(); err != nil {
log.Printf("startup: auto-start failed: %v", err)
}
}()
}
}
// Start the scheduled updater
scheduler.Start()
log.Printf("starting server on %s", listen)
srv := &http.Server{Addr: listen, Handler: r}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Print("shutting down server...")
scheduler.Stop()
if process.IsRunning() {
log.Print("stopping game server...")
if err := process.Stop(); err != nil {
log.Printf("stop game server: %v", err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("server forced shutdown: %v", err)
}
log.Print("server exited")
}
func mustAbs(p string) string {
a, err := filepath.Abs(p)
if err != nil {
log.Fatalf("abs %s: %v", p, err)
}
return a
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}