diff --git a/README.md b/README.md index 33830d6..de00643 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,61 @@ All paths are configurable via environment variables: | `FRONTEND_DIR` | `../frontend/dist` | Built frontend assets | | `LISTEN` | `:8080` | HTTP listen address | +## Automation + +The panel can perform automatic operations at startup and on a schedule. + +### Startup tasks + +Configured in the **Automation** section of the Settings UI: + +| Setting | Description | +|---------|-------------| +| **Auto-update server on startup** | Runs `steamcmd +app_update` for the Arma 3 server binary when the web service starts. | +| **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. + +### Scheduled updates + +Set a **cron expression** in the `Scheduled Update` field to run game + mod updates on a recurring schedule (e.g. `"0 4 * * *"` for daily at 4 AM). Leave empty to disable. + +The scheduled update runs even if the game server is currently running. + +## SteamCMD Authentication + +The panel integrates with SteamCMD for server binary updates and workshop mod downloads. + +### Anonymous (default) + +The `Steam User` field in the settings UI defaults to `anonymous`. +In this mode, **no Steam account is required** — SteamCMD uses `+login anonymous`. + +- Workshop mod downloads always use this mode, regardless of the configured user. +- The Arma 3 server binary update is available via `+login anonymous` only if the server files are publicly accessible on that Steam account. + +### Authenticated login + +To update the Arma 3 server binary (`app_update 233780`) with a real Steam account, set the `Steam User` field to your account name. + +The web panel does not pass a password — SteamCMD relies on a **cached session**. +You must authenticate manually once so the login token is persisted: + +```bash +# Docker +docker exec -it arma3-web steamcmd +login your_steam_username +``` + +Enter your password when prompted, including any Steam Guard code if enabled. The session is cached inside the container's filesystem (`~/.steam/`). + +> **Note:** SteamCMD sessions may expire. Re-run the login command if the "Update Server" button fails with an authentication error. + +### Security + +- Steam credentials saved in `data/settings.json` are stored as **plain text**. +- It is recommended to use a **dedicated Steam account** with only the necessary game licenses for automated server management. + ## API REST API at `/api/*` and WebSocket endpoints at `/ws/*`. Key routes: diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index d9bd6be..3931261 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -50,6 +50,7 @@ func main() { 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() @@ -62,9 +63,58 @@ func main() { log.Printf("serving frontend from %s", frontendDir) } - handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, streamer, serverfileDir, modsDir, cfgDir, profilesDir) + handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, scheduler, streamer, serverfileDir, modsDir, cfgDir, profilesDir) handler.SetupRoutes(r) + // Startup auto-tasks + s, err := settings.Load() + if err != nil { + log.Printf("startup: load settings: %v", err) + } else { + if s.AutoUpdateOnStartup && s.SteamUser != "" { + go func() { + 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 != "" { + go func() { + 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() { + 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} @@ -80,6 +130,7 @@ func main() { <-quit log.Print("shutting down server...") + scheduler.Stop() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/backend/go.mod b/backend/go.mod index b23cc5b..d55eb3c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -33,6 +33,7 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 33a5bed..e01bc90 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -65,6 +65,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 18940a1..599d079 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -12,6 +12,7 @@ type Handler struct { modlists *services.ModlistManager process *services.ProcessManager steamcmd *services.SteamCmdManager + scheduler *services.Scheduler streamer *services.LogStreamer serverfileDir string modsDir string @@ -25,6 +26,7 @@ func New( modlists *services.ModlistManager, process *services.ProcessManager, steamcmd *services.SteamCmdManager, + scheduler *services.Scheduler, streamer *services.LogStreamer, serverfileDir, modsDir, cfgDir, profilesDir string, ) *Handler { @@ -34,6 +36,7 @@ func New( modlists: modlists, process: process, steamcmd: steamcmd, + scheduler: scheduler, streamer: streamer, serverfileDir: serverfileDir, modsDir: modsDir, diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go index 242c44d..4b5cf97 100644 --- a/backend/internal/api/settings.go +++ b/backend/internal/api/settings.go @@ -7,16 +7,20 @@ import ( ) type updateSettingsInput struct { - IPPort *string `json:"ip_port"` - ServerParameters *string `json:"server_parameters"` - SteamBranch *string `json:"steam_branch"` - SteamUser *string `json:"steam_user"` - Platform *string `json:"platform"` - CBASettings *string `json:"cba_settings"` - AILevelPresets *string `json:"ai_level_presets"` - DifficultyPresets *string `json:"difficulty_presets"` - ActiveConfig *string `json:"active_config"` - ActiveModlist *string `json:"active_modlist"` + IPPort *string `json:"ip_port"` + ServerParameters *string `json:"server_parameters"` + SteamBranch *string `json:"steam_branch"` + SteamUser *string `json:"steam_user"` + Platform *string `json:"platform"` + CBASettings *string `json:"cba_settings"` + AILevelPresets *string `json:"ai_level_presets"` + DifficultyPresets *string `json:"difficulty_presets"` + ActiveConfig *string `json:"active_config"` + ActiveModlist *string `json:"active_modlist"` + AutoUpdateOnStartup *bool `json:"auto_update_on_startup"` + AutoStartOnStartup *bool `json:"auto_start_on_startup"` + AutoUpdateModsOnStartup *bool `json:"auto_update_mods_on_startup"` + ScheduledUpdate *string `json:"scheduled_update"` } func (h *Handler) GetSettings(c *gin.Context) { @@ -51,6 +55,10 @@ func (h *Handler) UpdateSettings(c *gin.Context) { 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()}) @@ -61,6 +69,11 @@ func (h *Handler) UpdateSettings(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + + if h.scheduler != nil { + h.scheduler.Refresh() + } + c.JSON(http.StatusOK, s) } diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go index 43b0ec4..3006591 100644 --- a/backend/internal/models/settings.go +++ b/backend/internal/models/settings.go @@ -3,15 +3,20 @@ package models import "time" type ServerSettings struct { - IPPort string `json:"ip_port"` - ServerParameters string `json:"server_parameters"` - SteamBranch string `json:"steam_branch"` - SteamUser string `json:"steam_user"` - Platform string `json:"platform"` - CBASettings string `json:"cba_settings"` - AILevelPresets string `json:"ai_level_presets"` - DifficultyPresets string `json:"difficulty_presets"` - ActiveConfig string `json:"active_config"` - ActiveModlist string `json:"active_modlist"` - UpdatedAt time.Time `json:"updated_at"` + IPPort string `json:"ip_port"` + ServerParameters string `json:"server_parameters"` + SteamBranch string `json:"steam_branch"` + SteamUser string `json:"steam_user"` + Platform string `json:"platform"` + CBASettings string `json:"cba_settings"` + AILevelPresets string `json:"ai_level_presets"` + DifficultyPresets string `json:"difficulty_presets"` + ActiveConfig string `json:"active_config"` + ActiveModlist string `json:"active_modlist"` + AutoUpdateOnStartup bool `json:"auto_update_on_startup"` + AutoStartOnStartup bool `json:"auto_start_on_startup"` + AutoUpdateModsOnStartup bool `json:"auto_update_mods_on_startup"` + WasRunning bool `json:"was_running"` + ScheduledUpdate string `json:"scheduled_update"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/services/scheduler.go b/backend/internal/services/scheduler.go new file mode 100644 index 0000000..edcd27d --- /dev/null +++ b/backend/internal/services/scheduler.go @@ -0,0 +1,92 @@ +package services + +import ( + "log" + "sync" + + "github.com/robfig/cron/v3" +) + +type Scheduler struct { + cron *cron.Cron + entryID cron.EntryID + settings *SettingsManager + modlists *ModlistManager + steamcmd *SteamCmdManager + mu sync.Mutex +} + +func NewScheduler(settings *SettingsManager, modlists *ModlistManager, steamcmd *SteamCmdManager) *Scheduler { + return &Scheduler{ + cron: cron.New(), + settings: settings, + modlists: modlists, + steamcmd: steamcmd, + } +} + +func (s *Scheduler) Start() { + s.cron.Start() + s.Refresh() +} + +func (s *Scheduler) Stop() { + s.cron.Stop() +} + +func (s *Scheduler) Refresh() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.entryID != 0 { + s.cron.Remove(s.entryID) + s.entryID = 0 + } + + sett, err := s.settings.Load() + if err != nil || sett.ScheduledUpdate == "" { + return + } + + id, err := s.cron.AddFunc(sett.ScheduledUpdate, s.runScheduledUpdate) + if err != nil { + log.Printf("scheduler: invalid cron expression %q: %v", sett.ScheduledUpdate, err) + return + } + s.entryID = id + log.Printf("scheduler: scheduled update with cron %q", sett.ScheduledUpdate) +} + +func (s *Scheduler) runScheduledUpdate() { + sett, err := s.settings.Load() + if err != nil { + return + } + + if sett.SteamUser != "" && sett.SteamUser != "anonymous" { + log.Print("scheduler: running gameserver update") + if err := s.steamcmd.UpdateGame(sett.SteamBranch, sett.SteamUser); err != nil { + log.Printf("scheduler: game update failed: %v", err) + } + } + + if sett.ActiveModlist != "" { + ml, err := s.modlists.Get(sett.ActiveModlist) + if err != nil { + log.Printf("scheduler: load modlist %s: %v", sett.ActiveModlist, err) + return + } + var modIDs []string + for _, m := range ml.Mods { + if m.Enabled { + modIDs = append(modIDs, m.ID) + } + } + if len(modIDs) > 0 { + log.Printf("scheduler: updating %d mod(s)", len(modIDs)) + if err := s.steamcmd.DownloadMods(modIDs); err != nil { + log.Printf("scheduler: mod update failed: %v", err) + } + } + } +} diff --git a/backend/internal/services/server_process.go b/backend/internal/services/server_process.go index dd6c7ee..bde235d 100644 --- a/backend/internal/services/server_process.go +++ b/backend/internal/services/server_process.go @@ -72,6 +72,11 @@ func (pm *ProcessManager) Start() error { return fmt.Errorf("load settings: %w", err) } + s.WasRunning = true + if err := pm.settings.Save(s); err != nil { + return fmt.Errorf("save was_running: %w", err) + } + binName := "arma3server_x64" if s.Platform == "windows" { binName = "arma3server_x64.exe" @@ -141,6 +146,13 @@ func (pm *ProcessManager) Stop() error { rp.cancel() <-rp.exited + + s, err := pm.settings.Load() + if err == nil { + s.WasRunning = false + pm.settings.Save(s) + } + return nil } diff --git a/backend/internal/services/settings.go b/backend/internal/services/settings.go index 2e22a6a..465d99b 100644 --- a/backend/internal/services/settings.go +++ b/backend/internal/services/settings.go @@ -54,13 +54,18 @@ func (sm *SettingsManager) Save(s *models.ServerSettings) error { func (sm *SettingsManager) defaults() *models.ServerSettings { return &models.ServerSettings{ - IPPort: "0.0.0.0:2302", - ServerParameters: "-server -world=empty -loadMissionToMemory -noPause", - SteamBranch: "stable", - SteamUser: "anonymous", - Platform: "linux", - ActiveConfig: "", - ActiveModlist: "", - UpdatedAt: time.Now().UTC(), + IPPort: "0.0.0.0:2302", + ServerParameters: "-server -world=empty -loadMissionToMemory -noPause", + SteamBranch: "stable", + SteamUser: "anonymous", + Platform: "linux", + ActiveConfig: "", + ActiveModlist: "", + AutoUpdateOnStartup: false, + AutoStartOnStartup: false, + AutoUpdateModsOnStartup: false, + WasRunning: false, + ScheduledUpdate: "", + UpdatedAt: time.Now().UTC(), } } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index b1aee4e..84734c5 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -26,6 +26,10 @@ export default function Settings() { const [difficulty, setDifficulty] = useState('') const [activeConfig, setActiveConfig] = useState('') const [activeModlist, setActiveModlist] = useState('') + const [autoUpdateOnStartup, setAutoUpdateOnStartup] = useState(false) + const [autoStartOnStartup, setAutoStartOnStartup] = useState(false) + const [autoUpdateModsOnStartup, setAutoUpdateModsOnStartup] = useState(false) + const [scheduledUpdate, setScheduledUpdate] = useState('') useEffect(() => { if (settings) { @@ -39,6 +43,10 @@ export default function Settings() { setDifficulty(settings.difficulty_presets) setActiveConfig(settings.active_config) setActiveModlist(settings.active_modlist) + setAutoUpdateOnStartup(settings.auto_update_on_startup) + setAutoStartOnStartup(settings.auto_start_on_startup) + setAutoUpdateModsOnStartup(settings.auto_update_mods_on_startup) + setScheduledUpdate(settings.scheduled_update) } }, [settings]) @@ -54,6 +62,10 @@ export default function Settings() { difficulty_presets: difficulty, active_config: activeConfig, active_modlist: activeModlist, + auto_update_on_startup: autoUpdateOnStartup, + auto_start_on_startup: autoStartOnStartup, + auto_update_mods_on_startup: autoUpdateModsOnStartup, + scheduled_update: scheduledUpdate, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }); setDirty(false) }, }) @@ -220,6 +232,45 @@ export default function Settings() { +