feat: add UE4SS DLL runtime extension
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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] + "…"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user