diff --git a/.vscode/launch.json b/.vscode/launch.json index b5d9d441..ed0a4c87 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -29,6 +29,6 @@ "console": "integratedTerminal", "showLog": false, // Hides some Go Debugger(Delve) log stuff that is not useful for debugging atm "args": ["--NoSteamCMD"] - } + }, ] } \ 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..28782aae --- /dev/null +++ b/src/advertiser/advertiser.go @@ -0,0 +1,180 @@ +package advertiser + +import ( + "bytes" + "encoding/json" + "errors" + "net" + "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() + +const maxTransientErrors = 5 +const advertiserIntervalSeconds = 30 + +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 getIpFromAdvertiserOverride(address string) (string, error) { + // If the address is "auto", we need to check our public IPv4 via ipify + if address == "auto" { + resp, err := http.Get("https://api4.ipify.org") + if err != nil { + return "", err + } + defer resp.Body.Close() + buf := new(bytes.Buffer) + buf.ReadFrom(resp.Body) + return buf.String(), nil + } + // If the address is an IP quad, return it as is + if ip := net.ParseIP(address); ip != nil { + if ip.To4() != nil { + return ip.To4().String(), nil + } else if ip.To16() != nil { + return "", errors.New("IPv6 addresses are not supported for advertiser override") + } + } + // If the address is a DNS name, resolve it + ips, err := net.LookupIP(address) + if err != nil { + return "", err + } + // Return the first resolved IPv4 address + for _, ip := range ips { + if ip.To4() != nil { + return ip.To4().String(), nil + } + } + // If the address is invalid, return an error + return "", errors.New("unable to resolve IP from advertiser override") +} + +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 + // Track accumulated transient errors and kill the advertiser if we exceed a threshold + transientErrors := 0 + for { + // Only advertise if we are running + if gamemgr.InternalIsServerRunning() { + // If we have exceeded the max transient errors, exit the advertiser + if transientErrors >= maxTransientErrors { + logger.Advertiser.Errorf("ServerAdvertiser exceeded max transient errors (%d). Stopping advertiser...", maxTransientErrors) + return + } + // 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 + } + // Get IP address + ipAddress, err := getIpFromAdvertiserOverride(config.GetAdvertiserOverride()) + if err != nil { + logger.Advertiser.Warnf("ServerAdvertiser failed to get IP address from config value '%s': %v", config.GetAdvertiserOverride(), err) + transientErrors++ + time.Sleep(advertiserIntervalSeconds * time.Second) + continue + } + adMessage := ServerAdMessage{ + SessionId: sessionId, + Name: config.GetServerName(), + Password: config.GetServerPassword() != "", + Version: config.GetExtractedGameVersion(), + Address: ipAddress, + Port: config.GetGamePort(), + Players: players, + MaxPlayers: maxplayers, + Type: platform, + } + body, err := json.Marshal(adMessage) + if err != nil { + logger.Advertiser.Warnf("ServerAdvertiser failed to Serialize to JSON from native Go struct type: %v", err) + transientErrors++ + time.Sleep(advertiserIntervalSeconds * time.Second) + continue + } + // Send advertisement + resp, err := http.Post(StationeersAdvertisementEndpoint, "application/json", bytes.NewBuffer(body)) + // Check for errors + if err != nil { + logger.Advertiser.Warnf("ServerAdvertiser failed to send request: %v", err) + transientErrors++ + time.Sleep(advertiserIntervalSeconds * time.Second) + continue + } + // Check for non-200 status codes + if resp.StatusCode != 200 { + logger.Advertiser.Warnf("ServerAdvertiser received non-200 status: %d", resp.StatusCode) + transientErrors++ + time.Sleep(advertiserIntervalSeconds * time.Second) + resp.Body.Close() // Close the response body + continue + } + // Read the response and update our sessionId if needed + adResponse := ServerAdResponse{} + err = json.NewDecoder(resp.Body).Decode(&adResponse) + resp.Body.Close() + if err != nil { + logger.Advertiser.Warnf("Failed to decode response body: %v", err) + transientErrors++ + time.Sleep(advertiserIntervalSeconds * time.Second) + continue + } + if adResponse.Status != "Success" { + logger.Advertiser.Warnf("ServerAdvertiser received unexpected status: %s", adResponse.Status) + transientErrors++ + time.Sleep(advertiserIntervalSeconds * time.Second) + continue + } + // Reset transient errors on success + sessionId = adResponse.SessionId + transientErrors = 0 + } else { + // Reset sessionid for the next run + sessionId = -1 + } + // Sleep for 30 seconds to follow the standard advertisement timer + time.Sleep(advertiserIntervalSeconds * time.Second) + } + }() +} diff --git a/src/config/config.go b/src/config/config.go index 26fcf72f..d9d01a0b 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"` + AdvertiserOverride string `json:"AdvertiserOverride"` // 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") + AdvertiserOverride = getString(cfg.AdvertiserOverride, "ADVERTISER_OVERRIDE", "") + safeSaveConfig() } @@ -365,6 +368,7 @@ func safeSaveConfig() error { AutoStartServerOnStartup: &AutoStartServerOnStartup, SSUIIdentifier: SSUIIdentifier, SSUIWebPort: SSUIWebPort, + AdvertiserOverride: AdvertiserOverride, } file, err := os.Create(ConfigPath) diff --git a/src/config/getters.go b/src/config/getters.go index 27067b83..a71d7ab0 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 GetAdvertiserOverride() string { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return AdvertiserOverride +} + +func GetStationeersServerPingEndpoint() string { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return StationeersServerPingEndpoint +} diff --git a/src/config/setters.go b/src/config/setters.go index 58627abb..3f8b603b 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 SetAdvertiserOverride(value string) error { + ConfigMu.Lock() + defer ConfigMu.Unlock() + + AdvertiserOverride = value + return safeSaveConfig() +} diff --git a/src/config/vars.go b/src/config/vars.go index a2f3a507..f1e6e192 100644 --- a/src/config/vars.go +++ b/src/config/vars.go @@ -61,6 +61,7 @@ var ( LanguageSetting string AutoStartServerOnStartup bool SSUIIdentifier string + AdvertiserOverride 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..82b472dd 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 advertiserOverrideFlag 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(&advertiserOverrideFlag, "AdvertiserOverride", "", "Override the advertised server IP. For this, the ServerVisible setting must be set to false. Use \"auto\" for automatic public IP detection, an IPv4 address, or a DNS hostname (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 advertiserOverrideFlag != "" { + oldAdvertiserOverride := config.GetAdvertiserOverride() + + if advertiserOverrideFlag == oldAdvertiserOverride { + logger.Advertiser.Info(fmt.Sprintf("Advertised Server IP is already set to %s", advertiserOverrideFlag)) + return + } + config.SetAdvertiserOverride(advertiserOverrideFlag) + logger.Advertiser.Info(fmt.Sprintf("Overriding Advertised Server IP from command line: Before=%s, Now=%s", oldAdvertiserOverride, advertiserOverrideFlag)) + } + if createSSUILogFileFlag { oldCreateSSUILogFile := config.GetCreateSSUILogFile() config.SetCreateSSUILogFile(true) diff --git a/src/core/loader/loader.go b/src/core/loader/loader.go index 6c224cc4..ca2c3810 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,22 +92,31 @@ func ReloadLocalizer() { localization.ReloadLocalizer() } +func StartIsGameServerRunningCheck() { + gamemgr.StartIsGameServerRunningCheck() +} + func ReloadAppInfoPoller() { steamcmd.AppInfoPoller() } +func LoadAdvertiser() { + if config.GetAdvertiserOverride() != "" { + 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()) - logger.Main.Info("If you want to continue anyway, run SSUI with the --noSanityCheck flag, but be aware there may be Dragons ahead.") + logger.Main.Info("If you want to continue anyway, run SSUI with the --NoSanityCheck flag, but be aware there may be Dragons ahead.") logger.Main.Info("This is not recommended nor supported and may cause unexpected behavior, including potential data loss!") time.Sleep(10 * time.Second) os.Exit(1) 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