Add SCUM Source RCON transport
This commit is contained in:
@@ -142,10 +142,11 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
|
||||
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
|
||||
|
||||
privateUpdateBodies := map[string]any{
|
||||
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8},
|
||||
"/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
|
||||
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
|
||||
"/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8},
|
||||
"/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"},
|
||||
}
|
||||
nonce := 10
|
||||
for path, request := range privateUpdateBodies {
|
||||
|
||||
@@ -69,6 +69,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime/actions", h.serverRuntimeActions)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/runtime-binding", h.serverRuntimeBinding)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset)
|
||||
@@ -109,6 +110,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/run/jobs/result", h.requireRunSignature(h.runJobResult))
|
||||
mux.HandleFunc("/api/v1/run/jobs/build-input", h.requireRunSignature(h.runJobBuildInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/dependency-input", h.requireRunSignature(h.runJobDependencyInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput))
|
||||
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
|
||||
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// sourceRCONCommands godoc
|
||||
// @Summary Queue a direct SCUM Source RCON chat or command
|
||||
// @Description Queues one non-retryable command without persisting the raw command, RCON password, or response body.
|
||||
// @Tags scum-rcon
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.SourceRCONCommandRequestBody true "SCUM Source RCON chat or command request"
|
||||
// @Success 202 {object} dto.SourceRCONCommandResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/rcon/commands [post]
|
||||
func (h *coreHandlers) sourceRCONCommands(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.SourceRCONCommandRequestBody](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
dispatch, err := h.core.DispatchSourceRCONCommandForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.SourceRCONCommandFromDomain(dispatch))
|
||||
}
|
||||
|
||||
// runSourceRCONInput godoc
|
||||
// @Summary Read one transient SCUM Source RCON command
|
||||
// @Description Returns the raw command exactly once only to the signed active Run lease; browser clients never receive this payload.
|
||||
// @Tags run-job-channel
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.SourceRCONExecutionInputRequest true "Fenced Source RCON input request"
|
||||
// @Success 200 {object} dto.SourceRCONExecutionInputResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/run/jobs/source-rcon-input [post]
|
||||
func (h *coreHandlers) runSourceRCONInput(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.SourceRCONExecutionInputRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
input, err := h.core.GetSourceRCONExecutionInput(request.ToDomain())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SourceRCONExecutionInputFromDomain(input))
|
||||
}
|
||||
@@ -149,6 +149,23 @@ type DependencyExecutionInput struct {
|
||||
Bindings map[string]string
|
||||
}
|
||||
|
||||
type SourceRCONExecutionInputRequest struct {
|
||||
RunEndpointID string
|
||||
SessionToken string
|
||||
JobID string
|
||||
LeaseToken string
|
||||
Attempt int
|
||||
}
|
||||
|
||||
// SourceRCONExecutionInput is sent only to the active signed Run lease. Its
|
||||
// Command value must never be persisted in a Job, assignment, or journal.
|
||||
type SourceRCONExecutionInput struct {
|
||||
JobID string
|
||||
ServerInstanceID string
|
||||
RunEndpointID string
|
||||
Command string
|
||||
}
|
||||
|
||||
type RunUpdateInputRequest struct {
|
||||
RunEndpointID string
|
||||
SessionToken string
|
||||
@@ -278,6 +295,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...)
|
||||
assignment.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(assignment.ExecutionInput.SourceRCON)
|
||||
return assignment
|
||||
}
|
||||
|
||||
@@ -331,6 +349,10 @@ func CopyDependencyExecutionInput(input DependencyExecutionInput) DependencyExec
|
||||
return input
|
||||
}
|
||||
|
||||
func CopySourceRCONExecutionInput(input SourceRCONExecutionInput) SourceRCONExecutionInput {
|
||||
return input
|
||||
}
|
||||
|
||||
func CopyRunUpdateChunk(chunk RunUpdateChunk) RunUpdateChunk {
|
||||
chunk.Payload = append([]byte(nil), chunk.Payload...)
|
||||
return chunk
|
||||
|
||||
@@ -112,6 +112,32 @@ type RemoteAdapterResult struct {
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
type SourceRCONCommandKind string
|
||||
|
||||
const (
|
||||
SourceRCONCommandKindChat SourceRCONCommandKind = "chat"
|
||||
SourceRCONCommandKindCommand SourceRCONCommandKind = "command"
|
||||
)
|
||||
|
||||
// SourceRCONCommandRequest is intentionally transient: Command and Message
|
||||
// are consumed by the service broker and are never copied into a durable Job.
|
||||
type SourceRCONCommandRequest struct {
|
||||
ServerInstanceID string
|
||||
Kind SourceRCONCommandKind
|
||||
ChatType int
|
||||
Message string
|
||||
TargetSteamID string
|
||||
Command string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type SourceRCONCommandDispatch struct {
|
||||
JobID string
|
||||
ServerInstanceID string
|
||||
Status string
|
||||
Message string
|
||||
}
|
||||
|
||||
func CopyMetricSample(sample MetricSample) MetricSample {
|
||||
sample.PlayerCount = copyIntPtr(sample.PlayerCount)
|
||||
sample.MaxPlayers = copyIntPtr(sample.MaxPlayers)
|
||||
@@ -188,3 +214,10 @@ func CopyRemoteAdapterRequest(request RemoteAdapterRequest) RemoteAdapterRequest
|
||||
return request
|
||||
}
|
||||
func CopyRemoteAdapterResult(result RemoteAdapterResult) RemoteAdapterResult { return result }
|
||||
|
||||
func CopySourceRCONCommandRequest(request SourceRCONCommandRequest) SourceRCONCommandRequest {
|
||||
return request
|
||||
}
|
||||
func CopySourceRCONCommandDispatch(dispatch SourceRCONCommandDispatch) SourceRCONCommandDispatch {
|
||||
return dispatch
|
||||
}
|
||||
|
||||
@@ -490,6 +490,17 @@ type RuntimeDLLExtensionPlan struct {
|
||||
RCONPort int
|
||||
}
|
||||
|
||||
// RuntimeSourceRCONPlan is a frozen, secret-free loopback connection plan for
|
||||
// a ready SCUM UE4SS extension. The generated local config remains Run-owned.
|
||||
type RuntimeSourceRCONPlan struct {
|
||||
Protocol string
|
||||
ExtensionKey string
|
||||
ModKey string
|
||||
ConfigRef string
|
||||
DeploymentStateRef string
|
||||
Port int
|
||||
}
|
||||
|
||||
type RuntimeConfigTemplate struct {
|
||||
Key string
|
||||
TemplateRef string
|
||||
@@ -849,6 +860,7 @@ type JobExecutionInput struct {
|
||||
TargetVersion string
|
||||
Inputs map[string]string
|
||||
DLLExtensions []RuntimeDLLExtensionPlan
|
||||
SourceRCON *RuntimeSourceRCONPlan
|
||||
}
|
||||
|
||||
type JobExecutionResult struct {
|
||||
@@ -1570,9 +1582,18 @@ 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...)
|
||||
job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON)
|
||||
return job
|
||||
}
|
||||
|
||||
func CopyRuntimeSourceRCONPlan(plan *RuntimeSourceRCONPlan) *RuntimeSourceRCONPlan {
|
||||
if plan == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *plan
|
||||
return ©
|
||||
}
|
||||
|
||||
func CopyArtifact(artifact Artifact) Artifact {
|
||||
return artifact
|
||||
}
|
||||
|
||||
@@ -105,6 +105,16 @@ type RunJobExecutionInputBody struct {
|
||||
TargetVersion string `json:"targetVersion,omitempty"`
|
||||
Inputs map[string]string `json:"inputs,omitempty"`
|
||||
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
|
||||
SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeSourceRCONPlanBody struct {
|
||||
Protocol string `json:"protocol"`
|
||||
ExtensionKey string `json:"extensionKey"`
|
||||
ModKey string `json:"modKey"`
|
||||
ConfigRef string `json:"configRef"`
|
||||
DeploymentStateRef string `json:"deploymentStateRef"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type RunJobExecutionResultBody struct {
|
||||
@@ -176,6 +186,21 @@ type DependencyExecutionInputResponse struct {
|
||||
Bindings map[string]string `json:"bindings"`
|
||||
}
|
||||
|
||||
type SourceRCONExecutionInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
}
|
||||
|
||||
type SourceRCONExecutionInputResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type RunUpdateInputRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
@@ -351,6 +376,10 @@ func (request DependencyExecutionInputRequest) ToDomain() domain.DependencyExecu
|
||||
return domain.DependencyExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
|
||||
}
|
||||
|
||||
func (request SourceRCONExecutionInputRequest) ToDomain() domain.SourceRCONExecutionInputRequest {
|
||||
return domain.SourceRCONExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
|
||||
}
|
||||
|
||||
func (request RunUpdateInputRequest) ToDomain() domain.RunUpdateInputRequest {
|
||||
return domain.RunUpdateInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt}
|
||||
}
|
||||
@@ -459,6 +488,10 @@ func DependencyExecutionInputFromDomain(input domain.DependencyExecutionInput) D
|
||||
return DependencyExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, PluginID: input.PluginID, PluginVersion: input.PluginVersion, ProfileKey: input.ProfileKey, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PlanDigest: input.PlanDigest, Probe: RuntimeDependencyProbeBody{Key: input.Probe.Key, Kind: input.Probe.Kind, TargetKey: input.Probe.TargetKey, Required: input.Probe.Required, MinimumVersion: input.Probe.MinimumVersion, Platforms: input.Probe.Platforms}, Plan: RuntimeInstallPlanBody{Key: input.Plan.Key, Title: input.Plan.Title, Platforms: input.Plan.Platforms, Steps: steps}, Bindings: input.Bindings}
|
||||
}
|
||||
|
||||
func SourceRCONExecutionInputFromDomain(input domain.SourceRCONExecutionInput) SourceRCONExecutionInputResponse {
|
||||
return SourceRCONExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, Command: input.Command}
|
||||
}
|
||||
|
||||
func RunUpdateInputFromDomain(input domain.RunUpdateInput) RunUpdateInputResponse {
|
||||
return RunUpdateInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, ArtifactID: input.ArtifactID, Checksum: input.Checksum, SizeBytes: input.SizeBytes, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PackageFormat: input.PackageFormat, ExecutableName: input.ExecutableName, TargetRelease: input.TargetRelease, ChunkSizeBytes: input.ChunkSizeBytes}
|
||||
}
|
||||
@@ -530,7 +563,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), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions)},
|
||||
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), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON)},
|
||||
LeaseToken: assignment.LeaseToken,
|
||||
Attempt: assignment.Attempt,
|
||||
MaxAttempts: assignment.MaxAttempts,
|
||||
@@ -543,6 +576,13 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeSourceRCONPlanFromDomain(plan *domain.RuntimeSourceRCONPlan) *RuntimeSourceRCONPlanBody {
|
||||
if plan == nil {
|
||||
return nil
|
||||
}
|
||||
return &RuntimeSourceRCONPlanBody{Protocol: plan.Protocol, ExtensionKey: plan.ExtensionKey, ModKey: plan.ModKey, ConfigRef: plan.ConfigRef, DeploymentStateRef: plan.DeploymentStateRef, Port: plan.Port}
|
||||
}
|
||||
|
||||
func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressReport {
|
||||
return domain.RunJobProgressReport{
|
||||
Percent: progress.Percent,
|
||||
|
||||
@@ -106,6 +106,22 @@ type RemoteAdapterResponse struct {
|
||||
CompletedAt time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
type SourceRCONCommandRequestBody struct {
|
||||
Kind domain.SourceRCONCommandKind `json:"kind"`
|
||||
ChatType int `json:"chatType,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
TargetSteamID string `json:"targetSteamId,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type SourceRCONCommandResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (request MetricBatchIngestRequest) ToDomain() domain.MetricBatchIngest {
|
||||
samples := make([]domain.MetricSample, len(request.Samples))
|
||||
for i, sample := range request.Samples {
|
||||
@@ -158,6 +174,14 @@ func RemoteAdapterFromDomain(result domain.RemoteAdapterResult) RemoteAdapterRes
|
||||
return RemoteAdapterResponse{RequestID: result.RequestID, ServerInstanceID: result.ServerInstanceID, DeclarationKey: result.DeclarationKey, TargetKey: result.TargetKey, Kind: result.Kind, Status: result.Status, Retryable: result.Retryable, Message: result.Message, ResultRef: result.ResultRef, AuditEventID: result.AuditEventID, CompletedAt: result.CompletedAt}
|
||||
}
|
||||
|
||||
func (request SourceRCONCommandRequestBody) ToDomain(serverInstanceID string) domain.SourceRCONCommandRequest {
|
||||
return domain.SourceRCONCommandRequest{ServerInstanceID: serverInstanceID, Kind: request.Kind, ChatType: request.ChatType, Message: request.Message, TargetSteamID: request.TargetSteamID, Command: request.Command, IdempotencyKey: request.IdempotencyKey}
|
||||
}
|
||||
|
||||
func SourceRCONCommandFromDomain(dispatch domain.SourceRCONCommandDispatch) SourceRCONCommandResponse {
|
||||
return SourceRCONCommandResponse{JobID: dispatch.JobID, ServerInstanceID: dispatch.ServerInstanceID, Status: dispatch.Status, Message: dispatch.Message}
|
||||
}
|
||||
|
||||
func metricSampleToDomain(sample MetricSampleBody) domain.MetricSample {
|
||||
return domain.MetricSample{ID: sample.ID, ServerInstanceID: sample.ServerInstanceID, Online: sample.Online, PlayerCount: sample.PlayerCount, MaxPlayers: sample.MaxPlayers, TPS: sample.TPS, LatencyMS: sample.LatencyMS, CPUPercent: sample.CPUPercent, MemoryPercent: sample.MemoryPercent, DiskPercent: sample.DiskPercent, Source: sample.Source, CollectedAt: sample.CollectedAt}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,8 @@ type JobExecutionInput struct {
|
||||
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"`
|
||||
// SourceRCON is secret-free connection metadata for a one-time Run command.
|
||||
SourceRCON *domain.RuntimeSourceRCONPlan `json:"sourceRcon,omitempty" db:"source_rcon"`
|
||||
}
|
||||
|
||||
type JobExecutionResult struct {
|
||||
@@ -884,11 +886,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), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...)}
|
||||
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...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON)}
|
||||
}
|
||||
|
||||
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), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), input.DLLExtensions...)}
|
||||
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...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(input.SourceRCON)}
|
||||
}
|
||||
|
||||
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), 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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -64,6 +64,10 @@ func ValidateDependencyExecutionInputRequest(request domain.DependencyExecutionI
|
||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||
}
|
||||
|
||||
func ValidateSourceRCONExecutionInputRequest(request domain.SourceRCONExecutionInputRequest) error {
|
||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||
}
|
||||
|
||||
func ValidateRunUpdateInputRequest(request domain.RunUpdateInputRequest) error {
|
||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||
}
|
||||
|
||||
@@ -1021,6 +1021,18 @@ func ValidateJob(job domain.Job) error {
|
||||
for i, plan := range job.ExecutionInput.DLLExtensions {
|
||||
violations = append(violations, validateRuntimeDLLExtensionPlan(fmt.Sprintf("executionInput.dllExtensions[%d]", i), plan)...)
|
||||
}
|
||||
if job.ExecutionInput.SourceRCON != nil {
|
||||
violations = append(violations, validateRuntimeSourceRCONPlan("executionInput.sourceRcon", job.ExecutionInput.SourceRCON)...)
|
||||
if job.Capability != domain.JobCapabilityRemoteRunRCONCommand || job.ExecutionInput.RemoteAdapterKind != "rcon" {
|
||||
violations = append(violations, "executionInput.sourceRcon is allowed only for rcon jobs")
|
||||
}
|
||||
if job.RetryPolicy.MaxAttempts != 1 {
|
||||
violations = append(violations, "executionInput.sourceRcon jobs must have one attempt")
|
||||
}
|
||||
if len(job.ExecutionInput.Inputs) != 0 {
|
||||
violations = append(violations, "executionInput.sourceRcon must not persist adapter inputs")
|
||||
}
|
||||
}
|
||||
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,91 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSourceRCONCommandBytes = 4000
|
||||
maxSourceRCONChatBytes = 1024
|
||||
)
|
||||
|
||||
var sourceRCONSteamIDPattern = regexp.MustCompile(`^[0-9]{17}$`)
|
||||
|
||||
func ValidateSourceRCONCommandRequest(request domain.SourceRCONCommandRequest) error {
|
||||
request = domain.CopySourceRCONCommandRequest(request)
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if len([]byte(request.IdempotencyKey)) > 128 || strings.ContainsAny(request.IdempotencyKey, "\x00\r\n") {
|
||||
violations = append(violations, "idempotencyKey is invalid")
|
||||
}
|
||||
switch request.Kind {
|
||||
case domain.SourceRCONCommandKindChat:
|
||||
if request.Command != "" {
|
||||
violations = append(violations, "command must be empty for chat")
|
||||
}
|
||||
violations = append(violations, validateSourceRCONText("message", request.Message, maxSourceRCONChatBytes, true)...)
|
||||
if request.ChatType < 0 || request.ChatType > 7 {
|
||||
violations = append(violations, "chatType must be between 0 and 7")
|
||||
}
|
||||
if request.TargetSteamID != "" && !sourceRCONSteamIDPattern.MatchString(request.TargetSteamID) {
|
||||
violations = append(violations, "targetSteamId must be a 17-digit SteamID64")
|
||||
}
|
||||
case domain.SourceRCONCommandKindCommand:
|
||||
if request.Message != "" || request.TargetSteamID != "" || request.ChatType != 0 {
|
||||
violations = append(violations, "chat fields are not allowed for a raw command")
|
||||
}
|
||||
violations = append(violations, validateSourceRCONText("command", request.Command, maxSourceRCONCommandBytes, true)...)
|
||||
default:
|
||||
violations = append(violations, "kind must be chat or command")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validateRuntimeSourceRCONPlan(prefix string, plan *domain.RuntimeSourceRCONPlan) []string {
|
||||
if plan == nil {
|
||||
return []string{prefix + " is required"}
|
||||
}
|
||||
var violations []string
|
||||
if plan.Protocol != "source-rcon" {
|
||||
violations = append(violations, prefix+".protocol must be source-rcon")
|
||||
}
|
||||
violations = append(violations, validateProfileKey(prefix+".extensionKey", plan.ExtensionKey)...)
|
||||
if !runtimeDLLModKeyPattern.MatchString(plan.ModKey) {
|
||||
violations = append(violations, prefix+".modKey is invalid")
|
||||
}
|
||||
if plan.ConfigRef != "ue4ss/Mods/"+plan.ModKey+"/config.ini" || !validLogicalFileKey(plan.ConfigRef) {
|
||||
violations = append(violations, prefix+".configRef must be the managed UE4SS config path")
|
||||
}
|
||||
if !validSourceRCONDeploymentStateRef(plan.DeploymentStateRef) {
|
||||
violations = append(violations, prefix+".deploymentStateRef must be a managed UE4SS deployment state path")
|
||||
}
|
||||
if plan.Port < 1024 || plan.Port > 65535 {
|
||||
violations = append(violations, prefix+".port must be an unprivileged TCP port")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validSourceRCONDeploymentStateRef(value string) bool {
|
||||
const prefix = "runtime/ue4ss-dll/"
|
||||
const suffix = "/release.json"
|
||||
if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, suffix) {
|
||||
return false
|
||||
}
|
||||
targetKey := strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix)
|
||||
return targetKey != "" && validLogicalFileKey(targetKey)
|
||||
}
|
||||
|
||||
func validateSourceRCONText(field string, value string, maxBytes int, required bool) []string {
|
||||
if required && strings.TrimSpace(value) == "" {
|
||||
return []string{field + " is required"}
|
||||
}
|
||||
if !utf8.ValidString(value) || len([]byte(value)) > maxBytes || strings.ContainsAny(value, "\x00\r\n") {
|
||||
return []string{field + " must be bounded UTF-8 without command framing controls"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user