Files
run/runtime/ue4ss_dll_extension.go

557 lines
21 KiB
Go

package runtime
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
pathpkg "path"
"path/filepath"
"strconv"
"strings"
"time"
"browser.local/run/protocol"
)
const (
ue4ssExtensionStateRoot = "runtime/ue4ss-dll"
ue4ssExtensionMarkerVersion = 2
maxUE4SSMetadataBytes int64 = 16 * 1024
maxUE4SSDLLBytes int64 = 128 * 1024 * 1024
maxTargetExecutableBytes int64 = 2 * 1024 * 1024 * 1024
managedRCONConfigMarker = "; managed by Run UE4SS DLL extension"
)
type dllExtensionError struct {
code string
message string
}
func (err dllExtensionError) Error() string { return err.message }
type managedDLLExtensionMarker struct {
Version int `json:"version"`
ReleaseVersion string `json:"releaseVersion"`
Checksum string `json:"checksum"`
SizeBytes int64 `json:"sizeBytes"`
ExtensionKey string `json:"extensionKey"`
ModKey string `json:"modKey"`
ConfigRef string `json:"configRef"`
RCONPort int `json:"rconPort"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (executor LifecycleExecutor) synchronizeUE4SSDLLExtensions(ctx context.Context, assignment protocol.RunJobAssignment, template LifecycleActionTemplate, scope string) error {
if executor.runtimeTargetOS != "windows" || executor.runtimeTargetArch != "amd64" {
return dllExtensionError{code: "unsupported_extension_platform", message: "UE4SS DLL extensions require Windows amd64"}
}
if err := ctx.Err(); err != nil {
return err
}
executableKey := template.TargetExecutableKey
// Older generated plugin packages do not have targetExecutableKey yet,
// but the existing start action already carries the same logical path
// in SERVER_EXECUTABLE_REF. Keep those packages forward-compatible.
if executableKey == "" && template.Environment != nil {
executableKey = template.Environment["SERVER_EXECUTABLE_REF"]
}
if executableKey == "" && template.Env != nil {
executableKey = template.Env["SERVER_EXECUTABLE_REF"]
}
if executableKey == "" && strings.HasSuffix(strings.ToLower(template.ExecutableKey), ".exe") {
executableKey = template.ExecutableKey
}
if executableKey == "" || !strings.HasSuffix(strings.ToLower(executableKey), ".exe") {
return dllExtensionError{code: "extension_target_executable_invalid", message: "UE4SS DLL extensions require a declared target executable"}
}
resolver := NewWorkspaceResolver(executor.workspaceRoot)
targetResolver, targetScope := resolver, scope
if deployment := assignment.ExecutionInput.Deployment; deployment != nil && deployment.ServerRoot != "" {
root := filepath.Clean(deployment.ServerRoot)
if root == "." || !filepath.IsAbs(root) {
return dllExtensionError{code: "extension_target_executable_invalid", message: "declared executable root is unsafe"}
}
targetResolver = NewWorkspaceResolver(filepath.Dir(root))
targetScope = root
}
executable, err := declaredLifecycleExecutable(targetResolver, targetScope, executableKey)
if err != nil {
return dllExtensionError{code: "extension_target_executable_invalid", message: "declared target executable is unavailable"}
}
executableChecksum, _, err := checksumRegularFile(executable, maxTargetExecutableBytes)
if err != nil {
return dllExtensionError{code: "extension_target_executable_invalid", message: "declared target executable cannot be verified"}
}
for _, plan := range assignment.ExecutionInput.DLLExtensions {
if !strings.EqualFold(executableChecksum, plan.TargetExecutableChecksum) {
return dllExtensionError{code: "extension_target_checksum_mismatch", message: "declared target executable does not match the extension release"}
}
}
gameRootKey, err := gameRootKeyForExecutable(template.ExecutableKey)
if err != nil {
return dllExtensionError{code: "extension_target_executable_invalid", message: "declared target executable location is unsafe"}
}
if err := verifyUE4SSBootstrap(targetResolver, targetScope, gameRootKey); err != nil {
return err
}
for _, plan := range assignment.ExecutionInput.DLLExtensions {
if err := executor.synchronizeUE4SSDLLExtension(ctx, targetResolver, targetScope, gameRootKey, plan); err != nil {
return err
}
}
return nil
}
func declaredLifecycleExecutable(resolver WorkspaceResolver, scope string, executableKey string) (string, error) {
if !protocol.ValidLogicalFileKey(filepath.ToSlash(executableKey)) {
return "", fmt.Errorf("declared executable key is unsafe")
}
return resolver.ExistingTarget(scope, executableKey)
}
func (executor LifecycleExecutor) synchronizeUE4SSDLLExtension(ctx context.Context, resolver WorkspaceResolver, scope string, gameRootKey string, plan protocol.RuntimeDLLExtensionPlan) error {
activeKey := gameRelativeKey(gameRootKey, plan.DLLRef)
configRef := managedRCONConfigRef(gameRootKey, plan.ModKey)
activePath, _, err := resolver.WritableTarget(scope, activeKey)
if err != nil {
return dllExtensionError{code: "dll_extension_workspace_failed", message: "extension deployment workspace is unavailable"}
}
markerPath, stagePath, previousPath, err := extensionStatePaths(resolver, scope, plan)
if err != nil {
return dllExtensionError{code: "dll_extension_workspace_failed", message: "extension state workspace is unavailable"}
}
marker, markerFound, err := loadManagedDLLExtensionMarker(markerPath)
if err != nil {
return dllExtensionError{code: "dll_extension_state_failed", message: "extension release state cannot be read"}
}
unchanged := markerFound && markerMatchesDeployment(marker, plan, configRef) && managedDLLMatchesPlan(activePath, plan)
if !unchanged {
_ = os.Remove(stagePath)
defer os.Remove(stagePath)
downloadedSize, downloadedChecksum, downloadErr := executor.dependencyDownloader.Download(ctx, plan.ReleaseURL, stagePath, plan.SizeBytes)
if downloadErr != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return dllExtensionError{code: "dll_extension_download_failed", message: "declared DLL download failed"}
}
verifiedChecksum, verifiedSize, verifyErr := checksumRegularFile(stagePath, plan.SizeBytes)
if verifyErr != nil || downloadedSize != plan.SizeBytes || verifiedSize != plan.SizeBytes || !strings.EqualFold(downloadedChecksum, plan.Checksum) || !strings.EqualFold(verifiedChecksum, plan.Checksum) {
return dllExtensionError{code: "dll_extension_verify_failed", message: "declared DLL did not match its fixed release checksum"}
}
}
if err := executor.ensureLoopbackRCONConfig(resolver, scope, gameRootKey, plan); err != nil {
return err
}
if err := executor.ensureUE4SSModsIndex(resolver, scope, gameRootKey, plan.ModKey); err != nil {
return err
}
if unchanged {
return nil
}
if err := executor.activateManagedDLLExtension(activePath, stagePath, previousPath, markerPath, plan, configRef); err != nil {
return err
}
return nil
}
func verifyUE4SSBootstrap(resolver WorkspaceResolver, scope string, gameRootKey string) error {
for _, filename := range []string{"dwmapi.dll", "UE4SS.dll"} {
if _, err := resolver.ExistingTarget(scope, gameRelativeKey(gameRootKey, filename)); err != nil {
return dllExtensionError{code: "ue4ss_bootstrap_missing", message: "required UE4SS bootstrap files are not installed"}
}
}
if err := existingRuntimeDirectory(scope, gameRelativeKey(gameRootKey, "ue4ss")); err != nil {
return dllExtensionError{code: "ue4ss_bootstrap_missing", message: "required UE4SS bootstrap files are not installed"}
}
return nil
}
func gameRootKeyForExecutable(executableKey string) (string, error) {
normalized := filepath.ToSlash(executableKey)
if !protocol.ValidLogicalFileKey(normalized) || strings.HasPrefix(normalized, "/") || strings.Contains(normalized, `\`) {
return "", fmt.Errorf("executable key is unsafe")
}
parent := pathpkg.Dir(normalized)
if parent == "." {
return "", nil
}
return parent, nil
}
func gameRelativeKey(gameRootKey string, relativeKey string) string {
if gameRootKey == "" {
return relativeKey
}
return gameRootKey + "/" + relativeKey
}
func extensionStatePaths(resolver WorkspaceResolver, scope string, plan protocol.RuntimeDLLExtensionPlan) (string, string, string, error) {
baseKey := ue4ssExtensionStateRoot + "/" + plan.TargetKey
markerPath, _, err := resolver.WritableTarget(scope, baseKey+"/release.json")
if err != nil {
return "", "", "", err
}
stagePath, _, err := resolver.WritableTarget(scope, baseKey+"/download.staged")
if err != nil {
return "", "", "", err
}
previousPath, _, err := resolver.WritableTarget(scope, baseKey+"/previous.dll")
if err != nil {
return "", "", "", err
}
return markerPath, stagePath, previousPath, nil
}
func loadManagedDLLExtensionMarker(path string) (managedDLLExtensionMarker, bool, error) {
body, found, err := readBoundedRegularFile(path, maxUE4SSMetadataBytes)
if err != nil || !found {
return managedDLLExtensionMarker{}, found, err
}
var marker managedDLLExtensionMarker
if err := json.Unmarshal(body, &marker); err != nil {
return managedDLLExtensionMarker{}, false, nil
}
if marker.Version != ue4ssExtensionMarkerVersion || !protocol.ValidLogicalFileKey(marker.ExtensionKey) || !protocol.ValidLogicalFileKey(marker.ModKey) || !managedRCONConfigRefForMod(marker.ConfigRef, marker.ModKey) || !protocolValidSHA256(marker.Checksum) || marker.SizeBytes < 1 || marker.ReleaseVersion == "" || marker.RCONPort < 1024 || marker.RCONPort > 65535 {
return managedDLLExtensionMarker{}, false, nil
}
return marker, true, nil
}
func markerMatchesPlan(marker managedDLLExtensionMarker, plan protocol.RuntimeDLLExtensionPlan) bool {
return marker.Version == ue4ssExtensionMarkerVersion && marker.ReleaseVersion == plan.Version && strings.EqualFold(marker.Checksum, plan.Checksum) && marker.SizeBytes == plan.SizeBytes && marker.ExtensionKey == plan.Key && marker.ModKey == plan.ModKey && marker.RCONPort == plan.RCONPort
}
func markerMatchesDeployment(marker managedDLLExtensionMarker, plan protocol.RuntimeDLLExtensionPlan, configRef string) bool {
return markerMatchesPlan(marker, plan) && marker.ConfigRef == configRef
}
func managedDLLMatchesPlan(path string, plan protocol.RuntimeDLLExtensionPlan) bool {
checksum, size, err := checksumRegularFile(path, plan.SizeBytes)
return err == nil && size == plan.SizeBytes && strings.EqualFold(checksum, plan.Checksum)
}
func (executor LifecycleExecutor) activateManagedDLLExtension(activePath string, stagePath string, previousPath string, markerPath string, plan protocol.RuntimeDLLExtensionPlan, configRef string) error {
if _, _, err := checksumRegularFile(stagePath, plan.SizeBytes); err != nil {
return dllExtensionError{code: "dll_extension_verify_failed", message: "staged DLL cannot be verified"}
}
previousMarker, previousMarkerFound, markerErr := readBoundedRegularFile(markerPath, maxUE4SSMetadataBytes)
if markerErr != nil {
return dllExtensionError{code: "dll_extension_state_failed", message: "extension release state cannot be read"}
}
activeExists := false
if _, _, err := checksumRegularFile(activePath, maxUE4SSDLLBytes); err == nil {
activeExists = true
if err := copyRegularFileAtomic(activePath, previousPath, maxUE4SSDLLBytes, 0o600); err != nil {
return dllExtensionError{code: "dll_extension_activation_failed", message: "previous DLL could not be retained"}
}
} else if !errors.Is(err, os.ErrNotExist) {
return dllExtensionError{code: "dll_extension_activation_failed", message: "current DLL cannot be safely replaced"}
}
if err := os.Rename(stagePath, activePath); err != nil {
return dllExtensionError{code: "dll_extension_activation_failed", message: "verified DLL could not be activated"}
}
if err := os.Chmod(activePath, 0o600); err != nil {
rollbackManagedDLLExtension(activePath, previousPath, activeExists)
return dllExtensionError{code: "dll_extension_activation_failed", message: "activated DLL permissions could not be secured"}
}
marker := managedDLLExtensionMarker{Version: ue4ssExtensionMarkerVersion, ReleaseVersion: plan.Version, Checksum: strings.ToLower(plan.Checksum), SizeBytes: plan.SizeBytes, ExtensionKey: plan.Key, ModKey: plan.ModKey, ConfigRef: configRef, RCONPort: plan.RCONPort, UpdatedAt: time.Now().UTC()}
body, err := json.Marshal(marker)
if err != nil || executor.writeRuntimeFile(markerPath, body, 0o600) != nil {
rollbackManagedDLLExtension(activePath, previousPath, activeExists)
if previousMarkerFound {
_ = executor.writeRuntimeFile(markerPath, previousMarker, 0o600)
} else {
_ = os.Remove(markerPath)
}
return dllExtensionError{code: "dll_extension_activation_failed", message: "extension release state could not be activated"}
}
return nil
}
func rollbackManagedDLLExtension(activePath string, previousPath string, activeExists bool) {
if activeExists {
_ = copyRegularFileAtomic(previousPath, activePath, maxUE4SSDLLBytes, 0o600)
return
}
_ = os.Remove(activePath)
}
func (executor LifecycleExecutor) ensureLoopbackRCONConfig(resolver WorkspaceResolver, scope string, gameRootKey string, plan protocol.RuntimeDLLExtensionPlan) error {
configKey := managedRCONConfigRef(gameRootKey, plan.ModKey)
configPath, _, err := resolver.WritableTarget(scope, configKey)
if err != nil {
return dllExtensionError{code: "dll_extension_config_failed", message: "loopback RCON configuration cannot be prepared"}
}
if managedLoopbackRCONConfigMatches(configPath, plan.RCONPort) {
return nil
}
password, err := randomRCONPassword()
if err != nil {
return dllExtensionError{code: "dll_extension_config_failed", message: "loopback RCON configuration cannot be secured"}
}
body := fmt.Sprintf("%s\n[rcon]\nbind_address=127.0.0.1\nport=%d\npassword=%s\n", managedRCONConfigMarker, plan.RCONPort, password)
if err := executor.writeRuntimeFile(configPath, []byte(body), 0o600); err != nil {
return dllExtensionError{code: "dll_extension_config_failed", message: "loopback RCON configuration cannot be written"}
}
return nil
}
func managedRCONConfigRef(gameRootKey string, modKey string) string {
return gameRelativeKey(gameRootKey, "ue4ss/Mods/"+modKey+"/config.ini")
}
func managedRCONConfigRefForMod(configRef string, modKey string) bool {
baseRef := "ue4ss/Mods/" + modKey + "/config.ini"
return protocol.ValidLogicalFileKey(configRef) && (configRef == baseRef || strings.HasSuffix(configRef, "/"+baseRef))
}
func managedLoopbackRCONConfigMatches(path string, port int) bool {
body, found, err := readBoundedRegularFile(path, maxUE4SSMetadataBytes)
if err != nil || !found {
return false
}
content := strings.ReplaceAll(string(body), "\r\n", "\n")
if !strings.Contains(content, managedRCONConfigMarker) {
return false
}
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 false
}
values[key] = strings.TrimSpace(value)
}
configuredPort, err := strconv.Atoi(values["port"])
if err != nil || values["bind_address"] != "127.0.0.1" || configuredPort != port || len(values["password"]) != 64 {
return false
}
_, err = hex.DecodeString(values["password"])
return err == nil
}
func randomRCONPassword() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func (executor LifecycleExecutor) ensureUE4SSModsIndex(resolver WorkspaceResolver, scope string, gameRootKey string, modKey string) error {
modsKey := gameRelativeKey(gameRootKey, "ue4ss/Mods/mods.txt")
modsPath, _, err := resolver.WritableTarget(scope, modsKey)
if err != nil {
return dllExtensionError{code: "dll_extension_mods_failed", message: "UE4SS mods index cannot be prepared"}
}
body, found, err := readBoundedRegularFile(modsPath, maxUE4SSMetadataBytes)
if err != nil {
return dllExtensionError{code: "dll_extension_mods_failed", message: "UE4SS mods index cannot be read"}
}
content := ""
if found {
content = strings.ReplaceAll(string(body), "\r\n", "\n")
}
lines := strings.Split(content, "\n")
if content == "" {
lines = nil
}
updated := make([]string, 0, len(lines)+1)
declared := false
for _, line := range lines {
if modsIndexLineKey(line) == modKey {
if !declared {
updated = append(updated, modKey+" : 1")
declared = true
}
continue
}
updated = append(updated, line)
}
if !declared {
updated = append(updated, modKey+" : 1")
}
next := strings.Join(updated, "\n")
if !strings.HasSuffix(next, "\n") {
next += "\n"
}
if content == next {
return nil
}
if err := executor.writeRuntimeFile(modsPath, []byte(next), 0o600); err != nil {
return dllExtensionError{code: "dll_extension_mods_failed", message: "UE4SS mods index cannot be updated"}
}
return nil
}
func modsIndexLineKey(line string) string {
withoutComment := strings.SplitN(line, "#", 2)[0]
parts := strings.SplitN(strings.TrimSpace(withoutComment), ":", 2)
if len(parts) != 2 {
return ""
}
return strings.TrimSpace(parts[0])
}
func existingRuntimeDirectory(scope string, key string) error {
if !protocol.ValidLogicalFileKey(key) || filepath.IsAbs(key) || strings.Contains(key, `\`) {
return fmt.Errorf("directory key is unsafe")
}
cleanScope, err := filepath.Abs(scope)
if err != nil {
return err
}
current := cleanScope
for _, part := range strings.Split(filepath.ToSlash(key), "/") {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("required directory is unavailable")
}
}
return nil
}
func checksumRegularFile(path string, maxBytes int64) (string, int64, error) {
info, err := os.Lstat(path)
if err != nil {
return "", 0, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return "", 0, fmt.Errorf("file is not regular")
}
file, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer file.Close()
hash := sha256.New()
size, err := io.Copy(hash, io.LimitReader(file, maxBytes+1))
if err != nil {
return "", 0, err
}
if size > maxBytes {
return "", size, fmt.Errorf("file exceeds maximum size")
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil)), size, nil
}
func copyRegularFileAtomic(source string, destination string, maxBytes int64, mode os.FileMode) error {
checksum, size, err := checksumRegularFile(source, maxBytes)
if err != nil || checksum == "" || size < 1 {
if err != nil {
return err
}
return fmt.Errorf("source file is empty")
}
input, err := os.Open(source)
if err != nil {
return err
}
defer input.Close()
temporary := destination + ".copying"
output, err := os.OpenFile(temporary, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
remove := true
defer func() {
_ = output.Close()
if remove {
_ = os.Remove(temporary)
}
}()
written, err := io.Copy(output, io.LimitReader(input, maxBytes+1))
if err != nil || written != size || written > maxBytes {
if err != nil {
return err
}
return fmt.Errorf("source file changed during copy")
}
if err := output.Sync(); err != nil {
return err
}
if err := output.Close(); err != nil {
return err
}
if err := os.Rename(temporary, destination); err != nil {
return err
}
remove = false
return os.Chmod(destination, mode)
}
func readBoundedRegularFile(path string, maxBytes int64) ([]byte, bool, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() > maxBytes {
return nil, false, fmt.Errorf("file is not a bounded regular file")
}
file, err := os.Open(path)
if err != nil {
return nil, false, err
}
defer file.Close()
body, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil || int64(len(body)) > maxBytes {
if err != nil {
return nil, false, err
}
return nil, false, fmt.Errorf("file exceeds maximum size")
}
return body, true, nil
}
func protocolValidSHA256(value string) bool {
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") {
return false
}
_, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:"))
return err == nil
}
func dllExtensionLifecycleFailure(err error) LifecycleExecutionResult {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return LifecycleExecutionResult{State: lifecycleResultStateCancelled, Progress: protocol.RunJobProgressReport{Percent: 100, Message: "DLL extension synchronization cancelled"}, Message: "DLL extension synchronization cancelled", ErrorCode: "dll_extension_cancelled"}
}
var extensionErr dllExtensionError
if errors.As(err, &extensionErr) {
return lifecycleFailure(extensionErr.code, extensionErr.message)
}
return lifecycleFailure("dll_extension_sync_failed", "DLL extension synchronization failed")
}