306 lines
12 KiB
Go
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"
|
|
}
|
|
}
|