diff --git a/src/config/getters.go b/src/config/getters.go index d5b78aaa..fff0e4e8 100644 --- a/src/config/getters.go +++ b/src/config/getters.go @@ -514,6 +514,11 @@ func GetIsDockerContainer() bool { return IsDockerContainer } +func GetIsGameServerRunning() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return IsGameServerRunning +} func GetOverrideAdvertisedIp() string { ConfigMu.RLock() defer ConfigMu.RUnlock() diff --git a/src/config/setters.go b/src/config/setters.go index 438482e7..c0f305b1 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 a3aacc24..5113f7c7 100644 --- a/src/config/vars.go +++ b/src/config/vars.go @@ -72,6 +72,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 fa6e1d3c..b8d79ce7 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" @@ -27,6 +28,7 @@ func InitBackend() { ReloadAppInfoPoller() ReloadDiscordBot() InitDetector() + StartIsGameServerRunningCheck() if config.GetOverrideAdvertisedIp() != "" { logger.Advertiser.Info("Starting server advertiser...") advertiser.StartAdvertiser() @@ -93,6 +95,10 @@ func ReloadLocalizer() { localization.ReloadLocalizer() } +func StartIsGameServerRunningCheck() { + gamemgr.StartIsGameServerRunningCheck() +} + func ReloadAppInfoPoller() { steamcmd.AppInfoPoller() } diff --git a/src/core/security/auth.go b/src/core/security/auth.go index a052eb24..01ccd6b0 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" @@ -19,8 +20,16 @@ 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-") { + durationMonths := 1 + if len(apikeyduration) > 0 { + durationMonths = apikeyduration[0] + } + expirationTime = time.Now().AddDate(0, durationMonths, 0) + } + claims := &jwt.MapClaims{ "exp": expirationTime.Unix(), "iss": "StationeersServerUI", @@ -37,7 +46,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/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/login.go b/src/web/login.go index e2c8f0db..a442a023 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 @@ -172,6 +173,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 { @@ -240,3 +248,87 @@ 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 + } + + // 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 + 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, durationMonths) + expires := time.Now().AddDate(0, durationMonths, 0) + 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), + }) + 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 new file mode 100644 index 00000000..3f524abb --- /dev/null +++ b/src/web/monitoring.go @@ -0,0 +1,21 @@ +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, + } + 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 + } +} diff --git a/src/web/routes.go b/src/web/routes.go index 9b7a45eb..394858ce 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -77,7 +77,11 @@ 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 + protectedMux.HandleFunc("/api/v2/monitor/gameserver/status", HandleMonitorStatus) + return mux, protectedMux }