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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+8
-7
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <token>`. 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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,12 +45,33 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
s, err := h.settings.Load()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
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
|
||||
}
|
||||
@@ -87,14 +114,14 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
|
||||
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()})
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.settings.Save(s); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
if err := h.process.WriteUserconfigFiles(s); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user