Add SCUM Source RCON transport
This commit is contained in:
@@ -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"})
|
||||
}
|
||||
Reference in New Issue
Block a user