diff --git a/.vscode/launch.json b/.vscode/launch.json index b5d9d441..23382b36 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -29,6 +29,16 @@ "console": "integratedTerminal", "showLog": false, // Hides some Go Debugger(Delve) log stuff that is not useful for debugging atm "args": ["--NoSteamCMD"] + }, + { + "name": "Debug Go Server noSteamCMD noSvelte overrideAdvertisedIp", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/server.go", + "console": "integratedTerminal", + "showLog": false, // Hides some Go Debugger(Delve) log stuff that is not useful for debugging atm + "args": ["--NoSteamCMD", "--OverrideAdvertisedIp=127.0.0.1"] } ] } \ No newline at end of file diff --git a/readme.md b/readme.md index a94a5ec7..c299aeed 100644 --- a/readme.md +++ b/readme.md @@ -4,7 +4,7 @@ # Stationeers Server UI -![Go](https://img.shields.io/badge/Go-1.24.2-blue?logo=go&logoColor=white) +![Go](https://img.shields.io/badge/Go-1.25.0-blue?logo=go&logoColor=white) ![Version](https://img.shields.io/github/v/release/jacksonthemaster/StationeersServerUI?logo=github&logoColor=white) ![Issues](https://img.shields.io/github/issues/jacksonthemaster/StationeersServerUI?logo=github&logoColor=white) ![Stars](https://img.shields.io/github/stars/jacksonthemaster/StationeersServerUI?style=social&logo=github) @@ -25,14 +25,8 @@
-### 🌟 Live UI Preview 🌟 +### 🌟 This is a WebUi, you don't need a graphical OS to run this 🌟 -Explore your fututre Stationeers Server UI in action—no setup required! -And the "best part?" The Demo current is not on the V5 but V4, so if you are convinced by the Demo, it will only get better when you actually try it out! - -### Preview is not available any more, but will be available soon! - -[![v4 Live Preview - Stationeers UI](https://img.shields.io/badge/Live%20Preview-Stationeers%20Server%20UI-blueviolet?style=for-the-badge&logo=github)](https://jacksonthemaster.github.io/StationeersServerUI/server.html) [![Download latest Version](https://img.shields.io/badge/Download-Stationeers%20Server%20UI-orange?style=for-the-badge)](https://jacksonthemaster.github.io/StationeersServerUI)
diff --git a/server.go b/server.go index 94cb1576..4a079f17 100644 --- a/server.go +++ b/server.go @@ -39,8 +39,7 @@ func main() { logger.ConfigureConsole() loader.ParseFlags() loader.HandleSanityCheckFlag() - loader.SanityCheck(&wg) - wg.Wait() + loader.SanityCheck() logger.Main.Info("Initializing resources...") loader.InitVirtFS(v1uiFS) logger.Install.Info("Starting setup...") @@ -49,11 +48,9 @@ func main() { setup.Install(&wg) wg.Wait() logger.Main.Debug("Initializing Backend...") - loader.InitBackend(&wg) - wg.Wait() + loader.InitBackend() logger.Main.Debug("Initializing after start tasks...") - loader.AfterStartComplete(&wg) - wg.Wait() + loader.AfterStartComplete() logger.Main.Debug("Starting webserver...") web.StartWebServer(&wg) logger.Main.Debug("Initializing SSUICLI...") diff --git a/src/advertiser/advertiser.go b/src/advertiser/advertiser.go new file mode 100644 index 00000000..f622b731 --- /dev/null +++ b/src/advertiser/advertiser.go @@ -0,0 +1,110 @@ +package advertiser + +import ( + "bytes" + "encoding/json" + "net/http" + "runtime" + "strconv" + "time" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" +) + +var StationeersAdvertisementEndpoint = config.GetStationeersServerPingEndpoint() + +type ServerAdMessage struct { + SessionId int + Name string + Password bool + Version string + Address string + Port string + Players int + MaxPlayers int + Type int +} + +type ServerAdResponse struct { + SessionId int + Status string +} + +func StartAdvertiser() { + if config.GetServerVisible() { + logger.Advertiser.Warn("Server advertisement is enabled. Disable it in the config and restart SSUI to use manual advertisement. Skipping for now...") + return + } + go func() { + sessionId := -1 + for { + // Only advertise if we are running + if gamemgr.InternalIsServerRunning() { + // Get max players + maxplayers, err := strconv.Atoi(config.GetServerMaxPlayers()) + if err != nil { + logger.Advertiser.Errorf("ServerAdvertiser failed to convert max players number to int: %s", config.GetServerMaxPlayers()) + return + } + // Get connected players + detector := detectionmgr.GetDetector() + players := len(detectionmgr.GetPlayers(detector)) + // Get platform + platform := 0 + switch runtime.GOOS { + case "windows": + platform = 1 + case "linux": + platform = 2 + } + adMessage := ServerAdMessage{ + SessionId: sessionId, + Name: config.GetServerName(), + Password: config.GetServerPassword() != "", + Version: config.GetExtractedGameVersion(), + Address: config.GetOverrideAdvertisedIp(), + Port: config.GetGamePort(), + Players: players, + MaxPlayers: maxplayers, + Type: platform, + } + body, err := json.Marshal(adMessage) + if err != nil { + logger.Advertiser.Errorf("ServerAdvertiser failed to Serialize to JSON from native Go struct type: %v", err) + return + } + // Send advertisement + resp, err := http.Post(StationeersAdvertisementEndpoint, "application/json", bytes.NewBuffer(body)) + // Check for errors + if err != nil { + logger.Advertiser.Errorf("ServerAdvertiser failed to send request: %v", err) + return + } + defer resp.Body.Close() + // Check the status + if resp.StatusCode != 200 { + logger.Advertiser.Warnf("ServerAdvertiser received non-200 status: %d", resp.StatusCode) + } + // Read the response and update our sessionId if needed + adResponse := ServerAdResponse{} + err = json.NewDecoder(resp.Body).Decode(&adResponse) + if err != nil { + logger.Advertiser.Errorf("Failed to decode response body: %v", err) + return + } + if adResponse.Status != "Success" { + logger.Advertiser.Warnf("ServerAdvertiser received unexpected status: %s", adResponse.Status) + } + sessionId = adResponse.SessionId + } else { + // Reset sessionid for the next run + sessionId = -1 + } + // Sleep for 30 seconds to follow the standard advertisement timer + time.Sleep(30 * time.Second) + } + }() +} diff --git a/src/config/config.go b/src/config/config.go index 26fcf72f..d1a6f52b 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -51,10 +51,11 @@ type JsonConfig struct { StartLocation string `json:"StartLocation"` // Logging and debug settings - Debug *bool `json:"Debug"` - CreateSSUILogFile *bool `json:"CreateSSUILogFile"` - LogLevel int `json:"LogLevel"` - SubsystemFilters []string `json:"subsystemFilters"` + Debug *bool `json:"Debug"` + CreateSSUILogFile *bool `json:"CreateSSUILogFile"` + LogLevel int `json:"LogLevel"` + SubsystemFilters []string `json:"subsystemFilters"` + OverrideAdvertisedIp string `json:"OverrideAdvertisedIp"` // Authentication Settings Users map[string]string `json:"users"` // Map of username to hashed password @@ -296,6 +297,8 @@ func applyConfig(cfg *JsonConfig) { // use Safebackups folder either way. ConfiguredSafeBackupDir = filepath.Join("./saves/", SaveName, "Safebackups") + OverrideAdvertisedIp = getString(cfg.OverrideAdvertisedIp, "OVERRIDE_ADVERTISED_IP", "") + safeSaveConfig() } @@ -365,6 +368,7 @@ func safeSaveConfig() error { AutoStartServerOnStartup: &AutoStartServerOnStartup, SSUIIdentifier: SSUIIdentifier, SSUIWebPort: SSUIWebPort, + OverrideAdvertisedIp: OverrideAdvertisedIp, } file, err := os.Create(ConfigPath) diff --git a/src/config/getters.go b/src/config/getters.go index 27067b83..2b8a7f7f 100644 --- a/src/config/getters.go +++ b/src/config/getters.go @@ -513,3 +513,20 @@ func GetIsDockerContainer() bool { defer ConfigMu.RUnlock() return IsDockerContainer } + +func GetIsGameServerRunning() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return IsGameServerRunning +} +func GetOverrideAdvertisedIp() string { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return OverrideAdvertisedIp +} + +func GetStationeersServerPingEndpoint() string { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return StationeersServerPingEndpoint +} diff --git a/src/config/setters.go b/src/config/setters.go index 58627abb..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 @@ -694,3 +702,11 @@ func SetAllowAutoGameServerUpdates(value bool) error { AllowAutoGameServerUpdates = value return safeSaveConfig() } + +func SetOverrideAdvertisedIp(value string) error { + ConfigMu.Lock() + defer ConfigMu.Unlock() + + OverrideAdvertisedIp = value + return safeSaveConfig() +} diff --git a/src/config/vars.go b/src/config/vars.go index a2f3a507..50ecb59e 100644 --- a/src/config/vars.go +++ b/src/config/vars.go @@ -61,6 +61,7 @@ var ( LanguageSetting string AutoStartServerOnStartup bool SSUIIdentifier string + OverrideAdvertisedIp string ) // Runtime only variables @@ -71,6 +72,7 @@ var ( SkipSteamCMD bool // ONLY RUNTIME IsDockerContainer bool // ONLY RUNTIME NoSanityCheck bool // ONLY RUNTIME + IsGameServerRunning bool // ONLY RUNTIME ) // Discord integration @@ -129,20 +131,21 @@ var ( // File paths var ( - TLSCertPath = "./UIMod/tls/cert.pem" - TLSKeyPath = "./UIMod/tls/key.pem" - ConfigPath = "./UIMod/config/config.json" - CustomDetectionsFilePath = "./UIMod/config/customdetections.json" - LogFolder = "./UIMod/logs/" - UIModFolder = "./UIMod/" - TwoBoxFormFolder = "./UIMod/twoboxform/" - ConfigHtmlPath = "./UIMod/ui/config.html" - DetectionManagerHtmlPath = "./UIMod/ui/detectionmanager.html" - TwoBoxFormHtmlPath = "./UIMod/twoboxform/twoboxform.html" - IndexHtmlPath = "./UIMod/ui/index.html" - SSCMWebDir = "./UIMod/sscm/" - SSCMFilePath = "./BepInEx/plugins/SSCM/SSCM.socket" - SSCMPluginDir = "./BepInEx/plugins/SSCM/" + TLSCertPath = "./UIMod/tls/cert.pem" + TLSKeyPath = "./UIMod/tls/key.pem" + ConfigPath = "./UIMod/config/config.json" + CustomDetectionsFilePath = "./UIMod/config/customdetections.json" + LogFolder = "./UIMod/logs/" + UIModFolder = "./UIMod/" + TwoBoxFormFolder = "./UIMod/twoboxform/" + ConfigHtmlPath = "./UIMod/ui/config.html" + DetectionManagerHtmlPath = "./UIMod/ui/detectionmanager.html" + TwoBoxFormHtmlPath = "./UIMod/twoboxform/twoboxform.html" + IndexHtmlPath = "./UIMod/ui/index.html" + SSCMWebDir = "./UIMod/sscm/" + SSCMFilePath = "./BepInEx/plugins/SSCM/SSCM.socket" + SSCMPluginDir = "./BepInEx/plugins/SSCM/" + StationeersServerPingEndpoint = "http://40.82.200.175:8081/Ping" ) // Bundled Assets diff --git a/src/core/loader/afterstart.go b/src/core/loader/afterstart.go index 91c3824b..1e72fee7 100644 --- a/src/core/loader/afterstart.go +++ b/src/core/loader/afterstart.go @@ -1,8 +1,6 @@ package loader import ( - "sync" - "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/discordrpc" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" @@ -10,9 +8,7 @@ import ( "github.com/JacksonTheMaster/StationeersServerUI/v5/src/setup" ) -func AfterStartComplete(wg *sync.WaitGroup) { - wg.Add(1) - defer wg.Done() +func AfterStartComplete() { config.SetSaveConfig() // Save config after startup through setters err := setup.CleanUpOldUIModFolderFiles() if err != nil { diff --git a/src/core/loader/cmdargs.go b/src/core/loader/cmdargs.go index 1b04418c..b6839216 100644 --- a/src/core/loader/cmdargs.go +++ b/src/core/loader/cmdargs.go @@ -21,6 +21,7 @@ var recoveryPasswordFlag string var devModeFlag bool var skipSteamCMDFlag bool var sanityCheckFlag bool +var overrideAdvertisedIpFlag string // ParseFlags parses command-line arguments ONCE at startup (called from func main) func ParseFlags() { @@ -39,6 +40,7 @@ func ParseFlags() { flag.BoolVar(&createSSUILogFileFlag, "lf", false, "(Alias) Create log files for SSUI") flag.BoolVar(&skipSteamCMDFlag, "NoSteamCMD", false, "Skips SteamCMD installation") flag.BoolVar(&sanityCheckFlag, "NoSanityCheck", false, "Skips the sanity check. Not recommended.") + flag.StringVar(&overrideAdvertisedIpFlag, "OverrideAdvertisedIp", "", "Override the advertised server IP (to allow server advertisement if you are behind a reverse proxy)") // Parse command-line flags flag.Parse() @@ -100,6 +102,17 @@ func HandleFlags() { logger.Main.Info(fmt.Sprintf("Overriding IsDebugMode from command line: Before=%t, Now=true", oldDebug)) } + if overrideAdvertisedIpFlag != "" { + oldOverrideAdvertisedIp := config.GetOverrideAdvertisedIp() + + if overrideAdvertisedIpFlag == oldOverrideAdvertisedIp { + logger.Advertiser.Info(fmt.Sprintf("Advertised Server IP is already set to %s", overrideAdvertisedIpFlag)) + return + } + config.SetOverrideAdvertisedIp(overrideAdvertisedIpFlag) + logger.Advertiser.Info(fmt.Sprintf("Overriding Advertised Server IP from command line: Before=%s, Now=%s", oldOverrideAdvertisedIp, overrideAdvertisedIpFlag)) + } + if createSSUILogFileFlag { oldCreateSSUILogFile := config.GetCreateSSUILogFile() config.SetCreateSSUILogFile(true) diff --git a/src/core/loader/loader.go b/src/core/loader/loader.go index 6c224cc4..3e9916e7 100644 --- a/src/core/loader/loader.go +++ b/src/core/loader/loader.go @@ -4,24 +4,23 @@ package loader import ( "embed" "os" - "sync" "time" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/advertiser" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/discordbot" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/localization" "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" ) // only call this once at startup -func InitBackend(wg *sync.WaitGroup) { - wg.Add(1) - defer wg.Done() +func InitBackend() { ReloadConfig() ReloadSSCM() ReloadBackupManager() @@ -29,6 +28,8 @@ func InitBackend(wg *sync.WaitGroup) { ReloadAppInfoPoller() ReloadDiscordBot() InitDetector() + StartIsGameServerRunningCheck() + LoadAdvertiser() } // use this to reload backend at runtime @@ -91,18 +92,27 @@ func ReloadLocalizer() { localization.ReloadLocalizer() } +func StartIsGameServerRunningCheck() { + gamemgr.StartIsGameServerRunningCheck() +} + func ReloadAppInfoPoller() { steamcmd.AppInfoPoller() } +func LoadAdvertiser() { + if config.GetOverrideAdvertisedIp() != "" { + logger.Advertiser.Info("Starting server advertiser...") + advertiser.StartAdvertiser() + } +} + // InitBundler initialized the onboard bundled assets for the web UI func InitVirtFS(v1uiFS embed.FS) { config.SetV1UIFS(v1uiFS) } -func SanityCheck(wg *sync.WaitGroup) { - wg.Add(1) - defer wg.Done() +func SanityCheck() { err := runSanityCheck() if err != nil { logger.Main.Error("Sanity check failed, exiting in 10 secconds: " + err.Error()) 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/core/ssestream/ssemanager.go b/src/core/ssestream/ssemanager.go index 16e86919..7d11efef 100644 --- a/src/core/ssestream/ssemanager.go +++ b/src/core/ssestream/ssemanager.go @@ -106,10 +106,7 @@ func (m *SSEManager) CreateStreamHandler(streamType string) http.HandlerFunc { notify := r.Context().Done() // Start streaming messages - go m.streamMessages(w, flusher, client, streamType, notify) - - // Wait for client disconnection - <-notify + m.streamMessages(w, flusher, client, streamType, notify) } } diff --git a/src/logger/logger.go b/src/logger/logger.go index 958d3971..a505e154 100644 --- a/src/logger/logger.go +++ b/src/logger/logger.go @@ -24,6 +24,7 @@ var ( SSE = &Logger{suffix: SYS_SSE} Security = &Logger{suffix: SYS_SECURITY} Localization = &Logger{suffix: SYS_LOCALIZATION} + Advertiser = &Logger{suffix: SYS_ADVERTISER} ) // Severity Levels @@ -48,6 +49,7 @@ const ( SYS_SSE = "SSE" SYS_SECURITY = "SECURITY" SYS_LOCALIZATION = "LOCALIZATION" + SYS_ADVERTISER = "ADVERTISER" ) const ( @@ -73,6 +75,7 @@ var subsystemColors = map[string]string{ SYS_SSE: colorCyan, // Matches WEB, streaming vibe SYS_SECURITY: colorRed, // Screams "pay attention" SYS_LOCALIZATION: colorCyan, // Matches WEB, localization-related + SYS_ADVERTISER: colorYellow, // Matches Config, advanced feature } // Global channels and mutex for all loggers 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/setup/sscm.go b/src/setup/sscm.go index a41cb8f3..d278fa97 100644 --- a/src/setup/sscm.go +++ b/src/setup/sscm.go @@ -150,10 +150,12 @@ func downloadAndInstallBepInEx(url string) error { } } - if runtime.GOOS != "linux" { - err = os.Remove("./run_bepinex.sh") - if err != nil { - logger.Install.Warn(fmt.Sprintf("⚠️Failed to remove obsoleterun_bepinex.sh: %v", err)) + if runtime.GOOS == "linux" { + if _, err := os.Stat("./run_bepinex.sh"); err == nil { + err = os.Remove("./run_bepinex.sh") + if err != nil { + logger.Install.Warn(fmt.Sprintf("⚠️Failed to remove run_bepinex.sh: %v", err)) + } } } diff --git a/src/steamcmd/steamcmd-helper.go b/src/steamcmd/steamcmd-helper.go index 617aeacb..4caee4fe 100644 --- a/src/steamcmd/steamcmd-helper.go +++ b/src/steamcmd/steamcmd-helper.go @@ -44,6 +44,15 @@ func isSymlinkInsideRoot(name, link, root string) bool { return !strings.HasPrefix(rel, "..") && !strings.HasPrefix(abs, string(os.PathSeparator)) } +func isPathInsideRoot(path, root string) bool { + cleanPath := filepath.Clean(path) + rel, err := filepath.Rel(root, cleanPath) + if err != nil { + return false + } + return !strings.HasPrefix(rel, "..") && !strings.HasPrefix(cleanPath, string(os.PathSeparator)) +} + // createSteamCMDDirectory creates the SteamCMD directory. func createSteamCMDDirectory(steamCMDDir string) error { if err := os.MkdirAll(steamCMDDir, os.ModePerm); err != nil { @@ -183,6 +192,9 @@ func untar(dest string, r io.Reader) error { return fmt.Errorf("failed to create directory %s: %v", target, err) } case tar.TypeReg: + if !isPathInsideRoot(target, dest) { + return fmt.Errorf("invalid file path attempts to write outside root directory: %s", target) + } outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode)) if err != nil { return fmt.Errorf("failed to create file %s: %v", target, err) @@ -230,6 +242,9 @@ func Unzip(zipReader io.ReaderAt, size int64, dest string) error { fpath := filepath.Join(dest, f.Name) // Ensure the file path is within the destination directory + if !isPathInsideRoot(fpath, dest) { + return fmt.Errorf("invalid file path attempts to write outside root directory: %s", fpath) + } relPath, err := filepath.Rel(dest, fpath) if err != nil || strings.HasPrefix(relPath, "..") || strings.HasPrefix(relPath, string(os.PathSeparator)) { return fmt.Errorf("invalid file path: %s", fpath) 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 } diff --git a/sscm/SSCM.dll b/sscm/SSCM.dll index 766d35c7..62b50569 100644 Binary files a/sscm/SSCM.dll and b/sscm/SSCM.dll differ