Files
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

455 lines
21 KiB
Go

package validator
import (
"encoding/hex"
"fmt"
"net"
"net/url"
"regexp"
"strings"
"browser.local/platform/domain"
)
var (
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
steamAppIDPattern = regexp.MustCompile(`^[0-9]{1,10}$`)
)
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
profiles = domain.CopyGamePluginRuntimeProfiles(profiles)
var violations []string
lifecycleKeys := map[string]struct{}{}
transportKeys := map[string]struct{}{}
transportProfiles := map[string]domain.RuntimeTransportProfile{}
dataTargetKeys := map[string]struct{}{}
dataTargetWorkspaces := map[string]struct{}{}
dllExtensionKeys := map[string]struct{}{}
dllExtensionStates := map[string]string{}
discoveryKeys := map[string]struct{}{}
dependencyKeys := map[string]struct{}{}
installPlanKeys := map[string]struct{}{}
logSourceKeys := map[string]struct{}{}
for i, probe := range profiles.Discovery {
prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", probe.Key)...)
violations = append(violations, recordRuntimeProfileKey(discoveryKeys, prefix+".key", probe.Key)...)
violations = append(violations, validateProfileKey(prefix+".targetKey", probe.TargetKey)...)
if !oneOf(probe.Kind, "file.exists", "command.version", "service.status", "port.open", "steam.app", "docker.container") {
violations = append(violations, prefix+".kind is invalid")
}
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", probe.Platforms)...)
violations = append(violations, validateSafeRuntimeValue(prefix+".expected", probe.Expected)...)
}
for i, profile := range profiles.LifecycleProfiles {
prefix := fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", profile.Key)...)
violations = append(violations, recordRuntimeProfileKey(lifecycleKeys, prefix+".key", profile.Key)...)
if !oneOf(profile.Mode, "local-process", "hosted-ftp-rcon", "ftp-only") {
violations = append(violations, prefix+".mode is invalid")
}
if len(profile.Capabilities) == 0 {
violations = append(violations, prefix+".capabilities must not be empty")
}
for j, capability := range profile.Capabilities {
if !validPluginRunCapability(capability) {
violations = append(violations, fmt.Sprintf("%s.capabilities[%d] is not allowed", prefix, j))
}
}
violations = append(violations, duplicateViolations(prefix+".capabilities", profile.Capabilities)...)
violations = append(violations, validateLifecycleActionsOptional(profile.ActionRefs)...)
for j, key := range profile.TransportKeys {
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.transportKeys[%d]", prefix, j), key)...)
}
violations = append(violations, duplicateViolations(prefix+".transportKeys", profile.TransportKeys)...)
for j, key := range profile.DLLExtensionRefs {
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.dllExtensionRefs[%d]", prefix, j), key)...)
}
violations = append(violations, duplicateViolations(prefix+".dllExtensionRefs", profile.DLLExtensionRefs)...)
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", profile.Platforms)...)
}
for i, probe := range profiles.DependencyProbes {
prefix := fmt.Sprintf("runtimeProfiles.dependencyProbes[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", probe.Key)...)
violations = append(violations, recordRuntimeProfileKey(dependencyKeys, prefix+".key", probe.Key)...)
violations = append(violations, validateProfileKey(prefix+".targetKey", probe.TargetKey)...)
if !oneOf(probe.Kind, "command.version", "service.exists", "port.available", "steam.app", "steam.update", "java.version", "docker.available", "package.installed", "file.exists") {
violations = append(violations, prefix+".kind is invalid")
}
violations = append(violations, validateSafeRuntimeValue(prefix+".minimumVersion", probe.MinimumVersion)...)
if probe.Kind == "steam.update" {
if !steamAppIDPattern.MatchString(probe.SteamAppID) {
violations = append(violations, prefix+".steamAppId is invalid for a Steam build probe")
}
} else if probe.SteamAppID != "" {
violations = append(violations, prefix+".steamAppId is allowed only for steam.update probes")
}
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", probe.Platforms)...)
}
for i, plan := range profiles.InstallPlans {
prefix := fmt.Sprintf("runtimeProfiles.installPlans[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", plan.Key)...)
violations = append(violations, recordRuntimeProfileKey(installPlanKeys, prefix+".key", plan.Key)...)
violations = appendRequired(violations, prefix+".title", plan.Title)
violations = append(violations, validateSafeRuntimeValue(prefix+".title", plan.Title)...)
if len(plan.Steps) == 0 {
violations = append(violations, prefix+".steps must not be empty")
}
if len(plan.Steps) > 64 {
violations = append(violations, prefix+".steps must not exceed 64")
}
for j, step := range plan.Steps {
stepPrefix := fmt.Sprintf("%s.steps[%d]", prefix, j)
if !oneOf(step.Type, "package", "verified-download", "manual") {
violations = append(violations, stepPrefix+".type is invalid")
}
violations = append(violations, validateProfileKey(stepPrefix+".targetKey", step.TargetKey)...)
for field, value := range map[string]string{"packageManager": step.PackageManager, "packageName": step.PackageName, "version": step.Version} {
violations = append(violations, validateSafeRuntimeValue(stepPrefix+"."+field, value)...)
}
if step.DownloadRef != "" {
parsed, err := url.Parse(step.DownloadRef)
host := ""
if parsed != nil {
host = strings.ToLower(parsed.Hostname())
}
ip := net.ParseIP(host)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()) {
violations = append(violations, stepPrefix+".downloadRef must be a credential-free HTTPS URL")
}
}
if step.Checksum != "" {
encoded := strings.TrimPrefix(step.Checksum, "sha256:")
if !strings.HasPrefix(step.Checksum, "sha256:") || len(encoded) != 64 {
violations = append(violations, stepPrefix+".checksum is invalid")
} else if _, err := hex.DecodeString(encoded); err != nil {
violations = append(violations, stepPrefix+".checksum is invalid")
}
}
switch step.Type {
case "package":
if !oneOf(step.PackageManager, "winget", "choco", "scoop", "apt", "yum", "dnf", "pacman", "zypper", "brew") {
violations = append(violations, stepPrefix+".packageManager is unsupported for package step")
}
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:+@/-]{0,119}$`).MatchString(step.PackageName) {
violations = append(violations, stepPrefix+".packageName is invalid")
}
case "verified-download":
if step.DownloadRef == "" || step.Checksum == "" {
violations = append(violations, stepPrefix+" requires downloadRef and checksum")
}
case "manual":
if step.DownloadRef != "" || step.Checksum != "" || step.PackageManager != "" || step.PackageName != "" || step.Version != "" {
violations = append(violations, stepPrefix+" manual step cannot contain machine execution fields")
}
}
}
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", plan.Platforms)...)
}
for i, source := range profiles.LogSources {
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...)
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query") {
violations = append(violations, prefix+".kind is invalid")
}
if source.TargetKey != "" {
violations = append(violations, validateProfileKey(prefix+".targetKey", source.TargetKey)...)
}
violations = append(violations, validateProfileKey(prefix+".streamKey", source.StreamKey)...)
if source.CursorKind != "" && !oneOf(source.CursorKind, "sequence", "offset", "fingerprint", "ftp-listing", "sql-cursor") {
violations = append(violations, prefix+".cursorKind is invalid")
}
if source.RetentionDays < 0 || source.RetentionDays > 365 {
violations = append(violations, prefix+".retentionDays is invalid")
}
}
for i, transport := range profiles.TransportProfiles {
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
violations = append(violations, recordRuntimeProfileKey(transportKeys, prefix+".key", transport.Key)...)
if !oneOf(transport.Kind, "file", "ftp", "rsync", "mysql", "sqlite", "rcon", "program") {
violations = append(violations, prefix+".kind is invalid")
}
if transport.TargetKey != "" {
violations = append(violations, validateProfileKey(prefix+".targetKey", transport.TargetKey)...)
}
if len(transport.Capabilities) == 0 {
violations = append(violations, prefix+".capabilities must not be empty")
}
for j, capability := range transport.Capabilities {
if !validPluginRunCapability(capability) {
violations = append(violations, fmt.Sprintf("%s.capabilities[%d] is not allowed", prefix, j))
}
}
violations = append(violations, duplicateViolations(prefix+".capabilities", transport.Capabilities)...)
if transport.Key != "" {
transportProfiles[transport.Key] = transport
}
}
if len(profiles.DataTargets) > 16 {
violations = append(violations, "runtimeProfiles.dataTargets must not exceed 16")
}
for i, target := range profiles.DataTargets {
prefix := fmt.Sprintf("runtimeProfiles.dataTargets[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", target.Key)...)
violations = append(violations, recordRuntimeProfileKey(dataTargetKeys, prefix+".key", target.Key)...)
violations = append(violations, validateProfileKey(prefix+".transportKey", target.TransportKey)...)
violations = append(violations, validateProfileKey(prefix+".sourceRootKey", target.SourceRootKey)...)
violations = append(violations, validateSafeRelativeRuntimePath(prefix+".sourcePath", target.SourcePath)...)
violations = append(violations, validateProfileKey(prefix+".workspaceKey", target.WorkspaceKey)...)
if target.Kind != "sqlite.snapshot" || target.RefreshPolicy != "on-demand-snapshot" || !strings.HasPrefix(target.WorkspaceKey, "databases/") {
violations = append(violations, prefix+" must declare an on-demand sqlite snapshot workspace")
}
if target.MaxBytes < 1 || target.MaxBytes > 1024*1024*1024 {
violations = append(violations, prefix+".maxBytes is invalid")
}
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", target.Platforms)...)
if _, exists := dataTargetWorkspaces[target.WorkspaceKey]; exists {
violations = append(violations, prefix+".workspaceKey is duplicated")
}
dataTargetWorkspaces[target.WorkspaceKey] = struct{}{}
transport, exists := transportProfiles[target.TransportKey]
if !exists || transport.Kind != "sqlite" || transport.TargetKey != target.TransportKey || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
violations = append(violations, prefix+".transportKey must reference a declared SQLite query transport")
}
}
for i, extension := range profiles.DLLExtensions {
prefix := fmt.Sprintf("runtimeProfiles.dllExtensions[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", extension.Key)...)
violations = append(violations, recordRuntimeProfileKey(dllExtensionKeys, prefix+".key", extension.Key)...)
if extension.Key != "" {
dllExtensionStates[extension.Key] = extension.ReleaseState
}
violations = append(violations, validateRuntimeDLLExtensionProfile(prefix, extension)...)
}
for i, profile := range profiles.LifecycleProfiles {
for _, key := range profile.TransportKeys {
if _, ok := transportKeys[key]; !ok {
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].transportKeys references undeclared transport %q", i, key))
}
}
if len(profile.DLLExtensionRefs) > 0 {
if profile.Mode != "local-process" || !containsString(profile.Capabilities, domain.LifecycleCapabilityStart) || len(profile.Platforms) != 1 || profile.Platforms[0] != "windows" {
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d] DLL extensions require a windows local-process start profile", i))
}
for _, key := range profile.DLLExtensionRefs {
state, exists := dllExtensionStates[key]
if !exists {
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].dllExtensionRefs references undeclared DLL extension %q", i, key))
continue
}
if state != "ready" {
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].dllExtensionRefs references unpublished DLL extension %q", i, key))
}
}
}
}
return finish(violations)
}
func validateRuntimeDLLExtensionProfile(prefix string, extension domain.RuntimeDLLExtensionProfile) []string {
var violations []string
if extension.Kind != "ue4ss-dll" || extension.Activation != "server-start" {
violations = append(violations, prefix+".kind and activation must be ue4ss-dll/server-start")
}
if !validSemanticVersion(extension.Version) {
violations = append(violations, prefix+".version must be semantic")
}
violations = append(violations, validateSafeRuntimeValue(prefix+".displayName", extension.DisplayName)...)
violations = append(violations, validateProfileKey(prefix+".targetKey", extension.TargetKey)...)
if !runtimeDLLModKeyPattern.MatchString(extension.ModKey) {
violations = append(violations, prefix+".modKey is invalid")
}
if extension.DLLRef != "ue4ss/Mods/"+extension.ModKey+"/dlls/main.dll" {
violations = append(violations, prefix+".dllRef must be the declared UE4SS main.dll path")
}
if extension.UpdateOnStart != true {
violations = append(violations, prefix+".updateOnStart must be true")
}
if extension.RCONPort < 1024 || extension.RCONPort > 65535 {
violations = append(violations, prefix+".rconPort must be an unprivileged port")
}
if len(extension.SupportedTargets) != 1 || extension.SupportedTargets[0].OS != "windows" || extension.SupportedTargets[0].Arch != "amd64" {
violations = append(violations, prefix+".supportedTargets must contain only windows/amd64")
}
if extension.ReleaseState != "ready" && extension.ReleaseState != "unpublished" {
violations = append(violations, prefix+".releaseState is invalid")
}
if extension.ReleaseURL != "" {
violations = append(violations, validateRuntimeDLLReleaseURL(prefix+".releaseUrl", extension.ReleaseURL)...)
}
if extension.ReleaseState == "ready" {
if extension.ReleaseURL == "" {
violations = append(violations, prefix+".releaseUrl is required for a ready release")
}
if !validSHA256Checksum(extension.Checksum) || !validSHA256Checksum(extension.TargetExecutableChecksum) {
violations = append(violations, prefix+".checksum and targetExecutableChecksum must be SHA-256")
}
if extension.SizeBytes < 1 || extension.SizeBytes > 128*1024*1024 {
violations = append(violations, prefix+".sizeBytes is out of bounds")
}
if !runtimeDLLABIPattern.MatchString(extension.UE4SSABI) {
violations = append(violations, prefix+".ue4ssAbi is invalid")
}
}
return violations
}
func validateRuntimeDLLReleaseURL(field string, value string) []string {
parsed, err := url.Parse(value)
host := ""
if parsed != nil {
host = strings.ToLower(parsed.Hostname())
}
ip := net.ParseIP(host)
if err != nil || parsed == nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || parsed.Port() != "" && parsed.Port() != "443" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") || ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()) || !strings.HasSuffix(strings.ToLower(parsed.Path), ".dll") {
return []string{field + " must be a public credential-free HTTPS DLL URL"}
}
return nil
}
func validateRuntimeDLLExtensionPlan(prefix string, plan domain.RuntimeDLLExtensionPlan) []string {
return validateRuntimeDLLExtensionProfile(prefix, domain.RuntimeDLLExtensionProfile{
Key: plan.Key,
Kind: "ue4ss-dll",
Activation: "server-start",
Version: plan.Version,
ReleaseState: "ready",
ReleaseURL: plan.ReleaseURL,
Checksum: plan.Checksum,
SizeBytes: plan.SizeBytes,
TargetKey: plan.TargetKey,
ModKey: plan.ModKey,
DLLRef: plan.DLLRef,
TargetExecutableChecksum: plan.TargetExecutableChecksum,
UE4SSABI: plan.UE4SSABI,
SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}},
UpdateOnStart: true,
RCONPort: plan.RCONPort,
})
}
func validateProfileKey(field, value string) []string {
if strings.TrimSpace(value) == "" {
return []string{field + " is required"}
}
if !validDistributionLogicalKey(value) {
return []string{field + " is invalid"}
}
return nil
}
func validateRuntimePlatforms(field string, platforms []string) []string {
var violations []string
for i, platform := range platforms {
if !validPluginSupportedOS(platform) {
violations = append(violations, fmt.Sprintf("%s[%d] is invalid", field, i))
}
}
return append(violations, duplicateViolations(field, platforms)...)
}
func validateSafeRuntimeValue(field, value string) []string {
if value == "" {
return nil
}
lowered := strings.ToLower(value)
if strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) || strings.Contains(value, "://") || containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(value, "..") || strings.ContainsAny(value, "\r\n") || strings.Contains(lowered, "bash -c") || strings.Contains(lowered, "powershell -") || strings.Contains(lowered, "cmd.exe") || strings.Contains(lowered, "curl |") {
return []string{field + " contains unsafe runtime content"}
}
return nil
}
func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []string {
if key == "" {
return nil
}
if _, exists := seen[key]; exists {
return []string{field + " is duplicated"}
}
seen[key] = struct{}{}
return nil
}
func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRuntimeProfiles, declared []string) []string {
declaredSet := map[string]struct{}{}
for _, capability := range declared {
declaredSet[capability] = struct{}{}
}
var violations []string
check := func(field string, capabilities []string) {
for i, capability := range capabilities {
if _, ok := declaredSet[capability]; !ok {
violations = append(violations, fmt.Sprintf("%s[%d] must also be declared in manifest capabilities", field, i))
}
}
}
for i, profile := range profiles.LifecycleProfiles {
check(fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].capabilities", i), profile.Capabilities)
}
for i, transport := range profiles.TransportProfiles {
check(fmt.Sprintf("runtimeProfiles.transportProfiles[%d].capabilities", i), transport.Capabilities)
}
return violations
}
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
var violations []string
for field, value := range map[string]string{"install": actions.Install, "start": actions.Start, "stop": actions.Stop, "restart": actions.Restart, "status": actions.Status} {
if value != "" && !safeRelativeJSONRef(value) {
violations = append(violations, "runtime actionRefs."+field+" must be a safe relative JSON reference")
}
}
return violations
}
func oneOf(value string, allowed ...string) bool {
for _, candidate := range allowed {
if value == candidate {
return true
}
}
return false
}
func validateSafeRelativeRuntimePath(field, value string) []string {
if strings.TrimSpace(value) == "" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) || strings.Contains(value, "..") || strings.Contains(value, "://") || strings.ContainsAny(value, "\r\n|;&`$<>") || len(value) >= 2 && value[1] == ':' || !regexp.MustCompile(`^[A-Za-z0-9_./-]{1,160}$`).MatchString(value) {
return []string{field + " must be a safe relative path"}
}
return nil
}
func validSemanticVersion(value string) bool {
_, ok := semanticVersionTuple(value)
return ok
}
func semanticVersionTuple(value string) ([3]int, bool) {
match := regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$`).FindStringSubmatch(value)
if match == nil {
return [3]int{}, false
}
var result [3]int
for i := 0; i < 3; i++ {
if _, err := fmt.Sscanf(match[i+1], "%d", &result[i]); err != nil {
return [3]int{}, false
}
}
return result, true
}
func compareSemanticVersion(left, right [3]int) int {
for i := 0; i < 3; i++ {
if left[i] < right[i] {
return -1
}
if left[i] > right[i] {
return 1
}
}
return 0
}