feat: add UE4SS DLL runtime extension
This commit is contained in:
@@ -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