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:
npc0-hue
2026-09-15 13:30:15 +08:00
parent 4ea27bda6a
commit 05f5a97ba9
39 changed files with 1197 additions and 56 deletions
@@ -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": [