187 lines
9.8 KiB
Go
187 lines
9.8 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"browser.local/run/protocol"
|
|
"browser.local/run/spool"
|
|
)
|
|
|
|
func TestWorkerExecutesFencedProtectedProgramAndSpoolsDedicatedLogs(t *testing.T) {
|
|
registry := NewProtectedRequestRegistry()
|
|
requestText := `{"operation":"status"}`
|
|
handlerCalled := false
|
|
if err := registry.Register("program", "scum-program", ProtectedRequestHandlerFunc(func(_ context.Context, request ProtectedRequest) (ProtectedRequestOutcome, error) {
|
|
handlerCalled = true
|
|
if request.ServerInstanceID != "server-worker" || request.FencingToken != 12 || request.TargetKey != "scum-program" || request.RequestText != requestText {
|
|
t.Fatalf("unexpected protected request: %+v", request)
|
|
}
|
|
return ProtectedRequestOutcome{Status: ProtectedRequestStatusSucceeded, Stdout: "SCUM ready\npassword=hidden\nconfig /Users/private/scum.ini\ndsn mysql://private", Stderr: "bounded warning"}, nil
|
|
})); err != nil {
|
|
t.Fatalf("register protected handler: %v", err)
|
|
}
|
|
assignment := protectedWorkerAssignment(protocol.RunCapabilityRemoteRunProgram, "program", "scum-program", 12)
|
|
client := newFakeWorkerClient()
|
|
client.claimJob = assignment
|
|
client.protectedInput = protectedWorkerInput(assignment, "program", requestText)
|
|
logSpool, err := spool.NewLogSpool(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("log spool: %v", err)
|
|
}
|
|
worker, err := NewWorker(workerTestConfig(t), client, WithProtectedRequestRegistry(registry), WithProcessLogSink(&SpoolLogSink{Spool: logSpool}))
|
|
if err != nil {
|
|
t.Fatalf("new worker: %v", err)
|
|
}
|
|
if err := worker.Register(context.Background()); err != nil {
|
|
t.Fatalf("register worker: %v", err)
|
|
}
|
|
if handled, err := worker.ClaimAndRunOnce(context.Background()); err != nil || !handled {
|
|
t.Fatalf("claim protected request handled=%v err=%v", handled, err)
|
|
}
|
|
if !handlerCalled || len(client.protectedRequests) != 1 || client.protectedRequests[0].FencingToken != assignment.FencingToken {
|
|
t.Fatalf("expected one fenced protected input read: %+v", client.protectedRequests)
|
|
}
|
|
if len(client.resultRequests) != 1 || client.resultRequests[0].State != lifecycleResultStateSucceeded || client.resultRequests[0].ExecutionResult.Kind != "protected.program" {
|
|
t.Fatalf("unexpected protected result: %+v", client.resultRequests)
|
|
}
|
|
batches, err := logSpool.Pending()
|
|
entryCount := 0
|
|
for _, batch := range batches {
|
|
entryCount += len(batch.Entries)
|
|
}
|
|
if err != nil || entryCount != 5 {
|
|
t.Fatalf("expected five program log lines: batches=%+v err=%v", batches, err)
|
|
}
|
|
redactedEntries := 0
|
|
for _, batch := range batches {
|
|
if batch.Source != "management-program" || batch.Source == "file" || batch.Source == "process" {
|
|
t.Fatalf("program output used wrong log source: %+v", batch)
|
|
}
|
|
for _, entry := range batch.Entries {
|
|
if entry.Redacted {
|
|
redactedEntries++
|
|
}
|
|
if strings.Contains(entry.Line, "password=hidden") || strings.Contains(entry.Line, "/Users/") || strings.Contains(entry.Line, "://") {
|
|
t.Fatalf("program log leaked protected output: %+v", entry)
|
|
}
|
|
}
|
|
}
|
|
if redactedEntries != 3 {
|
|
t.Fatalf("expected three explicitly redacted private lines, got %d", redactedEntries)
|
|
}
|
|
serialized, err := json.Marshal([]any{client.resultRequests, client.protectedRequests, worker.journal.ActiveJobs()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, private := range []string{requestText, "password=hidden", "/Users/private/scum.ini", "mysql://private", "bounded warning"} {
|
|
if strings.Contains(string(serialized), private) {
|
|
t.Fatalf("protected text or output leaked into control projection %q: %s", private, serialized)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProtectedRequestUnknownAndBindingFailureAreIsolated(t *testing.T) {
|
|
registry := NewProtectedRequestRegistry()
|
|
called := 0
|
|
if err := registry.Register("rcon", "scum-management", ProtectedRequestHandlerFunc(func(_ context.Context, request ProtectedRequest) (ProtectedRequestOutcome, error) {
|
|
called++
|
|
if request.RequestText == "unknown.command" {
|
|
return ProtectedRequestOutcome{Status: ProtectedRequestStatusUnknown}, ErrProtectedRequestUnknown
|
|
}
|
|
return ProtectedRequestOutcome{Status: ProtectedRequestStatusSucceeded}, nil
|
|
})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
executor := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(t.TempDir()), WithProtectedRequestRegistry(registry))
|
|
assignment := protectedWorkerAssignment(protocol.RunCapabilityRemoteRunProtectedRCON, "rcon", "scum-management", 21)
|
|
unknown := executor.ExecuteProtectedRequest(context.Background(), assignment, protectedWorkerInput(assignment, "rcon", "unknown.command"))
|
|
if unknown.State != lifecycleResultStateFailed || unknown.ErrorCode != "protected_request_unknown" || unknown.Retryable {
|
|
t.Fatalf("unexpected unknown outcome: %+v", unknown)
|
|
}
|
|
succeeded := executor.ExecuteProtectedRequest(context.Background(), assignment, protectedWorkerInput(assignment, "rcon", "status"))
|
|
if succeeded.State != lifecycleResultStateSucceeded || called != 2 {
|
|
t.Fatalf("unknown request affected later request: result=%+v called=%d", succeeded, called)
|
|
}
|
|
mismatched := protectedWorkerInput(assignment, "rcon", "status")
|
|
mismatched.FencingToken++
|
|
failed := executor.ExecuteProtectedRequest(context.Background(), assignment, mismatched)
|
|
if failed.ErrorCode != "protected_request_binding_invalid" || called != 2 {
|
|
t.Fatalf("binding failure reached handler: result=%+v called=%d", failed, called)
|
|
}
|
|
encoded, _ := json.Marshal([]LifecycleExecutionResult{unknown, failed})
|
|
if strings.Contains(string(encoded), "unknown.command") || strings.Contains(string(encoded), "status") {
|
|
t.Fatalf("safe failure leaked request text: %s", encoded)
|
|
}
|
|
}
|
|
|
|
func TestProtectedRCONFallsBackToSourceRCONPlan(t *testing.T) {
|
|
listener, port := newSourceRCONListener(t)
|
|
defer listener.Close()
|
|
password := strings.Repeat("f", 64)
|
|
assignment := protectedWorkerAssignment(protocol.RunCapabilityRemoteRunProtectedRCON, "rcon", "scum-management", 41)
|
|
assignment.ExecutionInput.WorkspaceScope = "run-local"
|
|
assignment.ExecutionInput.TimeoutSeconds = 5
|
|
assignment.ExecutionInput.SourceRCON = &protocol.RuntimeSourceRCONPlan{Protocol: "source-rcon", ExtensionKey: "scum-simple-rcon", ModKey: "scum_simple_rcon", ConfigRef: "ue4ss/Mods/scum_simple_rcon/config.ini", DeploymentStateRef: "runtime/ue4ss-dll/ue4ss/scum-simple-rcon/release.json", Port: port}
|
|
root := t.TempDir()
|
|
writeSourceRCONConfig(t, root, assignment, "127.0.0.1", password)
|
|
commands := make(chan string, 1)
|
|
serverDone := serveSourceRCONSession(listener, password, func(connection net.Conn, packet sourceRCONPacket) error {
|
|
commands <- packet.body
|
|
return writeSourceRCONPacket(context.Background(), connection, sourceRCONPacket{id: packet.id, typeCode: sourceRCONResponseValue})
|
|
})
|
|
|
|
result := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(root), WithDLLExtensionRuntimeTarget("windows", "amd64")).ExecuteProtectedRequest(context.Background(), assignment, protectedWorkerInput(assignment, "rcon", "#ListPlayers"))
|
|
if result.State != lifecycleResultStateSucceeded || result.ExecutionResult.Kind != "protected.rcon" {
|
|
t.Fatalf("expected protected RCON delivery through Source RCON, got %+v", result)
|
|
}
|
|
if got := <-commands; got != "#ListPlayers" {
|
|
t.Fatalf("expected SCUM command delivery, got %q", got)
|
|
}
|
|
awaitSourceRCONServer(t, serverDone)
|
|
serialized, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(serialized), "#ListPlayers") || strings.Contains(string(serialized), password) {
|
|
t.Fatalf("protected Source RCON result leaked private input: %s", serialized)
|
|
}
|
|
}
|
|
|
|
func TestUnknownProtectedProgramKeepsSafeDiagnosticInProgramLogOnly(t *testing.T) {
|
|
registry := NewProtectedRequestRegistry()
|
|
if err := registry.Register("program", "scum-program", ProtectedRequestHandlerFunc(func(context.Context, ProtectedRequest) (ProtectedRequestOutcome, error) {
|
|
return ProtectedRequestOutcome{Status: ProtectedRequestStatusUnknown, Stderr: "unknown field database=/private/scum.db"}, ErrProtectedRequestUnknown
|
|
})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sink := &recordingLogSink{}
|
|
executor := NewLifecycleExecutor(WithLifecycleWorkspaceRoot(t.TempDir()), WithProtectedRequestRegistry(registry), WithProcessLogSink(sink))
|
|
assignment := protectedWorkerAssignment(protocol.RunCapabilityRemoteRunProgram, "program", "scum-program", 31)
|
|
result := executor.ExecuteProtectedRequest(context.Background(), assignment, protectedWorkerInput(assignment, "program", `{"unexpected":true}`))
|
|
if result.ErrorCode != "protected_request_unknown" || result.State != lifecycleResultStateFailed {
|
|
t.Fatalf("unexpected unknown program result: %+v", result)
|
|
}
|
|
if len(sink.lines) != 1 || !strings.HasPrefix(sink.lines[0], "management-program.stderr:") || strings.Contains(sink.lines[0], "/private/") || !strings.Contains(sink.lines[0], "[redacted protected") {
|
|
t.Fatalf("unknown program diagnostic was not safely channelized: %+v", sink.lines)
|
|
}
|
|
}
|
|
|
|
func protectedWorkerAssignment(capability string, kind string, key string, fence uint64) protocol.RunJobAssignment {
|
|
assignment := workerJobAssignment(capability)
|
|
assignment.TargetKey = key
|
|
assignment.InputRef = "input://protected-request/" + assignment.JobID
|
|
assignment.FencingToken = fence
|
|
assignment.MaxAttempts = 1
|
|
assignment.ExecutionInput = protocol.RunJobExecutionInput{RemoteAdapterKey: key, RemoteAdapterKind: "protected-" + kind, TimeoutSeconds: 5}
|
|
return assignment
|
|
}
|
|
|
|
func protectedWorkerInput(assignment protocol.RunJobAssignment, kind string, text string) protocol.ProtectedRequestExecutionInputResponse {
|
|
return protocol.ProtectedRequestExecutionInputResponse{JobID: assignment.JobID, ServerInstanceID: assignment.ServerInstanceID, RunEndpointID: assignment.RunEndpointID, FencingToken: assignment.FencingToken, Authorized: true, ApprovalState: "approved", QueueState: "claimed", ExpiresAt: time.Now().UTC().Add(time.Minute), Kind: kind, TransportKey: assignment.ExecutionInput.RemoteAdapterKey, TargetKey: assignment.TargetKey, RequestText: text}
|
|
}
|