feat: add UE4SS DLL runtime extension

This commit is contained in:
npc0-hue
2026-07-23 09:49:14 +08:00
parent 8e34c8762a
commit 1caf40565c
30 changed files with 1179 additions and 70 deletions
+28
View File
@@ -1480,6 +1480,34 @@ func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) {
assertErrorResponse(t, missingServer, http.StatusNotFound, errorCodeNotFound)
}
func TestGamePluginManifestAPISafelyProjectsDLLReleaseDeclaration(t *testing.T) {
router := newTestRouter()
registration := validGamePluginManifestRegistrationRequest()
registration.Manifest.RuntimeProfiles = dto.GamePluginRuntimeProfilesBody{DLLExtensions: []dto.RuntimeDLLExtensionProfileBody{{
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024,
TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
}}}
recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", registration)
assertStatus(t, recorder, http.StatusCreated)
body := recorder.Body.String()
response := decodeBody[dto.GamePluginResponse](t, recorder)
if len(response.RuntimeProfiles.DLLExtensions) != 1 {
t.Fatalf("expected DLL declaration projection, got %+v", response.RuntimeProfiles)
}
projected := response.RuntimeProfiles.DLLExtensions[0]
if projected.ReleaseHost != "cdn.npc0.com" || projected.ReleaseFilename != "scum_simple_rcon_ue4s.dll" {
t.Fatalf("expected safe DLL release projection, got %+v", projected)
}
for _, unsafe := range []string{"https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", "ue4ss/Mods/", "targetKey", "modKey", "rconPort"} {
if strings.Contains(body, unsafe) {
t.Fatalf("browser projection exposed DLL deployment internals %q: %s", unsafe, body)
}
}
}
func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) {
router := newTestRouter()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest())
+1
View File
@@ -277,6 +277,7 @@ type RunJobReconcileResult struct {
func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment {
assignment.ExecutionInput.Inputs = CopyStringMap(assignment.ExecutionInput.Inputs)
assignment.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), assignment.ExecutionInput.DLLExtensions...)
return assignment
}
+43
View File
@@ -368,6 +368,7 @@ type RuntimeLifecycleProfile struct {
ActionRefs PluginLifecycleActions
TransportKeys []string
ClientManagerRef string
DLLExtensionRefs []string
Platforms []string
}
@@ -455,6 +456,40 @@ type RuntimeClientManagerProfile struct {
UpdatePolicy RuntimeClientManagerUpdatePolicy
}
type RuntimeDLLExtensionProfile struct {
Key string
DisplayName string
Kind string
Activation string
Version string
ReleaseState string
ReleaseURL string
Checksum string
SizeBytes int64
TargetKey string
ModKey string
DLLRef string
SCUMExecutableChecksum string
UE4SSABI string
SupportedTargets []RuntimeTarget
UpdateOnStart bool
RCONPort int
}
type RuntimeDLLExtensionPlan struct {
Key string
Version string
ReleaseURL string
Checksum string
SizeBytes int64
TargetKey string
ModKey string
DLLRef string
SCUMExecutableChecksum string
UE4SSABI string
RCONPort int
}
type RuntimeConfigTemplate struct {
Key string
TemplateRef string
@@ -470,6 +505,7 @@ type GamePluginRuntimeProfiles struct {
LogEvents []RuntimeLogEvent
TransportProfiles []RuntimeTransportProfile
ClientManagers []RuntimeClientManagerProfile
DLLExtensions []RuntimeDLLExtensionProfile
}
type GamePluginManifest struct {
@@ -812,6 +848,7 @@ type JobExecutionInput struct {
LifecycleOperation string
TargetVersion string
Inputs map[string]string
DLLExtensions []RuntimeDLLExtensionPlan
}
type JobExecutionResult struct {
@@ -1397,6 +1434,7 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
for i := range profiles.LifecycleProfiles {
profiles.LifecycleProfiles[i].Capabilities = CopyStringSlice(profiles.LifecycleProfiles[i].Capabilities)
profiles.LifecycleProfiles[i].TransportKeys = CopyStringSlice(profiles.LifecycleProfiles[i].TransportKeys)
profiles.LifecycleProfiles[i].DLLExtensionRefs = CopyStringSlice(profiles.LifecycleProfiles[i].DLLExtensionRefs)
profiles.LifecycleProfiles[i].Platforms = CopyStringSlice(profiles.LifecycleProfiles[i].Platforms)
}
profiles.DependencyProbes = append([]RuntimeDependencyProbe(nil), profiles.DependencyProbes...)
@@ -1424,6 +1462,10 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
profiles.ClientManagers[i].Lifecycle.Actions = CopyStringSlice(profiles.ClientManagers[i].Lifecycle.Actions)
profiles.ClientManagers[i].Health.RequiredCapabilities = CopyStringSlice(profiles.ClientManagers[i].Health.RequiredCapabilities)
}
profiles.DLLExtensions = append([]RuntimeDLLExtensionProfile(nil), profiles.DLLExtensions...)
for i := range profiles.DLLExtensions {
profiles.DLLExtensions[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.DLLExtensions[i].SupportedTargets...)
}
return profiles
}
@@ -1527,6 +1569,7 @@ func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint {
func CopyJob(job Job) Job {
job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs)
job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)
return job
}
+14 -13
View File
@@ -92,18 +92,19 @@ type RunJobResultRequest struct {
}
type RunJobExecutionInputBody struct {
WorkspaceScope string `json:"workspaceScope,omitempty"`
Content string `json:"content,omitempty"`
ExpectedVersion int `json:"expectedVersion,omitempty"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
MaxReadBytes int `json:"maxReadBytes,omitempty"`
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
PluginID string `json:"pluginId,omitempty"`
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
TargetVersion string `json:"targetVersion,omitempty"`
Inputs map[string]string `json:"inputs,omitempty"`
WorkspaceScope string `json:"workspaceScope,omitempty"`
Content string `json:"content,omitempty"`
ExpectedVersion int `json:"expectedVersion,omitempty"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
MaxReadBytes int `json:"maxReadBytes,omitempty"`
RemoteAdapterKey string `json:"remoteAdapterKey,omitempty"`
RemoteAdapterKind string `json:"remoteAdapterKind,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
PluginID string `json:"pluginId,omitempty"`
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
TargetVersion string `json:"targetVersion,omitempty"`
Inputs map[string]string `json:"inputs,omitempty"`
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
}
type RunJobExecutionResultBody struct {
@@ -529,7 +530,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
State: assignment.State,
Progress: progressReportFromDomain(assignment.Progress),
ResultRef: assignment.ResultRef,
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs)},
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions)},
LeaseToken: assignment.LeaseToken,
Attempt: assignment.Attempt,
MaxAttempts: assignment.MaxAttempts,
+47 -47
View File
@@ -330,29 +330,29 @@ type GamePluginCreateRequest struct {
}
type GamePluginResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
ServerType string `json:"serverType"`
ServerDisplayName string `json:"serverDisplayName,omitempty"`
SupportedOS []string `json:"supportedOs,omitempty"`
ManifestRef string `json:"manifestRef"`
CreateFormSchemaRef string `json:"createFormSchemaRef"`
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
DeclaredPermissions []string `json:"declaredPermissions"`
Permissions PluginPermissionsResponse `json:"permissions"`
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
BridgeActions []string `json:"bridgeActions"`
Pages []GamePluginPageBody `json:"pages"`
Tags []string `json:"tags"`
AIPurposes []string `json:"aiPurposes"`
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"`
Status domain.GamePluginStatus `json:"status"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
ServerType string `json:"serverType"`
ServerDisplayName string `json:"serverDisplayName,omitempty"`
SupportedOS []string `json:"supportedOs,omitempty"`
ManifestRef string `json:"manifestRef"`
CreateFormSchemaRef string `json:"createFormSchemaRef"`
RequiredRunCapabilities []string `json:"requiredRunCapabilities"`
DeclaredPermissions []string `json:"declaredPermissions"`
Permissions PluginPermissionsResponse `json:"permissions"`
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
BridgeActions []string `json:"bridgeActions"`
Pages []GamePluginPageBody `json:"pages"`
Tags []string `json:"tags"`
AIPurposes []string `json:"aiPurposes"`
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"`
Status domain.GamePluginStatus `json:"status"`
}
type GamePluginListResponse struct {
@@ -361,30 +361,30 @@ type GamePluginListResponse struct {
}
type MarketplacePluginResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
ServerType string `json:"serverType"`
ServerDisplayName string `json:"serverDisplayName,omitempty"`
SupportedOS []string `json:"supportedOs,omitempty"`
ManifestRef string `json:"manifestRef"`
CreateFormSchemaRef string `json:"createFormSchemaRef"`
Capabilities []string `json:"capabilities"`
DeclaredPermissions []string `json:"declaredPermissions"`
Permissions PluginPermissionsResponse `json:"permissions"`
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
BridgeActions []string `json:"bridgeActions"`
Pages []GamePluginPageBody `json:"pages"`
Tags []string `json:"tags"`
AIPurposes []string `json:"aiPurposes"`
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"`
Status domain.GamePluginStatus `json:"status"`
Source string `json:"source"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
ServerType string `json:"serverType"`
ServerDisplayName string `json:"serverDisplayName,omitempty"`
SupportedOS []string `json:"supportedOs,omitempty"`
ManifestRef string `json:"manifestRef"`
CreateFormSchemaRef string `json:"createFormSchemaRef"`
Capabilities []string `json:"capabilities"`
DeclaredPermissions []string `json:"declaredPermissions"`
Permissions PluginPermissionsResponse `json:"permissions"`
LifecycleActions PluginLifecycleActionsBody `json:"lifecycleActions"`
BridgeActions []string `json:"bridgeActions"`
Pages []GamePluginPageBody `json:"pages"`
Tags []string `json:"tags"`
AIPurposes []string `json:"aiPurposes"`
ProductionLifecycle GamePluginProductionLifecycleBody `json:"productionLifecycle"`
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"`
Status domain.GamePluginStatus `json:"status"`
Source string `json:"source"`
}
type MarketplacePluginListResponse struct {
+121 -5
View File
@@ -1,6 +1,11 @@
package dto
import "browser.local/platform/domain"
import (
"net/url"
"path"
"browser.local/platform/domain"
)
type RuntimeTargetBody struct {
OS string `json:"os"`
@@ -23,6 +28,7 @@ type RuntimeLifecycleProfileBody struct {
ActionRefs PluginLifecycleActionsBody `json:"actionRefs,omitempty"`
TransportKeys []string `json:"transportKeys,omitempty"`
ClientManagerRef string `json:"clientManagerRef,omitempty"`
DLLExtensionRefs []string `json:"dllExtensionRefs,omitempty"`
Platforms []string `json:"platforms,omitempty"`
}
@@ -150,6 +156,62 @@ type RuntimeClientManagerProfileBody struct {
UpdatePolicy RuntimeClientManagerUpdatePolicyBody `json:"updatePolicy,omitempty"`
}
type RuntimeDLLExtensionProfileBody struct {
Key string `json:"key"`
DisplayName string `json:"displayName"`
Kind string `json:"kind"`
Activation string `json:"activation"`
Version string `json:"version"`
ReleaseState string `json:"releaseState"`
ReleaseURL string `json:"releaseUrl,omitempty"`
ReleaseHost string `json:"releaseHost,omitempty"`
ReleaseFilename string `json:"releaseFilename,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
TargetKey string `json:"targetKey"`
ModKey string `json:"modKey"`
DLLRef string `json:"dllRef"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"`
UE4SSABI string `json:"ue4ssAbi,omitempty"`
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
UpdateOnStart bool `json:"updateOnStart"`
RCONPort int `json:"rconPort"`
}
// RuntimeDLLExtensionProfileResponseBody is the browser-safe projection of a
// declared DLL extension. The immutable deployment path, RCON port, and full
// release URL remain internal to the manifest/start-job contracts.
type RuntimeDLLExtensionProfileResponseBody struct {
Key string `json:"key"`
DisplayName string `json:"displayName"`
Kind string `json:"kind"`
Activation string `json:"activation"`
Version string `json:"version"`
ReleaseState string `json:"releaseState"`
ReleaseHost string `json:"releaseHost,omitempty"`
ReleaseFilename string `json:"releaseFilename,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum,omitempty"`
UE4SSABI string `json:"ue4ssAbi,omitempty"`
SupportedTargets []RuntimeTargetBody `json:"supportedTargets"`
UpdateOnStart bool `json:"updateOnStart"`
}
type RuntimeDLLExtensionPlanBody struct {
Key string `json:"key"`
Version string `json:"version"`
ReleaseURL string `json:"releaseUrl"`
Checksum string `json:"checksum"`
SizeBytes int64 `json:"sizeBytes"`
TargetKey string `json:"targetKey"`
ModKey string `json:"modKey"`
DLLRef string `json:"dllRef"`
SCUMExecutableChecksum string `json:"scumExecutableChecksum"`
UE4SSABI string `json:"ue4ssAbi"`
RCONPort int `json:"rconPort"`
}
type GamePluginRuntimeProfilesBody struct {
Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"`
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
@@ -159,6 +221,22 @@ type GamePluginRuntimeProfilesBody struct {
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
DLLExtensions []RuntimeDLLExtensionProfileBody `json:"dllExtensions,omitempty"`
}
// GamePluginRuntimeProfilesResponseBody is intentionally distinct from the
// manifest input shape so browser responses cannot accidentally include
// machine-local DLL placement or RCON activation details.
type GamePluginRuntimeProfilesResponseBody struct {
Discovery []RuntimeDiscoveryProbeBody `json:"discovery,omitempty"`
LifecycleProfiles []RuntimeLifecycleProfileBody `json:"lifecycleProfiles,omitempty"`
DependencyProbes []RuntimeDependencyProbeBody `json:"dependencyProbes,omitempty"`
InstallPlans []RuntimeInstallPlanBody `json:"installPlans,omitempty"`
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
LogEvents []RuntimeLogEventBody `json:"logEvents,omitempty"`
TransportProfiles []RuntimeTransportProfileBody `json:"transportProfiles,omitempty"`
ClientManagers []RuntimeClientManagerProfileBody `json:"clientManagers,omitempty"`
DLLExtensions []RuntimeDLLExtensionProfileResponseBody `json:"dllExtensions,omitempty"`
}
func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimeProfiles {
@@ -167,7 +245,7 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
profiles.Discovery = append(profiles.Discovery, domain.RuntimeDiscoveryProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, Expected: item.Expected, Platforms: domain.CopyStringSlice(item.Platforms)})
}
for _, item := range body.LifecycleProfiles {
profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), ClientManagerRef: item.ClientManagerRef, Platforms: domain.CopyStringSlice(item.Platforms)})
profiles.LifecycleProfiles = append(profiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: item.Key, Mode: item.Mode, Capabilities: domain.CopyStringSlice(item.Capabilities), ActionRefs: item.ActionRefs.ToDomain(), TransportKeys: domain.CopyStringSlice(item.TransportKeys), ClientManagerRef: item.ClientManagerRef, DLLExtensionRefs: domain.CopyStringSlice(item.DLLExtensionRefs), Platforms: domain.CopyStringSlice(item.Platforms)})
}
for _, item := range body.DependencyProbes {
profiles.DependencyProbes = append(profiles.DependencyProbes, domain.RuntimeDependencyProbe{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: domain.CopyStringSlice(item.Platforms)})
@@ -207,17 +285,24 @@ func (body GamePluginRuntimeProfilesBody) ToDomain() domain.GamePluginRuntimePro
}
profiles.ClientManagers = append(profiles.ClientManagers, manager)
}
for _, item := range body.DLLExtensions {
extension := domain.RuntimeDLLExtensionProfile{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, SCUMExecutableChecksum: item.SCUMExecutableChecksum, UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart, RCONPort: item.RCONPort}
for _, target := range item.SupportedTargets {
extension.SupportedTargets = append(extension.SupportedTargets, domain.RuntimeTarget{OS: target.OS, Arch: target.Arch})
}
profiles.DLLExtensions = append(profiles.DLLExtensions, extension)
}
return profiles
}
func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePluginRuntimeProfilesBody {
func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePluginRuntimeProfilesResponseBody {
profiles = domain.CopyGamePluginRuntimeProfiles(profiles)
body := GamePluginRuntimeProfilesBody{}
body := GamePluginRuntimeProfilesResponseBody{}
for _, item := range profiles.Discovery {
body.Discovery = append(body.Discovery, RuntimeDiscoveryProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, Expected: item.Expected, Platforms: item.Platforms})
}
for _, item := range profiles.LifecycleProfiles {
body.LifecycleProfiles = append(body.LifecycleProfiles, RuntimeLifecycleProfileBody{Key: item.Key, Mode: item.Mode, Capabilities: item.Capabilities, ActionRefs: lifecycleActionsFromDomain(item.ActionRefs), TransportKeys: item.TransportKeys, ClientManagerRef: item.ClientManagerRef, Platforms: item.Platforms})
body.LifecycleProfiles = append(body.LifecycleProfiles, RuntimeLifecycleProfileBody{Key: item.Key, Mode: item.Mode, Capabilities: item.Capabilities, ActionRefs: lifecycleActionsFromDomain(item.ActionRefs), TransportKeys: item.TransportKeys, ClientManagerRef: item.ClientManagerRef, DLLExtensionRefs: item.DLLExtensionRefs, Platforms: item.Platforms})
}
for _, item := range profiles.DependencyProbes {
body.DependencyProbes = append(body.DependencyProbes, RuntimeDependencyProbeBody{Key: item.Key, Kind: item.Kind, TargetKey: item.TargetKey, Required: item.Required, MinimumVersion: item.MinimumVersion, Platforms: item.Platforms})
@@ -257,5 +342,36 @@ func runtimeProfilesFromDomain(profiles domain.GamePluginRuntimeProfiles) GamePl
}
body.ClientManagers = append(body.ClientManagers, manager)
}
for _, item := range profiles.DLLExtensions {
host, filename := safeDLLReleaseLocation(item.ReleaseURL)
extension := RuntimeDLLExtensionProfileResponseBody{Key: item.Key, DisplayName: item.DisplayName, Kind: item.Kind, Activation: item.Activation, Version: item.Version, ReleaseState: item.ReleaseState, ReleaseHost: host, ReleaseFilename: filename, Checksum: safeDLLChecksumPrefix(item.Checksum), SizeBytes: item.SizeBytes, SCUMExecutableChecksum: safeDLLChecksumPrefix(item.SCUMExecutableChecksum), UE4SSABI: item.UE4SSABI, UpdateOnStart: item.UpdateOnStart}
for _, target := range item.SupportedTargets {
extension.SupportedTargets = append(extension.SupportedTargets, RuntimeTargetBody{OS: target.OS, Arch: target.Arch})
}
body.DLLExtensions = append(body.DLLExtensions, extension)
}
return body
}
func dllExtensionPlansFromDomain(plans []domain.RuntimeDLLExtensionPlan) []RuntimeDLLExtensionPlanBody {
items := make([]RuntimeDLLExtensionPlanBody, 0, len(plans))
for _, item := range plans {
items = append(items, RuntimeDLLExtensionPlanBody{Key: item.Key, Version: item.Version, ReleaseURL: item.ReleaseURL, Checksum: item.Checksum, SizeBytes: item.SizeBytes, TargetKey: item.TargetKey, ModKey: item.ModKey, DLLRef: item.DLLRef, SCUMExecutableChecksum: item.SCUMExecutableChecksum, UE4SSABI: item.UE4SSABI, RCONPort: item.RCONPort})
}
return items
}
func safeDLLReleaseLocation(value string) (string, string) {
parsed, err := url.Parse(value)
if err != nil || parsed == nil {
return "", ""
}
return parsed.Hostname(), path.Base(parsed.Path)
}
func safeDLLChecksumPrefix(value string) string {
if len(value) <= len("sha256:")+12 {
return value
}
return value[:len("sha256:")+12] + "…"
}
+4 -2
View File
@@ -293,6 +293,8 @@ type JobExecutionInput struct {
LifecycleOperation string `json:"lifecycleOperation,omitempty" db:"lifecycle_operation"`
TargetVersion string `json:"targetVersion,omitempty" db:"target_version"`
Inputs map[string]string `json:"inputs,omitempty" db:"inputs"`
// DLLExtensions is the frozen, ready-only DLL plan delivered to a scoped start job.
DLLExtensions []domain.RuntimeDLLExtensionPlan `json:"dllExtensions,omitempty" db:"dll_extensions"`
}
type JobExecutionResult struct {
@@ -882,11 +884,11 @@ func (job Job) ToDomain() domain.Job {
}
func executionInputFromDomain(input domain.JobExecutionInput) JobExecutionInput {
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs)}
return JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...)}
}
func (input JobExecutionInput) ToDomain() domain.JobExecutionInput {
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs)}
return domain.JobExecutionInput{WorkspaceScope: input.WorkspaceScope, Content: input.Content, ExpectedVersion: input.ExpectedVersion, ExpectedChecksum: input.ExpectedChecksum, MaxReadBytes: input.MaxReadBytes, RemoteAdapterKey: input.RemoteAdapterKey, RemoteAdapterKind: input.RemoteAdapterKind, TimeoutSeconds: input.TimeoutSeconds, PluginID: input.PluginID, LifecycleOperation: input.LifecycleOperation, TargetVersion: input.TargetVersion, Inputs: domain.CopyStringMap(input.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...)}
}
func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult {
+1 -1
View File
@@ -600,7 +600,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
State: job.State,
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
ResultRef: job.ResultRef,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs)},
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)},
LeaseToken: leaseToken,
Attempt: job.Attempt,
MaxAttempts: job.RetryPolicy.MaxAttempts,
+58 -1
View File
@@ -161,6 +161,19 @@ func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLif
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
return domain.ServerLifecycleResult{}, err
}
if action == domain.ServerLifecycleActionStart {
binding, bindingErr := svc.runtimeBindingForServer(instance.ID)
if bindingErr != nil && !errors.Is(bindingErr, repo.ErrNotFound) {
return domain.ServerLifecycleResult{}, bindingErr
}
if bindingErr == nil {
if profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey); exists && len(profile.DLLExtensionRefs) > 0 {
if _, extensionErr := lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint); extensionErr != nil {
return domain.ServerLifecycleResult{}, extensionErr
}
}
}
}
if err := svc.requireCompleteRuntimeBindings(instance.OwnerUserID, instance.ID, "server.lifecycle."+string(action)+".denied"); err != nil {
return domain.ServerLifecycleResult{}, err
}
@@ -203,7 +216,8 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
return domain.Job{}, err
}
actionRef := binding.ProfileKey
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey); ok {
profile, hasProfile := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey)
if hasProfile {
if ref := runtimeProfileActionRef(profile.ActionRefs, action); ref != "" {
actionRef = ref
}
@@ -213,6 +227,17 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
if strings.TrimSpace(actionRef) == "" {
return domain.Job{}, validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))
}
var dllExtensions []domain.RuntimeDLLExtensionPlan
if action == domain.ServerLifecycleActionStart && hasProfile {
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.Job{}, err
}
dllExtensions, err = lifecycleDLLExtensionPlans(plugin.RuntimeProfiles, profile, endpoint)
if err != nil {
return domain.Job{}, err
}
}
job, err := svc.CreateJob(domain.Job{
ID: lifecycleJobID(instance.ID, action, idempotencyKey),
ServerInstanceID: instance.ID,
@@ -224,6 +249,7 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
WorkspaceScope: binding.ProfileKey,
PluginID: plugin.ID,
LifecycleOperation: lifecycleExecutionOperation(action),
DLLExtensions: dllExtensions,
},
})
if err != nil {
@@ -274,6 +300,37 @@ func runtimeLifecycleProfileForKey(profiles domain.GamePluginRuntimeProfiles, ke
return domain.RuntimeLifecycleProfile{}, false
}
func lifecycleDLLExtensionPlans(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) ([]domain.RuntimeDLLExtensionPlan, error) {
if len(profile.DLLExtensionRefs) == 0 {
return nil, nil
}
byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(profiles.DLLExtensions))
for _, extension := range profiles.DLLExtensions {
byKey[extension.Key] = extension
}
plans := make([]domain.RuntimeDLLExtensionPlan, 0, len(profile.DLLExtensionRefs))
for _, key := range profile.DLLExtensionRefs {
extension, exists := byKey[key]
if !exists || extension.ReleaseState != "ready" {
return nil, validationError("extension_release_unavailable: selected DLL release is not ready")
}
if !runtimeDLLExtensionSupportsTarget(extension, endpoint.Platform, endpoint.Architecture) {
return nil, validationError("unsupported_extension_platform: UE4SS DLL requires windows/amd64")
}
plans = append(plans, domain.RuntimeDLLExtensionPlan{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort})
}
return plans, nil
}
func runtimeDLLExtensionSupportsTarget(extension domain.RuntimeDLLExtensionProfile, platform string, architecture string) bool {
for _, target := range extension.SupportedTargets {
if strings.EqualFold(target.OS, platform) && strings.EqualFold(target.Arch, architecture) {
return true
}
}
return false
}
func (svc *CoreService) validateLifecycleIdempotency(runEndpointID string, idempotencyKey string, serverInstanceID string, capability string) error {
existing, err := svc.store.Jobs().GetByIdempotency(runEndpointID, idempotencyKey)
if errors.Is(err, repo.ErrNotFound) {
+87
View File
@@ -85,6 +85,65 @@ func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
}
}
func TestCoreServiceFreezesReadyDLLExtensionIntoWindowsStartJob(t *testing.T) {
svc, sessionToken := newLifecycleRunService(t)
setLifecycleEndpointTarget(t, svc, "windows", "amd64")
plugin := createLifecyclePlugin(t, svc)
attachReadyLifecycleDLLExtension(t, svc, &plugin)
created, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ID: "server-dll", PluginID: plugin.ID, RunEndpointID: "run-local", Name: "SCUM DLL", IdempotencyKey: "idem-dll-create", ProfileKey: "local"})
if err != nil {
t.Fatalf("create DLL lifecycle server: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, created.Instance.ID, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
ready, err := svc.GetServerInstance(created.Instance.ID)
if err != nil {
t.Fatalf("get ready DLL server: %v", err)
}
started, err := svc.StartServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: ready.ID, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-dll-start"})
if err != nil {
t.Fatalf("start DLL lifecycle server: %v", err)
}
if len(started.Job.ExecutionInput.DLLExtensions) != 1 {
t.Fatalf("expected one frozen DLL plan, got %+v", started.Job.ExecutionInput)
}
if got := started.Job.ExecutionInput.DLLExtensions[0]; got.Key != "scum-simple-rcon" || got.Checksum != "sha256:"+strings.Repeat("a", 64) || got.ReleaseURL != "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll" {
t.Fatalf("unexpected frozen DLL plan: %+v", got)
}
plugin.RuntimeProfiles.DLLExtensions[0].Checksum = "sha256:" + strings.Repeat("c", 64)
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin after start fence: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capabilities: []string{domain.LifecycleCapabilityStart}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || len(claim.Job.ExecutionInput.DLLExtensions) != 1 {
t.Fatalf("claim frozen start job: claim=%+v err=%v", claim, err)
}
if got := claim.Job.ExecutionInput.DLLExtensions[0].Checksum; got != "sha256:"+strings.Repeat("a", 64) {
t.Fatalf("queued start job was not fenced to the original DLL pin: %s", got)
}
}
func TestCoreServiceRejectsLinuxDLLExtensionStartBeforeDispatch(t *testing.T) {
svc, sessionToken := newLifecycleRunService(t)
setLifecycleEndpointTarget(t, svc, "windows", "amd64")
plugin := createLifecyclePlugin(t, svc)
attachReadyLifecycleDLLExtension(t, svc, &plugin)
created, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ID: "server-dll-linux", PluginID: plugin.ID, RunEndpointID: "run-local", Name: "SCUM DLL Linux", IdempotencyKey: "idem-dll-linux-create", ProfileKey: "local"})
if err != nil {
t.Fatalf("create DLL lifecycle server: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, created.Instance.ID, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
ready, _ := svc.GetServerInstance(created.Instance.ID)
setLifecycleEndpointTarget(t, svc, "linux", "amd64")
_, err = svc.StartServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: ready.ID, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "idem-dll-linux-start"})
if err == nil || !strings.Contains(err.Error(), "unsupported_extension_platform") {
t.Fatalf("expected explicit Linux DLL rejection, got %v", err)
}
}
func TestCoreServiceServerLifecycleRejectsInvalidCommands(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
@@ -272,6 +331,34 @@ func createLifecyclePlugin(t *testing.T, svc *CoreService) domain.GamePlugin {
return plugin
}
func attachReadyLifecycleDLLExtension(t *testing.T, svc *CoreService, plugin *domain.GamePlugin) {
t.Helper()
plugin.RuntimeProfiles.LifecycleProfiles[0].Platforms = []string{"windows"}
plugin.RuntimeProfiles.LifecycleProfiles[0].DLLExtensionRefs = []string{"scum-simple-rcon"}
plugin.RuntimeProfiles.DLLExtensions = []domain.RuntimeDLLExtensionProfile{{
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024,
TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
}}
if err := svc.store.GamePlugins().Update(*plugin); err != nil {
t.Fatalf("attach ready DLL extension: %v", err)
}
}
func setLifecycleEndpointTarget(t *testing.T, svc *CoreService, platform string, architecture string) {
t.Helper()
endpoint, err := svc.store.RunEndpoints().Get("run-local")
if err != nil {
t.Fatalf("get lifecycle endpoint: %v", err)
}
endpoint.Platform = platform
endpoint.Architecture = architecture
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("set lifecycle endpoint target: %v", err)
}
}
func claimAndCompleteLifecycleJob(t *testing.T, svc *CoreService, sessionToken string, capability string, state domain.JobState) {
t.Helper()
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, "", capability, state)
+9
View File
@@ -1012,6 +1012,15 @@ func ValidateJob(job domain.Job) error {
if job.ExecutionInput.ExpectedChecksum != "" && !validSHA256Checksum(job.ExecutionInput.ExpectedChecksum) {
violations = append(violations, "executionInput.expectedChecksum must be sha256:<hex>")
}
if len(job.ExecutionInput.DLLExtensions) > 16 {
violations = append(violations, "executionInput.dllExtensions must not exceed 16")
}
if len(job.ExecutionInput.DLLExtensions) > 0 && (job.Capability != domain.LifecycleCapabilityStart || job.ExecutionInput.LifecycleOperation != "start" || job.ServerInstanceID == "") {
violations = append(violations, "executionInput.dllExtensions are allowed only for scoped process.start jobs")
}
for i, plan := range job.ExecutionInput.DLLExtensions {
violations = append(violations, validateRuntimeDLLExtensionPlan(fmt.Sprintf("executionInput.dllExtensions[%d]", i), plan)...)
}
violations = append(violations, validateRemoteAdapterInputs("executionInput.inputs", job.ExecutionInput.Inputs)...)
if job.ExecutionResult.Checksum != "" && !validSHA256Checksum(job.ExecutionResult.Checksum) {
violations = append(violations, "executionResult.checksum must be sha256:<hex>")
@@ -0,0 +1,71 @@
package validator
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func TestValidateGamePluginRuntimeProfilesAcceptsReadyUE4SSDLLExtension(t *testing.T) {
profiles := validRuntimeDLLExtensionProfiles()
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
t.Fatalf("expected ready DLL extension profile to validate: %v", err)
}
}
func TestValidateGamePluginRuntimeProfilesRejectsUnsafeOrUnpublishedDLLExtension(t *testing.T) {
cases := []struct {
name string
mutate func(*domain.GamePluginRuntimeProfiles)
want string
}{
{name: "linux target", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
profiles.DLLExtensions[0].SupportedTargets = []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}
}, want: "windows/amd64"},
{name: "unsafe URL", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
profiles.DLLExtensions[0].ReleaseURL = "https://127.0.0.1/plugin.dll"
}, want: "public credential-free HTTPS DLL URL"},
{name: "query URL", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
profiles.DLLExtensions[0].ReleaseURL = "https://cdn.npc0.com/plugin.dll?release=1"
}, want: "public credential-free HTTPS DLL URL"},
{name: "unsafe path", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
profiles.DLLExtensions[0].DLLRef = "ue4ss/Mods/scum_simple_rcon/dlls/plugin.exe"
}, want: "main.dll path"},
{name: "missing pin", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.DLLExtensions[0].Checksum = "" }, want: "SHA-256"},
{name: "unpublished reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
profiles.DLLExtensions[0].ReleaseState = "unpublished"
}, want: "unpublished DLL extension"},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
profiles := validRuntimeDLLExtensionProfiles()
testCase.mutate(&profiles)
err := ValidateGamePluginRuntimeProfiles(profiles)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("expected %q validation error, got %v", testCase.want, err)
}
})
}
}
func TestValidateJobRejectsDLLPlanOutsideProcessStart(t *testing.T) {
profiles := validRuntimeDLLExtensionProfiles()
extension := profiles.DLLExtensions[0]
job := domain.Job{ID: "dll-job", ServerInstanceID: "server-1", RunEndpointID: "run-1", Capability: domain.LifecycleCapabilityStop, IdempotencyKey: "dll-stop", State: domain.JobStateQueued, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{LifecycleOperation: "stop", DLLExtensions: []domain.RuntimeDLLExtensionPlan{{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort}}}}
if err := ValidateJob(job); err == nil || !strings.Contains(err.Error(), "process.start") {
t.Fatalf("expected process.start plan restriction, got %v", err)
}
}
func validRuntimeDLLExtensionProfiles() domain.GamePluginRuntimeProfiles {
return domain.GamePluginRuntimeProfiles{
LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityStart}, Platforms: []string{"windows"}, DLLExtensionRefs: []string{"scum-simple-rcon"}}},
DLLExtensions: []domain.RuntimeDLLExtensionProfile{{
Key: "scum-simple-rcon", DisplayName: "SCUM Simple RCON", Kind: "ue4ss-dll", Activation: "server-start", Version: "0.1.0", ReleaseState: "ready",
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", Checksum: "sha256:" + strings.Repeat("a", 64), SizeBytes: 1024,
TargetKey: "ue4ss/scum-simple-rcon", ModKey: "scum_simple_rcon", DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
SCUMExecutableChecksum: "sha256:" + strings.Repeat("b", 64), UE4SSABI: "ue4ss-3.0", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}}, UpdateOnStart: true, RCONPort: 27015,
}},
}
}
+114
View File
@@ -14,6 +14,8 @@ import (
var (
runtimeLogEventSchemaRefPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]+\.json$`)
runtimeLogEventTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,119}$`)
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
)
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
@@ -22,6 +24,8 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
lifecycleKeys := map[string]struct{}{}
transportKeys := map[string]struct{}{}
managerKeys := map[string]struct{}{}
dllExtensionKeys := map[string]struct{}{}
dllExtensionStates := map[string]string{}
discoveryKeys := map[string]struct{}{}
dependencyKeys := map[string]struct{}{}
installPlanKeys := map[string]struct{}{}
@@ -65,6 +69,10 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
if profile.ClientManagerRef != "" {
violations = append(violations, validateProfileKey(prefix+".clientManagerRef", profile.ClientManagerRef)...)
}
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 {
@@ -349,6 +357,15 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
}
}
}
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 {
@@ -360,10 +377,107 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].clientManagerRef references undeclared client manager", i))
}
}
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.SCUMExecutableChecksum) {
violations = append(violations, prefix+".checksum and scumExecutableChecksum 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,
SCUMExecutableChecksum: plan.SCUMExecutableChecksum,
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"}