Add graceful SCUM stop, restart, and version update flow
SCUM 停止/重启/更新以前只有“结束进程”这一条路,插件没有声明任何优雅关闭方式,
平台也没有把停止后重新启动串起来。现在插件声明自己的关闭脚本,run 先执行它,
平台在停止或更新成功后再自动拉起服务。
run:
- lifecycle stop 支持插件声明的 gracefulStop(可执行文件、参数、环境、超时、
fallback=report|terminate);关闭命令超时且声明 report 时任务失败,不再默默杀进程。
- 新增 steam.update 依赖探针:调用 steamcmd +app_info_print 获取公开分支 buildid,
与本地 steamapps/appmanifest_<appid>.acf 的 buildid 比较,输出
installed/latest/update=yes|no|unknown。
platform:
- 新增 POST /api/v1/server-instances/{id}/restart 与 /update。
- restart 派发插件 stop 动作(走优雅关闭),终态成功后入队 start 作业。
- update 派发插件 install 动作;插件在更新前必须先优雅关闭 SCUM,关闭失败直接拒绝
SteamCMD 更新,成功后平台再拉起服务。
- 依赖检查输入带上插件声明的服务器安装根目录,供 steam.update 读取 appmanifest。
plugin (SCUM server plugin 0.1.16):
- bin/scum-stop.cmd:解析已声明的可执行文件路径,定位同路径正在运行的 SCUMServer.exe,
通过本地 RCON 公告并发送关闭命令,等待进程自行退出;不再使用 taskkill。
- bin/scum-rcon.ps1:插件自有的 Source RCON 客户端,从 UE4SS mod config.ini 读取
密码/端口,密钥不离开本机。
- actions/stop.json 声明 gracefulStop;actions/install.json 更新前先执行同一关闭脚本。
platform_web:
- 服务器详情新增“重启”按钮和“SCUM 版本更新”面板;点“检查更新”查询公开分支版本,
只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
"SERVER_TEMPLATE": "scum-server",
|
||||
"SERVER_STEAM_APP_ID": "3792580",
|
||||
"SERVER_STEAMCMD_INSTALL_DIR_ARG": "+force_install_dir",
|
||||
"SERVER_STEAMCMD_UPDATE_ARGS": "+login anonymous +app_update 3792580 +quit",
|
||||
"SERVER_STEAMCMD_UPDATE_ARGS": "+login anonymous +app_update 3792580 validate +quit",
|
||||
"SERVER_EXECUTABLE_REF": "SCUM/Binaries/Win64/SCUMServer.exe"
|
||||
},
|
||||
"timeoutMs": 7200000
|
||||
|
||||
@@ -4,7 +4,20 @@
|
||||
"mode": "control",
|
||||
"environment": {
|
||||
"GAME_ID": "scum",
|
||||
"SERVER_ACTION": "stop"
|
||||
"SERVER_ACTION": "stop",
|
||||
"SERVER_STOP_SHUTDOWN_COMMAND": "Quit",
|
||||
"SERVER_STOP_NOTICE_SECONDS": "15",
|
||||
"SERVER_STOP_TIMEOUT_SECONDS": "120"
|
||||
},
|
||||
"stopTimeoutMs": 30000
|
||||
"gracefulStop": {
|
||||
"executableKey": "bin/scum-stop.cmd",
|
||||
"arguments": [],
|
||||
"environment": {
|
||||
"GAME_ID": "scum",
|
||||
"SERVER_ACTION": "stop"
|
||||
},
|
||||
"timeoutMs": 300000,
|
||||
"fallback": "terminate"
|
||||
},
|
||||
"stopTimeoutMs": 60000
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ if "%SERVER_ROOT%"=="" set "SERVER_ROOT=C:\scumserver"
|
||||
set "SERVER_ROOT_WINDOWS=%SERVER_ROOT:/=\%"
|
||||
if "%SERVER_STEAM_APP_ID%"=="" set "SERVER_STEAM_APP_ID=3792580"
|
||||
if "%SERVER_STEAMCMD_INSTALL_DIR_ARG%"=="" set "SERVER_STEAMCMD_INSTALL_DIR_ARG=+force_install_dir"
|
||||
if "%SERVER_STEAMCMD_UPDATE_ARGS%"=="" set "SERVER_STEAMCMD_UPDATE_ARGS=+login anonymous +app_update 3792580 +quit"
|
||||
if "%SERVER_STEAMCMD_UPDATE_ARGS%"=="" set "SERVER_STEAMCMD_UPDATE_ARGS=+login anonymous +app_update 3792580 validate +quit"
|
||||
if "%SERVER_EXECUTABLE_REF%"=="" set "SERVER_EXECUTABLE_REF=SCUM\Binaries\Win64\SCUMServer.exe"
|
||||
if "%SERVER_INSTALL_DIR%"=="" set "SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%"
|
||||
set "SERVER_INSTALL_DIR_WINDOWS=%SERVER_INSTALL_DIR:/=\%"
|
||||
@@ -29,8 +29,7 @@ if not exist "%STEAMCMD_EXE%" (
|
||||
if errorlevel 1 exit /b 1
|
||||
)
|
||||
|
||||
call :resolve_scum_exe
|
||||
if exist "%SCUM_EXE%" call :stop_matching_scum
|
||||
call :stop_running_scum
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
pushd "%STEAMCMD_DIR%"
|
||||
@@ -60,10 +59,21 @@ if exist "%SCUM_EXE%" exit /b 0
|
||||
set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%"
|
||||
exit /b 0
|
||||
|
||||
:stop_matching_scum
|
||||
if "%SCUM_EXE%"=="" exit /b 0
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$target=[IO.Path]::GetFullPath($env:SCUM_EXE); Get-CimInstance Win32_Process -Filter \"name='SCUMServer.exe'\" | Where-Object { $_.ExecutablePath -and ([IO.Path]::GetFullPath($_.ExecutablePath) -ieq $target) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
|
||||
exit /b %ERRORLEVEL%
|
||||
:stop_running_scum
|
||||
rem SteamCMD must never replace server files underneath a running process.
|
||||
rem The plugin-owned stop script asks the running SCUM server to shut down and
|
||||
rem waits for it to exit, so an update cannot silently kill a live server.
|
||||
set "SCUM_STOP_SCRIPT=%~dp0scum-stop.cmd"
|
||||
if not exist "%SCUM_STOP_SCRIPT%" (
|
||||
echo [scum-install-update] Missing plugin stop script: %SCUM_STOP_SCRIPT%
|
||||
exit /b 4
|
||||
)
|
||||
call "%SCUM_STOP_SCRIPT%"
|
||||
if errorlevel 1 (
|
||||
echo [scum-install-update] SCUM server did not shut down gracefully; refusing to update server files.
|
||||
exit /b 5
|
||||
)
|
||||
exit /b 0
|
||||
|
||||
:default_steamcmd_dir
|
||||
for %%I in ("%SERVER_ROOT_WINDOWS%") do set "SERVER_ROOT_DRIVE=%%~dI"
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Minimal plugin-owned Source RCON client for the local SCUM "Simple RCON"
|
||||
# listener declared by this plugin. It is used by plugin lifecycle scripts that
|
||||
# must speak to the running server (announce, save, shutdown). The listener
|
||||
# password stays on this machine: callers either pass -ConfigPath so the script
|
||||
# reads it from the local UE4SS mod config, or pass -Password directly.
|
||||
param(
|
||||
[string]$ConfigPath = "",
|
||||
[string]$Password = "",
|
||||
[int]$Port = 0,
|
||||
[string]$BindAddress = "",
|
||||
[string]$Command = "",
|
||||
[int]$TimeoutMs = 10000
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Read-RconConfig {
|
||||
param([string]$Path)
|
||||
$values = @{}
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $values }
|
||||
$section = ""
|
||||
foreach ($line in Get-Content -LiteralPath $Path) {
|
||||
$trimmed = $line.Trim()
|
||||
if (-not $trimmed -or $trimmed.StartsWith(";") -or $trimmed.StartsWith("#")) { continue }
|
||||
if ($trimmed.StartsWith("[") -and $trimmed.EndsWith("]")) {
|
||||
$section = $trimmed.Substring(1, $trimmed.Length - 2).Trim()
|
||||
continue
|
||||
}
|
||||
$separator = $trimmed.IndexOf("=")
|
||||
if ($separator -lt 1) { continue }
|
||||
$key = $trimmed.Substring(0, $separator).Trim()
|
||||
$value = $trimmed.Substring($separator + 1).Trim().Trim('"')
|
||||
$values[($section + "." + $key).ToLowerInvariant()] = $value
|
||||
}
|
||||
return $values
|
||||
}
|
||||
|
||||
function Send-RconPacket {
|
||||
param([System.Net.Sockets.NetworkStream]$Stream, [int]$Id, [int]$Type, [string]$Body)
|
||||
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($Body)
|
||||
$size = 4 + 4 + $bodyBytes.Length + 2
|
||||
$buffer = New-Object byte[] (4 + $size)
|
||||
[System.BitConverter]::GetBytes([int]$size).CopyTo($buffer, 0)
|
||||
[System.BitConverter]::GetBytes([int]$Id).CopyTo($buffer, 4)
|
||||
[System.BitConverter]::GetBytes([int]$Type).CopyTo($buffer, 8)
|
||||
$bodyBytes.CopyTo($buffer, 12)
|
||||
$Stream.Write($buffer, 0, $buffer.Length)
|
||||
$Stream.Flush()
|
||||
}
|
||||
|
||||
function Receive-RconPacket {
|
||||
param([System.Net.Sockets.NetworkStream]$Stream)
|
||||
$header = New-Object byte[] 4
|
||||
$read = 0
|
||||
while ($read -lt 4) {
|
||||
$chunk = $Stream.Read($header, $read, 4 - $read)
|
||||
if ($chunk -le 0) { throw "RCON connection closed before a response header arrived" }
|
||||
$read += $chunk
|
||||
}
|
||||
$size = [System.BitConverter]::ToInt32($header, 0)
|
||||
if ($size -lt 10 -or $size -gt 65536) { throw "RCON response size is invalid" }
|
||||
$body = New-Object byte[] $size
|
||||
$read = 0
|
||||
while ($read -lt $size) {
|
||||
$chunk = $Stream.Read($body, $read, $size - $read)
|
||||
if ($chunk -le 0) { throw "RCON connection closed mid response" }
|
||||
$read += $chunk
|
||||
}
|
||||
$id = [System.BitConverter]::ToInt32($body, 0)
|
||||
$type = [System.BitConverter]::ToInt32($body, 4)
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($body, 8, $size - 10)
|
||||
return @{ Id = $id; Type = $type; Body = $text }
|
||||
}
|
||||
|
||||
$config = Read-RconConfig -Path $ConfigPath
|
||||
if (-not $Password) { $Password = $config["rcon.password"] }
|
||||
if (-not $Port -or $Port -le 0) {
|
||||
$configuredPort = 0
|
||||
if ([int]::TryParse([string]$config["rcon.port"], [ref]$configuredPort) -and $configuredPort -ge 1 -and $configuredPort -le 65535) {
|
||||
$Port = $configuredPort
|
||||
}
|
||||
}
|
||||
if (-not $Port -or $Port -le 0) { $Port = 27015 }
|
||||
if (-not $BindAddress) { $BindAddress = $config["rcon.bind_address"] }
|
||||
if (-not $BindAddress) { $BindAddress = "127.0.0.1" }
|
||||
if (-not $Password) { Write-Error "SCUM RCON password is not available for the local listener"; exit 3 }
|
||||
if (-not $Command) { Write-Error "SCUM RCON command text is required"; exit 4 }
|
||||
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
try {
|
||||
$connect = $client.BeginConnect($BindAddress, $Port, $null, $null)
|
||||
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMs)) { Write-Error "SCUM RCON listener is not reachable on port $Port"; exit 5 }
|
||||
$client.EndConnect($connect)
|
||||
$client.ReceiveTimeout = $TimeoutMs
|
||||
$client.SendTimeout = $TimeoutMs
|
||||
$stream = $client.GetStream()
|
||||
|
||||
Send-RconPacket -Stream $stream -Id 1 -Type 3 -Body $Password
|
||||
$auth = Receive-RconPacket -Stream $stream
|
||||
if ($auth.Id -ne 1) { Write-Error "SCUM RCON authentication response did not match the request"; exit 6 }
|
||||
|
||||
Send-RconPacket -Stream $stream -Id 2 -Type 2 -Body $Command
|
||||
$response = Receive-RconPacket -Stream $stream
|
||||
if ($response.Body) { Write-Output $response.Body }
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error ("SCUM RCON command failed: " + $_.Exception.Message)
|
||||
exit 7
|
||||
} finally {
|
||||
$client.Close()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
|
||||
rem Plugin-owned graceful stop for the SCUM dedicated server.
|
||||
rem It announces the shutdown to players, asks the running server to shut down
|
||||
rem through the local RCON listener (port and password come from the UE4SS mod
|
||||
rem config), and waits for SCUMServer.exe to exit on its own. Run only
|
||||
rem terminates the process afterwards when the lifecycle action declares that
|
||||
rem fallback.
|
||||
|
||||
if "%SERVER_ROOT%"=="" set "SERVER_ROOT=C:\scumserver"
|
||||
set "SERVER_ROOT_WINDOWS=%SERVER_ROOT:/=\%"
|
||||
if "%SERVER_EXECUTABLE_REF%"=="" set "SERVER_EXECUTABLE_REF=SCUM\Binaries\Win64\SCUMServer.exe"
|
||||
if "%SERVER_INSTALL_DIR%"=="" set "SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%"
|
||||
set "SERVER_INSTALL_DIR_WINDOWS=%SERVER_INSTALL_DIR:/=\%"
|
||||
set "SCUM_RCON_PORT_ARG="
|
||||
if not "%SERVER_STOP_RCON_PORT%"=="" set "SCUM_RCON_PORT_ARG=-Port %SERVER_STOP_RCON_PORT%"
|
||||
if "%SERVER_STOP_SHUTDOWN_COMMAND%"=="" set "SERVER_STOP_SHUTDOWN_COMMAND=Quit"
|
||||
if "%SERVER_STOP_ANNOUNCE%"=="" set "SERVER_STOP_ANNOUNCE=Server shutdown requested by the operator. Save your progress and disconnect."
|
||||
if "%SERVER_STOP_NOTICE_SECONDS%"=="" set "SERVER_STOP_NOTICE_SECONDS=15"
|
||||
if "%SERVER_STOP_TIMEOUT_SECONDS%"=="" set "SERVER_STOP_TIMEOUT_SECONDS=120"
|
||||
if "%SERVER_STOP_RCON_TIMEOUT_MS%"=="" set "SERVER_STOP_RCON_TIMEOUT_MS=10000"
|
||||
|
||||
set "SCUM_RCON_SCRIPT=%~dp0scum-rcon.ps1"
|
||||
set "SCUM_RCON_CONFIG=%SERVER_INSTALL_DIR_WINDOWS%\ue4ss\Mods\scum_simple_rcon\config.ini"
|
||||
if not exist "%SCUM_RCON_CONFIG%" set "SCUM_RCON_CONFIG=%SERVER_ROOT_WINDOWS%\ue4ss\Mods\scum_simple_rcon\config.ini"
|
||||
|
||||
call :resolve_scum_exe
|
||||
call :find_running_scum
|
||||
if "%SCUM_RUNNING_PID%"=="" (
|
||||
echo [scum-stop] SCUM server is not running; nothing to stop.
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo [scum-stop] Requesting graceful shutdown of SCUM server pid %SCUM_RUNNING_PID%.
|
||||
if not exist "%SCUM_RCON_SCRIPT%" (
|
||||
echo [scum-stop] Missing plugin RCON client: %SCUM_RCON_SCRIPT%
|
||||
exit /b 3
|
||||
)
|
||||
if not exist "%SCUM_RCON_CONFIG%" (
|
||||
echo [scum-stop] Missing SCUM RCON configuration: %SCUM_RCON_CONFIG%
|
||||
exit /b 4
|
||||
)
|
||||
|
||||
if not "%SERVER_STOP_NOTICE_SECONDS%"=="0" (
|
||||
call :send_rcon "Announce %SERVER_STOP_ANNOUNCE%"
|
||||
if errorlevel 1 echo [scum-stop] Player announcement was not delivered; continuing with the shutdown request.
|
||||
timeout /T %SERVER_STOP_NOTICE_SECONDS% /NOBREAK >nul
|
||||
)
|
||||
|
||||
call :send_rcon "%SERVER_STOP_SHUTDOWN_COMMAND%"
|
||||
if errorlevel 1 echo [scum-stop] Declared shutdown command was not acknowledged; waiting for the server to exit anyway.
|
||||
|
||||
call :wait_for_exit
|
||||
if not "%SCUM_EXITED%"=="1" (
|
||||
echo [scum-stop] SCUM server pid %SCUM_RUNNING_PID% did not exit within %SERVER_STOP_TIMEOUT_SECONDS% seconds of the declared shutdown request.
|
||||
exit /b 2
|
||||
)
|
||||
echo [scum-stop] SCUM server shut down gracefully.
|
||||
exit /b 0
|
||||
|
||||
:send_rcon
|
||||
if "%~1"=="" exit /b 1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCUM_RCON_SCRIPT%" -ConfigPath "%SCUM_RCON_CONFIG%" %SCUM_RCON_PORT_ARG% -TimeoutMs %SERVER_STOP_RCON_TIMEOUT_MS% -Command "%~1"
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:wait_for_exit
|
||||
set "SCUM_EXITED=0"
|
||||
set /A SCUM_WAIT_TICKS=%SERVER_STOP_TIMEOUT_SECONDS%*2
|
||||
if %SCUM_WAIT_TICKS% LSS 1 set "SCUM_WAIT_TICKS=1"
|
||||
for /L %%A in (1,1,%SCUM_WAIT_TICKS%) do (
|
||||
call :find_running_scum
|
||||
if "!SCUM_RUNNING_PID!"=="" (
|
||||
set "SCUM_EXITED=1"
|
||||
goto :wait_done
|
||||
)
|
||||
timeout /T 1 /NOBREAK >nul
|
||||
)
|
||||
:wait_done
|
||||
exit /b 0
|
||||
|
||||
:find_running_scum
|
||||
set "SCUM_RUNNING_PID="
|
||||
for /f "usebackq delims=" %%P in (`powershell -NoProfile -ExecutionPolicy Bypass -Command "$target=$env:SCUM_EXE; if (-not $target) { exit 0 }; $full=[IO.Path]::GetFullPath($target); Get-CimInstance Win32_Process -Filter \"name='SCUMServer.exe'\" | Where-Object { $_.ExecutablePath -and ([IO.Path]::GetFullPath($_.ExecutablePath) -ieq $full) } | Select-Object -First 1 -ExpandProperty ProcessId"`) do set "SCUM_RUNNING_PID=%%P"
|
||||
exit /b 0
|
||||
|
||||
:resolve_scum_exe
|
||||
set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%"
|
||||
set "SCUM_EXE_MARKER=%SERVER_ROOT_WINDOWS%\.scum-exe-path"
|
||||
if exist "%SCUM_EXE_MARKER%" set /p SCUM_EXE=<"%SCUM_EXE_MARKER%"
|
||||
if exist "%SCUM_EXE%" exit /b 0
|
||||
set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%"
|
||||
if exist "%SCUM_EXE%" exit /b 0
|
||||
set "SCUM_EXE=%SERVER_INSTALL_DIR_WINDOWS%\%SERVER_EXECUTABLE_REF:/=\%"
|
||||
if exist "%SCUM_EXE%" exit /b 0
|
||||
set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\steamcmd\steamapps\common\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%"
|
||||
if exist "%SCUM_EXE%" exit /b 0
|
||||
set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%"
|
||||
exit /b 0
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "game.scum",
|
||||
"name": "SCUM Server",
|
||||
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and plugin-owned RCON data flows.",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"kind": "game-plugin",
|
||||
"tags": [
|
||||
"scum",
|
||||
@@ -778,6 +778,14 @@
|
||||
"path": "bin/scum-start.cmd",
|
||||
"mode": 448
|
||||
},
|
||||
{
|
||||
"path": "bin/scum-stop.cmd",
|
||||
"mode": 448
|
||||
},
|
||||
{
|
||||
"path": "bin/scum-rcon.ps1",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "assets/map/scum-map-overview.jpg",
|
||||
"mode": 384
|
||||
@@ -1037,6 +1045,16 @@
|
||||
"windows",
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "scum-server-build",
|
||||
"kind": "steam.update",
|
||||
"targetKey": "steamcmd",
|
||||
"steamAppId": "3792580",
|
||||
"required": false,
|
||||
"platforms": [
|
||||
"windows"
|
||||
]
|
||||
}
|
||||
],
|
||||
"installPlans": [
|
||||
|
||||
Reference in New Issue
Block a user