refactor(scum): declare protected run requests
This commit is contained in:
@@ -9,6 +9,7 @@ const (
|
||||
GameClientBridgeCommandClaimed GameClientBridgeCommandState = "claimed"
|
||||
GameClientBridgeCommandSucceeded GameClientBridgeCommandState = "succeeded"
|
||||
GameClientBridgeCommandFailed GameClientBridgeCommandState = "failed"
|
||||
GameClientBridgeCommandUnknown GameClientBridgeCommandState = "unknown"
|
||||
GameClientBridgeCommandCancelled GameClientBridgeCommandState = "cancelled"
|
||||
GameClientBridgeCommandExpired GameClientBridgeCommandState = "expired"
|
||||
)
|
||||
@@ -39,6 +40,17 @@ type GameClientBridgeCommandDeclaration struct {
|
||||
ResultSchemaRef string
|
||||
TimeoutSeconds int
|
||||
MaxPayloadBytes int
|
||||
ProtectedRequest *GameClientBridgeProtectedRequestDeclaration
|
||||
}
|
||||
|
||||
// GameClientBridgeProtectedRequestDeclaration binds plugin-generated text to a
|
||||
// logical server transport. It never carries its resolved connection details.
|
||||
type GameClientBridgeProtectedRequestDeclaration struct {
|
||||
Kind string
|
||||
TransportKey string
|
||||
TargetKey string
|
||||
TextField string
|
||||
MaxTextBytes int
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotDeclaration struct {
|
||||
@@ -108,6 +120,7 @@ type GameClientBridgeResultStatus string
|
||||
const (
|
||||
GameClientBridgeResultSucceeded GameClientBridgeResultStatus = "succeeded"
|
||||
GameClientBridgeResultFailed GameClientBridgeResultStatus = "failed"
|
||||
GameClientBridgeResultUnknown GameClientBridgeResultStatus = "unknown"
|
||||
GameClientBridgeResultCancelled GameClientBridgeResultStatus = "cancelled"
|
||||
)
|
||||
|
||||
@@ -384,6 +397,12 @@ func CopyGameClientBridgePayload(value map[string]any) map[string]any {
|
||||
|
||||
func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest {
|
||||
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
|
||||
for index := range value.Commands {
|
||||
if value.Commands[index].ProtectedRequest != nil {
|
||||
copy := *value.Commands[index].ProtectedRequest
|
||||
value.Commands[index].ProtectedRequest = ©
|
||||
}
|
||||
}
|
||||
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
||||
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
||||
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
|
||||
|
||||
@@ -1045,6 +1045,9 @@ const (
|
||||
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
|
||||
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||
JobCapabilityRemoteRunProtectedSQL = "remote.run.protected.sql"
|
||||
JobCapabilityRemoteRunProtectedRCON = "remote.run.protected.rcon"
|
||||
JobCapabilityRemoteRunProgram = "remote.run.program.command"
|
||||
JobCapabilityRunSelfUpdate = "run.self-update"
|
||||
JobCapabilityDistributionBuild = "distribution.build"
|
||||
JobCapabilityDependenciesCheck = "dependencies.check"
|
||||
|
||||
+33
-10
@@ -262,14 +262,23 @@ type GamePluginRemoteAccessBody struct {
|
||||
}
|
||||
|
||||
type GameClientBridgeCommandDeclarationBody struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Permission string `json:"permission"`
|
||||
ApprovalLevel string `json:"approvalLevel"`
|
||||
PayloadSchemaRef string `json:"payloadSchemaRef"`
|
||||
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
MaxPayloadBytes int `json:"maxPayloadBytes"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Permission string `json:"permission"`
|
||||
ApprovalLevel string `json:"approvalLevel"`
|
||||
PayloadSchemaRef string `json:"payloadSchemaRef"`
|
||||
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
MaxPayloadBytes int `json:"maxPayloadBytes"`
|
||||
ProtectedRequest *GameClientBridgeProtectedRequestDeclarationBody `json:"protectedRequest,omitempty"`
|
||||
}
|
||||
|
||||
type GameClientBridgeProtectedRequestDeclarationBody struct {
|
||||
Kind string `json:"kind"`
|
||||
TransportKey string `json:"transportKey"`
|
||||
TargetKey string `json:"targetKey"`
|
||||
TextField string `json:"textField"`
|
||||
MaxTextBytes int `json:"maxTextBytes"`
|
||||
}
|
||||
|
||||
type GameClientBridgeSnapshotDeclarationBody struct {
|
||||
@@ -1110,7 +1119,7 @@ func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAcces
|
||||
func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManifest {
|
||||
commands := make([]domain.GameClientBridgeCommandDeclaration, len(body.Commands))
|
||||
for index, command := range body.Commands {
|
||||
commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes}
|
||||
commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes, ProtectedRequest: protectedRequestToDomain(command.ProtectedRequest)}
|
||||
}
|
||||
snapshots := make([]domain.GameClientBridgeSnapshotDeclaration, len(body.Snapshots))
|
||||
for index, snapshot := range body.Snapshots {
|
||||
@@ -1135,6 +1144,13 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
||||
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &domain.GameClientBridgeProtectedRequestDeclaration{Kind: value.Kind, TransportKey: value.TransportKey, TargetKey: value.TargetKey, TextField: value.TextField, MaxTextBytes: value.MaxTextBytes}
|
||||
}
|
||||
|
||||
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
||||
return domain.PluginLifecycleActions{
|
||||
Install: actions.Install,
|
||||
@@ -1527,7 +1543,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
value = domain.CopyGameClientBridgeManifest(value)
|
||||
commands := make([]GameClientBridgeCommandDeclarationBody, len(value.Commands))
|
||||
for index, command := range value.Commands {
|
||||
commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: string(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes}
|
||||
commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: string(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes, ProtectedRequest: protectedRequestFromDomain(command.ProtectedRequest)}
|
||||
}
|
||||
snapshots := make([]GameClientBridgeSnapshotDeclarationBody, len(value.Snapshots))
|
||||
for index, snapshot := range value.Snapshots {
|
||||
@@ -1552,6 +1568,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
||||
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
|
||||
}
|
||||
|
||||
func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &GameClientBridgeProtectedRequestDeclarationBody{Kind: value.Kind, TransportKey: value.TransportKey, TargetKey: value.TargetKey, TextField: value.TextField, MaxTextBytes: value.MaxTextBytes}
|
||||
}
|
||||
|
||||
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
|
||||
items := make([]MarketplacePluginResponse, len(plugins))
|
||||
for i, plugin := range plugins {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
@@ -251,6 +252,30 @@ func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQue
|
||||
return values
|
||||
}
|
||||
|
||||
func validateProtectedGameClientBridgePayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) error {
|
||||
if declaration == nil {
|
||||
return nil
|
||||
}
|
||||
if len(payload) != 1 {
|
||||
return validationError("protected bridge request must contain only its declared text field")
|
||||
}
|
||||
value, exists := payload[declaration.TextField]
|
||||
if !exists {
|
||||
return validationError("protected bridge request text field is required")
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok || len([]byte(text)) == 0 || len([]byte(text)) > declaration.MaxTextBytes {
|
||||
return validationError("protected bridge request text is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func protectedGameClientBridgeAuditSummary(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) string {
|
||||
text, _ := payload[declaration.TextField].(string)
|
||||
digest := sha256.Sum256([]byte(text))
|
||||
return fmt.Sprintf("queued protected %s request transport=%s target=%s text=redacted sha256=%x", declaration.Kind, declaration.TransportKey, declaration.TargetKey, digest[:8])
|
||||
}
|
||||
|
||||
func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
|
||||
request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
|
||||
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||
@@ -274,6 +299,9 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
||||
if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
|
||||
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout")
|
||||
}
|
||||
if err := validateProtectedGameClientBridgePayload(declaration.ProtectedRequest, request.Payload); err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
|
||||
existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
|
||||
if err == nil {
|
||||
@@ -312,7 +340,11 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
|
||||
CreatedAt: stamp,
|
||||
UpdatedAt: stamp,
|
||||
}
|
||||
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, "queued declared game client bridge command")
|
||||
summary := "queued declared game client bridge command"
|
||||
if declaration.ProtectedRequest != nil {
|
||||
summary = protectedGameClientBridgeAuditSummary(declaration.ProtectedRequest, request.Payload)
|
||||
}
|
||||
auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, summary)
|
||||
if err != nil {
|
||||
return domain.GameClientBridgeCommand{}, err
|
||||
}
|
||||
@@ -426,6 +458,8 @@ func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBrid
|
||||
command.State = domain.GameClientBridgeCommandSucceeded
|
||||
case domain.GameClientBridgeResultFailed:
|
||||
command.State = domain.GameClientBridgeCommandFailed
|
||||
case domain.GameClientBridgeResultUnknown:
|
||||
command.State = domain.GameClientBridgeCommandUnknown
|
||||
case domain.GameClientBridgeResultCancelled:
|
||||
command.State = domain.GameClientBridgeCommandCancelled
|
||||
}
|
||||
@@ -684,7 +718,7 @@ func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, compon
|
||||
|
||||
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
|
||||
switch state {
|
||||
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
|
||||
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -82,6 +83,38 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedGameClientBridgeRequestIsScopedAndRedacted(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
plugin, err := svc.store.GamePlugins().Get("game.scum")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "database", Kind: "sqlite", TargetKey: "database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}}
|
||||
plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "database.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 60, MaxPayloadBytes: 4096, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "sql", TransportKey: "database", TargetKey: "database", TextField: "requestText", MaxTextBytes: 1024}})
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := "UPDATE players SET rank = 2 WHERE id = 7"
|
||||
request := domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text}, IdempotencyKey: "protected-1", ExpiresAt: clock.Add(time.Minute)}
|
||||
command, err := svc.queueGameClientBridgeCommand("user-1", request)
|
||||
if err != nil {
|
||||
t.Fatalf("queue protected request: %v", err)
|
||||
}
|
||||
if command.ApprovalState != domain.GameClientBridgeApprovalPending {
|
||||
t.Fatalf("protected request bypassed approval: %#v", command)
|
||||
}
|
||||
if _, err := svc.queueGameClientBridgeCommand("user-1", domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text, "unexpected": true}, IdempotencyKey: "protected-extra", ExpiresAt: clock.Add(time.Minute)}); err == nil {
|
||||
t.Fatal("protected request accepted undeclared payload field")
|
||||
}
|
||||
events, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: command.ID})
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("protected request audit: events=%#v err=%v", events, err)
|
||||
}
|
||||
if strings.Contains(events[0].Summary, text) || !strings.Contains(events[0].Summary, "text=redacted") {
|
||||
t.Fatalf("audit leaked protected request: %#v", events[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
|
||||
svc, clock := newGameClientBridgeService(t)
|
||||
request := bridgeQueueRequest(*clock, "scope-key")
|
||||
|
||||
@@ -19,7 +19,7 @@ func scumDeploymentTestPlugin() domain.GamePlugin {
|
||||
Key: "scum-steamcmd-windows", Version: "1.0.0", SteamAppID: "3792580", ExecutableKey: "scum/server-executable", InstallRootKey: "server/install-root", ConfigKey: "scum/server-settings", ConfigFormat: "ini",
|
||||
Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "steamcmd"}, {Key: "vcredist-2012-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2012-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x64", Kind: "windows-vcredist"}, {Key: "directx-jun2010", Kind: "windows-directx"}},
|
||||
ConfigMappings: []domain.RuntimeServerConfigMapping{{FieldKey: "serverName", ConfigKey: "server-settings.server-name", ValueType: "text", Required: true}, {FieldKey: "gamePort", ConfigKey: "server-settings.game-port", ValueType: "port", Required: true}, {FieldKey: "queryPort", ConfigKey: "server-settings.query-port", ValueType: "port", Required: true}, {FieldKey: "maxPlayers", ConfigKey: "server-settings.max-players", ValueType: "integer", Required: true}},
|
||||
VerificationChecks: []domain.RuntimeServerVerificationCheck{{Key: "executable", Kind: "executable.present", TargetKey: "scum/server-executable", Required: true}, {Key: "version", Kind: "version.matches", TargetKey: "scum/server-executable", Required: true}, {Key: "game-port", Kind: "port.bound", TargetKey: "game-port", Required: true}, {Key: "config", Kind: "config.readable", TargetKey: "scum/server-settings", Required: true}, {Key: "process", Kind: "process.healthy", TargetKey: "scum/server-executable", Required: true}},
|
||||
VerificationChecks: []domain.RuntimeServerVerificationCheck{{Key: "executable", Kind: "executable.present", TargetKey: "scum/server-executable", Required: true}, {Key: "game-port", Kind: "port.bound", TargetKey: "game-port", Required: true}, {Key: "config", Kind: "config.readable", TargetKey: "scum/server-settings", Required: true}, {Key: "process", Kind: "process.healthy", TargetKey: "scum/server-executable", Required: true}},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func ValidateGameClientBridgeResultRequest(request domain.GameClientBridgeResult
|
||||
if request.FencingToken == 0 {
|
||||
violations = append(violations, "fencingToken is required")
|
||||
}
|
||||
if request.Status != domain.GameClientBridgeResultSucceeded && request.Status != domain.GameClientBridgeResultFailed && request.Status != domain.GameClientBridgeResultCancelled {
|
||||
if request.Status != domain.GameClientBridgeResultSucceeded && request.Status != domain.GameClientBridgeResultFailed && request.Status != domain.GameClientBridgeResultUnknown && request.Status != domain.GameClientBridgeResultCancelled {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
violations = appendGameClientBridgeText(violations, "summary", request.Summary, 512)
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestValidateGameClientBridgeRequestFieldBounds(t *testing.T) {
|
||||
{name: "claim token", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: " session"}), want: "sessionToken"},
|
||||
{name: "claim limit", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 51}), want: "limit"},
|
||||
{name: "ack fence", err: ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1"}), want: "fencingToken"},
|
||||
{name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unknown"}), want: "status"},
|
||||
{name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unexpected"}), want: "status"},
|
||||
{name: "result text", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "read /etc/passwd"}), want: "unsafe"},
|
||||
{name: "cancel text", err: ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "Bearer private"}), want: "unsafe"},
|
||||
{name: "snapshot payload", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "payload"},
|
||||
|
||||
@@ -420,10 +420,14 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
violations = append(violations, prefix+".profileKey must reference a declared Client Manager profile")
|
||||
}
|
||||
}
|
||||
transports := map[string]domain.RuntimeTransportProfile{}
|
||||
for _, transport := range runtimeProfiles.TransportProfiles {
|
||||
transports[transport.Key] = transport
|
||||
}
|
||||
commandTypes := map[string]struct{}{}
|
||||
for index, command := range bridge.Commands {
|
||||
prefix := fmt.Sprintf("%s.commands[%d]", field, index)
|
||||
if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) {
|
||||
if !clientManagerIdentifierPattern.MatchString(command.Type) || command.ProtectedRequest == nil && unsafeGameClientBridgeCommandType(command.Type) {
|
||||
violations = append(violations, prefix+".type is invalid or unsafe")
|
||||
}
|
||||
if _, exists := commandTypes[command.Type]; exists {
|
||||
@@ -448,6 +452,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
|
||||
violations = append(violations, prefix+".maxPayloadBytes is invalid")
|
||||
}
|
||||
violations = append(violations, validateGameClientBridgeProtectedRequest(prefix+".protectedRequest", command.ProtectedRequest, transports)...)
|
||||
}
|
||||
snapshotTypes := map[string]struct{}{}
|
||||
for index, snapshot := range bridge.Snapshots {
|
||||
@@ -468,10 +473,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
||||
}
|
||||
}
|
||||
queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{}
|
||||
transports := map[string]domain.RuntimeTransportProfile{}
|
||||
for _, transport := range runtimeProfiles.TransportProfiles {
|
||||
transports[transport.Key] = transport
|
||||
}
|
||||
for index, template := range bridge.QueryTemplates {
|
||||
prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index)
|
||||
if !clientManagerIdentifierPattern.MatchString(template.Key) {
|
||||
@@ -649,6 +650,50 @@ func unsafeGameClientBridgeCommandType(value string) bool {
|
||||
return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run")
|
||||
}
|
||||
|
||||
func validateGameClientBridgeProtectedRequest(prefix string, request *domain.GameClientBridgeProtectedRequestDeclaration, transports map[string]domain.RuntimeTransportProfile) []string {
|
||||
if request == nil {
|
||||
return nil
|
||||
}
|
||||
var violations []string
|
||||
if !oneOf(request.Kind, "sql", "rcon", "program") {
|
||||
violations = append(violations, prefix+".kind is invalid")
|
||||
}
|
||||
for field, value := range map[string]string{"transportKey": request.TransportKey, "targetKey": request.TargetKey, "textField": request.TextField} {
|
||||
if !validDistributionLogicalKey(value) || unsafeGameClientBridgePayloadKey(value) {
|
||||
violations = append(violations, prefix+"."+field+" is invalid")
|
||||
}
|
||||
}
|
||||
if request.MaxTextBytes < 1 || request.MaxTextBytes > maxGameClientBridgePayloadString {
|
||||
violations = append(violations, prefix+".maxTextBytes is invalid")
|
||||
}
|
||||
transport, exists := transports[request.TransportKey]
|
||||
if !exists {
|
||||
return append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
||||
}
|
||||
if transport.TargetKey != request.TargetKey {
|
||||
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
|
||||
}
|
||||
wantKind, wantCapability := "", ""
|
||||
switch request.Kind {
|
||||
case "sql":
|
||||
wantCapability = domain.JobCapabilityRemoteRunProtectedSQL
|
||||
case "rcon":
|
||||
wantKind, wantCapability = "rcon", domain.JobCapabilityRemoteRunProtectedRCON
|
||||
case "program":
|
||||
wantKind, wantCapability = "program", domain.JobCapabilityRemoteRunProgram
|
||||
}
|
||||
if request.Kind == "sql" && transport.Kind != "mysql" && transport.Kind != "sqlite" {
|
||||
violations = append(violations, prefix+".transportKey must use mysql or sqlite for sql requests")
|
||||
}
|
||||
if wantKind != "" && transport.Kind != wantKind {
|
||||
violations = append(violations, prefix+".transportKey does not match protected request kind")
|
||||
}
|
||||
if wantCapability != "" && !containsString(transport.Capabilities, wantCapability) {
|
||||
violations = append(violations, prefix+".transportKey is missing required protected transport capability")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "pluginId", request.PluginID)
|
||||
@@ -1902,6 +1947,7 @@ func validPluginRunCapability(capability string) bool {
|
||||
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
||||
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram,
|
||||
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
|
||||
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
|
||||
@@ -1934,7 +1980,8 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
|
||||
domain.JobCapabilityRemoteRunFilesWrite,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery,
|
||||
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunRCONCommand:
|
||||
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL,
|
||||
domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -296,7 +296,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
|
||||
violations = append(violations, recordRuntimeProfileKey(transportKeys, prefix+".key", transport.Key)...)
|
||||
if !oneOf(transport.Kind, "file", "ftp", "rsync", "mysql", "sqlite", "rcon") {
|
||||
if !oneOf(transport.Kind, "file", "ftp", "rsync", "mysql", "sqlite", "rcon", "program") {
|
||||
violations = append(violations, prefix+".kind is invalid")
|
||||
}
|
||||
if transport.TargetKey != "" {
|
||||
@@ -480,7 +480,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
}
|
||||
|
||||
func containsRequiredVerification(checks []domain.RuntimeServerVerificationCheck) bool {
|
||||
required := map[string]bool{"executable.present": false, "version.matches": false, "port.bound": false, "config.readable": false, "process.healthy": false}
|
||||
required := map[string]bool{"executable.present": false, "port.bound": false, "config.readable": false, "process.healthy": false}
|
||||
for _, check := range checks {
|
||||
if check.Required {
|
||||
if _, ok := required[check.Kind]; ok {
|
||||
|
||||
Reference in New Issue
Block a user