Files
browser/plugins/examples/scum-server-plugin/bin/scum-rcon.ps1
T
npc0-hue 05f5a97ba9 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 版本更新”面板;点“检查更新”查询公开分支版本,
  只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。
2026-09-15 13:30:15 +08:00

112 lines
4.3 KiB
PowerShell

# 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()
}