Add SCUM Source RCON transport

This commit is contained in:
npc0-hue
2026-07-23 10:55:31 +08:00
parent ec96c22e4c
commit 742f96ea02
33 changed files with 1297 additions and 19 deletions
+1 -1
View File
@@ -600,7 +600,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
State: job.State,
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
ResultRef: job.ResultRef,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)},
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...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON)},
LeaseToken: leaseToken,
Attempt: job.Attempt,
MaxAttempts: job.RetryPolicy.MaxAttempts,
+14 -10
View File
@@ -134,6 +134,8 @@ type Core interface {
CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error)
GetDistributionBuildInput(domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error)
GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error)
DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error)
GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error)
GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error)
ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error)
@@ -223,6 +225,7 @@ type CoreService struct {
auditMu sync.Mutex
auditSeq uint64
productionMu sync.Mutex
sourceRCONCommands *sourceRCONCommandBroker
aiProviderClient AIProviderClient
secretEnvelope SecretEnvelope
}
@@ -247,16 +250,17 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
}
artifactStore := NewMemoryArtifactBodyStore()
service := &CoreService{
store: store,
now: now,
authSessions: map[string]string{},
runSessions: map[string]domain.RunControlSession{},
logStore: logStore,
artifactStore: artifactStore,
artifactTransfers: map[string]domain.ArtifactTransferSession{},
artifactPayloads: map[string][]byte{},
aiProviderClient: MockAIProviderClient{},
secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey),
store: store,
now: now,
authSessions: map[string]string{},
runSessions: map[string]domain.RunControlSession{},
logStore: logStore,
artifactStore: artifactStore,
artifactTransfers: map[string]domain.ArtifactTransferSession{},
artifactPayloads: map[string][]byte{},
sourceRCONCommands: newSourceRCONCommandBroker(now),
aiProviderClient: MockAIProviderClient{},
secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey),
}
return service
}
+270
View File
@@ -0,0 +1,270 @@
package service
import (
"errors"
"fmt"
"strings"
"sync"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const (
sourceRCONCommandTTL = 5 * time.Minute
sourceRCONTimeoutSeconds = 30
)
type sourceRCONCommandPayload struct {
command string
expiresAt time.Time
}
// sourceRCONCommandBroker deliberately retains the raw command only in memory
// until the current leased Run attempt consumes it. It is not recoverable.
type sourceRCONCommandBroker struct {
mu sync.Mutex
now func() time.Time
payloads map[string]sourceRCONCommandPayload
}
func newSourceRCONCommandBroker(now func() time.Time) *sourceRCONCommandBroker {
return &sourceRCONCommandBroker{now: now, payloads: map[string]sourceRCONCommandPayload{}}
}
func (broker *sourceRCONCommandBroker) Put(jobID string, command string) error {
broker.mu.Lock()
defer broker.mu.Unlock()
broker.pruneLocked()
if _, exists := broker.payloads[jobID]; exists {
return validationError("source RCON command idempotency key is already pending")
}
broker.payloads[jobID] = sourceRCONCommandPayload{command: command, expiresAt: broker.now().Add(sourceRCONCommandTTL)}
return nil
}
func (broker *sourceRCONCommandBroker) Consume(jobID string) (string, error) {
broker.mu.Lock()
defer broker.mu.Unlock()
broker.pruneLocked()
payload, exists := broker.payloads[jobID]
if !exists {
return "", validationError("source RCON command input is unavailable")
}
delete(broker.payloads, jobID)
return payload.command, nil
}
func (broker *sourceRCONCommandBroker) Delete(jobID string) {
broker.mu.Lock()
defer broker.mu.Unlock()
delete(broker.payloads, jobID)
}
func (broker *sourceRCONCommandBroker) pruneLocked() {
stamp := broker.now()
for jobID, payload := range broker.payloads {
if !stamp.Before(payload.expiresAt) {
delete(broker.payloads, jobID)
}
}
}
func (svc *CoreService) DispatchSourceRCONCommandForSession(sessionID string, request domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error) {
request = domain.CopySourceRCONCommandRequest(request)
if err := validator.ValidateSourceRCONCommandRequest(request); err != nil {
return domain.SourceRCONCommandDispatch{}, err
}
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
if err != nil {
return domain.SourceRCONCommandDispatch{}, err
}
if instance.State != domain.ServerInstanceStateRunning {
return domain.SourceRCONCommandDispatch{}, validationError("SCUM RCON requires a running server")
}
resolution, err := svc.resolveSourceRCONDispatch(instance)
if err != nil {
return domain.SourceRCONCommandDispatch{}, err
}
if existing, err := svc.store.Jobs().GetByIdempotency(instance.RunEndpointID, request.IdempotencyKey); err == nil {
if existing.ServerInstanceID != instance.ID || existing.Capability != domain.JobCapabilityRemoteRunRCONCommand || existing.ExecutionInput.SourceRCON == nil {
return domain.SourceRCONCommandDispatch{}, validationError("idempotencyKey is already used for a different RCON command")
}
return sourceRCONDispatchFromJob(existing), nil
} else if !errors.Is(err, repo.ErrNotFound) {
return domain.SourceRCONCommandDispatch{}, err
}
command := sourceRCONCommandText(request)
jobID := jobIDFromParts("job-source-rcon", instance.ID, request.IdempotencyKey)
if err := svc.sourceRCONCommands.Put(jobID, command); err != nil {
return domain.SourceRCONCommandDispatch{}, err
}
job := domain.Job{
ID: jobID,
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: domain.JobCapabilityRemoteRunRCONCommand,
TargetKey: resolution.transport.TargetKey,
InputRef: "input://source-rcon/" + jobID,
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Message: "SCUM RCON command queued"},
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1},
ExecutionInput: domain.JobExecutionInput{
WorkspaceScope: resolution.binding.ProfileKey,
RemoteAdapterKey: resolution.transport.Key,
RemoteAdapterKind: string(domain.RemoteAdapterRCON),
TimeoutSeconds: sourceRCONTimeoutSeconds,
PluginID: resolution.plugin.ID,
SourceRCON: resolution.plan,
},
}
created, err := svc.CreateJob(job)
if err != nil {
svc.sourceRCONCommands.Delete(jobID)
return domain.SourceRCONCommandDispatch{}, err
}
if created.ID != jobID {
svc.sourceRCONCommands.Delete(jobID)
if created.ServerInstanceID != instance.ID || created.Capability != domain.JobCapabilityRemoteRunRCONCommand || created.ExecutionInput.SourceRCON == nil {
return domain.SourceRCONCommandDispatch{}, validationError("idempotencyKey is already used for a different RCON command")
}
}
return sourceRCONDispatchFromJob(created), nil
}
func (svc *CoreService) GetSourceRCONExecutionInput(request domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error) {
if err := validator.ValidateSourceRCONExecutionInputRequest(request); err != nil {
return domain.SourceRCONExecutionInput{}, err
}
job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
if err != nil {
return domain.SourceRCONExecutionInput{}, err
}
if job.Capability != domain.JobCapabilityRemoteRunRCONCommand || job.ExecutionInput.SourceRCON == nil {
return domain.SourceRCONExecutionInput{}, validationError("job is not a source RCON command")
}
command, err := svc.sourceRCONCommands.Consume(job.ID)
if err != nil {
return domain.SourceRCONExecutionInput{}, err
}
return domain.CopySourceRCONExecutionInput(domain.SourceRCONExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, Command: command}), nil
}
type sourceRCONDispatchResolution struct {
plugin domain.GamePlugin
binding domain.RuntimeBinding
transport domain.RuntimeTransportProfile
plan *domain.RuntimeSourceRCONPlan
}
func (svc *CoreService) resolveSourceRCONDispatch(instance domain.ServerInstance) (sourceRCONDispatchResolution, error) {
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion || !plugin.Permissions.RemoteAccess || !plugin.RemoteAccess.RCON || !containsString(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand) || !containsString(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunRCONCommand) {
return sourceRCONDispatchResolution{}, forbiddenError("plugin does not declare SCUM RCON command access")
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
if err := validateRunnableEndpoint(endpoint, domain.JobCapabilityRemoteRunRCONCommand); err != nil {
return sourceRCONDispatchResolution{}, err
}
if !strings.EqualFold(endpoint.Platform, "windows") || !strings.EqualFold(endpoint.Architecture, "amd64") {
return sourceRCONDispatchResolution{}, validationError("unsupported_extension_platform: SCUM Source RCON requires windows/amd64")
}
binding, err := svc.runtimeBindingForServer(instance.ID)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
binding, err = normalizeRuntimeBinding(plugin, binding)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
if binding.Status != domain.RuntimeBindingStatusComplete || binding.PluginVersion != plugin.Version {
return sourceRCONDispatchResolution{}, validationError("runtime binding is incomplete or stale")
}
profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey)
if !exists || !containsString(profile.Capabilities, domain.JobCapabilityRemoteRunRCONCommand) || !runtimePlatformsContain(profile.Platforms, "windows") {
return sourceRCONDispatchResolution{}, validationError("selected runtime profile does not support SCUM RCON")
}
transport, err := sourceRCONTransport(plugin.RuntimeProfiles, profile)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
extension, err := sourceRCONExtension(plugin.RuntimeProfiles, profile, endpoint)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
plan := &domain.RuntimeSourceRCONPlan{
Protocol: "source-rcon",
ExtensionKey: extension.Key,
ModKey: extension.ModKey,
ConfigRef: "ue4ss/Mods/" + extension.ModKey + "/config.ini",
DeploymentStateRef: "runtime/ue4ss-dll/" + extension.TargetKey + "/release.json",
Port: extension.RCONPort,
}
return sourceRCONDispatchResolution{plugin: plugin, binding: binding, transport: transport, plan: plan}, nil
}
func sourceRCONTransport(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile) (domain.RuntimeTransportProfile, error) {
var selected domain.RuntimeTransportProfile
for _, candidate := range profiles.TransportProfiles {
if !containsString(profile.TransportKeys, candidate.Key) || candidate.Kind != "rcon" || !containsString(candidate.Capabilities, domain.JobCapabilityRemoteRunRCONCommand) {
continue
}
if selected.Key != "" {
return domain.RuntimeTransportProfile{}, validationError("selected runtime profile has multiple SCUM RCON transports")
}
selected = candidate
}
if selected.Key == "" || strings.TrimSpace(selected.TargetKey) == "" {
return domain.RuntimeTransportProfile{}, validationError("selected runtime profile has no SCUM RCON transport")
}
return selected, nil
}
func sourceRCONExtension(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) (domain.RuntimeDLLExtensionProfile, error) {
byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(profiles.DLLExtensions))
for _, extension := range profiles.DLLExtensions {
byKey[extension.Key] = extension
}
var selected domain.RuntimeDLLExtensionProfile
for _, key := range profile.DLLExtensionRefs {
extension, exists := byKey[key]
if !exists || extension.Kind != "ue4ss-dll" || extension.ModKey != "scum_simple_rcon" || extension.ReleaseState != "ready" {
continue
}
if !runtimeDLLExtensionSupportsTarget(extension, endpoint.Platform, endpoint.Architecture) {
return domain.RuntimeDLLExtensionProfile{}, validationError("unsupported_extension_platform: SCUM Source RCON requires windows/amd64")
}
if selected.Key != "" {
return domain.RuntimeDLLExtensionProfile{}, validationError("selected runtime profile has multiple SCUM Source RCON extensions")
}
selected = extension
}
if selected.Key == "" {
return domain.RuntimeDLLExtensionProfile{}, validationError("extension_release_unavailable: ready SCUM Source RCON DLL is not selected")
}
return selected, nil
}
func sourceRCONCommandText(request domain.SourceRCONCommandRequest) string {
if request.Kind == domain.SourceRCONCommandKindCommand {
return strings.TrimSpace(request.Command)
}
message := strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(request.Message)
command := fmt.Sprintf("SendChat %d \"%s\"", request.ChatType, message)
if request.TargetSteamID != "" {
command += " " + request.TargetSteamID
}
return command
}
func sourceRCONDispatchFromJob(job domain.Job) domain.SourceRCONCommandDispatch {
return domain.CopySourceRCONCommandDispatch(domain.SourceRCONCommandDispatch{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, Status: string(job.State), Message: "SCUM RCON command queued"})
}
+188
View File
@@ -0,0 +1,188 @@
package service
import (
"encoding/json"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/repo"
)
func TestSourceRCONDispatchUsesOneTimeRedactedInput(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
request := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindChat, ChatType: 4, Message: `Bounty "claimed"`, TargetSteamID: "76561198000000001", IdempotencyKey: "rcon-chat-1"}
dispatch, err := svc.DispatchSourceRCONCommandForSession(session, request)
if err != nil {
t.Fatalf("dispatch chat: %v", err)
}
if dispatch.Status != string(domain.JobStateQueued) || dispatch.JobID == "" {
t.Fatalf("unexpected safe dispatch: %+v", dispatch)
}
job, err := svc.store.Jobs().Get(dispatch.JobID)
if err != nil {
t.Fatalf("get RCON job: %v", err)
}
if job.RetryPolicy.MaxAttempts != 1 || job.ExecutionInput.SourceRCON == nil || job.ExecutionInput.SourceRCON.ConfigRef != "ue4ss/Mods/scum_simple_rcon/config.ini" || job.ExecutionInput.SourceRCON.DeploymentStateRef != "runtime/ue4ss-dll/ue4ss/scum-simple-rcon/release.json" || len(job.ExecutionInput.Inputs) != 0 {
t.Fatalf("expected one-attempt frozen RCON plan without inputs, got %+v", job)
}
for _, value := range []string{request.Message, request.TargetSteamID, "password=", "127.0.0.1"} {
body, marshalErr := json.Marshal(job)
if marshalErr != nil {
t.Fatalf("marshal stored job: %v", marshalErr)
}
if strings.Contains(string(body), value) {
t.Fatalf("stored job exposed %q: %s", value, body)
}
}
assignment := dto.RunJobAssignmentFromDomain(domain.RunJobAssignment{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, Capability: job.Capability, TargetKey: job.TargetKey, InputRef: job.InputRef, IdempotencyKey: job.IdempotencyKey, State: job.State, ExecutionInput: job.ExecutionInput})
wire, err := json.Marshal(assignment)
if err != nil {
t.Fatalf("marshal Run assignment: %v", err)
}
if strings.Contains(string(wire), request.Message) || strings.Contains(string(wire), "password=") {
t.Fatalf("Run assignment exposed transient RCON material: %s", wire)
}
duplicate, err := svc.DispatchSourceRCONCommandForSession(session, domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "SetTime 12", IdempotencyKey: request.IdempotencyKey})
if err != nil || duplicate.JobID != dispatch.JobID {
t.Fatalf("expected idempotent dispatch without replacement, duplicate=%+v err=%v", duplicate, err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != job.ID {
t.Fatalf("claim RCON job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
if err != nil || !ack.Accepted {
t.Fatalf("ack RCON job: ack=%+v err=%v", ack, err)
}
if _, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: job.ID, LeaseToken: "wrong", Attempt: ack.Job.Attempt}); err == nil {
t.Fatal("expected foreign lease rejection")
}
input, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: job.ID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt})
if err != nil {
t.Fatalf("consume one-time RCON input: %v", err)
}
if input.Command != `SendChat 4 "Bounty \"claimed\"" 76561198000000001` {
t.Fatalf("unexpected formatted RCON chat command: %q", input.Command)
}
if _, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: job.ID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}); err == nil {
t.Fatal("expected repeated one-time input rejection")
}
stored, err := svc.store.Jobs().Get(job.ID)
if err != nil {
t.Fatalf("get stored RCON job after consume: %v", err)
}
storedJSON, _ := json.Marshal(stored)
if strings.Contains(string(storedJSON), input.Command) || strings.Contains(string(storedJSON), request.Message) {
t.Fatalf("consumed command was persisted: %s", storedJSON)
}
if events, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: instance.ID}); err != nil || len(events) != 0 {
t.Fatalf("RCON command must not add an audit event, events=%+v err=%v", events, err)
}
}
func TestSourceRCONDispatchRejectsUnsafeOrIncompatibleState(t *testing.T) {
svc, session, _, instance := newSourceRCONFixture(t)
unsafe := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "SetTime 12\nSpawnItem", IdempotencyKey: "rcon-unsafe"}
if _, err := svc.DispatchSourceRCONCommandForSession(session, unsafe); err == nil {
t.Fatal("expected framing control rejection")
}
endpoint, err := svc.store.RunEndpoints().Get("run-local")
if err != nil {
t.Fatal(err)
}
endpoint.Platform = "linux"
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatal(err)
}
_, err = svc.DispatchSourceRCONCommandForSession(session, domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "rcon.status", IdempotencyKey: "rcon-linux"})
if err == nil || !strings.Contains(err.Error(), "unsupported_extension_platform") {
t.Fatalf("expected explicit Linux rejection, got %v", err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 0 {
t.Fatalf("rejected RCON requests must not create jobs, jobs=%+v err=%v", jobs, err)
}
}
func TestSourceRCONBrokerExpiresWithoutReplay(t *testing.T) {
stamp := fixedTime
broker := newSourceRCONCommandBroker(func() time.Time { return stamp })
if err := broker.Put("job-rcon-expired", "rcon.status"); err != nil {
t.Fatal(err)
}
stamp = stamp.Add(sourceRCONCommandTTL)
if _, err := broker.Consume("job-rcon-expired"); err == nil {
t.Fatal("expected expired command to fail closed")
}
}
func newSourceRCONFixture(t *testing.T) (*CoreService, string, string, domain.ServerInstance) {
t.Helper()
svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime })
capability := domain.JobCapabilityRemoteRunRCONCommand
plugin, err := svc.CreateGamePlugin(domain.GamePlugin{
ID: "server.scum",
Name: "SCUM",
Version: "1.0.0",
ServerType: "scum",
ManifestRef: "artifact://manifests/server.scum/1.0.0",
CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0",
RequiredRunCapabilities: []string{domain.LifecycleCapabilityStart, capability},
DeclaredPermissions: []string{"server.remote.access"},
Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true},
RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, RCON: true},
LifecycleActions: domain.PluginLifecycleActions{Start: "actions/start.json"},
RuntimeProfiles: domain.GamePluginRuntimeProfiles{
LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityStart, capability}, TransportKeys: []string{"rcon"}, DLLExtensionRefs: []string{"scum-simple-rcon"}, Platforms: []string{"windows"}}},
TransportProfiles: []domain.RuntimeTransportProfile{{Key: "rcon", Kind: "rcon", TargetKey: "rcon", Capabilities: []string{capability}}},
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 != nil {
t.Fatalf("create SCUM RCON plugin: %v", err)
}
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Capabilities: []string{domain.LifecycleCapabilityStart, capability}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil {
t.Fatalf("create RCON endpoint: %v", err)
}
session := createServiceUserAndLogin(t, svc, domain.User{ID: "user-rcon-owner", DisplayName: "RCON Owner", Email: "rcon-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-rcon", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "RCON Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create RCON server: %v", err)
}
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon": "runtime-rcon"}}, true)
if err != nil {
t.Fatalf("create RCON binding: %v", err)
}
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
t.Fatalf("store RCON binding: %v", err)
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = []string{capability}
helloRequest.CapabilityReport.Fingerprint = "cap-source-rcon"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register RCON Run: %v", err)
}
endpoint, err = svc.store.RunEndpoints().Get(endpoint.ID)
if err != nil {
t.Fatal(err)
}
endpoint.Platform = "windows"
endpoint.Architecture = "amd64"
endpoint.Capabilities = []string{domain.LifecycleCapabilityStart, capability}
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update RCON endpoint: %v", err)
}
return svc, session, hello.SessionToken, instance
}