449 lines
18 KiB
Go
449 lines
18 KiB
Go
package runtime
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"browser.local/run/protocol"
|
|
)
|
|
|
|
type ue4ssTestDownloader struct {
|
|
mu sync.Mutex
|
|
payload []byte
|
|
calls int
|
|
lastURL string
|
|
started chan struct{}
|
|
unblock <-chan struct{}
|
|
startOnce sync.Once
|
|
}
|
|
|
|
func (downloader *ue4ssTestDownloader) Download(ctx context.Context, sourceURL string, destination string, _ int64) (int64, string, error) {
|
|
downloader.mu.Lock()
|
|
downloader.calls++
|
|
downloader.lastURL = sourceURL
|
|
payload := append([]byte(nil), downloader.payload...)
|
|
started := downloader.started
|
|
unblock := downloader.unblock
|
|
downloader.mu.Unlock()
|
|
if started != nil {
|
|
downloader.startOnce.Do(func() { close(started) })
|
|
}
|
|
if unblock != nil {
|
|
select {
|
|
case <-unblock:
|
|
case <-ctx.Done():
|
|
return 0, "", ctx.Err()
|
|
}
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
|
|
return 0, "", err
|
|
}
|
|
if err := os.WriteFile(destination, payload, 0o600); err != nil {
|
|
return 0, "", err
|
|
}
|
|
return int64(len(payload)), bytesChecksum(payload), nil
|
|
}
|
|
|
|
func (downloader *ue4ssTestDownloader) SetPayload(payload []byte) {
|
|
downloader.mu.Lock()
|
|
defer downloader.mu.Unlock()
|
|
downloader.payload = append([]byte(nil), payload...)
|
|
}
|
|
|
|
func (downloader *ue4ssTestDownloader) Count() int {
|
|
downloader.mu.Lock()
|
|
defer downloader.mu.Unlock()
|
|
return downloader.calls
|
|
}
|
|
|
|
func (downloader *ue4ssTestDownloader) LastURL() string {
|
|
downloader.mu.Lock()
|
|
defer downloader.mu.Unlock()
|
|
return downloader.lastURL
|
|
}
|
|
|
|
type ue4ssManagedProcessSupervisor struct {
|
|
mu sync.Mutex
|
|
starts int
|
|
command ProcessCommand
|
|
}
|
|
|
|
func (supervisor *ue4ssManagedProcessSupervisor) Start(_ context.Context, command ProcessCommand, identity ProcessIdentity, output ManagedProcessOutput) (ProcessIdentity, error) {
|
|
supervisor.mu.Lock()
|
|
supervisor.starts++
|
|
supervisor.command = ProcessCommand{WorkDir: command.WorkDir, Args: append([]string(nil), command.Args...), Env: command.Env, Timeout: command.Timeout}
|
|
supervisor.mu.Unlock()
|
|
if output.Stdout != nil {
|
|
_ = output.Stdout(identity, ManagedProcessLine{Text: "ue4ss managed process started", EndOffset: int64(len("ue4ss managed process started"))})
|
|
}
|
|
identity.State = "running"
|
|
return identity, nil
|
|
}
|
|
|
|
func (supervisor *ue4ssManagedProcessSupervisor) Stop(_ context.Context, identity ProcessIdentity) (ProcessIdentity, error) {
|
|
identity.State = "stopped"
|
|
return identity, nil
|
|
}
|
|
|
|
func (supervisor *ue4ssManagedProcessSupervisor) Status(identity ProcessIdentity) ProcessIdentity {
|
|
if identity.State == "" {
|
|
identity.State = "stopped"
|
|
}
|
|
return identity
|
|
}
|
|
|
|
func (supervisor *ue4ssManagedProcessSupervisor) ResumeOutput(ManagedProcessOutput) {}
|
|
|
|
func (supervisor *ue4ssManagedProcessSupervisor) Starts() int {
|
|
supervisor.mu.Lock()
|
|
defer supervisor.mu.Unlock()
|
|
return supervisor.starts
|
|
}
|
|
|
|
func (supervisor *ue4ssManagedProcessSupervisor) Command() ProcessCommand {
|
|
supervisor.mu.Lock()
|
|
defer supervisor.mu.Unlock()
|
|
return ProcessCommand{WorkDir: supervisor.command.WorkDir, Args: append([]string(nil), supervisor.command.Args...), Env: supervisor.command.Env, Timeout: supervisor.command.Timeout}
|
|
}
|
|
|
|
type ue4ssExtensionFixture struct {
|
|
assignment protocol.RunJobAssignment
|
|
plan protocol.RuntimeDLLExtensionPlan
|
|
activePath string
|
|
markerPath string
|
|
previous string
|
|
stagePath string
|
|
configPath string
|
|
modsPath string
|
|
}
|
|
|
|
func TestUE4SSDLLExtensionSynchronizesNoOpsUpdatesAndKeepsPriorRelease(t *testing.T) {
|
|
root := t.TempDir()
|
|
fixture := newUE4SSExtensionFixture(t, root, true)
|
|
payloadV1 := []byte("scum-simple-rcon DLL release one")
|
|
downloader := &ue4ssTestDownloader{payload: payloadV1}
|
|
supervisor := &ue4ssManagedProcessSupervisor{}
|
|
executor := newUE4SSExtensionExecutor(root, downloader, supervisor, "windows", "amd64")
|
|
|
|
first := executor.Execute(fixture.assignment)
|
|
if first.State != lifecycleResultStateSucceeded {
|
|
t.Fatalf("expected first DLL sync and start to succeed, got %+v", first)
|
|
}
|
|
if downloader.Count() != 1 || downloader.LastURL() != fixture.plan.ReleaseURL {
|
|
t.Fatalf("expected one frozen release download, count=%d url=%q", downloader.Count(), downloader.LastURL())
|
|
}
|
|
assertUE4SSFileEquals(t, fixture.activePath, payloadV1)
|
|
marker, found, err := loadManagedDLLExtensionMarker(fixture.markerPath)
|
|
if err != nil || !found || !markerMatchesPlan(marker, fixture.plan) || marker.ConfigRef != "bin/ue4ss/Mods/scum_simple_rcon/config.ini" {
|
|
t.Fatalf("expected managed release marker for first DLL, marker=%+v found=%v err=%v", marker, found, err)
|
|
}
|
|
config := readUE4SSFile(t, fixture.configPath)
|
|
if !managedLoopbackRCONConfigMatches(fixture.configPath, fixture.plan.RCONPort) {
|
|
t.Fatal("expected protected loopback RCON configuration")
|
|
}
|
|
password := rconPasswordFromConfig(string(config))
|
|
if len(password) != 64 {
|
|
t.Fatal("expected a 32-byte generated RCON password")
|
|
}
|
|
if _, err := hex.DecodeString(password); err != nil {
|
|
t.Fatalf("expected hexadecimal generated RCON password: %v", err)
|
|
}
|
|
if info, err := os.Stat(fixture.configPath); err != nil || info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("expected protected RCON configuration permissions, info=%v err=%v", info, err)
|
|
}
|
|
mods := string(readUE4SSFile(t, fixture.modsPath))
|
|
if strings.Count(mods, "scum_simple_rcon : 1\n") != 1 || strings.Contains(mods, "scum_simple_rcon : 0") {
|
|
t.Fatalf("expected exactly one enabled managed mod entry, mods=%q", mods)
|
|
}
|
|
command := supervisor.Command()
|
|
if len(command.Args) != 1 || filepath.Base(command.Args[0]) != "SCUMServer.exe" || strings.HasSuffix(strings.ToLower(command.Args[0]), ".dll") {
|
|
t.Fatalf("expected normal SCUM executable start without a DLL loader, command=%+v", command)
|
|
}
|
|
|
|
second := executor.Execute(fixture.assignment)
|
|
if second.State != lifecycleResultStateSucceeded || downloader.Count() != 1 {
|
|
t.Fatalf("expected unchanged release to skip download, result=%+v downloads=%d", second, downloader.Count())
|
|
}
|
|
if nextConfig := readUE4SSFile(t, fixture.configPath); !bytes.Equal(config, nextConfig) {
|
|
t.Fatal("expected unchanged release to retain its protected RCON configuration")
|
|
}
|
|
|
|
payloadV2 := []byte("scum-simple-rcon DLL release two")
|
|
planV2 := fixture.plan
|
|
planV2.Version = "1.1.0"
|
|
planV2.Checksum = bytesChecksum(payloadV2)
|
|
planV2.SizeBytes = int64(len(payloadV2))
|
|
downloader.SetPayload(payloadV2)
|
|
updatedAssignment := fixture.assignment
|
|
updatedAssignment.ExecutionInput.DLLExtensions = []protocol.RuntimeDLLExtensionPlan{planV2}
|
|
updated := executor.Execute(updatedAssignment)
|
|
if updated.State != lifecycleResultStateSucceeded || downloader.Count() != 2 {
|
|
t.Fatalf("expected changed release to update before start, result=%+v downloads=%d", updated, downloader.Count())
|
|
}
|
|
assertUE4SSFileEquals(t, fixture.activePath, payloadV2)
|
|
assertUE4SSFileEquals(t, fixture.previous, payloadV1)
|
|
marker, found, err = loadManagedDLLExtensionMarker(fixture.markerPath)
|
|
if err != nil || !found || !markerMatchesPlan(marker, planV2) {
|
|
t.Fatalf("expected managed release marker for updated DLL, marker=%+v found=%v err=%v", marker, found, err)
|
|
}
|
|
if mods := string(readUE4SSFile(t, fixture.modsPath)); strings.Count(mods, "scum_simple_rcon : 1\n") != 1 {
|
|
t.Fatalf("expected deterministic mod index after update, mods=%q", mods)
|
|
}
|
|
|
|
badPlan := planV2
|
|
badPlan.Version = "1.2.0"
|
|
badPlan.Checksum = bytesChecksum([]byte("expected-but-not-delivered"))
|
|
badPlan.SizeBytes = int64(len([]byte("expected-but-not-delivered")))
|
|
downloader.SetPayload([]byte("tampered release payload"))
|
|
badAssignment := fixture.assignment
|
|
badAssignment.ExecutionInput.DLLExtensions = []protocol.RuntimeDLLExtensionPlan{badPlan}
|
|
failed := executor.Execute(badAssignment)
|
|
if failed.State != lifecycleResultStateFailed || failed.ErrorCode != "dll_extension_verify_failed" {
|
|
t.Fatalf("expected checksum mismatch to fail before process start, got %+v", failed)
|
|
}
|
|
if supervisor.Starts() != 3 {
|
|
t.Fatalf("expected failed update not to start SCUM, starts=%d", supervisor.Starts())
|
|
}
|
|
assertUE4SSFileEquals(t, fixture.activePath, payloadV2)
|
|
assertUE4SSFileEquals(t, fixture.previous, payloadV1)
|
|
marker, found, err = loadManagedDLLExtensionMarker(fixture.markerPath)
|
|
if err != nil || !found || !markerMatchesPlan(marker, planV2) {
|
|
t.Fatalf("expected failed update to retain prior managed release, marker=%+v found=%v err=%v", marker, found, err)
|
|
}
|
|
if _, err := os.Stat(fixture.stagePath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("expected failed staged DLL to be removed, err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestUE4SSDLLExtensionRollsBackWhenReleaseMarkerCannotActivate(t *testing.T) {
|
|
root := t.TempDir()
|
|
fixture := newUE4SSExtensionFixture(t, root, true)
|
|
payloadV1 := []byte("scum-simple-rcon DLL release one")
|
|
downloader := &ue4ssTestDownloader{payload: payloadV1}
|
|
supervisor := &ue4ssManagedProcessSupervisor{}
|
|
executor := newUE4SSExtensionExecutor(root, downloader, supervisor, "windows", "amd64")
|
|
if result := executor.Execute(fixture.assignment); result.State != lifecycleResultStateSucceeded {
|
|
t.Fatalf("install initial release: %+v", result)
|
|
}
|
|
|
|
payloadV2 := []byte("scum-simple-rcon DLL release two")
|
|
planV2 := fixture.plan
|
|
planV2.Version = "1.1.0"
|
|
planV2.Checksum = bytesChecksum(payloadV2)
|
|
planV2.SizeBytes = int64(len(payloadV2))
|
|
downloader.SetPayload(payloadV2)
|
|
failMarkerWrite := true
|
|
executor.runtimeFileWriter = func(path string, body []byte, mode os.FileMode) error {
|
|
if path == fixture.markerPath && failMarkerWrite {
|
|
failMarkerWrite = false
|
|
return errors.New("injected marker write failure")
|
|
}
|
|
return writeRuntimeAtomicFile(path, body, mode)
|
|
}
|
|
assignment := fixture.assignment
|
|
assignment.ExecutionInput.DLLExtensions = []protocol.RuntimeDLLExtensionPlan{planV2}
|
|
result := executor.Execute(assignment)
|
|
if result.State != lifecycleResultStateFailed || result.ErrorCode != "dll_extension_activation_failed" {
|
|
t.Fatalf("expected failed marker activation to fail closed, got %+v", result)
|
|
}
|
|
if supervisor.Starts() != 1 {
|
|
t.Fatalf("expected marker activation failure not to start SCUM, starts=%d", supervisor.Starts())
|
|
}
|
|
assertUE4SSFileEquals(t, fixture.activePath, payloadV1)
|
|
assertUE4SSFileEquals(t, fixture.previous, payloadV1)
|
|
marker, found, err := loadManagedDLLExtensionMarker(fixture.markerPath)
|
|
if err != nil || !found || !markerMatchesPlan(marker, fixture.plan) {
|
|
t.Fatalf("expected previous release marker after rollback, marker=%+v found=%v err=%v", marker, found, err)
|
|
}
|
|
}
|
|
|
|
func TestUE4SSDLLExtensionRejectsLinuxBeforeDownloadOrStart(t *testing.T) {
|
|
root := t.TempDir()
|
|
fixture := newUE4SSExtensionFixture(t, root, false)
|
|
downloader := &ue4ssTestDownloader{payload: []byte("must not download")}
|
|
supervisor := &ue4ssManagedProcessSupervisor{}
|
|
executor := newUE4SSExtensionExecutor(root, downloader, supervisor, "linux", "amd64")
|
|
|
|
result := executor.Execute(fixture.assignment)
|
|
if result.State != lifecycleResultStateFailed || result.ErrorCode != "unsupported_extension_platform" {
|
|
t.Fatalf("expected Linux DLL extension rejection, got %+v", result)
|
|
}
|
|
if downloader.Count() != 0 || supervisor.Starts() != 0 {
|
|
t.Fatalf("expected Linux rejection before download or start, downloads=%d starts=%d", downloader.Count(), supervisor.Starts())
|
|
}
|
|
}
|
|
|
|
func TestUE4SSDLLExtensionRequiresInstalledBootstrap(t *testing.T) {
|
|
root := t.TempDir()
|
|
fixture := newUE4SSExtensionFixture(t, root, false)
|
|
downloader := &ue4ssTestDownloader{payload: []byte("must not download")}
|
|
supervisor := &ue4ssManagedProcessSupervisor{}
|
|
executor := newUE4SSExtensionExecutor(root, downloader, supervisor, "windows", "amd64")
|
|
|
|
result := executor.Execute(fixture.assignment)
|
|
if result.State != lifecycleResultStateFailed || result.ErrorCode != "ue4ss_bootstrap_missing" {
|
|
t.Fatalf("expected bootstrap precondition failure, got %+v", result)
|
|
}
|
|
if downloader.Count() != 0 || supervisor.Starts() != 0 {
|
|
t.Fatalf("expected missing bootstrap to prevent download and start, downloads=%d starts=%d", downloader.Count(), supervisor.Starts())
|
|
}
|
|
}
|
|
|
|
func TestUE4SSDLLExtensionSlowDownloadPreservesHeartbeat(t *testing.T) {
|
|
client := newFakeWorkerClient()
|
|
cfg := workerTestConfig(t)
|
|
fixture := newUE4SSExtensionFixture(t, cfg.WorkspaceRoot, true)
|
|
releaseDownload := make(chan struct{})
|
|
downloader := &ue4ssTestDownloader{payload: []byte("scum-simple-rcon DLL release one"), started: make(chan struct{}), unblock: releaseDownload}
|
|
supervisor := &ue4ssManagedProcessSupervisor{}
|
|
worker, err := NewWorker(cfg, client,
|
|
WithDependencyDownloader(downloader),
|
|
WithManagedProcessSupervisor(supervisor),
|
|
WithDLLExtensionRuntimeTarget("windows", "amd64"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("new worker: %v", err)
|
|
}
|
|
worker.state.SessionToken = "session-token"
|
|
|
|
executionDone := make(chan LifecycleExecutionResult, 1)
|
|
go func() { executionDone <- worker.executeAssignment(context.Background(), fixture.assignment) }()
|
|
select {
|
|
case <-downloader.started:
|
|
case <-time.After(2 * time.Second):
|
|
close(releaseDownload)
|
|
t.Fatal("expected lifecycle job to begin its bounded DLL download")
|
|
}
|
|
|
|
heartbeatDone := make(chan error, 1)
|
|
go func() { heartbeatDone <- worker.HeartbeatOnce(context.Background()) }()
|
|
select {
|
|
case err := <-heartbeatDone:
|
|
if err != nil {
|
|
close(releaseDownload)
|
|
t.Fatalf("heartbeat during DLL download: %v", err)
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
close(releaseDownload)
|
|
t.Fatal("slow DLL download blocked the control heartbeat")
|
|
}
|
|
if len(client.heartbeatRequests) != 1 {
|
|
close(releaseDownload)
|
|
t.Fatalf("expected heartbeat request during DLL download, got %d", len(client.heartbeatRequests))
|
|
}
|
|
close(releaseDownload)
|
|
select {
|
|
case result := <-executionDone:
|
|
if result.State != lifecycleResultStateSucceeded {
|
|
t.Fatalf("expected lifecycle job to finish after download release, got %+v", result)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("lifecycle job did not finish after slow download was released")
|
|
}
|
|
}
|
|
|
|
func newUE4SSExtensionExecutor(root string, downloader DependencyDownloader, supervisor ManagedProcessSupervisor, targetOS string, targetArch string) LifecycleExecutor {
|
|
return NewLifecycleExecutor(
|
|
WithLifecycleWorkspaceRoot(root),
|
|
WithDependencyDownloader(downloader),
|
|
WithManagedProcessSupervisor(supervisor),
|
|
WithDLLExtensionRuntimeTarget(targetOS, targetArch),
|
|
)
|
|
}
|
|
|
|
func newUE4SSExtensionFixture(t *testing.T, root string, bootstrap bool) ue4ssExtensionFixture {
|
|
t.Helper()
|
|
assignment := lifecycleAssignment(protocol.RunCapabilityProcessStart)
|
|
assignment.TargetKey = "actions/start.json"
|
|
assignment.ExecutionInput.WorkspaceScope = "run-local"
|
|
scope, err := NewWorkspaceResolver(root).Scope(assignment.ServerInstanceID, assignment.ExecutionInput.WorkspaceScope)
|
|
if err != nil {
|
|
t.Fatalf("create workspace scope: %v", err)
|
|
}
|
|
writeUE4SSFixtureFile(t, filepath.Join(scope, "actions", "start.json"), []byte(`{"version":1,"action":"start","mode":"supervised","executableKey":"bin/SCUMServer.exe"}`), 0o600)
|
|
executable := []byte("SCUM server executable fixture")
|
|
writeUE4SSFixtureFile(t, filepath.Join(scope, "bin", "SCUMServer.exe"), executable, 0o700)
|
|
if bootstrap {
|
|
writeUE4SSFixtureFile(t, filepath.Join(scope, "bin", "dwmapi.dll"), []byte("UE4SS proxy fixture"), 0o600)
|
|
writeUE4SSFixtureFile(t, filepath.Join(scope, "bin", "UE4SS.dll"), []byte("UE4SS loader fixture"), 0o600)
|
|
writeUE4SSFixtureFile(t, filepath.Join(scope, "bin", "ue4ss", "Mods", "mods.txt"), []byte("OtherMod : 1\nscum_simple_rcon : 0\nscum_simple_rcon : 1\n"), 0o600)
|
|
}
|
|
plan := protocol.RuntimeDLLExtensionPlan{
|
|
Key: "scum-simple-rcon",
|
|
Version: "1.0.0",
|
|
ReleaseURL: "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll",
|
|
Checksum: bytesChecksum([]byte("scum-simple-rcon DLL release one")),
|
|
SizeBytes: int64(len([]byte("scum-simple-rcon DLL release one"))),
|
|
TargetKey: "ue4ss/scum-simple-rcon",
|
|
ModKey: "scum_simple_rcon",
|
|
DLLRef: "ue4ss/Mods/scum_simple_rcon/dlls/main.dll",
|
|
SCUMExecutableChecksum: bytesChecksum(executable),
|
|
UE4SSABI: "ue4ss-3.0",
|
|
RCONPort: 27015,
|
|
}
|
|
assignment.ExecutionInput.DLLExtensions = []protocol.RuntimeDLLExtensionPlan{plan}
|
|
return ue4ssExtensionFixture{
|
|
assignment: assignment,
|
|
plan: plan,
|
|
activePath: filepath.Join(scope, "bin", "ue4ss", "Mods", plan.ModKey, "dlls", "main.dll"),
|
|
markerPath: filepath.Join(scope, "runtime", "ue4ss-dll", "ue4ss", "scum-simple-rcon", "release.json"),
|
|
previous: filepath.Join(scope, "runtime", "ue4ss-dll", "ue4ss", "scum-simple-rcon", "previous.dll"),
|
|
stagePath: filepath.Join(scope, "runtime", "ue4ss-dll", "ue4ss", "scum-simple-rcon", "download.staged"),
|
|
configPath: filepath.Join(scope, "bin", "ue4ss", "Mods", plan.ModKey, "config.ini"),
|
|
modsPath: filepath.Join(scope, "bin", "ue4ss", "Mods", "mods.txt"),
|
|
}
|
|
}
|
|
|
|
func writeUE4SSFixtureFile(t *testing.T, path string, body []byte, mode os.FileMode) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
t.Fatalf("create fixture directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(path, body, mode); err != nil {
|
|
t.Fatalf("write fixture file: %v", err)
|
|
}
|
|
}
|
|
|
|
func assertUE4SSFileEquals(t *testing.T, path string, expected []byte) {
|
|
t.Helper()
|
|
if actual := readUE4SSFile(t, path); !bytes.Equal(actual, expected) {
|
|
t.Fatalf("unexpected managed file contents at %s", filepath.Base(path))
|
|
}
|
|
}
|
|
|
|
func readUE4SSFile(t *testing.T, path string) []byte {
|
|
t.Helper()
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read fixture file %s: %v", filepath.Base(path), err)
|
|
}
|
|
return body
|
|
}
|
|
|
|
func rconPasswordFromConfig(content string) string {
|
|
inRCON := false
|
|
for _, rawLine := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
|
|
line := strings.TrimSpace(rawLine)
|
|
if line == "[rcon]" {
|
|
inRCON = true
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "[") {
|
|
inRCON = false
|
|
continue
|
|
}
|
|
if inRCON && strings.HasPrefix(line, "password=") {
|
|
return strings.TrimPrefix(line, "password=")
|
|
}
|
|
}
|
|
return ""
|
|
}
|