feat(plugin): add SCUM ownership migration foundation
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SafeAdapter is intentionally narrow: it receives typed values only and has
|
||||
// no raw RCON, SQL, host-path, credential, or shell access.
|
||||
type SafeAdapter interface {
|
||||
ReadConfiguration(context.Context) (map[string]any, error)
|
||||
PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
|
||||
Diagnostics(context.Context) (map[string]any, error)
|
||||
PatchGameState(context.Context, map[string]any) (map[string]any, error)
|
||||
DeliverReward(context.Context, map[string]any) (map[string]any, error)
|
||||
NotifyPlayer(context.Context, map[string]any) (map[string]any, error)
|
||||
}
|
||||
|
||||
type HandlerAvailability struct { ServerVersion string; Capabilities map[string]bool; Approved bool }
|
||||
type CommandHandler func(context.Context, map[string]any) (map[string]any, error)
|
||||
type HandlerRegistry struct { availability HandlerAvailability; handlers map[string]CommandHandler }
|
||||
|
||||
func NewHandlerRegistry(availability HandlerAvailability, adapter SafeAdapter) *HandlerRegistry {
|
||||
registry := &HandlerRegistry{availability: availability, handlers: map[string]CommandHandler{}}
|
||||
if adapter == nil { return registry }
|
||||
registry.handlers["config.read"] = func(ctx context.Context, _ map[string]any) (map[string]any, error) { return adapter.ReadConfiguration(ctx) }
|
||||
registry.handlers["config.patch"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.PatchConfiguration(ctx, payload) }
|
||||
registry.handlers["companion.diagnostics"] = func(ctx context.Context, _ map[string]any) (map[string]any, error) { return adapter.Diagnostics(ctx) }
|
||||
registry.handlers["game-state.patch"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.PatchGameState(ctx, payload) }
|
||||
registry.handlers["reward.deliver"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.DeliverReward(ctx, payload) }
|
||||
registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) { return adapter.NotifyPlayer(ctx, payload) }
|
||||
return registry
|
||||
}
|
||||
|
||||
func (registry *HandlerRegistry) Execute(ctx context.Context, command ClaimedCommand) (CommandResult, error) {
|
||||
if err := validateDeclaredCommand(command); err != nil { return unsupportedResult("validation-failed"), nil }
|
||||
if !registry.availability.Approved || !registry.availability.Capabilities[command.CommandType] { return unsupportedResult("unsupported"), nil }
|
||||
handler, exists := registry.handlers[command.CommandType]
|
||||
if !exists || strings.TrimSpace(registry.availability.ServerVersion) == "" { return unsupportedResult("unsupported"), nil }
|
||||
payload, err := handler(ctx, command.Payload)
|
||||
if err != nil { return CommandResult{Status: "failed", Summary: "typed adapter failed", Payload: map[string]any{"result": "failed"}}, nil }
|
||||
return CommandResult{Status: "succeeded", Summary: "typed adapter completed", Payload: redactTypedPayload(payload)}, nil
|
||||
}
|
||||
|
||||
func validateDeclaredCommand(command ClaimedCommand) error {
|
||||
if command.ID == "" || command.ProfileKey != ProfileKey || command.FencingToken == 0 || command.Payload == nil || command.ExpiresAt.IsZero() || !time.Now().Before(command.ExpiresAt) { return fmt.Errorf("invalid command") }
|
||||
for key, value := range command.Payload { if !safeCommandField(key, value) { return fmt.Errorf("unsafe payload") } }
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeCommandField(key string, value any) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(key)); if lower == "" || strings.Contains(lower, "path") || strings.Contains(lower, "credential") || strings.Contains(lower, "password") || strings.Contains(lower, "sql") || strings.Contains(lower, "rcon") || strings.Contains(lower, "command") { return false }
|
||||
if text, ok := value.(string); ok { compact := strings.ToLower(text); return !strings.Contains(compact, "bearer ") && !strings.Contains(compact, "password=") && !strings.Contains(compact, "select ") && !strings.Contains(compact, "/users/") }
|
||||
return true
|
||||
}
|
||||
|
||||
func unsupportedResult(code string) CommandResult { return CommandResult{Status: "failed", Summary: "typed operation unavailable", Payload: map[string]any{"result": code}} }
|
||||
func redactTypedPayload(payload map[string]any) map[string]any { result := map[string]any{}; for key, value := range payload { if safeCommandField(key, value) { result[key] = value } }; return result }
|
||||
|
||||
type Dispatcher struct { Client *Client; Registry *HandlerRegistry; PollLimit int; Backoff time.Duration }
|
||||
func (dispatcher Dispatcher) DispatchOnce(ctx context.Context) error {
|
||||
if dispatcher.Client == nil || dispatcher.Registry == nil { return fmt.Errorf("dispatcher is not configured") }
|
||||
limit := dispatcher.PollLimit; if limit == 0 { limit = 10 }; if limit < 1 || limit > 50 { return fmt.Errorf("dispatcher poll limit is invalid") }
|
||||
commands, err := dispatcher.Client.ClaimCommands(ctx, limit); if err != nil { return err }
|
||||
for _, command := range commands { if _, err = dispatcher.Client.AckCommand(ctx, command.ID, command.FencingToken); err != nil { return err }; result, executionErr := dispatcher.Registry.Execute(ctx, command); if executionErr != nil { result = CommandResult{Status: "failed", Summary: "typed adapter failed", Payload: map[string]any{"result": "failed"}} }; if _, err = dispatcher.Client.CompleteCommand(ctx, command.ID, command.FencingToken, result); err != nil { return err } }
|
||||
return nil
|
||||
}
|
||||
func (dispatcher Dispatcher) Run(ctx context.Context) error { backoff := dispatcher.Backoff; if backoff <= 0 { backoff = 2 * time.Second }; for { if err := dispatcher.DispatchOnce(ctx); err != nil { select { case <-ctx.Done(): return ctx.Err(); case <-time.After(backoff): continue } }; select { case <-ctx.Done(): return ctx.Err(); case <-time.After(backoff): } } }
|
||||
@@ -0,0 +1,32 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const semanticEventsSnapshotType = "semantic.events"
|
||||
const semanticEventsSchemaVersion = "1"
|
||||
const semanticEventsRetentionSeconds = 7 * 24 * 60 * 60
|
||||
const semanticEventsMaxRecords = 1000
|
||||
|
||||
type SemanticEvent struct { Type string `json:"type"`; OccurredAt time.Time `json:"occurredAt"`; PlayerID string `json:"playerId,omitempty"`; DisplayName string `json:"displayName,omitempty"`; NetworkCorrelation string `json:"networkCorrelation,omitempty"` }
|
||||
var supportedLoginLine = regexp.MustCompile(`^LOGIN player=([A-Za-z0-9._:-]{1,96}) name=([^\n]{1,80}) at=([0-9TZ:+.-]{20,40})(?: network=([^\s]{1,128}))?$`)
|
||||
var supportedLogoutLine = regexp.MustCompile(`^LOGOUT player=([A-Za-z0-9._:-]{1,96}) at=([0-9TZ:+.-]{20,40})$`)
|
||||
|
||||
// ParseSemanticEvent supports only versioned, allow-listed extension output.
|
||||
// Unknown formats deliberately yield no event and may be reported as a bounded
|
||||
// diagnostic by the caller.
|
||||
func ParseSemanticEvent(line string, serverCorrelationKey []byte) (SemanticEvent, bool) {
|
||||
if match := supportedLoginLine.FindStringSubmatch(strings.TrimSpace(line)); len(match) != 0 { occurred, err := time.Parse(time.RFC3339, match[3]); if err != nil { return SemanticEvent{}, false }; event := SemanticEvent{Type: "scum.login", PlayerID: match[1], DisplayName: match[2], OccurredAt: occurred}; if match[4] != "" { event.NetworkCorrelation = irreversibleServerCorrelation(serverCorrelationKey, match[4]) }; return event, true }
|
||||
if match := supportedLogoutLine.FindStringSubmatch(strings.TrimSpace(line)); len(match) != 0 { occurred, err := time.Parse(time.RFC3339, match[2]); if err != nil { return SemanticEvent{}, false }; return SemanticEvent{Type: "scum.logout", PlayerID: match[1], OccurredAt: occurred}, true }
|
||||
return SemanticEvent{}, false
|
||||
}
|
||||
func irreversibleServerCorrelation(key []byte, source string) string { if len(key) == 0 || source == "" { return "" }; mac := hmac.New(sha256.New, key); _, _ = mac.Write([]byte(source)); return hex.EncodeToString(mac.Sum(nil)) }
|
||||
func (client *Client) UploadSemanticEvents(ctx context.Context, streamKey string, sequence uint64, events []SemanticEvent) (AcceptedSnapshot, error) { if len(events) == 0 || len(events) > 100 { return AcceptedSnapshot{}, fmt.Errorf("semantic event batch is invalid") }; payload := map[string]any{"events": events}; return client.UploadSnapshot(ctx, Snapshot{Type: semanticEventsSnapshotType, SchemaVersion: semanticEventsSchemaVersion, StreamKey: streamKey, Sequence: sequence, ObservedAt: client.now().UTC(), Payload: payload, KeepForSeconds: semanticEventsRetentionSeconds, MaxRecords: semanticEventsMaxRecords}) }
|
||||
Reference in New Issue
Block a user