From 4d6f162b8f3c870f9767b4b81be4a80132276b6b Mon Sep 17 00:00:00 2001 From: MrFastwind Date: Sat, 25 Jul 2026 02:47:04 +0200 Subject: [PATCH] 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 --- .gitea/workflows/ci.yml | 9 +- .gitea/workflows/release.yml | 2 +- Dockerfile | 15 +- Makefile | 2 +- README.md | 27 ++- backend/cmd/server/main.go | 15 ++ backend/internal/api/logs.go | 18 +- backend/internal/api/mods.go | 10 +- backend/internal/api/router.go | 47 +++- backend/internal/api/settings.go | 125 +++++++---- backend/internal/services/config_manager.go | 4 +- backend/internal/services/mod_manager_test.go | 209 ++++++++++++++++++ backend/internal/services/modlist_manager.go | 6 +- backend/internal/services/scheduler_test.go | 136 ++++++++++++ backend/internal/services/server_process.go | 7 +- .../internal/services/server_process_test.go | 26 +++ backend/internal/services/settings.go | 63 +++++- backend/internal/services/steamcmd.go | 5 +- 18 files changed, 638 insertions(+), 88 deletions(-) create mode 100644 backend/internal/services/mod_manager_test.go create mode 100644 backend/internal/services/scheduler_test.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0e36f44..21f0dd9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -12,12 +12,17 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.25" + go-version: "1.24" + cache: true + cache-dependency-path: backend/go.sum - uses: actions/setup-node@v4 with: node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json - run: cd frontend && npm ci && npm run build - run: mkdir -p backend/embed/dist && cp -r frontend/dist/. backend/embed/dist/ - run: cd backend && go build ./... - - run: cd backend && go test ./... + - run: cd backend && go test -race ./... + - run: cd frontend && npm run lint - run: cd frontend && npm test diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 952ea72..039a13b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -13,7 +13,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v5 with: - go-version: "1.25" + go-version: "1.24" - uses: actions/setup-node@v4 with: node-version: "22" diff --git a/Dockerfile b/Dockerfile index 7532937..4a7d329 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,3 @@ -FROM golang:1.25-bookworm AS backend -WORKDIR /src -COPY backend/go.mod backend/go.sum ./ -RUN go mod download -COPY backend/ . -RUN CGO_ENABLED=0 go build -o /server ./cmd/server/ - FROM node:22-alpine AS frontend WORKDIR /src COPY frontend/package.json frontend/package-lock.json ./ @@ -12,6 +5,14 @@ RUN npm ci COPY frontend/ . RUN npm run build +FROM golang:1.24-bookworm AS backend +WORKDIR /src +COPY backend/go.mod backend/go.sum ./ +RUN go mod download +COPY --from=frontend /src/dist /src/embed/dist +COPY backend/ . +RUN CGO_ENABLED=0 go build -o /server ./cmd/server/ + FROM debian:bookworm-slim RUN apt-get update && \ apt-get install -y --no-install-recommends \ diff --git a/Makefile b/Makefile index 835c267..32241fd 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ clean: rm -rf frontend/dist backend/embed/dist bin test-backend: - cd backend && go test ./... + cd backend && go test -race ./... test-frontend: cd frontend && npm test diff --git a/README.md b/README.md index 5755ac3..f963f18 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,22 @@ The web UI is served on `http://localhost:8080`. ### Manual -```bash -# Backend -cd backend -go build -o arma3-web-server ./cmd/server -./arma3-web-server +**Prerequisites:** +- [Go](https://go.dev/dl/) >= 1.24 +- [Node.js](https://nodejs.org/) >= 22 with npm +- GNU Make (optional, for `make` targets) -# Frontend (development) -cd frontend -npm install -npm run dev +```bash +# Build for production (frontend + backend in one binary) +make build +./backend/arma3-web-server + +# Or develop with hot reload (uses test stubs, no real SteamCMD needed) +make dev ``` +The web UI is served on `http://localhost:8080`. + ## Configuration All paths are configurable via environment variables: @@ -51,6 +55,9 @@ All paths are configurable via environment variables: | `SERVER_BINARY` | `arma3server_x64` | Server binary filename (overrides platform default) | | `SERVER_PARAMS` | `-server -world=empty ...` | Override server launch parameters | | `STEAMCMD_PATH` | `steamcmd` | Path to steamcmd binary | +| `AUTH_TOKEN` | _(empty)_ | Bearer token for API/WS auth. When set, all requests must include `Authorization: Bearer `. When empty, no auth required. | +| `GIN_MODE` | `debug` | Gin framework mode. Use `release` for production (set automatically in Docker). | +| `SERVERS_DIR` | `./serverfiles` | Docker Compose only: host directory mounted as `/servers` in the container. | ## Automation @@ -66,7 +73,7 @@ Configured in the **Automation** section of the Settings UI: | **Auto-update mods on startup** | Downloads workshop updates for every enabled mod in the active modlist when the web service starts. | | **Auto-start server on startup** | Restarts the game server if it was running when the web service last stopped. Useful for recovery after host backup cycles or container restarts. | -All startup tasks run asynchronously — the web UI is available immediately. +All startup tasks run asynchronously — the web UI is available immediately. Server and mod updates run in parallel; auto-start waits for both to complete before launching the game server. ### Scheduled updates diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index cdb4623..0a9afc4 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "time" @@ -91,8 +92,12 @@ func main() { 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) @@ -101,7 +106,9 @@ func main() { } 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 { @@ -124,6 +131,7 @@ func main() { 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) @@ -152,6 +160,13 @@ func main() { 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 { diff --git a/backend/internal/api/logs.go b/backend/internal/api/logs.go index 4dd111c..dd5490e 100644 --- a/backend/internal/api/logs.go +++ b/backend/internal/api/logs.go @@ -195,7 +195,13 @@ func (h *Handler) StreamRPTLogs(c *gin.Context) { if _, err := f.Seek(currentOffset, io.SeekStart); err != nil { return nil } - buf := make([]byte, fi.Size()-currentOffset) + remaining := fi.Size() - currentOffset + const maxBuf = 64 * 1024 + bufSize := remaining + if bufSize > maxBuf { + bufSize = maxBuf + } + buf := make([]byte, bufSize) n, _ := io.ReadFull(f, buf) currentOffset += int64(n) @@ -260,6 +266,16 @@ func (h *Handler) GetLog(c *gin.Context) { } } + fi, err := os.Stat(path) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + const maxLogSize = 10 * 1024 * 1024 + if fi.Size() > maxLogSize { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "log file too large, use live streaming"}) + return + } data, err := os.ReadFile(path) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/backend/internal/api/mods.go b/backend/internal/api/mods.go index e63cef4..365267b 100644 --- a/backend/internal/api/mods.go +++ b/backend/internal/api/mods.go @@ -49,6 +49,14 @@ func (h *Handler) ListMods(c *gin.Context) { c.JSON(http.StatusOK, all) } +func isUnderDir(path, dir string) bool { + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + func (h *Handler) DeleteMod(c *gin.Context) { var input deleteModInput if err := c.ShouldBindJSON(&input); err != nil { @@ -62,7 +70,7 @@ func (h *Handler) DeleteMod(c *gin.Context) { return } - if !strings.HasPrefix(absPath, h.serverfileDir) && !strings.HasPrefix(absPath, h.modsDir) { + if !isUnderDir(absPath, h.serverfileDir) && !isUnderDir(absPath, h.modsDir) { c.JSON(http.StatusForbidden, gin.H{"error": "path outside allowed directories"}) return } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 0e6b7ef..d9dd32b 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -1,6 +1,10 @@ package api import ( + "net/http" + "os" + "strings" + "arma3-web-server/internal/services" "github.com/gin-gonic/gin" @@ -20,6 +24,7 @@ type Handler struct { cfgDir string profilesDir string frontendServed bool + authToken string } func New( @@ -47,11 +52,44 @@ func New( cfgDir: cfgDir, profilesDir: profilesDir, frontendServed: frontendServed, + authToken: os.Getenv("AUTH_TOKEN"), } } +func (h *Handler) authMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if h.authToken == "" { + c.Next() + return + } + auth := c.GetHeader("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid authorization header"}) + return + } + token := strings.TrimPrefix(auth, "Bearer ") + if !secureCompare(token, h.authToken) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + c.Next() + } +} + +func secureCompare(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + if a[i] != b[i] { + return false + } + } + return true +} + func (h *Handler) SetupRoutes(r *gin.Engine) { - api := r.Group("/api") + api := r.Group("/api", h.authMiddleware()) { api.GET("/server/settings", h.GetSettings) api.PUT("/server/settings", h.UpdateSettings) @@ -93,7 +131,8 @@ func (h *Handler) SetupRoutes(r *gin.Engine) { api.POST("/mods/cleanup", h.CleanupMods) } - r.GET("/ws/server/logs", h.StreamLogs) - r.GET("/ws/steamcmd/logs", h.StreamSteamCMDLogs) - r.GET("/ws/server/rpt", h.StreamRPTLogs) + ws := r.Group("/ws", h.authMiddleware()) + ws.GET("/server/logs", h.StreamLogs) + ws.GET("/steamcmd/logs", h.StreamSteamCMDLogs) + ws.GET("/server/rpt", h.StreamRPTLogs) } diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go index 1726921..cdc0638 100644 --- a/backend/internal/api/settings.go +++ b/backend/internal/api/settings.go @@ -1,9 +1,15 @@ package api import ( + "fmt" + "net" "net/http" + "strings" + + "arma3-web-server/internal/models" "github.com/gin-gonic/gin" + "github.com/robfig/cron/v3" ) type updateSettingsInput struct { @@ -39,65 +45,86 @@ func (h *Handler) UpdateSettings(c *gin.Context) { return } - s, err := h.settings.Load() + if input.IPPort != nil && *input.IPPort != "" { + if _, _, err := net.SplitHostPort(*input.IPPort); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid ip_port: must be host:port format: %v", err)}) + return + } + } + if input.ServerParameters != nil && len(*input.ServerParameters) > 8192 { + c.JSON(http.StatusBadRequest, gin.H{"error": "server_parameters too long (max 8192)"}) + return + } + if input.ScheduledUpdate != nil && *input.ScheduledUpdate != "" { + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + if _, err := parser.Parse(*input.ScheduledUpdate); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid cron expression: %v", err)}) + return + } + } + if input.ServerParameters != nil { + for _, forbidden := range []string{"--dry-run", "--rm", "--privileged"} { + if strings.Contains(*input.ServerParameters, forbidden) { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("server_parameters contains forbidden flag: %s", forbidden)}) + return + } + } + } + + s, err := h.settings.Update(func(s *models.ServerSettings) { + if input.IPPort != nil { + s.IPPort = *input.IPPort + } + if input.ServerParameters != nil { + s.ServerParameters = *input.ServerParameters + } + if input.SteamBranch != nil { + s.SteamBranch = *input.SteamBranch + } + if input.SteamUser != nil { + s.SteamUser = *input.SteamUser + } + if input.Platform != nil { + s.Platform = *input.Platform + } + if input.CBASettings != nil { + s.CBASettings = *input.CBASettings + } + if input.AILevelPresets != nil { + s.AILevelPresets = *input.AILevelPresets + } + if input.DifficultyPresets != nil { + s.DifficultyPresets = *input.DifficultyPresets + } + if input.ActiveConfig != nil { + s.ActiveConfig = *input.ActiveConfig + } + if input.ActiveModlist != nil { + s.ActiveModlist = *input.ActiveModlist + } + if input.AutoUpdateOnStartup != nil { + s.AutoUpdateOnStartup = *input.AutoUpdateOnStartup + } + if input.AutoStartOnStartup != nil { + s.AutoStartOnStartup = *input.AutoStartOnStartup + } + if input.AutoUpdateModsOnStartup != nil { + s.AutoUpdateModsOnStartup = *input.AutoUpdateModsOnStartup + } + if input.ScheduledUpdate != nil { + s.ScheduledUpdate = *input.ScheduledUpdate + } + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if input.IPPort != nil { - s.IPPort = *input.IPPort - } - if input.ServerParameters != nil { - s.ServerParameters = *input.ServerParameters - } - if input.SteamBranch != nil { - s.SteamBranch = *input.SteamBranch - } - if input.SteamUser != nil { - s.SteamUser = *input.SteamUser - } - if input.Platform != nil { - s.Platform = *input.Platform - } - if input.CBASettings != nil { - s.CBASettings = *input.CBASettings - } - if input.AILevelPresets != nil { - s.AILevelPresets = *input.AILevelPresets - } - if input.DifficultyPresets != nil { - s.DifficultyPresets = *input.DifficultyPresets - } - if input.ActiveConfig != nil { - s.ActiveConfig = *input.ActiveConfig - } - if input.ActiveModlist != nil { - s.ActiveModlist = *input.ActiveModlist - } - if input.AutoUpdateOnStartup != nil { - s.AutoUpdateOnStartup = *input.AutoUpdateOnStartup - } - if input.AutoStartOnStartup != nil { - s.AutoStartOnStartup = *input.AutoStartOnStartup - } - if input.AutoUpdateModsOnStartup != nil { - s.AutoUpdateModsOnStartup = *input.AutoUpdateModsOnStartup - } - if input.ScheduledUpdate != nil { - s.ScheduledUpdate = *input.ScheduledUpdate - } - if err := h.process.WriteUserconfigFiles(s); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()}) return } - if err := h.settings.Save(s); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if h.scheduler != nil { h.scheduler.Refresh() } diff --git a/backend/internal/services/config_manager.go b/backend/internal/services/config_manager.go index a1823f3..34b1f98 100644 --- a/backend/internal/services/config_manager.go +++ b/backend/internal/services/config_manager.go @@ -59,11 +59,11 @@ func (cm *ConfigManager) Create(name, content string) error { if _, err := os.Stat(path); err == nil { return fmt.Errorf("config already exists") } - return os.WriteFile(path, []byte(content), 0644) + return writeFileAtomic(path, []byte(content)) } func (cm *ConfigManager) Update(name, content string) error { - return os.WriteFile(cm.path(name), []byte(content), 0644) + return writeFileAtomic(cm.path(name), []byte(content)) } func (cm *ConfigManager) Delete(name string) error { diff --git a/backend/internal/services/mod_manager_test.go b/backend/internal/services/mod_manager_test.go new file mode 100644 index 0000000..b6dc483 --- /dev/null +++ b/backend/internal/services/mod_manager_test.go @@ -0,0 +1,209 @@ +package services + +import ( + "os" + "path/filepath" + "testing" + + "arma3-web-server/internal/models" +) + +func TestListWorkshopMods_Empty(t *testing.T) { + dir := t.TempDir() + mods := ListWorkshopMods(dir) + if len(mods) != 0 { + t.Fatalf("expected 0 mods, got %d", len(mods)) + } +} + +func TestListWorkshopMods_WithMods(t *testing.T) { + dir := t.TempDir() + workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410") + if err := os.MkdirAll(filepath.Join(workshopDir, "123456"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(workshopDir, "789012"), 0755); err != nil { + t.Fatal(err) + } + + mods := ListWorkshopMods(dir) + if len(mods) != 2 { + t.Fatalf("expected 2 mods, got %d", len(mods)) + } + + ids := map[string]bool{} + for _, m := range mods { + ids[m.ID] = true + if m.Source != "workshop" { + t.Errorf("expected source 'workshop', got %q", m.Source) + } + } + if !ids["123456"] || !ids["789012"] { + t.Errorf("expected both mod IDs, got %v", ids) + } +} + +func TestListWorkshopMods_SkipsFiles(t *testing.T) { + dir := t.TempDir() + workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410") + if err := os.MkdirAll(workshopDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workshopDir, "notadir.txt"), []byte("hi"), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(workshopDir, "111111"), 0755); err != nil { + t.Fatal(err) + } + + mods := ListWorkshopMods(dir) + if len(mods) != 1 { + t.Fatalf("expected 1 mod, got %d", len(mods)) + } + if mods[0].ID != "111111" { + t.Errorf("expected ID '111111', got %q", mods[0].ID) + } +} + +func TestListLocalMods_Empty(t *testing.T) { + dir := t.TempDir() + mods := ListLocalMods(dir) + if len(mods) != 0 { + t.Fatalf("expected 0 mods, got %d", len(mods)) + } +} + +func TestListLocalMods_WithMods(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "@ACE3"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "@TFAR"), 0755); err != nil { + t.Fatal(err) + } + + mods := ListLocalMods(dir) + if len(mods) != 2 { + t.Fatalf("expected 2 mods, got %d", len(mods)) + } + + for _, m := range mods { + if m.Source != "local" { + t.Errorf("expected source 'local', got %q", m.Source) + } + if len(m.Name) > 0 && m.Name[0] != '@' { + t.Errorf("expected name to start with '@', got %q", m.Name) + } + } +} + +func TestListLocalMods_SkipsFiles(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("hi"), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "@Mod"), 0755); err != nil { + t.Fatal(err) + } + + mods := ListLocalMods(dir) + if len(mods) != 1 { + t.Fatalf("expected 1 mod, got %d", len(mods)) + } +} + +func TestBuildUsageMap(t *testing.T) { + dataDir := t.TempDir() + mm := NewModlistManager(dataDir) + + ml, err := mm.Create("Test List") + if err != nil { + t.Fatal(err) + } + + ml, err = mm.Update(ml.ID, ml.Name, []models.ModEntry{ + {ID: "111111", Name: "ACE3", Enabled: true}, + {ID: "222222", Name: "", Enabled: true}, + }) + if err != nil { + t.Fatal(err) + } + _ = ml + + usedWorkshop, usedLocal := BuildUsageMap(mm) + + if lists, ok := usedWorkshop["111111"]; !ok || len(lists) == 0 { + t.Errorf("expected workshop mod 111111 to be in use") + } + if _, ok := usedWorkshop["222222"]; !ok { + t.Errorf("expected workshop mod 222222 to be in use") + } + if _, ok := usedLocal["ACE3"]; !ok { + t.Errorf("expected local mod ACE3 to be in use") + } + if _, ok := usedLocal["@ACE3"]; !ok { + t.Errorf("expected local mod @ACE3 to be in use") + } +} + +func TestBuildUsageMap_Empty(t *testing.T) { + dataDir := t.TempDir() + mm := NewModlistManager(dataDir) + + usedWorkshop, usedLocal := BuildUsageMap(mm) + if len(usedWorkshop) != 0 || len(usedLocal) != 0 { + t.Errorf("expected empty maps, got workshop=%v local=%v", usedWorkshop, usedLocal) + } +} + +func TestRemoveMod(t *testing.T) { + dir := t.TempDir() + modDir := filepath.Join(dir, "@TestMod") + if err := os.MkdirAll(modDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(modDir, "config.cpp"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + + if err := RemoveMod(modDir); err != nil { + t.Fatalf("RemoveMod failed: %v", err) + } + if _, err := os.Stat(modDir); !os.IsNotExist(err) { + t.Errorf("expected directory to be removed") + } +} + +func TestRemoveMod_Nonexistent(t *testing.T) { + err := RemoveMod("/nonexistent/path/that/does/not/exist") + if err != nil { + t.Errorf("RemoveMod on nonexistent path should not error, got: %v", err) + } +} + +func TestDirSize(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + sub := filepath.Join(dir, "sub") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "b.txt"), []byte("world!"), 0644); err != nil { + t.Fatal(err) + } + + size := dirSize(dir) + if size != 11 { + t.Errorf("expected size 11, got %d", size) + } +} + +func TestDirSize_Empty(t *testing.T) { + dir := t.TempDir() + size := dirSize(dir) + if size != 0 { + t.Errorf("expected size 0, got %d", size) + } +} diff --git a/backend/internal/services/modlist_manager.go b/backend/internal/services/modlist_manager.go index 93ed9aa..90b89b4 100644 --- a/backend/internal/services/modlist_manager.go +++ b/backend/internal/services/modlist_manager.go @@ -103,7 +103,7 @@ func (mm *ModlistManager) Create(name string) (*models.Modlist, error) { if err := os.MkdirAll(mm.dir, 0755); err != nil { return nil, err } - if err := os.WriteFile(mm.path(m.ID), data, 0644); err != nil { + if err := writeFileAtomic(mm.path(m.ID), data); err != nil { return nil, err } return m, nil @@ -126,7 +126,7 @@ func (mm *ModlistManager) Update(id, name string, mods []models.ModEntry) (*mode if err != nil { return nil, err } - if err := os.WriteFile(mm.path(id), data, 0644); err != nil { + if err := writeFileAtomic(mm.path(id), data); err != nil { return nil, err } return m, nil @@ -160,7 +160,7 @@ func (mm *ModlistManager) Duplicate(id, newName string) (*models.Modlist, error) if err != nil { return nil, err } - if err := os.WriteFile(mm.path(dup.ID), data, 0644); err != nil { + if err := writeFileAtomic(mm.path(dup.ID), data); err != nil { return nil, err } return dup, nil diff --git a/backend/internal/services/scheduler_test.go b/backend/internal/services/scheduler_test.go new file mode 100644 index 0000000..0bf2d45 --- /dev/null +++ b/backend/internal/services/scheduler_test.go @@ -0,0 +1,136 @@ +package services + +import ( + "testing" + "time" + + "arma3-web-server/internal/models" +) + +func TestNewScheduler(t *testing.T) { + dataDir := t.TempDir() + settings := NewSettingsManager(dataDir) + modlists := NewModlistManager(dataDir) + streamer := NewLogStreamer() + steamcmd := NewSteamCmdManager(t.TempDir(), streamer) + + s := NewScheduler(settings, modlists, steamcmd) + if s == nil { + t.Fatal("expected non-nil scheduler") + } + if s.cron == nil { + t.Fatal("expected non-nil cron") + } +} + +func TestScheduler_StartStop(t *testing.T) { + dataDir := t.TempDir() + settings := NewSettingsManager(dataDir) + modlists := NewModlistManager(dataDir) + streamer := NewLogStreamer() + steamcmd := NewSteamCmdManager(t.TempDir(), streamer) + + s := NewScheduler(settings, modlists, steamcmd) + s.Start() + time.Sleep(50 * time.Millisecond) + s.Stop() +} + +func TestScheduler_Refresh_Empty(t *testing.T) { + dataDir := t.TempDir() + settings := NewSettingsManager(dataDir) + modlists := NewModlistManager(dataDir) + streamer := NewLogStreamer() + steamcmd := NewSteamCmdManager(t.TempDir(), streamer) + + s := NewScheduler(settings, modlists, steamcmd) + s.Start() + defer s.Stop() + + s.Refresh() + if s.entryID != 0 { + t.Errorf("expected entryID 0 with empty settings, got %d", s.entryID) + } +} + +func TestScheduler_Refresh_InvalidCron(t *testing.T) { + dataDir := t.TempDir() + settings := NewSettingsManager(dataDir) + modlists := NewModlistManager(dataDir) + streamer := NewLogStreamer() + steamcmd := NewSteamCmdManager(t.TempDir(), streamer) + + _, err := settings.Update(func(s *models.ServerSettings) { + s.ScheduledUpdate = "not-a-cron" + }) + if err != nil { + t.Fatal(err) + } + + sched := NewScheduler(settings, modlists, steamcmd) + sched.Start() + defer sched.Stop() + + sched.Refresh() + if sched.entryID != 0 { + t.Errorf("expected entryID 0 with invalid cron, got %d", sched.entryID) + } +} + +func TestScheduler_Refresh_ValidCron(t *testing.T) { + dataDir := t.TempDir() + settings := NewSettingsManager(dataDir) + modlists := NewModlistManager(dataDir) + streamer := NewLogStreamer() + steamcmd := NewSteamCmdManager(t.TempDir(), streamer) + + _, err := settings.Update(func(s *models.ServerSettings) { + s.ScheduledUpdate = "0 4 * * *" + }) + if err != nil { + t.Fatal(err) + } + + sched := NewScheduler(settings, modlists, steamcmd) + sched.Start() + defer sched.Stop() + + sched.Refresh() + if sched.entryID == 0 { + t.Errorf("expected non-zero entryID with valid cron") + } +} + +func TestScheduler_Refresh_Replace(t *testing.T) { + dataDir := t.TempDir() + settings := NewSettingsManager(dataDir) + modlists := NewModlistManager(dataDir) + streamer := NewLogStreamer() + steamcmd := NewSteamCmdManager(t.TempDir(), streamer) + + sched := NewScheduler(settings, modlists, steamcmd) + sched.Start() + defer sched.Stop() + + _, err := settings.Update(func(s *models.ServerSettings) { + s.ScheduledUpdate = "0 4 * * *" + }) + if err != nil { + t.Fatal(err) + } + sched.Refresh() + id1 := sched.entryID + + _, err = settings.Update(func(s *models.ServerSettings) { + s.ScheduledUpdate = "0 5 * * *" + }) + if err != nil { + t.Fatal(err) + } + sched.Refresh() + id2 := sched.entryID + + if id1 == id2 { + t.Errorf("expected different entryIDs after refresh, both got %d", id1) + } +} diff --git a/backend/internal/services/server_process.go b/backend/internal/services/server_process.go index 58dc2b8..fea743a 100644 --- a/backend/internal/services/server_process.go +++ b/backend/internal/services/server_process.go @@ -3,6 +3,7 @@ package services import ( "context" "fmt" + "log" "net" "os" "os/exec" @@ -184,7 +185,9 @@ func (pm *ProcessManager) Stop() error { s, err := pm.settings.Load() if err == nil { s.WasRunning = false - pm.settings.Save(s) + if err := pm.settings.Save(s); err != nil { + log.Printf("stop: save was_running: %v", err) + } } return nil @@ -307,7 +310,7 @@ func (pm *ProcessManager) WriteUserconfigFiles(s *models.ServerSettings) error { } for name, content := range files { path := filepath.Join(userconfigDir, name) - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + if err := writeFileAtomic(path, []byte(content)); err != nil { return fmt.Errorf("write %s: %w", name, err) } } diff --git a/backend/internal/services/server_process_test.go b/backend/internal/services/server_process_test.go index ba6e86f..a9dd4b5 100644 --- a/backend/internal/services/server_process_test.go +++ b/backend/internal/services/server_process_test.go @@ -983,3 +983,29 @@ func TestStart_StubWritesRPTLog(t *testing.T) { pm.Stop() time.Sleep(100 * time.Millisecond) } + +func TestRestart(t *testing.T) { + pm, _ := setupStubPM(t, "-t 10") + + if err := pm.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + if !pm.IsRunning() { + t.Fatal("IsRunning() should be true after Start()") + } + + if err := pm.Restart(); err != nil { + t.Fatalf("Restart() error = %v", err) + } + if !pm.IsRunning() { + t.Fatal("IsRunning() should be true after Restart()") + } + + if err := pm.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + time.Sleep(100 * time.Millisecond) + if pm.IsRunning() { + t.Fatal("IsRunning() should be false after Stop()") + } +} diff --git a/backend/internal/services/settings.go b/backend/internal/services/settings.go index c721e0e..b1459f6 100644 --- a/backend/internal/services/settings.go +++ b/backend/internal/services/settings.go @@ -10,6 +10,29 @@ import ( "arma3-web-server/internal/models" ) +func writeFileAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return err + } + return nil +} + type SettingsManager struct { path string mu sync.Mutex @@ -49,7 +72,45 @@ func (sm *SettingsManager) Save(s *models.ServerSettings) error { if err != nil { return err } - return os.WriteFile(sm.path, data, 0644) + return writeFileAtomic(sm.path, data) +} + +func (sm *SettingsManager) Update(fn func(*models.ServerSettings)) (*models.ServerSettings, error) { + sm.mu.Lock() + defer sm.mu.Unlock() + + data, err := os.ReadFile(sm.path) + if err != nil { + if os.IsNotExist(err) { + s := sm.defaults() + fn(s) + s.UpdatedAt = time.Now().UTC() + out, err := json.MarshalIndent(s, "", " ") + if err != nil { + return nil, err + } + if err := writeFileAtomic(sm.path, out); err != nil { + return nil, err + } + return s, nil + } + return nil, err + } + + var s models.ServerSettings + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + fn(&s) + s.UpdatedAt = time.Now().UTC() + out, err := json.MarshalIndent(s, "", " ") + if err != nil { + return nil, err + } + if err := writeFileAtomic(sm.path, out); err != nil { + return nil, err + } + return &s, nil } func (sm *SettingsManager) defaults() *models.ServerSettings { diff --git a/backend/internal/services/steamcmd.go b/backend/internal/services/steamcmd.go index 37bebe1..2e536b0 100644 --- a/backend/internal/services/steamcmd.go +++ b/backend/internal/services/steamcmd.go @@ -25,7 +25,6 @@ type SteamCmdManager struct { serverfileDir string streamer *LogStreamer running atomic.Bool - cancel context.CancelFunc } func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdManager { @@ -115,15 +114,13 @@ func (s *SteamCmdManager) run(label string, args []string) error { return fmt.Errorf("start steamcmd: %w", err) } - s.cancel = cancel - go s.streamer.Stream(ctx, "steamcmd", stdout, "") go s.streamer.Stream(ctx, "steamcmd", stderr, "") go func() { err := cmd.Wait() + cancel() s.running.Store(false) - s.cancel = nil if err == nil { s.streamer.Broadcast("steamcmd", "[STEAMCMD] SUCCESS: "+label+" finished") } else {