Files
run/runtime/source_rcon.go
2026-08-26 09:56:43 +08:00

306 lines
12 KiB
Go

package runtime
import (
"context"
"encoding/binary"
"encoding/hex"
"errors"
"io"
"net"
"strconv"
"strings"
"time"
"unicode/utf8"
"browser.local/run/protocol"
)
const (
sourceRCONAuthRequestID int32 = 1
sourceRCONCommandRequestID int32 = 2
sourceRCONResponseValue int32 = 0
sourceRCONAuthResponse int32 = 2
sourceRCONExecuteCommand int32 = 2
sourceRCONAuthenticate int32 = 3
sourceRCONMaxPacketSize = 4096
sourceRCONMaxCommandBytes = 4000
sourceRCONMaxResponsePackets = 32
sourceRCONMaxResponseBytes = 64 * 1024
sourceRCONIOTimeout = 10 * time.Second
sourceRCONMaxExecutionTimeout = 60 * time.Second
)
type sourceRCONPacket struct {
id int32
typeCode int32
body string
}
type sourceRCONConfig struct {
password string
}
type sourceRCONError struct {
code string
}
func (err sourceRCONError) Error() string { return err.code }
// ExecuteSourceRCON connects only to the local UE4SS listener described by a
// frozen plan. The command, config, password, and response body are transient.
func (executor LifecycleExecutor) ExecuteSourceRCON(ctx context.Context, assignment protocol.RunJobAssignment, command string) LifecycleExecutionResult {
if assignment.ExecutionInput.SourceRCON == nil || protocol.ValidateRunJobAssignment(assignment) != nil {
return lifecycleFailure("unsafe_source_rcon_plan", "Source RCON plan is invalid")
}
if executor.runtimeTargetOS != "windows" || executor.runtimeTargetArch != "amd64" {
return lifecycleFailure("unsupported_extension_platform", "Source RCON requires Windows amd64")
}
if !validSourceRCONCommand(command) {
return lifecycleFailure("source_rcon_input_invalid", "Source RCON command input is invalid")
}
executionCtx, cancel := sourceRCONExecutionContext(ctx, assignment.ExecutionInput.TimeoutSeconds)
defer cancel()
resolver := NewWorkspaceResolver(executor.workspaceRoot)
scope, err := resolver.Scope(assignment.ServerInstanceID, assignment.ExecutionInput.WorkspaceScope)
if err != nil {
return lifecycleFailure("source_rcon_workspace_unavailable", "Source RCON workspace is unavailable")
}
configPath, err := sourceRCONConfigPath(resolver, scope, *assignment.ExecutionInput.SourceRCON)
if err != nil {
return lifecycleFailure("source_rcon_config_unavailable", "Source RCON configuration is unavailable")
}
config, err := loadSourceRCONConfig(configPath, assignment.ExecutionInput.SourceRCON.Port)
if err != nil {
return lifecycleFailure("source_rcon_config_invalid", "Source RCON configuration is invalid")
}
if err := executeSourceRCONWire(executionCtx, assignment.ExecutionInput.SourceRCON.Port, config.password, command); err != nil {
if errors.Is(executionCtx.Err(), context.Canceled) {
return LifecycleExecutionResult{State: lifecycleResultStateCancelled, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "Source RCON command cancelled"}, Message: "Source RCON command cancelled", ErrorCode: "source_rcon_cancelled"}
}
if errors.Is(executionCtx.Err(), context.DeadlineExceeded) {
return lifecycleFailure("source_rcon_timeout", "Source RCON command timed out")
}
var sourceErr sourceRCONError
if errors.As(err, &sourceErr) {
return lifecycleFailure(sourceErr.code, sourceRCONSafeMessage(sourceErr.code))
}
return lifecycleFailure("source_rcon_execution_failed", "Source RCON command failed")
}
return LifecycleExecutionResult{
State: lifecycleResultStateSucceeded,
Progress: protocol.RunJobProgressReport{Percent: 100, Message: "Source RCON command delivered"},
Message: "Source RCON command delivered",
ExecutionResult: protocol.RunJobExecutionResult{
Kind: "source-rcon",
Summary: "one-time loopback Source RCON command delivered",
},
}
}
func sourceRCONExecutionContext(ctx context.Context, timeoutSeconds int) (context.Context, context.CancelFunc) {
timeout := time.Duration(timeoutSeconds) * time.Second
if timeout <= 0 || timeout > sourceRCONMaxExecutionTimeout {
timeout = sourceRCONMaxExecutionTimeout
}
return context.WithTimeout(ctx, timeout)
}
func validSourceRCONCommand(command string) bool {
return strings.TrimSpace(command) != "" && utf8.ValidString(command) && len([]byte(command)) <= sourceRCONMaxCommandBytes && !strings.ContainsAny(command, "\x00\r\n")
}
func loadSourceRCONConfig(path string, expectedPort int) (sourceRCONConfig, error) {
body, found, err := readBoundedRegularFile(path, maxUE4SSMetadataBytes)
if err != nil || !found || !utf8.Valid(body) {
return sourceRCONConfig{}, sourceRCONError{code: "source_rcon_config_invalid"}
}
content := strings.ReplaceAll(string(body), "\r\n", "\n")
if !strings.Contains(content, managedRCONConfigMarker) {
return sourceRCONConfig{}, sourceRCONError{code: "source_rcon_config_invalid"}
}
values := map[string]string{}
inRCON := false
for _, rawLine := range strings.Split(content, "\n") {
line := strings.TrimSpace(rawLine)
if line == "[rcon]" {
inRCON = true
continue
}
if strings.HasPrefix(line, "[") {
inRCON = false
continue
}
if !inRCON || line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
if key != "bind_address" && key != "port" && key != "password" {
continue
}
if _, duplicate := values[key]; duplicate {
return sourceRCONConfig{}, sourceRCONError{code: "source_rcon_config_invalid"}
}
values[key] = strings.TrimSpace(value)
}
configuredPort, err := strconv.Atoi(values["port"])
if err != nil || values["bind_address"] != "127.0.0.1" || configuredPort != expectedPort || len(values["password"]) != 64 {
return sourceRCONConfig{}, sourceRCONError{code: "source_rcon_config_invalid"}
}
if _, err := hex.DecodeString(values["password"]); err != nil {
return sourceRCONConfig{}, sourceRCONError{code: "source_rcon_config_invalid"}
}
return sourceRCONConfig{password: values["password"]}, nil
}
func sourceRCONConfigPath(resolver WorkspaceResolver, scope string, plan protocol.RuntimeSourceRCONPlan) (string, error) {
markerPath, err := resolver.ExistingTarget(scope, plan.DeploymentStateRef)
if err != nil {
return "", sourceRCONError{code: "source_rcon_config_unavailable"}
}
marker, found, err := loadManagedDLLExtensionMarker(markerPath)
if err != nil || !found || !sourceRCONMarkerMatchesPlan(marker, plan) {
return "", sourceRCONError{code: "source_rcon_config_unavailable"}
}
configPath, err := resolver.ExistingTarget(scope, marker.ConfigRef)
if err != nil {
return "", sourceRCONError{code: "source_rcon_config_unavailable"}
}
return configPath, nil
}
func sourceRCONMarkerMatchesPlan(marker managedDLLExtensionMarker, plan protocol.RuntimeSourceRCONPlan) bool {
if marker.ExtensionKey != plan.ExtensionKey || marker.ModKey != plan.ModKey || marker.RCONPort != plan.Port || !managedRCONConfigRefForMod(marker.ConfigRef, plan.ModKey) {
return false
}
return marker.ConfigRef == plan.ConfigRef || strings.HasSuffix(marker.ConfigRef, "/"+plan.ConfigRef)
}
func executeSourceRCONWire(ctx context.Context, port int, password string, command string) error {
dialer := net.Dialer{Timeout: sourceRCONIOTimeout}
connection, err := dialer.DialContext(ctx, "tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
if err != nil {
return sourceRCONError{code: "source_rcon_connection_failed"}
}
defer connection.Close()
if err := writeSourceRCONPacket(ctx, connection, sourceRCONPacket{id: sourceRCONAuthRequestID, typeCode: sourceRCONAuthenticate, body: password}); err != nil {
return sourceRCONError{code: "source_rcon_connection_failed"}
}
auth, err := readSourceRCONPacket(ctx, connection)
if err != nil {
return sourceRCONError{code: "source_rcon_protocol_failed"}
}
if auth.typeCode != sourceRCONAuthResponse || auth.id == -1 {
return sourceRCONError{code: "source_rcon_authentication_failed"}
}
if auth.id != sourceRCONAuthRequestID || auth.body != "" {
return sourceRCONError{code: "source_rcon_protocol_failed"}
}
if err := writeSourceRCONPacket(ctx, connection, sourceRCONPacket{id: sourceRCONCommandRequestID, typeCode: sourceRCONExecuteCommand, body: command}); err != nil {
return sourceRCONError{code: "source_rcon_connection_failed"}
}
responseBytes := 0
sourceError := false
responsePrefix := make([]byte, 0, len("error:"))
for packetIndex := 0; packetIndex < sourceRCONMaxResponsePackets; packetIndex++ {
response, err := readSourceRCONPacket(ctx, connection)
if err != nil {
return sourceRCONError{code: "source_rcon_protocol_failed"}
}
if response.id != sourceRCONCommandRequestID || response.typeCode != sourceRCONResponseValue {
return sourceRCONError{code: "source_rcon_protocol_failed"}
}
responseBytes += len([]byte(response.body))
if responseBytes > sourceRCONMaxResponseBytes {
return sourceRCONError{code: "source_rcon_protocol_failed"}
}
if len(responsePrefix) < cap(responsePrefix) {
remaining := cap(responsePrefix) - len(responsePrefix)
chunk := []byte(response.body)
if len(chunk) > remaining {
chunk = chunk[:remaining]
}
responsePrefix = append(responsePrefix, chunk...)
}
if len(responsePrefix) == len("error:") && strings.EqualFold(string(responsePrefix), "error:") {
sourceError = true
}
if response.body == "" {
if sourceError {
return sourceRCONError{code: "source_rcon_command_failed"}
}
return nil
}
}
return sourceRCONError{code: "source_rcon_protocol_failed"}
}
func writeSourceRCONPacket(ctx context.Context, connection net.Conn, packet sourceRCONPacket) error {
if !utf8.ValidString(packet.body) || len([]byte(packet.body)) > sourceRCONMaxCommandBytes {
return sourceRCONError{code: "source_rcon_protocol_failed"}
}
size := 8 + len(packet.body) + 2
if size < 10 || size > sourceRCONMaxPacketSize {
return sourceRCONError{code: "source_rcon_protocol_failed"}
}
buffer := make([]byte, 4+size)
binary.LittleEndian.PutUint32(buffer[0:4], uint32(size))
binary.LittleEndian.PutUint32(buffer[4:8], uint32(packet.id))
binary.LittleEndian.PutUint32(buffer[8:12], uint32(packet.typeCode))
copy(buffer[12:], packet.body)
if err := setSourceRCONDeadline(ctx, connection); err != nil {
return err
}
_, err := connection.Write(buffer)
return err
}
func readSourceRCONPacket(ctx context.Context, connection net.Conn) (sourceRCONPacket, error) {
if err := setSourceRCONDeadline(ctx, connection); err != nil {
return sourceRCONPacket{}, err
}
var sizeBuffer [4]byte
if _, err := io.ReadFull(connection, sizeBuffer[:]); err != nil {
return sourceRCONPacket{}, err
}
size := int(int32(binary.LittleEndian.Uint32(sizeBuffer[:])))
if size < 10 || size > sourceRCONMaxPacketSize {
return sourceRCONPacket{}, sourceRCONError{code: "source_rcon_protocol_failed"}
}
payload := make([]byte, size)
if _, err := io.ReadFull(connection, payload); err != nil {
return sourceRCONPacket{}, err
}
if payload[size-2] != 0 || payload[size-1] != 0 || !utf8.Valid(payload[8:size-2]) {
return sourceRCONPacket{}, sourceRCONError{code: "source_rcon_protocol_failed"}
}
return sourceRCONPacket{id: int32(binary.LittleEndian.Uint32(payload[0:4])), typeCode: int32(binary.LittleEndian.Uint32(payload[4:8])), body: string(payload[8 : size-2])}, nil
}
func setSourceRCONDeadline(ctx context.Context, connection net.Conn) error {
deadline := time.Now().Add(sourceRCONIOTimeout)
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
deadline = contextDeadline
}
return connection.SetDeadline(deadline)
}
func sourceRCONSafeMessage(code string) string {
switch code {
case "source_rcon_connection_failed":
return "Source RCON listener is unavailable"
case "source_rcon_authentication_failed":
return "Source RCON authentication failed"
case "source_rcon_command_failed":
return "Source RCON command was rejected"
case "source_rcon_protocol_failed":
return "Source RCON protocol exchange failed"
default:
return "Source RCON command failed"
}
}