<# .SYNOPSIS Game server watcher script intended to update, restart and 'watch' a server and it's sub processes .DESCRIPTION - Will update a game server using steamcmd if a new version is released (checked against steam's API) - Checks if a game server process goes over a specified RAM limit then restarts it if so - Sends RCON broadcasts to the game server if available to warn active users w/ a 5min warning - Verifies the game server process is running before checking for updates, if not the server process will be started again - Processes are checked against their directory path to allow multiple instances of game servers to be unaffected by eachother - Automatically reboots the server once a day if an hour is specified and it hasn't started that same day .PARAMETER GameName Name of the game, used for defining a heirarchical path and can be used in RCON or Discord notifications .PARAMETER ServerName Name of the server, used for defining a heirarchical path and can be used in RCON or Discord notifications .PARAMETER RconProfile Name of the profile in the rcon.yaml file to use for issuing RCON commands, requires 'RconPath' to be a valid rcon.exe path .PARAMETER RconPath Full path to where the rcon.exe client exists .PARAMETER DiscordWebhookUrl Url of a Discord webhook to call when sending a discord notification via a command .PARAMETER ExecutablePath Full path to the server executable that needs to be launched .PARAMETER ExecutableArgs Additional arguments to pass to the server executable if needed .PARAMETER SteamAppId AppId of the steam app .PARAMETER CheckInterval Interval in seconds to check for updates and that the process running .PARAMETER RebootHour Hour in the day to reboot the server, once a day. Put 0 if you don't want the server to auto-reboot daily (0-24) .PARAMETER RamThresholdMB RAM threshold in Megabytes to verify against, intended to handle RAM leaks in game server clients .PARAMETER VersionFilePath Full path to the file used for validating versions and persist across runs, doesn't need to exist before running .PARAMETER SteamCmdPath Full path to the steamcmd client .PARAMETER GameServerPath Directory / folder path where you wish to have the game server installed/updated, doesn't need to exist before running .PARAMETER CommandsUpdate List of commands / actions to take before rebooting due to an update Syntax: command:value Example: wait:60 (waits 60 seconds) -or- rcon:Save (issues rcon command 'Save') Options: rcon, wait, discord rcon: sends an rcon command wait: waits the desired amount of seconds discord: sends a discord notification, requires DiscordWebhookUrl to be provided. Currently CANNOT contain a colon ':' .PARAMETER CommandsReboot List of commands / actions to take before rebooting due to the daily reboot Syntax: command:value Example: wait:60 (waits 60 seconds) -or- rcon:Save (issues rcon command 'Save') Options: rcon, wait, discord rcon: sends an rcon command wait: waits the desired amount of seconds discord: sends a discord notification, requires DiscordWebhookUrl to be provided. Currently CANNOT contain a colon ':' .PARAMETER CommandsMemoryLeak List of commands / actions to take before rebooting due to a memory leak Syntax: command:value Example: wait:60 (waits 60 seconds) -or- rcon:Save (issues rcon command 'Save') Options: rcon, wait, discord rcon: sends an rcon command wait: waits the desired amount of seconds discord: sends a discord notification, requires DiscordWebhookUrl to be provided. Currently CANNOT contain a colon ':' .PARAMETER Testing Testing argument, will show verbose output and pause at certain script locations .PARAMETER PathLogs Root directory where logs are generated/cleaned up .PARAMETER ScriptFilePrefix File prefix for generated files like logs, used for consistency and cleanup .EXAMPLE PS C:\> # Run script locally PS C:\> .\game-server-watcher.ps1 .EXAMPLE PS C:\> # Run script remotely PS C:\> Set-ExecutionPolicy -ExecutionPolicy Bypass PS C:\> iex "& { $(irm https://wobig.tech/downloads/scripts/game-server-watcher.ps1) }" .NOTES Author: Rick Wobig Source: https://wobig.tech/downloads/scripts/game-server-watcher.ps1 #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$GameName = "Palworld", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$ServerName = "NebraskaPals", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$RconProfile = "nebraskapals", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$RconPath = "D:\Game_Servers\_tools\rcon-cli\", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$DiscordWebhookUrl = "", # "https://discord.com/api/webhooks//" [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$PreStartScript = "Remove-Item -Recurse -Path `"D:\Game_Servers\$GameName\$ServerName\folder-to-remove`"", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$ExecutablePath = "D:\Game_Servers\$GameName\$ServerName\PalServer.exe", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$ExecutableArgs = "-port=30535 -queryport=40535 -players=32 -useperfthreads -NoAsyncLoadingThread -UseMultithreadForDS", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [int]$SteamAppId = 2394010, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [int]$CheckInterval = 120, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [int]$RebootHour = 5, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [int]$RamThresholdMB = 15000, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$VersionFilePath = "D:\Game_Servers\$GameName\$ServerName\.server-version.txt", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$SteamCmdPath = "D:\Game_Servers\_source\steamcmd\steamcmd.exe", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$GameServerPath = "D:\Game_Servers\$GameName\$ServerName\", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [Collections.Generic.List[string]]$CommandsUpdate = ( "discord:<@&role_id> A new update for $GameName server '$ServerName' is available and will be applied in 10min", "rcon:Save", "rcon:`"Broadcast Server_Will_Restart_In_10_Minutes_For_Updates`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_9_Minutes_For_Updates`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_8_Minutes_For_Updates`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_7_Minutes_For_Updates`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_6_Minutes_For_Updates`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_5_Minutes_For_Updates`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_4_Minutes_For_Updates`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_3_Minutes_For_Updates`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_2_Minutes_For_Updates`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_1_Minute_For_Updates`"", "rcon:Save", "wait:60", "rcon:DoExit", "discord:<@&role_id> $GameName server '$ServerName' has been updated to a new version and is running again" ), [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [Collections.Generic.List[string]]$CommandsReboot = ( "rcon:Save", "rcon:`"Broadcast Server_Will_Restart_In_10_Minutes_For_Daily_Reboot`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_9_Minutes_For_Daily_Reboot`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_8_Minutes_For_Daily_Reboot`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_7_Minutes_For_Daily_Reboot`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_6_Minutes_For_Daily_Reboot`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_5_Minutes_For_Daily_Reboot`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_4_Minutes_For_Daily_Reboot`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_3_Minutes_For_Daily_Reboot`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_2_Minutes_For_Daily_Reboot`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_1_Minute_For_Daily_Reboot`"", "rcon:Save", "wait:60", "rcon:DoExit", "discord:$GameName server '$ServerName' has been rebooted for it's daily reboot during non-peak hours" ), [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [Collections.Generic.List[string]]$CommandsMemoryLeak = ( "discord:$GameName server '$ServerName' will be rebooted in 10min due to a memory leak", "rcon:Save", "rcon:`"Broadcast Server_Will_Restart_In_10_Minutes_For_Memory_Leak`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_9_Minutes_For_Memory_Leak`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_8_Minutes_For_Memory_Leak`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_7_Minutes_For_Memory_Leak`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_6_Minutes_For_Memory_Leak`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_5_Minutes_For_Memory_Leak`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_4_Minutes_For_Memory_Leak`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_3_Minutes_For_Memory_Leak`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_2_Minutes_For_Memory_Leak`"", "wait:60", "rcon:`"Broadcast Server_Will_Restart_In_1_Minute_For_Memory_Leak`"", "rcon:Save", "wait:60", "rcon:DoExit", "discord:$GameName server '$ServerName' has been rebooted due to a memory leak and is running again" ), [Parameter(Mandatory = $false)] [Switch] [bool]$Testing = $false, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$PathLogs = "D:\Game_Servers\$GameName\$ServerName\WatcherLogs\", [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$ScriptFilePrefix = "GameServer_$($GameName)_$($ServerName)_" ) # region Script Execution try { # Instantiate Logger and indicate the script has started [Logger]::new($Testing, $PathLogs, $ScriptFilePrefix) | Out-Null [Logger]::Logger.LogDisplay("Starting script execution", "Cyan") # Create GameServer directory if it doesn't exist $gameServerPathCreated = [Windows]::CreateFolderIfNotExist($GameServerPath) if ($gameServerPathCreated){ [Logger]::Logger.LogDisplay("Created missing game server path: $GameServerPath", "Cyan") } # Create version file if it doesn't exist $versionFileCreated = [Windows]::CreateFileIfNotExist($VersionFilePath, "Unchecked") if ($versionFileCreated){ [Logger]::Logger.LogDisplay("Created missing version file: $VersionFilePath", "DarkGray") } # Check if the process is already running from the specified path $runningProcess = [GameServer]::GetProcessFromExecutablePath($ExecutablePath) # If server isn't running check for a version update then start the server if (!$runningProcess) { [Logger]::Logger.LogDisplay("Process isn't running, checking for update before starting", "DarkGray") $updateNeeded = [GameServer]::DoesServerNeedUpdated($VersionFilePath, $SteamCmdPath, $GameServerPath, $SteamAppId) if ($updateNeeded){ $latestVersion = [GameServer]::GetLatestServerVersionFromSteam($SteamAppId) if ($null -eq $latestVersion){ [Logger]::Logger.LogDisplay("Was unable to get the latest version from steam, will try again on the interval", "Red") } else { $updateSuccessful = [GameServer]::UpdateGameServer($SteamCmdPath, $GameServerPath, $SteamAppId, $VersionFilePath, $latestVersion) if (!$updateSuccessful){ [ScriptControl]::StopOnFailure("Failure occurred attempting to update the game server, please see logs for details. Log path: $PathLogs") } [Logger]::Logger.LogDisplay("Successfully updated game server to new version: $latestVersion", "Green") } } $serverProcess = [GameServer]::StartServerProcess($ExecutablePath, $ExecutableArgs, $GameServerPath, $PreStartScript) [Logger]::Logger.LogDisplay("Server has been started, now waiting 30 seconds before starting update check [$($serverProcess.Id)]$($serverProcess.Name)", "Green") Start-Sleep -Seconds 30 } $lastServerStartDate = (Get-Date).Date [Logger]::Logger.LogDisplay("Started update watcher, check interval: $CheckInterval seconds", "Cyan") # Continuously check for version changes while ($true) { Start-Sleep -Seconds $CheckInterval # Verify the process is still running, if not it might have crashed $runningProcess = [GameServer]::GetProcessFromExecutablePath($ExecutablePath) # If the server isn't running we'll be nice and start it back up again if (!$runningProcess) { [Logger]::Logger.LogDisplay("The server isn't running! Maybe it crashed?", "Red") $runningProcess = [GameServer]::StartServerProcess($ExecutablePath, $ExecutableArgs, $GameServerPath, $PreStartScript) $lastServerStartDate = (Get-Date).Date [Logger]::Logger.LogDisplay("Server is up again, now waiting 30 seconds before continuing update check [$($runningProcess.Id)]$($runningProcess.Name)", "Cyan") $webhookSuccess = [Discord]::SendWebhook($DiscordWebhookUrl, "$GameName server '$ServerName' crashed, the server has been started and is running again") if (-not $webhookSuccess){ [Logger]::Logger.LogDisplay("Failure occurred attempting to send discord webhook", "Red") } Start-Sleep -Seconds 30 [Logger]::Logger.LogDisplay("Watcher is watching again", "Cyan") } # Check if there is a new version of the game server $updateNeeded = [GameServer]::DoesServerNeedUpdated($VersionFilePath, $SteamCmdPath, $GameServerPath, $SteamAppId) if ($updateNeeded) { # Send notification to those on the server and save $updateCommandsSuccessful = [GameServer]::HandleCommands($CommandsUpdate, $RconPath, $RconProfile, $DiscordWebhookUrl) if (!$updateCommandsSuccessful){ [Logger]::Logger.LogDisplay("Warning! Update commands failed, check logs for details. Log path: $PathLogs", "Red") } [Logger]::Logger.LogDisplay("Restarting server...", "Cyan") # Stop all game server processes that are still running $serverProcesses = [GameServer]::GetProcessesFromPath($GameServerPath) $processesStopped = [GameServer]::KillGameServerProcesses($serverProcesses) if (!$processesStopped){ [ScriptControl]::StopOnFailure("Failure occurred attempting to stop the game server processes, please see logs for details. Log path: $PathLogs") } # Update the game server $latestVersion = [GameServer]::GetLatestServerVersionFromSteam($SteamAppId) if ($null -eq $latestVersion){ [Logger]::Logger.LogDisplay("Was unable to get the latest version from steam, waiting 30 seconds then trying again", "Red") Start-Sleep -Seconds 30 } else { $updateSuccessful = [GameServer]::UpdateGameServer($SteamCmdPath, $GameServerPath, $SteamAppId, $VersionFilePath, $latestVersion) if (!$updateSuccessful){ [ScriptControl]::StopOnFailure("Failure occurred attempting to update the game server, please see logs for details. Log path: $PathLogs") } [Logger]::Logger.LogDisplay("Successfully updated game server to new version: $latestVersion", "Green") $serverProcess = [GameServer]::StartServerProcess($ExecutablePath, $ExecutableArgs, $GameServerPath, $PreStartScript) $lastServerStartDate = (Get-Date).Date [Logger]::Logger.LogDisplay("Server has been started, waiting 30 seconds before continuing update check [$($serverProcess.Id)]$($serverProcess.Name)", "Green") Start-Sleep -Seconds 30 [Logger]::Logger.LogDisplay("Watcher is watching again", "Cyan") } } # Validate RAM utilization to combat RAM leaks $ramUtilizationOverThreshold = [GameServer]::IsServerProcessesOverRamThreshold($GameServerPath, $RamThresholdMB) if ($ramUtilizationOverThreshold){ # Send notification to those on the server and save $leakCommandsSuccessful = [GameServer]::HandleCommands($CommandsMemoryLeak, $RconPath, $RconProfile, $DiscordWebhookUrl) if (!$leakCommandsSuccessful){ [Logger]::Logger.LogDisplay("Warning! Memory leak commands failed, check logs for details. Log path: $PathLogs", "Red") } [Logger]::Logger.LogDisplay("Restarting server...", "Cyan") # Stop all game server processes that are still running $serverProcesses = [GameServer]::GetProcessesFromPath($GameServerPath) $processesStopped = [GameServer]::KillGameServerProcesses($serverProcesses) if (!$processesStopped){ [ScriptControl]::StopOnFailure("Failure occurred attempting to stop the game server processes, please see logs for details. Log path: $PathLogs") } $serverProcess = [GameServer]::StartServerProcess($ExecutablePath, $ExecutableArgs, $GameServerPath, $PreStartScript) $lastServerStartDate = (Get-Date).Date [Logger]::Logger.LogDisplay("Server has been started, waiting 30 seconds before continuing update check [$($serverProcess.Id)]$($serverProcess.Name)", "Green") Start-Sleep -Seconds 30 [Logger]::Logger.LogDisplay("Watcher is watching again", "Cyan") } # Validate if auto-reboot threshold has passed and isn't set to 0 if (-not ($RebootHour -eq 0)){ $currentDateTime = Get-Date # Reboot will occur once a day after the specified hour (24 hour scale) if ($currentDateTime.Hour -ge $RebootHour -and $lastServerStartDate -ne $currentDateTime.Date){ [Logger]::Logger.LogDisplay("Daily server reboot time is upon us, rebooting server!", "Cyan") # Send notification to those on the server and save $rebootCommandsSuccessful = [GameServer]::HandleCommands($CommandsReboot, $RconPath, $RconProfile, $DiscordWebhookUrl) if (!$rebootCommandsSuccessful){ [Logger]::Logger.LogDisplay("Warning! Reboot commands failed, check logs for details. Log path: $PathLogs", "Red") } [Logger]::Logger.LogDisplay("Restarting server...", "Cyan") # Stop all game server processes that are still running $serverProcesses = [GameServer]::GetProcessesFromPath($GameServerPath) $processesStopped = [GameServer]::KillGameServerProcesses($serverProcesses) if (!$processesStopped){ [ScriptControl]::StopOnFailure("Failure occurred attempting to stop the game server processes, please see logs for details. Log path: $PathLogs") } $serverProcess = [GameServer]::StartServerProcess($ExecutablePath, $ExecutableArgs, $GameServerPath, $PreStartScript) $lastServerStartDate = (Get-Date).Date [Logger]::Logger.LogDisplay("Server has been started, waiting 30 seconds before continuing update check [$($serverProcess.Id)]$($serverProcess.Name)", "Green") Start-Sleep -Seconds 30 [Logger]::Logger.LogDisplay("Watcher is watching again", "Cyan") } } } # Finish up [Logger]::Logger.LogDisplay("Stopping script execution", "Cyan") } catch { [ScriptControl]::StopOnFailure("GLOBAL FAILURE: $($PSItem.ToString())") } #endregion # region Classes class Logger { static [Logger] $Logger [bool] hidden $TestMode [string] hidden $LogPath [string] hidden $FilePrefix [string] hidden $LogDate = "$(Get-Date -Format %M_%d)" Logger( [bool]$testing, [string]$pathLogs, [string]$filePrefix ) { $this.TestMode = $testing $this.LogPath = $pathLogs $this.FilePrefix = $filePrefix [Logger]::Logger = $this [Logger]::GenerateLogDir($this.LogPath) } Log($message) { $methodName = [Logger]::GetMethodName(2) Add-Content "$($this.LogPath)$($this.FilePrefix)$($this.LogDate).log" "[$(Get-Date -UFormat %H:%M:%S)] $($methodName): $($message)" if ($this.TestMode) { Write-Host -fore DarkGray "[$(Get-Date -UFormat %H:%M:%S)] $($methodName): $($message)" } } LogPause($message) { if ($this.TestMode) { $methodName = [Logger]::GetMethodName(2) Add-Content "$($this.LogPath)$($this.FilePrefix)$($this.LogDate).log" "[$(Get-Date -UFormat %H:%M:%S)] $($methodName): $($message)" Write-Host -fore DarkGray "[$(Get-Date -UFormat %H:%M:%S)] $($methodName): $($message)" Pause } } LogDisplay($message, $color = "Gray"){ $methodName = [Logger]::GetMethodName(2) Add-Content "$($this.LogPath)$($this.FilePrefix)$($this.LogDate).log" "[$(Get-Date -UFormat %H:%M:%S)] $($methodName): $($message)" Write-Host -fore $color "[$(Get-Date -UFormat %H:%M:%S)] $($message)" } LogLoading() { if (!($this.TestMode)) { Add-Content "$($this.LogPath)$($this.FilePrefix)$($this.LogDate).log" "." Write-Host -fore DarkGray -NoNewline "." } } static [string] GetMethodName([int]$StackNumber = 1) { return [string]$(Get-PSCallStack)[$StackNumber].FunctionName.ToUpper() } static [string] GenerateLogDir($logDir) { try { if (!(Test-Path -Path $logDir)) { New-Item $logDir -ItemType Directory } return $logDir } catch { Write-Host -fore Red "FAILURE: $($PSItem.ToString())" return $null } } } class ScriptControl { static [void] FinishScriptExecution($startTime){ [Logger]::Logger.LogDisplay("Script Finished", "Cyan") $endTime = Get-Date [Logger]::Logger.LogDisplay("Total script time: $(($endTime - $startTime).TotalSeconds)", "Cyan") Exit } static [void] StopOnFailure($exitMessage) { if ($null -ne [Logger]::Logger) { [Logger]::Logger.LogDisplay("$exitMessage | Stopping script execution", "Red") } Exit } } class Windows { static [bool] RemoveFolderIfExist($folderPath){ try { if (Test-Path -PathType Container $folderPath) { Remove-Item $folderPath -Recurse -Force -ErrorAction SilentlyContinue [Logger]::Logger.Log("Removed folder recursively: $folderPath") return $true } else { [Logger]::Logger.Log("Folder doesn't exist, skipping removal: $folderPath") return $true } } catch { [Logger]::Logger.LogDisplay("FAILURE: $($PSItem.ToString())", "Red") return $false } } static [bool] CreateFolderIfNotExist($folderPath){ if (!(Test-Path -PathType Container $folderPath)) { try { New-Item -Path $folderPath -ItemType "directory" | Out-Null [Logger]::Logger.Log("Created non-existant folder: $folderPath") return $true } catch { [Logger]::Logger.LogDisplay("FAILURE: $($PSItem.ToString())", "Red") return $false } } return $false } static [bool] CreateFileIfNotExist($filePath, $initialValue){ try { if (-not (Test-Path -Path $filePath -PathType Leaf)){ New-Item -Path $filePath -Value $initialValue -Force | Out-Null [Logger]::Logger.Log("Created non-existant file: $filePath") return $true } return $false } catch { [Logger]::Logger.LogDisplay("FAILURE: $($PSItem.ToString())", "Red") return $false } } static [string] ParseCurrentUser(){ # Get logged on user instead of run as user $currentUser = Get-CimInstance -ClassName Win32_ComputerSystem | Select-Object UserName # Remove domain prefix from user account string $currentUser = ($currentUser -creplace '^[^\\]*\\', '').Replace('}', '') return $currentUser } static [void] SaveFileContents($filePath, $fileContents){ try { $fileContents | Set-Content -Path $filePath -Force [Logger]::Logger.Log("Saved file contents: $filePath") } catch { [Logger]::Logger.LogDisplay("FAILURE: Couldn't save file contents at [$filePath]: $($PSItem.ToString())", "Red") } } static [void] EnsureScriptElevation($currentDirectory){ # Check if the script is running with administrative privileges $isAdmin = [bool]([System.Security.Principal.WindowsIdentity]::GetCurrent()).IsAdmin [Logger]::Logger.Log("Script run as admin: $isAdmin") if (-not $isAdmin) { # Relaunch the script with administrative privileges [Logger]::Logger.Log("Script isn't running with elevated permissions, attempting to elevate") Start-Process powershell.exe -WorkingDirectory $currentDirectory -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" -Verb RunAs exit } [Logger]::Logger.LogDisplay("Script has admin permissions! Continuing script execution", "Green") } } class GameServer { static [System.Diagnostics.Process] StartServerProcess($executablePath, $executableArgs, $gameServerPath, $preStartScript) { try { if (-not ([string]::IsNullOrWhiteSpace($preStartScript))){ $output = & "powershell.exe" $preStartScript [Logger]::Logger.LogDisplay("Ran pre start script: $preStartScript", "DarkGray") [Logger]::Logger.Log(" Output: $output") Start-Sleep -Seconds 2 } $workingDirectory = (Get-Item $executablePath).DirectoryName Set-Location -Path $workingDirectory [Logger]::Logger.LogDisplay("Set working directory: $workingDirectory", "DarkGray") $process = Start-Process -FilePath $executablePath -ArgumentList $executableArgs -PassThru [Logger]::Logger.LogDisplay("Started process [$($process.Id)]$($process.Name), waiting 10 seconds to update process and sub-processes to high CPU priority", "Green") Start-Sleep -Seconds 10 $gameServerProcesses = [GameServer]::GetProcessesFromPath($gameServerPath) # 128 == High | For priority ID's SEE: https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/setpriority-method-in-class-win32-process $gameServerProcesses | ForEach-Object { Get-WmiObject Win32_process -Filter "ProcessID=$($_.Id)" | ForEach-Object { $_.SetPriority(128) } } [Logger]::Logger.LogDisplay("Updated $($gameServerProcesses.Count) processes with high CPU priority!", "Cyan") return $process } catch { [Logger]::Logger.LogDisplay("FAILURE: Couldn't start server process [$executablePath]: $($PSItem.ToString())", "Red") return $null } } static [System.Diagnostics.Process] GetProcessFromExecutablePath($executablePath) { try { $executableName = (Get-Item $executablePath).BaseName $processes = Get-Process $executableName -ErrorAction SilentlyContinue foreach ($process in $processes) { if ($process.MainModule.FileName -eq $executablePath) { [Logger]::Logger.Log("Found process from executable path: [$($process.Id)]$($process.Name) => $executablePath") return $process } } return $null } catch { [Logger]::Logger.LogDisplay("FAILURE: Couldn't get process from path [$executablePath]: $($PSItem.ToString())", "Red") return $null } } static [Collections.Generic.List[System.Diagnostics.Process]] GetProcessesFromPath($processesPath) { try { $processes = Get-Process [Collections.Generic.List[System.Diagnostics.Process]]$pathProcesses = New-Object Collections.Generic.List[System.Diagnostics.Process] foreach ($process in $processes) { if ($process.MainModule.FileName -like "$($processesPath)*") { $pathProcesses.Add($process) | Out-Null } } return $pathProcesses } catch { [Logger]::Logger.LogDisplay("FAILURE: Couldn't get processes from path [$processesPath]: $($PSItem.ToString())", "Red") return $null } } static [string] GetCurrentVersionFromFile($filePath) { try { [Logger]::Logger.Log("Attempting to get current version from file: $filePath") $currentVersion = (Get-Content $filePath -Raw).Trim() [Logger]::Logger.Log("Current version gathered from file: $currentVersion") return $currentVersion } catch { [Logger]::Logger.LogDisplay("FAILURE: Couldn't get current version from file: $($PSItem.ToString())", "Red") return $null } } static [string] GetLatestServerVersionFromSteam($steamAppId) { try { # Don't display the web request loading bar (shows as a repeating quick flash at runtime) # SEE: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_preference_variables?view=powershell-7.4#progresspreference $ProgressPreference = 'SilentlyContinue' $response = Invoke-WebRequest -Uri "https://api.steamcmd.net/v1/info/$steamAppId" $jsonContent = $response | ConvertFrom-Json $version = $jsonContent.data."$steamAppId".depots.branches.public.buildid $timeUpdated = $jsonContent.data."$steamAppId".depots.branches.public.timeupdated $epoch = [DateTime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) $releaseDate = $epoch.AddSeconds($timeUpdated) [Logger]::Logger.Log("Latest version $version was released on $($releaseDate.DateTime)") return $version } catch { [Logger]::Logger.LogDisplay("FAILURE: Couldn't get latest server version from steam: $($PSItem.ToString())", "Red") return $null } } static [bool] UpdateGameServer($steamCmdPath, $gameServerPath, $steamAppId, $versionFilePath, $latestVersion) { try { [Logger]::Logger.Log("Attempting to update game server for App [$steamAppId] at: $gameServerPath") Start-Process -FilePath $steamCmdPath -Wait -NoNewWindow -ArgumentList "+@ShutdownOnFailedCommand 1 +@NoPromptForPassword 1 +force_install_dir $GameServerPath +login anonymous +app_info_update 1 +app_update $steamAppId +app_status $steamAppId +quit" [Logger]::Logger.Log("Game server update complete for App [$steamAppId] at: $gameServerPath") [Windows]::SaveFileContents($versionFilePath, $latestVersion) [Logger]::Logger.Log("Game server is now on version: $latestVersion") return $true } catch { [Logger]::Logger.LogDisplay("FAILURE: Couldn't update App [$steamAppId] at $($gameServerPath): $($PSItem.ToString())", "Red") return $false } } static [bool] HandleCommands($commands, $rconPath, $rconProfile, $discordWebhookUrl) { if ($commands.Count -lt 1){ [Logger]::Logger.Log("No action commands were provided so we're skipping sending any") return $true } $allCommandsSuccessful = $true foreach ($command in $commands){ try { [Logger]::Logger.Log("Received action command: $command") $commandValue = $command.Split(":")[1] if ($command.StartsWith("wait")){ [Logger]::Logger.Log("Wait command recieved, waiting $commandValue seconds") [Logger]::Logger.Log("Waiting $commandValue seconds...") Start-Sleep -Seconds $commandValue } if ($command.StartsWith("rcon")){ [Logger]::Logger.Log("RCON command recieved, sending RCON command: $commandValue") $rconSuccessful = [GameServer]::SendRconCommand($rconPath, $rconProfile, $commandValue) if (-not ($rconSuccessful)){ $allCommandsSuccessful = $false } } if ($command.StartsWith("discord")){ [Logger]::Logger.Log("Discord command recieved, sending discord notification: $commandValue") $discordSuccessful = [Discord]::SendWebhook($discordWebhookUrl, $commandValue) if (-not ($discordSuccessful)){ $allCommandsSuccessful = $false } } } catch { [Logger]::Logger.LogDisplay("FAILURE: Action command failed [$($command)]: $($PSItem.ToString())", "Red") $allCommandsSuccessful = $false } } [Logger]::Logger.LogDisplay("Finished handling $($commands.Count) action commands", "Cyan") return $allCommandsSuccessful } static [bool] SendRconCommand($rconPath, $rconProfile, $rconCommand) { if ([string]::IsNullOrWhiteSpace($rconCommand)){ [Logger]::Logger.Log("Empty RCON command was provided so we're moving on...") return $true } try { Start-Process -FilePath "$($rconPath)rcon.exe" -ArgumentList "-c $($rconPath)rcon.yaml -e $rconProfile $rconCommand" -Wait -NoNewWindow [Logger]::Logger.Log("Sent RCON command to profile [$rconProfile]: $rconCommand") } catch { [Logger]::Logger.LogDisplay("FAILURE: RCON command to profile [$rconProfile] failed [$($rconCommand)]: $($PSItem.ToString())", "Red") return $false } [Logger]::Logger.LogDisplay("Finished sending RCON command: $rconCommand", "Cyan") return $true } static [bool] IsServerProcessesOverRamThreshold($gameServerPath, $ramThresholdMB) { $gameServerProcesses = [GameServer]::GetProcessesFromPath($gameServerPath) if ($ramThresholdMB -eq 0){ [Logger]::Logger.Log("RAM threshold is 0 $($ramThresholdMB)MB, moving on") return $false } foreach ($process in $gameServerProcesses){ try { $ramUtilization = [Math]::Round($process.WorkingSet64 / 1MB) [Logger]::Logger.Log("Game server process [$($process.Id)]$($process.Name) RAM Utilization: $($ramUtilization)MB") if ($ramUtilization -gt $ramThresholdMB){ [Logger]::Logger.LogDisplay("Game server process is using over our RAM threshold: $($ramUtilization)MB / $($ramThresholdMB)MB", "Red") return $true } } catch { [Logger]::Logger.LogDisplay("FAILURE: Couldn't enumerate process to validate RAM utilization: $($PSItem.ToString())", "Red") } } return $false } static [bool] DoesServerNeedUpdated($versionFilePath, $steamCmdPath, $gameServerPath, $steamAppId) { try { $currentVersion = [GameServer]::GetCurrentVersionFromFile($versionFilePath) $latestVersion = [GameServer]::GetLatestServerVersionFromSteam($steamAppId) if ([string]::IsNullOrWhiteSpace($latestVersion)){ [Logger]::Logger.LogDisplay("Was unable to get the latest version due to a failure, skipping update...", "Red") return $false } if (-not ($currentVersion -eq $latestVersion)){ [Logger]::Logger.LogDisplay("Current version doesn't match latest version! Updated is needed [current]$currentVersion => [latest]$latestVersion", "Yellow") return $true } [Logger]::Logger.Log("Current and latest versions match, no updated needed [current]$currentVersion => [latest]$latestVersion") return $false } catch { [Logger]::Logger.LogDisplay("FAILURE: Couldn't compare current and latest versions: $($PSItem.ToString())", "Red") return $false } } static [bool] KillGameServerProcesses($serverProcesses) { try { if ($serverProcesses.Count -lt 1){ [Logger]::Logger.Log("$($serverProcesses.Count) game server processes are running, moving on...") return $true } [Logger]::Logger.Log("Killing $($serverProcesses.Count) game server processes") $serverProcesses | ForEach-Object { $_ | Stop-Process -Force -ErrorAction SilentlyContinue } [Logger]::Logger.LogDisplay("Stopped $($serverProcesses.Count) game server processes", "Red") return $true } catch { [Logger]::Logger.LogDisplay("FAILURE: Couldn't stop game server processes: $($PSItem.ToString())", "Red") return $false } } } class Discord { static [bool] SendWebhook($webhookUrl, $content){ if ([string]::IsNullOrWhiteSpace($webhookUrl)){ [Logger]::Logger.Log("Discord webhook Url is empty so we're skipping sending the discord notification action") return $true } if ([string]::IsNullOrWhiteSpace($content)){ [Logger]::Logger.Log("Discord notification content is empty so we're skipping sending the discord notification action") return $true } [Logger]::Logger.Log("Sending discord webhook to: $webhookUrl") [System.Collections.ArrayList]$embedArray = @() try { $embed = [PSCustomObject]@{ color = "9442302" title = "Game Server Notification" description = $content } $embedArray.Add($embed) $payload = [PSCustomObject]@{ embeds = $embedArray } Invoke-RestMethod -Uri $webhookUrl -Method Post -ContentType "Application/Json" -Body ($payload | ConvertTo-Json -Depth 4) [Logger]::Logger.LogDisplay("Sent discord webhook message: $content", "Green") return $true } catch { [Logger]::Logger.LogDisplay("FAILURE: Failed to send discord webhook: $($PSItem.ToString())", "Red") return $false } } } # endregion