Add SCUM Source RCON transport
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-23
|
||||
@@ -0,0 +1,77 @@
|
||||
## Context
|
||||
|
||||
The prior UE4SS DLL change provisions `ue4ss/Mods/scum_simple_rcon/config.ini` before a normal SCUM start, but the declared Run `rcon` adapter is only an envelope placeholder. The shipped DLL is a Source RCON server that is intentionally loopback-only and accepts raw SCUM commands on its game-thread queue; `SendChat <type> "message" [SteamID64]` is its supported chat form.
|
||||
|
||||
Platform and Run already use leased jobs, signed Run-only input routes, scoped workspaces, and durable job state. Sending a raw admin command through the generic remote-adapter `inputs` map would persist it in the Platform job and Run journal, conflicting with the requested no-history/no-audit behavior.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Let an authorized server operator send a typed chat message or raw SCUM command through the existing job channel without a second confirmation or command audit record.
|
||||
- Transfer a command exactly once to the active leased Run worker without storing the raw text or RCON password in Platform persistence, job assignments, Run journals, browser DTOs, logs, or result messages.
|
||||
- Bind every dispatch to a ready, selected Windows amd64 UE4SS DLL declaration and a fixed Source RCON loopback plan.
|
||||
- Have Run authenticate to `127.0.0.1` using the protected generated config and implement the Source RCON wire protocol with bounded I/O.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Do not modify, build, or redeploy the UE4SS DLL source as part of command transport.
|
||||
- Do not create a generic remote TCP/RCON gateway, expose an RCON host or password, or allow browser-to-Run sockets.
|
||||
- Do not persist raw command text, chat text, source responses, a command history, or per-command audit events.
|
||||
- Do not retry a mutating command automatically, provide a Linux `.dll` fallback, or implement a separate Linux UE4SS `.so` runtime.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: Use a dedicated typed SCUM RCON request instead of generic remote-adapter inputs
|
||||
|
||||
Platform will expose a typed server-scoped request that accepts either a chat payload (`type`, message, optional SteamID64) or a bounded raw command. It validates the source DLL grammar and formats chat as `SendChat` internally. It validates ownership, the declared RCON capability, selected runtime binding, ready extension release, Windows amd64 endpoint, and the selected profile's RCON transport before it creates a job.
|
||||
|
||||
The job contains only an opaque input reference and a frozen `source-rcon` connection plan: extension key/mod key, logical config reference, and port. It contains no host, command, response, or secret. The existing generic remote-adapter endpoint remains suitable for declared non-secret inputs but is not used for raw SCUM commands.
|
||||
|
||||
Alternative considered: put `command` in `ExecutionInput.Inputs`. Rejected because Platform persistence, job claims, and Run's durable journal would retain the command.
|
||||
|
||||
### Decision: Use a one-time in-memory input broker fenced by the active Run lease
|
||||
|
||||
Platform stores each validated command in a mutex-protected, TTL-bounded in-memory broker before the corresponding job becomes claimable. The broker key is the job ID and cannot overwrite an outstanding submission with the same idempotency key. The signed Run-only RCON-input route first verifies endpoint, session, job, lease, attempt, capability, and frozen plan, then consumes and removes the payload.
|
||||
|
||||
The RCON job has exactly one attempt. A Platform restart, expired broker entry, worker crash after consume, or repeat input read fails closed rather than replaying a possibly mutating game command. Job/audit/result summaries use only generic delivery state.
|
||||
|
||||
Alternative considered: encrypting a durable broker row. Rejected because the explicit product boundary is no raw command persistence; encryption would still create history and recovery/replay semantics.
|
||||
|
||||
### Decision: Freeze a source-RCON loopback plan from the selected ready UE4SS extension
|
||||
|
||||
Platform derives the config reference from the extension's declared DLL layout (`ue4ss/Mods/<modKey>/config.ini`) and freezes the published port plus mod/extension identity. Because the actual SCUM executable may be below the workspace root, Run also owns a verified deployment-state marker that records the scoped logical config location written during DLL activation. The frozen plan carries only that safe marker reference; Run validates its extension/mod/port and expected config suffix before it reads the protected file. Run accepts only the `source-rcon` kind, Windows amd64, safe logical references, and an unprivileged declared port. There is no configurable host; Run always dials `127.0.0.1:<port>` in the server's scoped workspace.
|
||||
|
||||
The generator is corrected to write the DLL's actual `bind_address=127.0.0.1` setting. Run rejects a managed config whose port or bind address no longer matches the frozen plan.
|
||||
|
||||
Alternative considered: resolve paths or credentials in Platform. Rejected because Platform must not receive host paths or the generated RCON password.
|
||||
|
||||
### Decision: Treat Source RCON result bodies as sensitive transient data
|
||||
|
||||
Run authenticates, executes one command, and reads bounded response packets until the DLL's empty response sentinel. It classifies an `error:` response as a failed job but returns only a safe status/error code; it never includes raw command text, password, or response body in logs, artifacts, result messages, or Platform-facing payloads.
|
||||
|
||||
Alternative considered: stream full RCON output to the browser. Rejected because source responses may echo command text or game/player data and would reintroduce command history through job results/logs.
|
||||
|
||||
### Decision: Give the management console two direct entry points
|
||||
|
||||
The existing server detail surface gets a compact chat form and a raw-command form. Both submit immediately using typed API contracts and display only the current safe queue/result status. They intentionally do not add a confirmation modal, saved form history, command list, secret field, host field, or response transcript.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Platform or worker restarts during a command] → the broker payload is gone or consumed; the job fails closed and an operator can explicitly submit a new command.
|
||||
- [SCUM/UE4SS is not loaded or the port is unavailable] → Run returns a safe connection/auth/protocol failure without falling back to a remote address.
|
||||
- [An RCON response echoes sensitive input] → Run only classifies it and does not surface the body.
|
||||
- [A user submits an unsupported chat target or malformed text] → Platform rejects it before a job/broker entry is created.
|
||||
- [A DLL declaration is still unpublished] → the feature stays unavailable until real immutable release pins are published and selected.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Deploy Platform, Run, plugin manifest, and management-console changes together.
|
||||
2. Publish a real ready DLL declaration, ensure the Windows UE4SS bootstrap is present, and restart SCUM so Run writes the managed loopback config.
|
||||
3. Verify `rcon.status` or a harmless `rcon.chat` through the console on a non-production server, then send a broadcast and an allowed admin command.
|
||||
4. Roll back by removing the new Run capability/using the previous Platform and Run releases; no persistent commands or secrets require migration or cleanup.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- The current SCUM manifest is intentionally unpublished; end-to-end production dispatch remains gated until the publisher supplies the real DLL URL, checksum, size, executable checksum, and UE4SS ABI.
|
||||
- Future command-output streaming would need a separate transient, authorization-reviewed design rather than reusing durable job results.
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
The SCUM UE4SS lifecycle now provisions a protected, loopback-only Source RCON listener, but Run's declared `rcon` adapter is still a placeholder that reports success without connecting to the listener. Operators therefore cannot actually send chat text or SCUM admin commands through the platform, and raw commands must not become a durable command-history or audit feature.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a typed SCUM RCON dispatch path for direct chat delivery and raw SCUM admin commands, with no secondary confirmation step and no persisted command/audit payload.
|
||||
- Freeze a ready Windows UE4SS extension's safe loopback Source RCON connection metadata into a single-attempt Run job; reject unpublished, incompatible, or non-SCUM extension states before dispatch.
|
||||
- Keep the raw command only in a bounded, one-time, in-memory Platform input broker. The signed active Run lease retrieves it once; database jobs, Run journals, browser responses, audit events, logs, and result messages contain no command text or RCON password.
|
||||
- Replace Run's placeholder RCON adapter with a bounded Source RCON client that reads the generated protected config inside its scoped workspace, authenticates only to `127.0.0.1`, and sends the command using standard framed packets.
|
||||
- Add a server-management-console RCON panel for chat broadcasts/targeted chat and raw commands. It reports safe queued/succeeded/failed state without building a command history.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `scum-source-rcon-command-dispatch`: Platform authorization, one-time command delivery, safe browser contracts, and SCUM-specific dispatch constraints.
|
||||
- `run-source-rcon-execution`: Run-side loopback Source RCON authentication, packet exchange, response classification, and failure handling.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- `platform/`: domain/DTO/API contracts, command validation, transient input broker, fenced Run-only input endpoint, lifecycle/extension resolution, and focused tests.
|
||||
- `platform_web/`: typed API client, schemas, and the existing server-detail management surface.
|
||||
- `plugins/`: SCUM lifecycle capability declaration and manifest tests.
|
||||
- Independent `run/`: protocol copy, Platform client, Worker dispatch, Source RCON adapter, and unit/integration tests.
|
||||
- The UE4SS DLL source remains unchanged. No user-side compiler, generic remote socket, direct browser-to-RCON connection, Linux DLL substitute, raw command persistence, or command audit trail is introduced.
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Run executes a frozen Source RCON plan over loopback
|
||||
Run SHALL execute a `source-rcon` plan only for Windows amd64 and only by dialing `127.0.0.1` at the frozen declared port. It SHALL resolve the plan's generated config through the scoped workspace, read the protected password locally, and use Source RCON authentication and framed command/response packets. It SHALL not accept a browser-provided host, socket, path, or password.
|
||||
|
||||
#### Scenario: Valid loopback Source RCON command completes
|
||||
- **WHEN** Run receives a valid frozen plan and one-time command for a running compatible SCUM server
|
||||
- **THEN** it authenticates to the local DLL listener, sends the command, consumes the terminal response sentinel, and completes the job with a safe success status.
|
||||
|
||||
#### Scenario: Unsafe plan or local configuration is rejected
|
||||
- **WHEN** a plan is not `source-rcon`, is non-Windows, has an unsafe config key/port, or the local config is missing, non-loopback, or inconsistent with the frozen port
|
||||
- **THEN** Run fails before opening a socket or sending a command.
|
||||
|
||||
### Requirement: Run bounds and redacts Source RCON I/O
|
||||
Run SHALL bound command bytes, packet sizes, response bytes, response packet count, dialing, authentication, and command execution by the job context. It SHALL classify an RCON error response as a failed job but SHALL NOT persist or return the raw command, password, response body, config contents, or local path in logs, artifacts, progress, or result messages.
|
||||
|
||||
#### Scenario: Source RCON error response remains private
|
||||
- **WHEN** the DLL returns an `error:` response or malformed response framing
|
||||
- **THEN** Run returns a safe error code/message without exposing the returned body or submitted command.
|
||||
|
||||
### Requirement: Source RCON transport is at-most-once
|
||||
Run SHALL retrieve the command from the active Platform input route once per job and SHALL not retry a command after input, authentication, dial, or response failure. It SHALL not fall back to the placeholder remote adapter, a generic TCP address, `rundll32`, process injection, or a Linux loader.
|
||||
|
||||
#### Scenario: Lost one-time input does not replay a command
|
||||
- **WHEN** Run cannot retrieve a fresh one-time command or loses execution after it was consumed
|
||||
- **THEN** it fails the job safely and does not send a duplicate command.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Platform dispatches a typed SCUM Source RCON command
|
||||
Platform SHALL accept a server-authorized typed request for either a SCUM chat message or a bounded raw SCUM command only when the installed plugin declares `remote.run.rcon.command`, the selected runtime profile declares the RCON transport, the endpoint reports the capability, and a selected ready Windows amd64 UE4SS DLL extension provides the Source RCON plan. Chat requests SHALL validate a type from 0 through 7, 1–1024 UTF-8 message bytes, and an optional 17-digit SteamID64 before Platform formats the DLL-supported `SendChat` command. Raw commands SHALL be non-empty UTF-8, contain no NUL/newline control framing, and fit within the declared Source RCON packet bound.
|
||||
|
||||
#### Scenario: Authorized chat dispatch is queued
|
||||
- **WHEN** an authorized operator submits a valid broadcast or targeted chat request for a compatible ready SCUM server
|
||||
- **THEN** Platform creates one scoped `remote.run.rcon.command` job with a frozen Source RCON plan and returns only safe queued state.
|
||||
|
||||
#### Scenario: Unsupported command request is rejected before dispatch
|
||||
- **WHEN** a request has malformed chat data, an unsafe raw command, an undeclared RCON capability, an unpublished extension, or a non-Windows endpoint
|
||||
- **THEN** Platform rejects it without creating a job or retaining command text.
|
||||
|
||||
### Requirement: Raw RCON input is transient and one-time
|
||||
Platform SHALL hold raw RCON command text only in a bounded in-memory one-time broker keyed by the dispatch job. The persisted job, job assignment, browser DTOs, audit events, and result messages SHALL omit command text, chat text, source responses, host paths, and RCON credentials. RCON command jobs SHALL have one attempt and SHALL not automatically retry.
|
||||
|
||||
#### Scenario: Active Run lease consumes a command once
|
||||
- **WHEN** the active leased Run attempt requests its RCON input
|
||||
- **THEN** Platform returns the command once and removes it from the broker.
|
||||
|
||||
#### Scenario: Repeated, expired, or restarted delivery fails closed
|
||||
- **WHEN** a broker payload was already consumed, expired, or lost after a Platform restart
|
||||
- **THEN** a later Run input request fails safely and does not replay the command.
|
||||
|
||||
### Requirement: Only the active signed Run attempt receives RCON input
|
||||
Platform SHALL expose RCON command input only through a signed Run-only route after validating the endpoint, session, job ID, lease token, attempt, scoped server, RCON capability, and frozen Source RCON plan. Browser and plugin APIs SHALL never receive the RCON input, password, host address, or generated local config path.
|
||||
|
||||
#### Scenario: Stale or foreign lease cannot read a command
|
||||
- **WHEN** a Run input request has a wrong endpoint, session, lease token, attempt, or job capability
|
||||
- **THEN** Platform denies it and leaves a valid unconsumed broker payload intact.
|
||||
|
||||
### Requirement: Management console provides direct chat and command controls
|
||||
The server management surface SHALL render direct chat and raw-command controls only through the typed Platform API. It SHALL not request a secondary confirmation, retain a command transcript, expose RCON connection material, or show raw Source RCON replies.
|
||||
|
||||
#### Scenario: Console sends a chat without creating history
|
||||
- **WHEN** an authorized operator submits a valid chat form
|
||||
- **THEN** the console displays the safe dispatch status and does not render the chat text as a durable command record.
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1. Platform one-time command contracts
|
||||
|
||||
- [x] 1.1 Add typed Source RCON plan, chat/raw-command request, safe dispatch response, and bounded validators without exposing command or secret values in persisted job structures.
|
||||
- [x] 1.2 Resolve a selected ready SCUM UE4SS extension into a Windows-only frozen plan; queue one-attempt RCON jobs through a TTL-bounded in-memory broker with no per-command audit record.
|
||||
- [x] 1.3 Add the signed active-lease Run input contract, API route, DTOs, safe error behavior, and focused Platform tests for authorization, one-time consume, rejection, and redaction.
|
||||
|
||||
## 2. Independent Run Source RCON execution
|
||||
|
||||
- [x] 2.1 Mirror the frozen plan and one-time input protocol; add Platform client and Worker plumbing that keeps raw command text out of job journals.
|
||||
- [x] 2.2 Implement scoped managed-config parsing, Windows loopback validation, bounded Source RCON authentication/packet exchange, response redaction, and safe failure codes; correct generated UE4SS config to use `bind_address`.
|
||||
- [x] 2.3 Add Run tests for successful auth/command, source error, malformed/unsafe config or packets, at-most-once input, and non-Windows rejection.
|
||||
|
||||
## 3. Plugin and management console
|
||||
|
||||
- [x] 3.1 Declare the SCUM local lifecycle RCON capability and add manifest coverage without activating the unpublished DLL release.
|
||||
- [x] 3.2 Add typed frontend client/schema contracts and a direct chat/raw-command server-detail panel with no confirmation, transcript, secret, or source-response display.
|
||||
- [x] 3.3 Add focused frontend rendering/submission tests and confirm safe projections exclude RCON material.
|
||||
|
||||
## 4. Verification and delivery
|
||||
|
||||
- [x] 4.1 Run Platform, plugin, frontend, and Run tests; run `openspec validate add-scum-source-rcon-transport --strict` and `scripts/check-structure.sh`.
|
||||
- [x] 4.2 Review scoped diffs, stage only task files in the browser and independent Run repositories, commit, and push both configured branches.
|
||||
@@ -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
|
||||
}
|
||||
@@ -90,6 +90,8 @@ import type {
|
||||
ServerLifecycleResponse,
|
||||
ServerConfigWriteApprovalRequest,
|
||||
ServerConfigWriteDispatchResponse,
|
||||
SourceRCONCommandRequest,
|
||||
SourceRCONCommandResponse,
|
||||
ServerInstanceListResponse,
|
||||
ServerDeletionRequest,
|
||||
ServerInstanceUpdateRequest,
|
||||
@@ -565,6 +567,10 @@ export class PlatformApiClient {
|
||||
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async sendSourceRCONCommand(serverInstanceId: string, request: SourceRCONCommandRequest): Promise<SourceRCONCommandResponse> {
|
||||
return this.request<SourceRCONCommandResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/rcon/commands`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async getServerConfig(id: string): Promise<ServerConfigResponse> {
|
||||
return this.request<ServerConfigResponse>(`/server-instances/${encodeURIComponent(id)}/config`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PlatformApiClient } from "./client";
|
||||
import type { SourceRCONCommandRequest } from "./types";
|
||||
|
||||
describe("PlatformApiClient Source RCON command dispatch", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("posts the typed server-scoped request and projects only safe queue state", async () => {
|
||||
const request: SourceRCONCommandRequest = {
|
||||
kind: "chat",
|
||||
chatType: 4,
|
||||
message: "Maintenance complete",
|
||||
targetSteamId: "76561198000000001",
|
||||
idempotencyKey: "web:source-rcon:chat:server-1:12"
|
||||
};
|
||||
const calls: Array<{ url: string; method: string; body?: unknown; authorization: string | null }> = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
authorization: new Headers(init?.headers).get("Authorization")
|
||||
});
|
||||
return new Response(JSON.stringify({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" }), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}));
|
||||
|
||||
const response = await new PlatformApiClient("/api/v1", () => "operator-session").sendSourceRCONCommand("server/1", request);
|
||||
|
||||
expect(calls).toEqual([{
|
||||
url: "/api/v1/server-instances/server%2F1/rcon/commands",
|
||||
method: "POST",
|
||||
body: request,
|
||||
authorization: "Bearer operator-session"
|
||||
}]);
|
||||
expect(response).toEqual({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" });
|
||||
for (const forbidden of ["command", "password", "host", "configRef", "response"]) {
|
||||
expect(Object.keys(response)).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -485,6 +485,26 @@ export interface ServerLifecycleResponse {
|
||||
job: JobResponse;
|
||||
}
|
||||
|
||||
export type SourceRCONCommandKind = "chat" | "command";
|
||||
|
||||
export interface SourceRCONCommandRequest {
|
||||
kind: SourceRCONCommandKind;
|
||||
chatType?: number;
|
||||
message?: string;
|
||||
targetSteamId?: string;
|
||||
command?: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
// The Platform response intentionally excludes the command, local listener
|
||||
// details, credentials, and Source RCON response body.
|
||||
export interface SourceRCONCommandResponse {
|
||||
jobId: string;
|
||||
serverInstanceId: string;
|
||||
status: JobState;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RunCapacityResponse {
|
||||
maxJobs: number;
|
||||
runningJobs: number;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import sourceRCONCommandPanelSource from "./SourceRCONCommandPanel.tsx?raw";
|
||||
|
||||
describe("SourceRCONCommandPanel", () => {
|
||||
it("uses the typed dispatch API without confirmation, transcript, or connection fields", () => {
|
||||
expect(sourceRCONCommandPanelSource).toContain("sendSourceRCONCommand");
|
||||
expect(sourceRCONCommandPanelSource).toContain("sourceRCONChatRequest");
|
||||
expect(sourceRCONCommandPanelSource).toContain("sourceRCONRawCommandRequest");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("transcript");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("history");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("password");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("host");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import { sourceRCONChatRequest, sourceRCONRawCommandRequest } from "../schemas/sourceRcon";
|
||||
import { ResultBadge } from "./StateViews";
|
||||
|
||||
interface SourceRCONCommandPanelProps {
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
type DispatchState = { status: "pending" | "succeeded" | "failed"; label: string } | null;
|
||||
|
||||
export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommandPanelProps) {
|
||||
const [chatType, setChatType] = useState(4);
|
||||
const [chatMessage, setChatMessage] = useState("");
|
||||
const [targetSteamId, setTargetSteamId] = useState("");
|
||||
const [rawCommand, setRawCommand] = useState("");
|
||||
const [pending, setPending] = useState<"chat" | "command" | null>(null);
|
||||
const [dispatch, setDispatch] = useState<DispatchState>(null);
|
||||
|
||||
if (pluginId !== "game.scum") {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendChat(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPending("chat");
|
||||
setDispatch({ status: "pending", label: "正在提交聊天消息" });
|
||||
try {
|
||||
const submitted = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONChatRequest(serverId, { chatType, message: chatMessage, targetSteamId }));
|
||||
setChatMessage("");
|
||||
setTargetSteamId("");
|
||||
setDispatch({ status: "succeeded", label: sourceRCONDispatchLabel(submitted.jobId, submitted.status) });
|
||||
} catch (error) {
|
||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "聊天消息提交失败" });
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRawCommand(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPending("command");
|
||||
setDispatch({ status: "pending", label: "正在提交原始管理员指令" });
|
||||
try {
|
||||
const submitted = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, rawCommand));
|
||||
setRawCommand("");
|
||||
setDispatch({ status: "succeeded", label: sourceRCONDispatchLabel(submitted.jobId, submitted.status) });
|
||||
} catch (error) {
|
||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "原始管理员指令提交失败" });
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="SCUM Source RCON controls">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>SCUM 聊天与管理员指令</h2>
|
||||
<p className="page-status">立即派发一次性任务;只显示安全状态,不保留聊天或指令记录。</p>
|
||||
</div>
|
||||
{dispatch && <ResultBadge status={dispatch.status} label={dispatch.label} />}
|
||||
</div>
|
||||
<div className="operations-command-grid">
|
||||
<section className="console-module" aria-label="SCUM chat command">
|
||||
<div className="panel-header"><h2>发送聊天</h2></div>
|
||||
<form className="provider-form" onSubmit={(event) => void sendChat(event)}>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
聊天类型
|
||||
<select value={chatType} onChange={(event) => setChatType(Number(event.target.value))} disabled={pending !== null}>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map((value) => <option key={value} value={value}>类型 {value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
目标 SteamID64(可选)
|
||||
<input value={targetSteamId} inputMode="numeric" maxLength={17} onChange={(event) => setTargetSteamId(event.target.value)} disabled={pending !== null} placeholder="留空为广播" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
聊天内容
|
||||
<textarea value={chatMessage} maxLength={1024} rows={3} onChange={(event) => setChatMessage(event.target.value)} disabled={pending !== null} placeholder="输入单行聊天内容" />
|
||||
</label>
|
||||
<div className="action-strip"><button type="submit" className="primary-command" disabled={pending !== null || !chatMessage.trim()}>{pending === "chat" ? "提交中…" : "发送聊天"}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section className="console-module" aria-label="SCUM raw administrator command">
|
||||
<div className="panel-header"><h2>原始管理员指令</h2></div>
|
||||
<form className="provider-form" onSubmit={(event) => void sendRawCommand(event)}>
|
||||
<label>
|
||||
指令
|
||||
<textarea value={rawCommand} maxLength={4000} rows={5} onChange={(event) => setRawCommand(event.target.value)} disabled={pending !== null} placeholder="例如 SetTime 12" />
|
||||
</label>
|
||||
<p className="page-status">指令会直接交给当前运行中的 SCUM,不会显示执行回包。</p>
|
||||
<div className="action-strip"><button type="submit" className="icon-command" disabled={pending !== null || !rawCommand.trim()}>{pending === "command" ? "提交中…" : "发送指令"}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceRCONDispatchLabel(jobId: string, status: string): string {
|
||||
return `已${status === "queued" ? "排队" : "提交"} · 任务 ${jobId}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { configDiffViewFromPreview } from "./ServerDetailPage";
|
||||
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
||||
import clientManagerLifecyclePanelSource from "../components/ClientManagerLifecyclePanel.tsx?raw";
|
||||
import runtimeDLLExtensionsPanelSource from "../components/RuntimeDLLExtensionsPanel.tsx?raw";
|
||||
import sourceRCONCommandPanelSource from "../components/SourceRCONCommandPanel.tsx?raw";
|
||||
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
|
||||
import type { ServerConfigDiffPreviewResponse } from "../api/types";
|
||||
|
||||
@@ -115,6 +116,18 @@ describe("ServerDetailPage config write approval", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("adds direct SCUM chat and raw commands through the one-time typed RCON API", () => {
|
||||
expect(serverDetailPageSource).toContain("SourceRCONCommandPanel");
|
||||
expect(sourceRCONCommandPanelSource).toContain("sendSourceRCONCommand");
|
||||
expect(sourceRCONCommandPanelSource).toContain("不保留聊天或指令记录");
|
||||
expect(sourceRCONCommandPanelSource).toContain("不会显示执行回包");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
||||
for (const forbidden of ["password", "host", "transcript", "history"]) {
|
||||
expect(sourceRCONCommandPanelSource).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("loads the dependency catalog only after runtime actions expose dependency operations", () => {
|
||||
const runtimeDistributionSectionSource = serverDetailPageSource.split("function RuntimeDistributionSection")[1]?.split("function RuntimeBindingFields")[0] ?? "";
|
||||
expect(runtimeDistributionSectionSource).toContain('action.key === "dependencies-check" || action.key === "dependencies-install"');
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecycl
|
||||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||||
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
|
||||
import { RuntimeDLLExtensionsPanel } from "../components/RuntimeDLLExtensionsPanel";
|
||||
import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel";
|
||||
import {
|
||||
RuntimeTaskProgressDialog,
|
||||
runtimeBuildStages,
|
||||
@@ -295,6 +296,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
/>
|
||||
)}
|
||||
{section === "overview" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
|
||||
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
|
||||
{section === "overview" && (
|
||||
<RuntimeDistributionSection
|
||||
instance={instance.data}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sourceRCONChatRequest, sourceRCONRawCommandRequest } from "./sourceRcon";
|
||||
|
||||
describe("Source RCON browser request schemas", () => {
|
||||
it("builds a bounded typed chat request without connection material", () => {
|
||||
expect(sourceRCONChatRequest("server-1", { chatType: 4, message: "hello", targetSteamId: "76561198000000001" }, 12)).toEqual({
|
||||
kind: "chat",
|
||||
chatType: 4,
|
||||
message: "hello",
|
||||
targetSteamId: "76561198000000001",
|
||||
idempotencyKey: "web:source-rcon:chat:server-1:12"
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects framed text and formats raw commands without chat fields", () => {
|
||||
expect(() => sourceRCONChatRequest("server-1", { chatType: 2, message: "line one\nline two" }, 13)).toThrow("受限的单行文本");
|
||||
expect(() => sourceRCONChatRequest("server-1", { chatType: 8, message: "hello" }, 13)).toThrow("聊天类型");
|
||||
expect(sourceRCONRawCommandRequest("server-1", " SetTime 12 ", 14)).toEqual({
|
||||
kind: "command",
|
||||
command: "SetTime 12",
|
||||
idempotencyKey: "web:source-rcon:command:server-1:14"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SourceRCONCommandRequest } from "../api/types";
|
||||
|
||||
const maxChatBytes = 1024;
|
||||
const maxCommandBytes = 4000;
|
||||
const steamID64 = /^[0-9]{17}$/;
|
||||
|
||||
export interface SourceRCONChatDraft {
|
||||
chatType: number;
|
||||
message: string;
|
||||
targetSteamId?: string;
|
||||
}
|
||||
|
||||
export function sourceRCONChatRequest(serverInstanceId: string, draft: SourceRCONChatDraft, sequence = Date.now()): SourceRCONCommandRequest {
|
||||
const message = validateSourceRCONText(draft.message, maxChatBytes, "聊天内容");
|
||||
if (!Number.isInteger(draft.chatType) || draft.chatType < 0 || draft.chatType > 7) {
|
||||
throw new Error("聊天类型必须在 0 到 7 之间。");
|
||||
}
|
||||
const targetSteamId = draft.targetSteamId?.trim() ?? "";
|
||||
if (targetSteamId && !steamID64.test(targetSteamId)) {
|
||||
throw new Error("目标 SteamID64 必须为 17 位数字。");
|
||||
}
|
||||
return {
|
||||
kind: "chat",
|
||||
chatType: draft.chatType,
|
||||
message,
|
||||
targetSteamId: targetSteamId || undefined,
|
||||
idempotencyKey: sourceRCONIdempotencyKey("chat", serverInstanceId, sequence)
|
||||
};
|
||||
}
|
||||
|
||||
export function sourceRCONRawCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): SourceRCONCommandRequest {
|
||||
return {
|
||||
kind: "command",
|
||||
command: validateSourceRCONText(command, maxCommandBytes, "原始指令"),
|
||||
idempotencyKey: sourceRCONIdempotencyKey("command", serverInstanceId, sequence)
|
||||
};
|
||||
}
|
||||
|
||||
function validateSourceRCONText(value: string, maxBytes: number, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || new TextEncoder().encode(normalized).byteLength > maxBytes || /[\u0000\r\n]/.test(normalized)) {
|
||||
throw new Error(`${label}必须是受限的单行文本。`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sourceRCONIdempotencyKey(kind: "chat" | "command", serverInstanceId: string, sequence: number): string {
|
||||
return `web:source-rcon:${kind}:${serverInstanceId.trim()}:${Math.max(0, Math.floor(sequence))}`;
|
||||
}
|
||||
@@ -460,7 +460,8 @@
|
||||
"process.restart",
|
||||
"process.status",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop"
|
||||
"remote.run.process.stop",
|
||||
"remote.run.rcon.command"
|
||||
],
|
||||
"actionRefs": {
|
||||
"install": "actions/install.json",
|
||||
|
||||
@@ -187,6 +187,23 @@ describe("plugin manifest validation", () => {
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("declares Source RCON for the Windows local lifecycle without activating the unpublished DLL", () => {
|
||||
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||
runtimeProfiles?: {
|
||||
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[]; dllExtensionRefs?: string[] }>;
|
||||
dllExtensions?: Array<{ key: string; releaseState: string }>;
|
||||
};
|
||||
};
|
||||
const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local");
|
||||
const extension = manifest.runtimeProfiles?.dllExtensions?.find((candidate) => candidate.key === "scum-simple-rcon-ue4ss");
|
||||
|
||||
expect(local?.capabilities).toContain("remote.run.rcon.command");
|
||||
expect(local?.transportKeys).toContain("rcon");
|
||||
expect(local?.dllExtensionRefs).toBeUndefined();
|
||||
expect(extension?.releaseState).toBe("unpublished");
|
||||
});
|
||||
|
||||
it("rejects unpinned, unsafe, or unpublished SCUM UE4SS DLL activation", () => {
|
||||
const missingPins = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
const extension = manifest.runtimeProfiles.dllExtensions[0];
|
||||
|
||||
Reference in New Issue
Block a user