From bb1ad400b06c9ceb1b2b18869536b8918dd6c147 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster <81807824+JacksonTheMaster@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:36:48 +0200 Subject: [PATCH 1/6] refactored IsServerRunning checks to use a global setting instead of individually getting the state, reducing load on the backend. --- src/config/getters.go | 6 ++++++ src/config/setters.go | 8 ++++++++ src/config/vars.go | 1 + src/core/loader/loader.go | 6 ++++++ src/managers/gamemgr/processmanagement.go | 2 ++ src/managers/gamemgr/runcheck.go | 19 ++++++++++++++++++- src/web/http.go | 2 +- src/web/routes.go | 3 +++ 8 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/config/getters.go b/src/config/getters.go index 27067b83..85ba8dc7 100644 --- a/src/config/getters.go +++ b/src/config/getters.go @@ -513,3 +513,9 @@ func GetIsDockerContainer() bool { defer ConfigMu.RUnlock() return IsDockerContainer } + +func GetIsGameServerRunning() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return IsGameServerRunning +} diff --git a/src/config/setters.go b/src/config/setters.go index 58627abb..7c934dea 100644 --- a/src/config/setters.go +++ b/src/config/setters.go @@ -86,6 +86,14 @@ func SetWorldID(value string) error { return nil } +func SetIsGameServerRunning(value bool) error { + ConfigMu.Lock() + defer ConfigMu.Unlock() + + IsGameServerRunning = value + return nil +} + // ALL SETTERS BELOW THIS LINE ARE UNUSED AT THE MOMENT // ALL SETTERS BELOW THIS LINE ARE UNUSED AT THE MOMENT // ALL SETTERS BELOW THIS LINE ARE UNUSED AT THE MOMENT diff --git a/src/config/vars.go b/src/config/vars.go index a2f3a507..7aa37788 100644 --- a/src/config/vars.go +++ b/src/config/vars.go @@ -71,6 +71,7 @@ var ( SkipSteamCMD bool // ONLY RUNTIME IsDockerContainer bool // ONLY RUNTIME NoSanityCheck bool // ONLY RUNTIME + IsGameServerRunning bool // ONLY RUNTIME ) // Discord integration diff --git a/src/core/loader/loader.go b/src/core/loader/loader.go index 6c224cc4..9f0790c3 100644 --- a/src/core/loader/loader.go +++ b/src/core/loader/loader.go @@ -13,6 +13,7 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/backupmgr" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup/update" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/steamcmd" @@ -29,6 +30,7 @@ func InitBackend(wg *sync.WaitGroup) { ReloadAppInfoPoller() ReloadDiscordBot() InitDetector() + StartIsGameServerRunningCheck() } // use this to reload backend at runtime @@ -91,6 +93,10 @@ func ReloadLocalizer() { localization.ReloadLocalizer() } +func StartIsGameServerRunningCheck() { + gamemgr.StartIsGameServerRunningCheck() +} + func ReloadAppInfoPoller() { steamcmd.AppInfoPoller() } diff --git a/src/managers/gamemgr/processmanagement.go b/src/managers/gamemgr/processmanagement.go index 06e1a296..280eba9c 100644 --- a/src/managers/gamemgr/processmanagement.go +++ b/src/managers/gamemgr/processmanagement.go @@ -129,6 +129,7 @@ func InternalStartServer() error { } // create a UUID for this specific run createGameServerUUID() + config.SetIsGameServerRunning(true) // Start auto-restart goroutine if AutoRestartServerTimer is set greater than 0 if config.GetAutoRestartServerTimer() != "0" { @@ -217,6 +218,7 @@ func InternalStopServer() error { // Process is confirmed stopped, clear cmd cmd = nil + config.SetIsGameServerRunning(false) clearGameServerUUID() return nil } diff --git a/src/managers/gamemgr/runcheck.go b/src/managers/gamemgr/runcheck.go index a57734f4..cf83544e 100644 --- a/src/managers/gamemgr/runcheck.go +++ b/src/managers/gamemgr/runcheck.go @@ -3,16 +3,33 @@ package gamemgr import ( "runtime" "syscall" + "time" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" ) +func StartIsGameServerRunningCheck() { + go func() { + for { + if InternalIsServerRunning() { + config.SetIsGameServerRunning(true) + } else { + config.SetIsGameServerRunning(false) + } + time.Sleep(4 * time.Second) + } + }() +} + // InternalIsServerRunning checks if the server process is running. // Safe to call standalone as it manages its own locking. func InternalIsServerRunning() bool { mu.Lock() defer mu.Unlock() - return internalIsServerRunningNoLock() + status := internalIsServerRunningNoLock() + config.SetIsGameServerRunning(status) + return status } // internalIsServerRunningNoLock checks if the server process is running. diff --git a/src/web/http.go b/src/web/http.go index fd010f65..1b214fe6 100644 --- a/src/web/http.go +++ b/src/web/http.go @@ -48,7 +48,7 @@ func StopServer(w http.ResponseWriter, r *http.Request) { } func GetGameServerRunState(w http.ResponseWriter, r *http.Request) { - runState := gamemgr.InternalIsServerRunning() + runState := config.GetIsGameServerRunning() response := map[string]interface{}{ "isRunning": runState, "uuid": gamemgr.GameServerUUID.String(), diff --git a/src/web/routes.go b/src/web/routes.go index 9b7a45eb..4975bb86 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -79,5 +79,8 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { protectedMux.HandleFunc("/api/v2/auth/setup/register", RegisterUserHandler) // user registration protectedMux.HandleFunc("/api/v2/auth/setup/finalize", SetupFinalizeHandler) + // Monitoring + protectedMux.HandleFunc("/api/v2/monitor/status", HandleMonitorStatus) + return mux, protectedMux } From bb2bd3454dc535906d8cdc7228848fa322c9a1b2 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster <81807824+JacksonTheMaster@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:37:26 +0200 Subject: [PATCH 2/6] added an endpoint to get the server status --- src/web/monitoring.go | 20 ++++++++++++++++++++ src/web/routes.go | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/web/monitoring.go diff --git a/src/web/monitoring.go b/src/web/monitoring.go new file mode 100644 index 00000000..74dff40f --- /dev/null +++ b/src/web/monitoring.go @@ -0,0 +1,20 @@ +package web + +import ( + "encoding/json" + "net/http" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" +) + +func HandleMonitorStatus(w http.ResponseWriter, r *http.Request) { + runState := config.GetIsGameServerRunning() + response := map[string]interface{}{ + "isRunning": runState, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, "Failed to respond with Game Server status", http.StatusInternalServerError) + return + } +} diff --git a/src/web/routes.go b/src/web/routes.go index 4975bb86..f52dcd65 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -80,7 +80,7 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { protectedMux.HandleFunc("/api/v2/auth/setup/finalize", SetupFinalizeHandler) // Monitoring - protectedMux.HandleFunc("/api/v2/monitor/status", HandleMonitorStatus) + protectedMux.HandleFunc("/api/v2/monitor/gameserver/status", HandleMonitorStatus) return mux, protectedMux } From cf95a315c6e0480f13c9d3bb11f0a7f73187f328 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster <81807824+JacksonTheMaster@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:30:20 +0200 Subject: [PATCH 3/6] adds the ability to add apikey- users with a 3 year token expiration time --- src/core/security/auth.go | 6 +++++- src/web/login.go | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/core/security/auth.go b/src/core/security/auth.go index a052eb24..88f76be0 100644 --- a/src/core/security/auth.go +++ b/src/core/security/auth.go @@ -4,6 +4,7 @@ package security //repurposed from a Jacksonthemaster private repo import ( + "strings" "time" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" @@ -21,6 +22,10 @@ type UserCredentials struct { // GenerateJWT creates a JWT for a given username func GenerateJWT(username string) (string, error) { expirationTime := time.Now().Add(time.Duration(config.GetAuthTokenLifetime()) * time.Minute) + if strings.HasPrefix(username, "apikey-") { + expirationTime = time.Now().Add(3 * 365 * 24 * time.Hour) + } + claims := &jwt.MapClaims{ "exp": expirationTime.Unix(), "iss": "StationeersServerUI", @@ -37,7 +42,6 @@ func GenerateJWT(username string) (string, error) { // ValidateCredentials checks username and password against stored users func ValidateCredentials(creds UserCredentials) (bool, error) { - // Placeholder: assumes config.Users is a map[string]string (username -> hashed password) storedHash, exists := config.GetUsers()[creds.Username] if !exists { return false, nil diff --git a/src/web/login.go b/src/web/login.go index e2c8f0db..a4e82d1a 100644 --- a/src/web/login.go +++ b/src/web/login.go @@ -172,6 +172,13 @@ func RegisterUserHandler(w http.ResponseWriter, r *http.Request) { return } + if strings.HasPrefix(creds.Username, "apikey-") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Bad Request - Invalid Username"}) + return + } + // Hash the password hashedPassword, err := security.HashPassword(creds.Password) if err != nil { From 13ef650f129cd51254a380239b1ff5fd55bafb21 Mon Sep 17 00:00:00 2001 From: JacksonTheMaster <81807824+JacksonTheMaster@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:30:57 +0200 Subject: [PATCH 4/6] reflect runState in http code as well --- src/web/monitoring.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/web/monitoring.go b/src/web/monitoring.go index 74dff40f..ab5ad023 100644 --- a/src/web/monitoring.go +++ b/src/web/monitoring.go @@ -13,6 +13,13 @@ func HandleMonitorStatus(w http.ResponseWriter, r *http.Request) { "isRunning": runState, } w.Header().Set("Content-Type", "application/json") + + if !runState { + w.WriteHeader(http.StatusServiceUnavailable) // 503 + } else { + w.WriteHeader(http.StatusOK) // 200 + } + if err := json.NewEncoder(w).Encode(response); err != nil { http.Error(w, "Failed to respond with Game Server status", http.StatusInternalServerError) return From 5ef10ec3c57871712b8241f92620a20bdd37d1ec Mon Sep 17 00:00:00 2001 From: JacksonTheMaster <81807824+JacksonTheMaster@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:32:15 +0200 Subject: [PATCH 5/6] added a dedicated endpoint to request an APIKey --- src/web/login.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++ src/web/routes.go | 1 + 2 files changed, 57 insertions(+) diff --git a/src/web/login.go b/src/web/login.go index a4e82d1a..a3c38ba4 100644 --- a/src/web/login.go +++ b/src/web/login.go @@ -14,6 +14,7 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/security" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/google/uuid" ) var setupReminderCount = 0 // to limit the number of setup reminders shown to the user @@ -247,3 +248,58 @@ func SetupFinalizeHandler(w http.ResponseWriter, r *http.Request) { }) loader.ReloadBackend() } + +func RegisterAPIKeyHandler(w http.ResponseWriter, r *http.Request) { + + // Handle preflight OPTIONS requests + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + // reject requests with non-GET methods + if r.Method != http.MethodGet { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]string{"error": "Method Not Allowed"}) + return + } + + var creds security.UserCredentials + + // Generate a random UUID as the username + creds.Username = "apikey-" + uuid.NewString() + + // Hash a random UUID as the password + hashedPassword, err := security.HashPassword(uuid.NewString()) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Internal Server Error"}) + return + } + + // Initialize Users map if nil + if config.GetUsers() == nil { + config.SetUsers(make(map[string]string)) + } + + // Add or update the user + config.SetUsers(map[string]string{creds.Username: hashedPassword}) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + apikey, err := security.GenerateJWT(creds.Username) + expires := time.Now().Add(3 * 365 * 24 * time.Hour) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Internal Server Error"}) + return + } + json.NewEncoder(w).Encode(map[string]string{ + "message": "APIKey registered successfully", + "apikey": apikey, + "expires": expires.Format(time.RFC3339), + }) +} diff --git a/src/web/routes.go b/src/web/routes.go index f52dcd65..394858ce 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -77,6 +77,7 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { // Setup protectedMux.HandleFunc("/setup", ServeTwoBoxFormTemplate) protectedMux.HandleFunc("/api/v2/auth/setup/register", RegisterUserHandler) // user registration + protectedMux.HandleFunc("/api/v2/auth/setup/apikey", RegisterAPIKeyHandler) // API Key registration protectedMux.HandleFunc("/api/v2/auth/setup/finalize", SetupFinalizeHandler) // Monitoring From 57d0389a37a47b7c217626649f191f77d54638aa Mon Sep 17 00:00:00 2001 From: JacksonTheMaster <81807824+JacksonTheMaster@users.noreply.github.com> Date: Tue, 30 Sep 2025 19:58:39 +0200 Subject: [PATCH 6/6] improve APIKey handling: support dynamic expiration duration (POST) and adjust JWT generation accordingly. Defaults to 1 month if user sends a GET instead. --- src/core/security/auth.go | 8 ++++++-- src/web/login.go | 37 +++++++++++++++++++++++++++++++++---- src/web/monitoring.go | 10 ++-------- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/core/security/auth.go b/src/core/security/auth.go index 88f76be0..01ccd6b0 100644 --- a/src/core/security/auth.go +++ b/src/core/security/auth.go @@ -20,10 +20,14 @@ type UserCredentials struct { } // GenerateJWT creates a JWT for a given username -func GenerateJWT(username string) (string, error) { +func GenerateJWT(username string, apikeyduration ...int) (string, error) { expirationTime := time.Now().Add(time.Duration(config.GetAuthTokenLifetime()) * time.Minute) if strings.HasPrefix(username, "apikey-") { - expirationTime = time.Now().Add(3 * 365 * 24 * time.Hour) + durationMonths := 1 + if len(apikeyduration) > 0 { + durationMonths = apikeyduration[0] + } + expirationTime = time.Now().AddDate(0, durationMonths, 0) } claims := &jwt.MapClaims{ diff --git a/src/web/login.go b/src/web/login.go index a3c38ba4..a442a023 100644 --- a/src/web/login.go +++ b/src/web/login.go @@ -257,14 +257,42 @@ func RegisterAPIKeyHandler(w http.ResponseWriter, r *http.Request) { return } - // reject requests with non-GET methods - if r.Method != http.MethodGet { + // Allow only GET or POST methods + if r.Method != http.MethodGet && r.Method != http.MethodPost { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]string{"error": "Method Not Allowed"}) return } + // Set default duration for GET requests, require duration for POST + durationMonths := 1 + if r.Method == http.MethodPost { + var reqBody struct { + DurationMonths *int `json:"durationMonths"` // Use pointer to distinguish between 0 and unspecified + } + err := json.NewDecoder(r.Body).Decode(&reqBody) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Bad Request - Invalid JSON"}) + return + } + if reqBody.DurationMonths == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Bad Request - durationMonths is required for POST"}) + return + } + if *reqBody.DurationMonths <= 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Bad Request - Duration must be positive"}) + return + } + durationMonths = *reqBody.DurationMonths + } + var creds security.UserCredentials // Generate a random UUID as the username @@ -289,8 +317,8 @@ func RegisterAPIKeyHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - apikey, err := security.GenerateJWT(creds.Username) - expires := time.Now().Add(3 * 365 * 24 * time.Hour) + apikey, err := security.GenerateJWT(creds.Username, durationMonths) + expires := time.Now().AddDate(0, durationMonths, 0) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) @@ -302,4 +330,5 @@ func RegisterAPIKeyHandler(w http.ResponseWriter, r *http.Request) { "apikey": apikey, "expires": expires.Format(time.RFC3339), }) + logger.Security.Infof("APIKey %s registered successfully. Expires: %s ", creds.Username, expires.Format(time.RFC3339)) } diff --git a/src/web/monitoring.go b/src/web/monitoring.go index ab5ad023..3f524abb 100644 --- a/src/web/monitoring.go +++ b/src/web/monitoring.go @@ -12,15 +12,9 @@ func HandleMonitorStatus(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{ "isRunning": runState, } - w.Header().Set("Content-Type", "application/json") - - if !runState { - w.WriteHeader(http.StatusServiceUnavailable) // 503 - } else { - w.WriteHeader(http.StatusOK) // 200 - } - if err := json.NewEncoder(w).Encode(response); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) // 200 OK http.Error(w, "Failed to respond with Game Server status", http.StatusInternalServerError) return }