- Add winePath helper that prepends Z: when platform is windows on Linux - Thread platform through buildModPath, buildAutoArgs, and Start() - Apply Z: prefix to -config=, -mod=, -profiles=, and %command% binary paths - Add tests for winePath, buildAutoArgs, and buildModPath with platform param - Fix formatting across services package with gofmt - Include all prior bug fixes from this branch
16 KiB
Arma 3 Web Server — Architecture Plan
Overview
A web-based control panel to configure, update, install, and execute a single Arma 3 dedicated server instance. Supports both Windows and Linux server binaries via SteamCMD.
Tech Stack
| Layer | Choice | Rationale |
|---|---|---|
| Backend | Go 1.25 (gin, gorilla/websocket) |
Single binary, cross-compiles, goroutines for process streaming |
| Storage | File-based (JSON on disk) | No database dependency; settings, configs, and modlists are files |
| Scheduling | robfig/cron/v3 |
Cron-based scheduled updates (game + mods) |
| Frontend | React 19 + TypeScript 6 + Vite 8 | Fast iteration, rich ecosystem |
| Data Fetching | TanStack Query v5 | Server state, caching, polling, mutations |
| UI Kit | Tailwind CSS 4 | Dark-theme UI, utility-first styling |
| Code Editor | Monaco (VS Code) | Syntax-highlighted config editing |
| Terminal | xterm.js | Live log display from server stdout |
Project Structure
arma3-web-server/
├── backend/
│ ├── cmd/server/main.go # Entry point, env parsing, dir creation, startup auto-tasks
│ ├── embed/
│ │ ├── embed.go # //go:embed dist — embeds frontend into Go binary
│ │ └── dist/ # Pre-built frontend SPA assets
│ ├── internal/
│ │ ├── api/ # HTTP handlers (Gin routes)
│ │ │ ├── router.go # Route registration, Handler struct + New()
│ │ │ ├── settings.go # Settings CRUD + server start/stop/restart + steamcmd
│ │ │ ├── configs.go # .cfg file CRUD handlers
│ │ │ ├── modlists.go # Modlist CRUD + import + export + check/download-missing/update-all
│ │ │ ├── mods.go # Mod listing, deletion, bulk cleanup
│ │ │ ├── health.go # Comprehensive health check endpoint
│ │ │ └── logs.go # WS streaming (server, steamcmd, rpt) + log file listing
│ │ ├── models/ # Data structs
│ │ │ ├── settings.go # ServerSettings (singleton, 15 fields)
│ │ │ └── modlist.go # Modlist + ModEntry + ModlistListItem
│ │ └── services/ # Business logic layer
│ │ ├── settings.go # JSON load/save from data/settings.json
│ │ ├── config_manager.go # .cfg file I/O in $CFG_DIR
│ │ ├── modlist_manager.go # Modlist CRUD against data/modlists/*.json
│ │ ├── modlist_parser.go # HTML Arma Launcher preset parser + renderer
│ │ ├── mod_manager.go # Workshop + local mod discovery + usage map
│ │ ├── server_process.go # Process lifecycle, arg builder, mod path resolver
│ │ ├── steamcmd.go # SteamCMD manager (UpdateGame, DownloadMod, DownloadMods)
│ │ ├── scheduler.go # Cron-based scheduled updates
│ │ ├── log_streamer.go # Pub/sub fan-out for stdout/stderr via channels
│ │ └── testdata/ # Stub binaries for testing
│ │ ├── arma3server_x64 # Server stub: logs args, heartbeat, -t N exit, RPT writes
│ │ └── steamcmd # SteamCMD stub: fake downloads, creates mod dirs
│ ├── go.mod
│ └── go.sum
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Router setup (React Router v7)
│ │ ├── main.tsx # Entry point (QueryClient + BrowserRouter)
│ │ ├── index.css # Tailwind v4 imports
│ │ ├── api/
│ │ │ └── client.ts # Typed fetch wrappers + WS URL builders
│ │ ├── types/
│ │ │ └── index.ts # ServerSettings, Modlist, ModEntry, ConfigInfo, ModInfo, ServerHealth
│ │ ├── pages/
│ │ │ ├── Dashboard.tsx # Overview cards (status, configs, modlists)
│ │ │ ├── Settings.tsx # Main settings tab + userconfig tabs + SteamCMD + automation
│ │ │ ├── Configs.tsx # List/create/duplicate/delete configs
│ │ │ ├── ConfigEditor.tsx # Full-page Monaco editor for a single config
│ │ │ ├── Modlists.tsx # List/create/duplicate/delete + HTML import
│ │ │ ├── ModlistEditor.tsx # Mod list reorder + enable/disable + check/download-missing/update-all
│ │ │ ├── Mods.tsx # Installed mods table + search + delete + cleanup
│ │ │ ├── Logs.tsx # Tabbed LiveTerminal (Server Console / RPT / SteamCMD) + file browser
│ │ │ └── Status.tsx # Health check / deploy status dashboard
│ │ └── components/
│ │ ├── ConfigEditor.tsx # Monaco editor wrapper
│ │ ├── LiveTerminal.tsx # xterm.js + auto-reconnect WebSocket
│ │ └── Layout.tsx # Sidebar nav shell
│ ├── package.json
│ └── vite.config.ts # Proxy /api + /ws to :8080
├── data/ # Runtime data (mounted volume in Docker)
│ ├── settings.json # Singleton server settings
│ └── modlists/ # Modlist JSON files (*.json)
├── dev-deploy/ # Local development runtime data (git-ignored)
├── .gitea/workflows/
│ ├── ci.yml # Build-only CI (Go + frontend)
│ └── release.yml # GoReleaser-based release on tag push
├── docker-compose.yml
├── Dockerfile # Multi-stage: Go build -> npm build -> alpine runtime + SteamCMD
├── Dockerfile.goreleaser # Single-stage for GoReleaser (pre-built artifacts injected)
├── .goreleaser.yaml # GoReleaser v2 config (Gitea release target)
└── Makefile # Convenience targets: backend, frontend, build, run, dev, clean, test
Environment Variables
| Env | Purpose | Default |
|---|---|---|
SERVERFILE_DIR |
Base dir where arma3server_x64 binary + userconfig/ live |
./serverfiles |
MODS_DIR |
Base dir where @modname folders are stored |
$SERVERFILE_DIR/mods |
CFG_DIR |
Base dir where .cfg config files are stored |
$SERVERFILE_DIR/cfg |
PROFILES_DIR |
Server profile/save/log path | $SERVERFILE_DIR/profiles |
DATA_DIR |
Internal data (settings.json, modlists/) | ./data |
LISTEN |
HTTP listen address | :8080 |
SERVER_BINARY |
Server binary filename (overrides platform default) | arma3server_x64 (.exe on Windows) |
SERVER_PARAMS |
Override server launch parameters (overrides settings.json) | -server -world=empty -loadMissionToMemory -noPause |
STEAMCMD_PATH |
Path to steamcmd binary | steamcmd |
Note:
FRONTEND_DIRwas removed — the frontend is now embedded into the Go binary via//go:embed.
Data Model
Server Settings (singleton) — data/settings.json
{
"ip_port": "0.0.0.0:2302",
"server_parameters": "-server -world=empty -loadMissionToMemory -noPause",
"steam_branch": "stable",
"steam_user": "anonymous",
"platform": "linux",
"cba_settings": "",
"ai_level_presets": "",
"difficulty_presets": "",
"active_config": "server",
"active_modlist": "uuid-of-modlist",
"auto_update_on_startup": false,
"auto_start_on_startup": false,
"auto_update_mods_on_startup": false,
"was_running": false,
"scheduled_update": "",
"updated_at": "2026-01-01T00:00:00Z"
}
Configs — $CFG_DIR/*.cfg
Each config is a plain .cfg file stored in the env-defined CFG_DIR.
CRUD operations create/rename/delete these files directly on disk.
Modlists — data/modlists/{uuid}.json
{
"id": "uuid",
"name": "My Modlist",
"mods": [
{ "id": "450814997", "name": "CBA_A3", "enabled": true },
{ "id": "463939057", "name": "ACE", "enabled": false }
],
"created_at": "...",
"updated_at": "..."
}
Server Configuration
The three userconfig files are singletons — only one version can exist on disk at a time:
| File | Disk location | Managed in |
|---|---|---|
cba_settings.sqf |
$SERVERFILE_DIR/userconfig/cba_settings.sqf |
Settings page (textarea) |
CfgAILevelPresets.sqf |
$SERVERFILE_DIR/userconfig/CfgAILevelPresets.sqf |
Settings page (textarea) |
CfgDifficultyPresets.sqf |
$SERVERFILE_DIR/userconfig/CfgDifficultyPresets.sqf |
Settings page (textarea) |
These are written to disk immediately when settings are saved.
API Routes
# Server Settings (singleton)
GET /api/server/settings
PUT /api/server/settings # also writes userconfig files to disk
POST /api/server/start
POST /api/server/stop
POST /api/server/restart
GET /api/server/status
GET /api/server/paths # returns configured directory paths
GET /api/server/health # comprehensive health check (binary, paths, disk, mods, steamcmd, frontend)
# SteamCMD
GET /api/server/steamcmd # returns current branch/user/platform
POST /api/server/steamcmd/update-game
POST /api/server/steamcmd/download-mod
GET /api/server/steamcmd/status
# Logs
GET /api/server/logs # list log files (.log, .rpt)
GET /api/server/logs/:file # read log file content
# Configs (.cfg files in $CFG_DIR)
GET /api/configs
POST /api/configs
GET /api/configs/:name
PUT /api/configs/:name
DELETE /api/configs/:name
POST /api/configs/:name/duplicate
# Modlists
GET /api/modlists
POST /api/modlists
GET /api/modlists/:id
PUT /api/modlists/:id
DELETE /api/modlists/:id
POST /api/modlists/:id/duplicate
POST /api/modlists/import (multipart form: file=*.html — parses Arma Launcher HTML preset)
GET /api/modlists/:id/export (returns downloadable Arma Launcher HTML preset)
GET /api/modlists/:id/check (returns mod entries with downloaded: bool)
POST /api/modlists/:id/download-missing
POST /api/modlists/:id/update-all
# Mods (installed on disk)
GET /api/mods # combined workshop + local mods with usage info
DELETE /api/mods # delete mod by path (validates path prefix)
POST /api/mods/cleanup # bulk-delete all mods not referenced by any modlist
# WebSocket Endpoints
GET /ws/server/logs # live server process stdout/stderr
GET /ws/steamcmd/logs # live steamcmd output
GET /ws/server/rpt # tail latest .rpt crash dump (250ms polling)
Total: 27 REST endpoints + 3 WebSocket endpoints
Process Start Flow
User clicks Start
→ Settings loaded from data/settings.json
→ WasRunning set to true, saved to disk
→ SERVER_PARAMS env overrides ServerParameters if set
→ SERVER_BINARY env overrides platform default binary name if set
→ If ServerParameters contains %command%:
→ Replace %command% with binary path (enables Wine wrappers)
→ Split into command + args
→ Append auto-args: -config=, -mod=, -profiles=, -port=
→ exec.CommandContext(ctx, parts[0], parts[1:]..., autoArgs...)
Else:
→ Split server_parameters into args
→ Append auto-args
→ exec.CommandContext(ctx, binPath, args...)
→ cmd.Dir = serverfileDir
→ stdout/stderr piped to LogStreamer fan-out
→ cmd.Start()
→ WebSocket clients receive live output
→ On exit, process reference cleaned up, [SERVER_PROCESS_EXITED] broadcast
→ Frontend polls GET /api/server/status every 3-5s → shows "Running" badge
Startup Auto-Tasks
Configured via the Automation section of the Settings UI. All run asynchronously at boot:
| Setting | Behavior |
|---|---|
| Auto-update server on startup | Runs steamcmd +app_update if steam_user is set |
| Auto-update mods on startup | Downloads workshop updates for every enabled mod in the active modlist |
| Auto-start server on startup | Restarts the game server if was_running was true when the service last stopped |
Scheduled Updates
A cron expression in the scheduled_update field runs game + mod updates on a schedule (e.g. "0 4 * * *" for daily at 4 AM). Uses robfig/cron/v3. The scheduled update runs even if the game server is currently running.
Key Design Decisions
- File-based storage — No database. Settings, configs, and modlists are files on disk. Portable, backup-able with simple file copy.
- Env-defined paths —
SERVERFILE_DIR,MODS_DIR,CFG_DIR,PROFILES_DIRare environment variables, not stored in the UI. - Embedded frontend — The built React SPA is embedded into the Go binary via
//go:embed, creating a single self-contained deployment artifact. TheNoRoutehandler servesindex.htmlfor client-side routing. - Configs are real .cfg files — Stored in
$CFG_DIR, directly usable by the Arma 3 server's-config=parameter. - Modlist = mod references + enabled state — The
-mod=parameter is built at start time by resolving enabled mods. Two-tier resolution:$MODS_DIR/@namefirst (manually placed or symlinked); fallback to$SERVERFILE_DIR/steamapps/workshop/content/107410/<id>for workshop downloads. %command%substitution — IfServerParameterscontains%command%, the entire textarea is treated as a shell template with%command%replaced by the binary path and auto-args appended. Enables Wine wrappers.- Single server instance — Only one server at a time. No multi-instance support.
- No auth for v1 — JWT auth can be added later without breaking the API design.
- Docker-first deployment — Single docker-compose.yml bundles SteamCMD, backend, and frontend serving. Also supports GoReleaser for automated releases to Gitea.
- Crash recovery —
WasRunningflag persists across restarts. Ifauto_start_on_startupis enabled, the server restarts automatically. - SteamCMD async with live streaming — All steamcmd operations run in background goroutines, output broadcast to
"steamcmd"key via LogStreamer pub/sub. - Health endpoint —
/api/server/healthchecks binary existence, directory writability, disk usage, mod counts, SteamCMD status, and frontend serving.
Testing
Backend tests
Run with make test-backend or cd backend && go test ./....
Test files in backend/internal/services/:
settings_test.go— SettingsManager load/save/defaultsconfig_manager_test.go— ConfigManager CRUD + path traversal protectionmodlist_manager_test.go— ModlistManager CRUD + duplicatemodlist_parser_test.go— HTML preset parser + rendererlog_streamer_test.go— Pub/sub subscribe/unsubscribe/broadcast/streamserver_process_test.go— Process lifecycle, arg building, mod path resolution, env var overrides, stub-based integration testssteamcmd_test.go— SteamCMD state machine, CheckWorkshopMod, stub-based download tests
Test stubs
Stub binaries in backend/internal/services/testdata/ replace real server/steamcmd during tests:
arma3server_x64 stub:
- Logs binary path and all arguments to stdout
- Prints heartbeat every 5 seconds
- Accepts
-t Nto exit after N seconds (for auto-exit and timeout tests) - Parses
-profiles=<dir>and writes RPT log entries toprofilesDir/arma3server_x64_*.rpt
steamcmd stub:
- Parses
+force_install_dirand+workshop_download_itemargs - Creates fake mod directories at the expected workshop content path
- Prints fake download progress and success logs
Frontend tests
Run with make test-frontend or cd frontend && npm test.
Uses Vitest + React Testing Library + jsdom:
utils/format.test.ts— fmtSize utilitycomponents/Layout.test.tsx— Sidebar navigationpages/Mods.test.tsx— Mods page with mods present
Makefile test targets
| Target | Description |
|---|---|
make test |
Run both backend and frontend tests |
make test-backend |
Run go test ./... in backend |
make test-frontend |
Run vitest run in frontend |