243 lines
9.5 KiB
Go
243 lines
9.5 KiB
Go
package companion
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
var errAdapterUnsupported = errors.New("versioned adapter is unsupported")
|
|
|
|
// AuthorizedConfigPort is supplied by a version-bound Companion integration.
|
|
// It exposes logical configuration values only: never a host path, connection
|
|
// string, credential, arbitrary command, or direct database handle.
|
|
type AuthorizedConfigPort interface {
|
|
ReadConfig(context.Context) (map[string]string, error)
|
|
ApplyConfigPatch(ctx context.Context, revision string, fields []ConfigFieldPatch) (map[string]string, error)
|
|
}
|
|
type ConfigFieldPatch struct {
|
|
Key string
|
|
Value string
|
|
}
|
|
|
|
const (
|
|
UE4SSReferenceRevision = "bae91527355f14faa63c1df65f742cc48594ba1b"
|
|
UE4SSReferenceBuild = "3.0.1"
|
|
fixedNotificationType = 4
|
|
)
|
|
|
|
// UE4SSNotificationPort is implemented only by a Companion-local,
|
|
// platform-authorized transport for the pinned UE4SS build. It receives a
|
|
// fixed typed notification, never a raw RCON command, credential, socket, or
|
|
// host path. Its private audit text is not part of command result payloads.
|
|
type UE4SSNotificationPort interface {
|
|
SendPlayerNotification(context.Context, ue4SSPlayerNotification) (UE4SSNotificationReceipt, error)
|
|
}
|
|
type UE4SSNotificationReceipt struct{ Accepted bool }
|
|
type ue4SSPlayerNotification struct {
|
|
ServerID string
|
|
RecipientSteamID string
|
|
Message string
|
|
chatType int
|
|
protectedAuditCommand string
|
|
}
|
|
|
|
// UE4SSVehicleSpawnPort is a Companion-local, platform-authorized transport
|
|
// for one fixed template. It accepts no raw command text, socket, credential,
|
|
// or host path. Implementations remain in this Companion package so the
|
|
// protected audit template cannot cross a general transport boundary.
|
|
type UE4SSVehicleSpawnPort interface {
|
|
SpawnVehicle(context.Context, ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error)
|
|
}
|
|
type UE4SSVehicleSpawnOutcome string
|
|
|
|
const (
|
|
UE4SSVehicleSpawnAccepted UE4SSVehicleSpawnOutcome = "accepted"
|
|
UE4SSVehicleSpawnRejected UE4SSVehicleSpawnOutcome = "rejected"
|
|
UE4SSVehicleSpawnUnknown UE4SSVehicleSpawnOutcome = "unknown"
|
|
)
|
|
|
|
type UE4SSVehicleSpawnReceipt struct{ Outcome UE4SSVehicleSpawnOutcome }
|
|
type ue4SSVehicleSpawn struct {
|
|
ServerID string
|
|
VehicleCode string
|
|
protectedAuditCommand string
|
|
}
|
|
|
|
type VersionedAdapter struct {
|
|
BoundServerID string
|
|
ServerVersion string
|
|
UE4SSBuild string
|
|
UE4SSReferenceRevision string
|
|
Config AuthorizedConfigPort
|
|
Notification UE4SSNotificationPort
|
|
VehicleSpawn UE4SSVehicleSpawnPort
|
|
DiagnosticsState map[string]string
|
|
}
|
|
|
|
func (adapter VersionedAdapter) ServerBinding() string { return adapter.BoundServerID }
|
|
|
|
func (adapter VersionedAdapter) ReadConfiguration(ctx context.Context) (map[string]any, error) {
|
|
if !supportedAdapterVersion(adapter.ServerVersion) || adapter.Config == nil {
|
|
return nil, errAdapterUnsupported
|
|
}
|
|
fields, err := adapter.Config.ReadConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"version": adapter.ServerVersion, "fields": redactConfigValues(fields)}, nil
|
|
}
|
|
func (adapter VersionedAdapter) PatchConfiguration(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
|
if !supportedAdapterVersion(adapter.ServerVersion) || adapter.Config == nil {
|
|
return nil, errAdapterUnsupported
|
|
}
|
|
revision, _ := payload["revision"].(string)
|
|
raw, _ := payload["fields"].([]any)
|
|
fields := make([]ConfigFieldPatch, 0, len(raw))
|
|
for _, value := range raw {
|
|
item, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("configuration patch payload is invalid")
|
|
}
|
|
key, keyOK := item["key"].(string)
|
|
fieldValue, valueOK := item["value"].(string)
|
|
if !keyOK || !valueOK || !supportedConfigKey(key) {
|
|
return nil, fmt.Errorf("configuration patch field is unsupported")
|
|
}
|
|
fields = append(fields, ConfigFieldPatch{Key: key, Value: fieldValue})
|
|
}
|
|
applied, err := adapter.Config.ApplyConfigPatch(ctx, revision, fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"version": adapter.ServerVersion, "appliedFields": redactConfigValues(applied)}, nil
|
|
}
|
|
func (adapter VersionedAdapter) Diagnostics(context.Context) (map[string]any, error) {
|
|
state := map[string]any{"version": adapter.ServerVersion, "adapter": "version-bound", "configuration": supportedAdapterVersion(adapter.ServerVersion)}
|
|
for key, value := range adapter.DiagnosticsState {
|
|
if safeDiagnosticField(key, value) {
|
|
state[key] = value
|
|
}
|
|
}
|
|
return state, nil
|
|
}
|
|
func (VersionedAdapter) PatchGameState(context.Context, map[string]any) (map[string]any, error) {
|
|
return nil, errAdapterUnsupported
|
|
}
|
|
func (VersionedAdapter) DeliverReward(context.Context, map[string]any) (map[string]any, error) {
|
|
return nil, errAdapterUnsupported
|
|
}
|
|
func (adapter VersionedAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
|
if !adapter.supportsPinnedUE4SS() || adapter.Notification == nil {
|
|
return nil, errAdapterUnsupported
|
|
}
|
|
playerID, playerOK := payload["playerId"].(string)
|
|
message, messageOK := payload["message"].(string)
|
|
notification, err := newUE4SSPlayerNotification(adapter.BoundServerID, playerID, message)
|
|
if !playerOK || !messageOK || err != nil {
|
|
return nil, fmt.Errorf("notification payload is invalid")
|
|
}
|
|
receipt, err := adapter.Notification.SendPlayerNotification(ctx, notification)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("notification transport failed")
|
|
}
|
|
if !receipt.Accepted {
|
|
return map[string]any{"accepted": false, "message": "notification was not accepted"}, nil
|
|
}
|
|
return map[string]any{"accepted": true, "message": "notification accepted for online recipient"}, nil
|
|
}
|
|
func (adapter VersionedAdapter) SpawnVehicle(ctx context.Context, payload map[string]any) (map[string]any, error) {
|
|
if !adapter.supportsPinnedUE4SS() || adapter.VehicleSpawn == nil {
|
|
return nil, errAdapterUnsupported
|
|
}
|
|
vehicleCode, ok := payload["vehicleCode"].(string)
|
|
spawn, err := newUE4SSVehicleSpawn(adapter.BoundServerID, vehicleCode)
|
|
if !ok || err != nil {
|
|
return nil, fmt.Errorf("vehicle spawn payload is invalid")
|
|
}
|
|
receipt, err := adapter.VehicleSpawn.SpawnVehicle(ctx, spawn)
|
|
if err != nil || receipt.Outcome == UE4SSVehicleSpawnUnknown {
|
|
return map[string]any{"outcome": "unknown"}, nil
|
|
}
|
|
if receipt.Outcome == UE4SSVehicleSpawnRejected {
|
|
return map[string]any{"outcome": "failed"}, nil
|
|
}
|
|
if receipt.Outcome != UE4SSVehicleSpawnAccepted {
|
|
return map[string]any{"outcome": "unknown"}, nil
|
|
}
|
|
return map[string]any{"outcome": "succeeded"}, nil
|
|
}
|
|
|
|
func (adapter VersionedAdapter) supportsPinnedUE4SS() bool {
|
|
// The pinned source reflects SendChatLineToPlayer at runtime and fails
|
|
// closed on a schema change, so no unverified SCUM-version mapping is
|
|
// embedded here. The dispatcher still requires a discovered server version.
|
|
return adapter.BoundServerID != "" && supportedAdapterVersion(adapter.ServerVersion) && adapter.UE4SSBuild == UE4SSReferenceBuild && adapter.UE4SSReferenceRevision == UE4SSReferenceRevision
|
|
}
|
|
func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayerNotification, error) {
|
|
if strings.TrimSpace(serverID) == "" || !steamID64(playerID) || !validNotificationMessage(message) {
|
|
return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed UE4SS notification")
|
|
}
|
|
return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, protectedAuditCommand: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil
|
|
}
|
|
func newUE4SSVehicleSpawn(serverID, vehicleCode string) (ue4SSVehicleSpawn, error) {
|
|
if strings.TrimSpace(serverID) == "" || !supportedVehicleSpawnCode(vehicleCode) {
|
|
return ue4SSVehicleSpawn{}, fmt.Errorf("invalid typed UE4SS vehicle spawn")
|
|
}
|
|
return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, protectedAuditCommand: "#spawnvehicle " + vehicleCode}, nil
|
|
}
|
|
func steamID64(value string) bool {
|
|
if len(value) != 17 {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if character < '0' || character > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
func validNotificationMessage(value string) bool {
|
|
if value == "" || len(value) > 200 || !utf8.ValidString(value) {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if character < 0x20 || character == 0x7f {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
func escapeUE4SSChatMessage(value string) string {
|
|
return strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(value)
|
|
}
|
|
|
|
func supportedAdapterVersion(version string) bool { return version == "0.9.700.90357" }
|
|
func supportedVehicleSpawnCode(value string) bool {
|
|
return map[string]bool{"BPC_Laika_C": true, "BPC_WolfsWagen_C": true}[value]
|
|
}
|
|
func supportedConfigKey(key string) bool {
|
|
return map[string]bool{"ServerName": true, "GamePort": true, "QueryPort": true, "MaxPlayers": true, "WelcomeMessage": true}[key]
|
|
}
|
|
func redactConfigValues(values map[string]string) map[string]string {
|
|
result := map[string]string{}
|
|
keys := make([]string, 0, len(values))
|
|
for key := range values {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
if supportedConfigKey(key) && safeDiagnosticField(key, values[key]) {
|
|
result[key] = values[key]
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
func safeDiagnosticField(key, value string) bool {
|
|
lowered := strings.ToLower(key + "=" + value)
|
|
return !strings.Contains(lowered, "path") && !strings.Contains(lowered, "credential") && !strings.Contains(lowered, "password") && !strings.Contains(lowered, "bearer ") && !strings.Contains(lowered, "rcon") && !strings.Contains(lowered, "sql") && !strings.Contains(lowered, "://")
|
|
}
|