init
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
# Development guide
|
||||
|
||||
This repository is a UE4SS C++ mod for a SCUM dedicated server. Its purpose is
|
||||
to provide a password-protected Source RCON endpoint while keeping all Unreal
|
||||
object work on the game's thread.
|
||||
|
||||
## Runtime architecture
|
||||
|
||||
```text
|
||||
RCON TCP worker
|
||||
-> CommandQueue
|
||||
-> EngineTick game-thread drain
|
||||
-> ScumBridge
|
||||
-> RealChatDispatcher (ordinary SendChat)
|
||||
-> OfflineAdminExecutor (AdminCommand path)
|
||||
-> server console fallback (non-AdminCommand console commands)
|
||||
```
|
||||
|
||||
- `RconServer` owns sockets, Source-RCON framing, authentication, and response
|
||||
splitting. It must never create Unreal objects or call Unreal APIs.
|
||||
- `CommandQueue` is the sole boundary between RCON threads and the game thread.
|
||||
Keep all `UObject` lookup, `ProcessEvent`, object creation, and command
|
||||
execution inside its EngineTick drain.
|
||||
- `SimpleRconMod` exposes the internal diagnostics: `rcon.status`,
|
||||
`rcon.admins`, `rcon.dispatch`, and `rcon.chat`.
|
||||
- `ScumBridge` routes `SendChat` before checking `AdminUsers.ini`; all other
|
||||
game commands follow the admin/offline-dispatch route.
|
||||
|
||||
## Admin-command route
|
||||
|
||||
- `OfflineAdminExecutor` may create and reuse the synthetic
|
||||
`BP_ConZPlayerController` and `ConZCharacter`. This exists only so native
|
||||
SCUM `AdminCommand` execution can run with no human administrator online.
|
||||
- Read valid administrator SteamID64 values from
|
||||
`SCUM/Saved/Config/WindowsServer/AdminUsers.ini`. Never hard-code one and
|
||||
never substitute an arbitrary connected player or `PlayerRpcChannel`.
|
||||
- The selected ID must come from that file (the optional preferred ID is valid
|
||||
only when also present there). Refresh a cached synthetic caller if the file
|
||||
changes.
|
||||
- Resolve the native executor only through its exact, build-specific signature.
|
||||
On zero or multiple matches, fail closed and report the problem; do not call a
|
||||
guessed address or weaken the game's authorization check.
|
||||
|
||||
## Ordinary-chat route
|
||||
|
||||
`SendChat <type 0-7> "message" [SteamID64]` is deliberately not an
|
||||
`AdminCommand`.
|
||||
|
||||
- It requires RCON authentication, but not `AdminUsers.ini`.
|
||||
- Resolve SCUM's real server-side entry point at runtime:
|
||||
`MiscStatics:SendChatLineToPlayer`, its `Default__MiscStatics`, and
|
||||
`ConZPlayerController:GetUserId`.
|
||||
- A targeted send must match the supplied SteamID64 against an actual loaded
|
||||
`ConZPlayerController` whose inherited `Player` is a live `UNetConnection`.
|
||||
A missing/offline target is an error.
|
||||
- A broadcast is a separate send to each such real online controller. `type`
|
||||
controls the SCUM chat style; recipient scope comes from the optional
|
||||
SteamID64, not from `type`.
|
||||
- Do not construct a chat controller, fabricate a network/RPC channel, use the
|
||||
first `PlayerRpcChannel`, or impersonate a player. The SCUM helper receives
|
||||
the real target controller and uses the game's own delivery path.
|
||||
- Inspect the UFunction parameter schema through reflection before calling it.
|
||||
If SCUM changes the schema, reject the command and log the discovered schema
|
||||
instead of guessing parameter offsets.
|
||||
|
||||
## Change and test rules
|
||||
|
||||
- Keep command parsing and messages UTF-8-safe. Quoted RCON arguments contain
|
||||
spaces; do not append shell-style inline comments to command examples.
|
||||
- Keep `SendChat` semantics separate from notifications and admin commands.
|
||||
An administrator may be offline; a chat recipient cannot be offline.
|
||||
- Update `README.md` and `INSTALL.md` with every externally visible behavior
|
||||
change. Describe what this repository does; do not retain stale feature
|
||||
comparison or proprietary-bundle claims.
|
||||
- Before committing, run `git diff --check`. On a Windows server built against
|
||||
the deployed UE4SS ABI, test `rcon.status`, `rcon.admins`, `rcon.dispatch`,
|
||||
`rcon.chat`, one targeted `SendChat`, and one broadcast `SendChat`.
|
||||
- macOS-only checks cannot validate the final DLL/SCUM ABI. Do not claim a
|
||||
Windows runtime test passed until it has run on the target server.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Build
|
||||
|
||||
UE4SS C++ mods need to be built with the same UE4SS source/ABI line as the runtime you deploy with. Build this beside a working RE-UE4SS checkout.
|
||||
|
||||
## Folder layout
|
||||
|
||||
```text
|
||||
MyMods/
|
||||
CMakeLists.txt
|
||||
RE-UE4SS/
|
||||
scum_simple_rcon_ue4ss/
|
||||
```
|
||||
|
||||
`MyMods/CMakeLists.txt`:
|
||||
|
||||
```cmake
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
project(MyMods)
|
||||
|
||||
add_subdirectory(RE-UE4SS)
|
||||
add_subdirectory(scum_simple_rcon_ue4ss)
|
||||
```
|
||||
|
||||
## Configure and build
|
||||
|
||||
Visual Studio:
|
||||
|
||||
```bat
|
||||
cmake -B build -G "Visual Studio 17 2022"
|
||||
cmake --build build --config Game__Shipping__Win64 --target scum_simple_rcon
|
||||
```
|
||||
|
||||
Ninja:
|
||||
|
||||
```bat
|
||||
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Game__Shipping__Win64
|
||||
cmake --build build --target scum_simple_rcon
|
||||
```
|
||||
|
||||
Output package:
|
||||
|
||||
```text
|
||||
build/package/ue4ss/Mods/scum_simple_rcon/
|
||||
config.ini
|
||||
enabled.txt
|
||||
dlls/main.dll
|
||||
```
|
||||
|
||||
If your UE4SS source checkout cannot initialize `deps/first/Unreal`, link your GitHub account to Epic Games and rerun:
|
||||
|
||||
```bat
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
Required UE4SS hooks/settings:
|
||||
|
||||
```ini
|
||||
[Hooks]
|
||||
HookEngineTick = 1
|
||||
HookUObjectProcessEvent = 1
|
||||
```
|
||||
|
||||
`HookEngineTick` is mandatory because RCON worker threads must never create
|
||||
UObjects or call the SCUM command executor themselves. The mod performs that
|
||||
work only from the game-thread drain callback.
|
||||
@@ -0,0 +1,52 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
set(TARGET scum_simple_rcon)
|
||||
project(${TARGET} LANGUAGES CXX)
|
||||
|
||||
add_library(${TARGET} SHARED
|
||||
src/dllmain.cpp
|
||||
src/SimpleRconMod.cpp
|
||||
src/RconServer.cpp
|
||||
src/CommandQueue.cpp
|
||||
src/ScumBridge.cpp
|
||||
src/OfflineAdminExecutor.cpp
|
||||
src/RealChatDispatcher.cpp
|
||||
)
|
||||
|
||||
target_compile_features(${TARGET} PRIVATE cxx_std_20)
|
||||
target_include_directories(${TARGET} PRIVATE include)
|
||||
|
||||
target_compile_definitions(${TARGET} PRIVATE
|
||||
WIN32_LEAN_AND_MEAN
|
||||
NOMINMAX
|
||||
_WINSOCK_DEPRECATED_NO_WARNINGS
|
||||
)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(${TARGET} PRIVATE /permissive- /EHsc /bigobj)
|
||||
endif()
|
||||
|
||||
if(NOT TARGET UE4SS)
|
||||
message(FATAL_ERROR
|
||||
"UE4SS target not found. Build this as a sibling project next to RE-UE4SS, "
|
||||
"or add_subdirectory(RE-UE4SS) before add_subdirectory(scum_simple_rcon_ue4ss).")
|
||||
endif()
|
||||
|
||||
target_link_libraries(${TARGET} PUBLIC UE4SS)
|
||||
target_link_libraries(${TARGET} PRIVATE ws2_32)
|
||||
|
||||
set_target_properties(${TARGET} PROPERTIES
|
||||
OUTPUT_NAME "main"
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/package/ue4ss/Mods/scum_simple_rcon/dlls"
|
||||
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/package/ue4ss/Mods/scum_simple_rcon/dlls"
|
||||
)
|
||||
|
||||
add_custom_command(TARGET ${TARGET} POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/package/ue4ss/Mods/scum_simple_rcon"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/ue4ss/Mods/scum_simple_rcon/config.ini"
|
||||
"${CMAKE_BINARY_DIR}/package/ue4ss/Mods/scum_simple_rcon/config.ini"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/ue4ss/Mods/scum_simple_rcon/enabled.txt"
|
||||
"${CMAKE_BINARY_DIR}/package/ue4ss/Mods/scum_simple_rcon/enabled.txt"
|
||||
)
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# Install
|
||||
|
||||
1. Build the mod; see `BUILD.md`.
|
||||
2. Copy the built folder into the SCUM server Win64 directory:
|
||||
|
||||
```text
|
||||
<ServerInstall>\SCUM\Binaries\Win64\ue4ss\Mods\scum_simple_rcon\
|
||||
```
|
||||
|
||||
Expected layout:
|
||||
|
||||
```text
|
||||
Win64/
|
||||
dwmapi.dll
|
||||
SCUMServer.exe
|
||||
ue4ss/
|
||||
UE4SS.dll
|
||||
UE4SS-settings.ini
|
||||
Mods/
|
||||
mods.txt
|
||||
scum_simple_rcon/
|
||||
enabled.txt
|
||||
config.ini
|
||||
dlls/
|
||||
main.dll
|
||||
```
|
||||
|
||||
3. Edit `config.ini`:
|
||||
|
||||
```ini
|
||||
[rcon]
|
||||
bind_address = 127.0.0.1
|
||||
port = 27015
|
||||
password = use-a-real-password
|
||||
```
|
||||
|
||||
4. Enable the mod in `ue4ss/Mods/mods.txt`:
|
||||
|
||||
```text
|
||||
scum_simple_rcon : 1
|
||||
```
|
||||
|
||||
5. Start the server and check `ue4ss/UE4SS.log` for:
|
||||
|
||||
```text
|
||||
[scum_simple_rcon] rcon: listening on 127.0.0.1:27015
|
||||
```
|
||||
|
||||
6. Test locally:
|
||||
|
||||
```bat
|
||||
mcrcon -H 127.0.0.1 -P 27015 -p use-a-real-password rcon.status
|
||||
mcrcon -H 127.0.0.1 -P 27015 -p use-a-real-password rcon.admins
|
||||
mcrcon -H 127.0.0.1 -P 27015 -p use-a-real-password rcon.dispatch
|
||||
mcrcon -H 127.0.0.1 -P 27015 -p use-a-real-password rcon.chat
|
||||
```
|
||||
|
||||
Security note: keep `bind_address = 127.0.0.1` unless you are putting this behind a firewall/VPN. Source RCON is not encrypted.
|
||||
|
||||
Before sending an `AdminCommand` (for example `SetTime`, `SpawnItem`, or an
|
||||
admin-targeted `SendNotification`), make sure the server has:
|
||||
|
||||
```text
|
||||
<ServerInstall>\SCUM\Saved\Config\WindowsServer\AdminUsers.ini
|
||||
```
|
||||
|
||||
with at least one 17-digit SteamID64. This mod intentionally does not fall back to the first online player.
|
||||
|
||||
Ordinary `SendChat` is different: it only needs an authenticated RCON client
|
||||
and a real online recipient. It does not need `AdminUsers.ini`, does not create
|
||||
a controller/channel, and refuses a targeted send when that SteamID is not
|
||||
currently connected.
|
||||
|
||||
For the no-player test, wait until the SCUM world has loaded, leave the server
|
||||
empty, then run `rcon.dispatch` followed by a harmless command such as:
|
||||
|
||||
```bat
|
||||
mcrcon -H 127.0.0.1 -P 27015 -p use-a-real-password SetTime 12
|
||||
```
|
||||
|
||||
`rcon.dispatch` must report `synthetic=ready`. If the native executor signature
|
||||
is not found, do not force an address: the SCUM build changed and the mod needs
|
||||
an updated, tested signature.
|
||||
@@ -0,0 +1,90 @@
|
||||
# scum_simple_rcon_ue4ss
|
||||
|
||||
Minimal UE4SS C++ Mod that exposes a Source RCON listener and dispatches raw SCUM admin command text on the game thread.
|
||||
|
||||
This intentionally implements only the scope requested:
|
||||
|
||||
- UE4SS C++ mod lifecycle: `start_mod`, `uninstall_mod`, `on_unreal_init`.
|
||||
- Source RCON server: TCP listener, password auth, request/response packet framing, multi-packet replies.
|
||||
- Game-thread queue: RCON worker threads enqueue commands; EngineTick drains and executes them.
|
||||
- Offline AdminCommand execution: builds a synthetic `BP_ConZPlayerController` + `ConZCharacter` in the dedicated-server world, then invokes SCUM's native `AdminCommand` executor. An administrator does **not** need to be online.
|
||||
- Unified RCON endpoint: normal admin commands and notifications enter through the authenticated, game-thread queue. `SendChat` has a deliberate chat-only branch: it uses SCUM `MiscStatics:SendChatLineToPlayer` against real, online target controllers, never a fabricated or arbitrary `PlayerRpcChannel`.
|
||||
- Admin identity guard: reads `SCUM/Saved/Config/WindowsServer/AdminUsers.ini` and refuses game commands when no configured admin SteamID64 exists, unless disabled in config.
|
||||
|
||||
The implementation fails closed when a required SCUM binding, native executor
|
||||
signature, or reflected function schema does not match the expected runtime
|
||||
shape. It reports the mismatch instead of guessing an address, object, or
|
||||
network channel.
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
1. UE4SS loads `ue4ss/Mods/scum_simple_rcon/dlls/main.dll`.
|
||||
2. The mod reads `ue4ss/Mods/scum_simple_rcon/config.ini`.
|
||||
3. If the password is still `CHANGE_ME_BEFORE_USE`, the listener refuses to start.
|
||||
4. After RCON auth, any command except `rcon.status`, `rcon.admins`, and `rcon.help` is queued to the game thread. `rcon.dispatch` and `rcon.chat` are safe game-thread diagnostics.
|
||||
5. The game thread checks `AdminUsers.ini`, discovers SCUM `AdminCommand` classes, and creates/reuses a synthetic server-only caller.
|
||||
6. The mod resolves SCUM's native AdminCommand executor with one exact signature. It only calls the executor when exactly one match is found; otherwise it returns a clear error. Non-AdminCommand Unreal console commands use the server-console fallback.
|
||||
|
||||
Normal server admin commands can therefore run while the player count is **zero**. `SendChat` is not an admin command in this mod: it does not need `AdminUsers.ini`, but it does require each recipient to be online with a real network-backed controller. A player-targeted notification likewise needs its recipient online in order to be delivered/displayed; neither feature requires the administrator to be online.
|
||||
|
||||
## Example commands
|
||||
|
||||
```text
|
||||
rcon.status
|
||||
rcon.admins
|
||||
rcon.dispatch
|
||||
rcon.chat
|
||||
Announce Hello from RCON
|
||||
SendChat 4 "Bounty claimed: +500" 7656119XXXXXXXXXX
|
||||
SendChat 2 "xxx killed xxx"
|
||||
SendNotification 2 0 "Welcome!" 7656119XXXXXXXXXX
|
||||
```
|
||||
|
||||
For `SendChat`, an omitted SteamID broadcasts by resolving and sending once to
|
||||
each real online controller. A supplied SteamID routes only to that controller.
|
||||
The implementation requires the controller's inherited `Player` object to be
|
||||
a real `UNetConnection`, so synthetic offline admin controllers and
|
||||
class-default objects are deliberately excluded.
|
||||
|
||||
The last example is a broadcast only to real online players. Do not add an
|
||||
inline `# ...` comment to an RCON command: it is command text, not a shell
|
||||
script, so it would become part of the message/argument stream.
|
||||
|
||||
Run `rcon.chat` after world load to validate the non-admin chat route without
|
||||
sending anything. A healthy result contains `real chat dispatcher ready` and
|
||||
reports the number of real online controllers; zero is valid on an empty server.
|
||||
|
||||
Do not prefix commands with `#`; if you do, this mod strips one leading `#` before dispatch.
|
||||
|
||||
## AdminUsers.ini
|
||||
|
||||
Default lookup:
|
||||
|
||||
```text
|
||||
<ServerInstall>\SCUM\Saved\Config\WindowsServer\AdminUsers.ini
|
||||
```
|
||||
|
||||
The parser accepts any line containing a 17-digit SteamID64, so these all work:
|
||||
|
||||
```ini
|
||||
7656119XXXXXXXXXX
|
||||
Admin=7656119XXXXXXXXXX
|
||||
+AdminUsers=7656119XXXXXXXXXX
|
||||
```
|
||||
|
||||
No SteamID is compiled into the DLL and no online `PlayerRpcChannel` is used as
|
||||
an admin surrogate. The file is re-read after changes; with multiple configured
|
||||
admins the dispatcher selects the lexicographically first valid ID, unless the
|
||||
optional `[admin] preferred_steam_id` names one that is also present in the file.
|
||||
An already-created synthetic caller refreshes its selected identity after a file
|
||||
change.
|
||||
|
||||
## Offline-dispatch diagnostics
|
||||
|
||||
Run `rcon.dispatch` after the world has loaded. A healthy result contains:
|
||||
|
||||
```text
|
||||
synthetic=ready; executor=native AdminCommand executor resolved by exact signature
|
||||
```
|
||||
|
||||
If SCUM changes its executable code, the signature may no longer match. In that case the mod deliberately refuses to call a guessed address; update the signature for that SCUM build and retest on a non-production server.
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <string>
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
class CommandQueue
|
||||
{
|
||||
public:
|
||||
using Executor = std::function<std::string(const std::string&)>;
|
||||
|
||||
explicit CommandQueue(Executor executor);
|
||||
|
||||
std::string enqueue_and_wait(const std::string& command, std::chrono::milliseconds timeout);
|
||||
int drain(int max_items = 32);
|
||||
void shutdown();
|
||||
|
||||
private:
|
||||
struct Task
|
||||
{
|
||||
std::string command;
|
||||
std::promise<std::string> result;
|
||||
};
|
||||
|
||||
Executor m_executor;
|
||||
std::mutex m_mutex;
|
||||
std::queue<std::shared_ptr<Task>> m_tasks;
|
||||
bool m_stopping{false};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
struct Config
|
||||
{
|
||||
std::string bind_address{"127.0.0.1"};
|
||||
uint16_t port{27015};
|
||||
std::string password{"CHANGE_ME_BEFORE_USE"};
|
||||
bool auth_log{true};
|
||||
int max_connections{4};
|
||||
int packet_body_bytes{4000};
|
||||
std::chrono::milliseconds command_timeout{std::chrono::milliseconds{10000}};
|
||||
std::string dispatch_context{"auto"};
|
||||
bool offline_admin_dispatch{true};
|
||||
bool native_admin_executor{true};
|
||||
bool allow_empty_password{false};
|
||||
|
||||
// Read from SCUM/Saved/Config/WindowsServer/AdminUsers.ini by default.
|
||||
// This prevents silently treating an arbitrary online player as an admin.
|
||||
std::string admin_users_file{"auto"};
|
||||
// Optional selector only; it is accepted only when also present in
|
||||
// admin_users_file and is never a replacement authorization source.
|
||||
std::string preferred_admin_steam_id{};
|
||||
bool require_configured_admin{true};
|
||||
};
|
||||
|
||||
Config load_config(const std::filesystem::path& path);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
/**
|
||||
* SCUM's normal admin commands are not Unreal console commands. They are
|
||||
* instances of AdminCommand executed by a native server function. This
|
||||
* class creates a server-only ConZPlayerController/ConZCharacter pair and
|
||||
* calls that function, so an actual player account does not need to be
|
||||
* connected.
|
||||
*
|
||||
* It is deliberately game-build guarded: if the known executor signature
|
||||
* does not resolve to exactly one location, no native call is made.
|
||||
*/
|
||||
class OfflineAdminExecutor
|
||||
{
|
||||
public:
|
||||
// Kept public solely so the .cpp's game-thread helper functions can
|
||||
// operate on it without exposing any Unreal types in this header.
|
||||
struct RuntimeState;
|
||||
|
||||
OfflineAdminExecutor();
|
||||
~OfflineAdminExecutor();
|
||||
OfflineAdminExecutor(const OfflineAdminExecutor&) = delete;
|
||||
OfflineAdminExecutor& operator=(const OfflineAdminExecutor&) = delete;
|
||||
|
||||
struct Result
|
||||
{
|
||||
bool attempted{false};
|
||||
bool success{false};
|
||||
std::string message;
|
||||
};
|
||||
|
||||
void configure(bool enabled, bool native_executor_enabled);
|
||||
void on_unreal_init();
|
||||
// Runs the non-mutating portion of the offline path on the game
|
||||
// thread: discover command classes, create/reuse the synthetic caller,
|
||||
// and resolve the guarded native executor address.
|
||||
Result probe(std::string_view configured_admin_steam_id);
|
||||
Result execute(std::string_view command, std::string_view configured_admin_steam_id);
|
||||
std::string status() const;
|
||||
|
||||
private:
|
||||
bool m_enabled{true};
|
||||
bool m_native_executor_enabled{true};
|
||||
bool m_unreal_ready{false};
|
||||
RuntimeState* m_state{nullptr};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include <winsock2.h>
|
||||
|
||||
#include "Config.hpp"
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
class RconServer
|
||||
{
|
||||
public:
|
||||
using CommandHandler = std::function<std::string(const std::string&)>;
|
||||
using Logger = std::function<void(const std::string&)>;
|
||||
|
||||
RconServer(Config config, CommandHandler handler, Logger logger);
|
||||
~RconServer();
|
||||
|
||||
bool start();
|
||||
void stop();
|
||||
bool running() const;
|
||||
|
||||
private:
|
||||
struct Packet
|
||||
{
|
||||
int32_t id{};
|
||||
int32_t type{};
|
||||
std::string body;
|
||||
};
|
||||
|
||||
void accept_loop();
|
||||
void client_loop(SOCKET client, std::string remote);
|
||||
bool read_packet(SOCKET socket, Packet& out);
|
||||
bool send_packet(SOCKET socket, int32_t id, int32_t type, const std::string& body);
|
||||
bool send_response_chunks(SOCKET socket, int32_t id, const std::string& body);
|
||||
void close_client_socket(SOCKET socket);
|
||||
void log(const std::string& line);
|
||||
|
||||
Config m_config;
|
||||
CommandHandler m_handler;
|
||||
Logger m_logger;
|
||||
std::atomic<bool> m_running{false};
|
||||
std::atomic<int> m_active_clients{0};
|
||||
std::thread m_accept_thread;
|
||||
SOCKET m_listen_socket{INVALID_SOCKET};
|
||||
std::mutex m_clients_mutex;
|
||||
std::unordered_set<SOCKET> m_clients;
|
||||
std::vector<std::thread> m_client_threads;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
/**
|
||||
* Handles ordinary SCUM chat independently of AdminCommand dispatch.
|
||||
*
|
||||
* It never creates or picks an arbitrary PlayerRpcChannel. A targeted
|
||||
* chat message is sent only after a configured SteamID is matched against
|
||||
* a real, currently network-backed ConZPlayerController whose Player is a
|
||||
* live UNetConnection. Broadcast chat is the same operation performed
|
||||
* once for each such controller.
|
||||
*/
|
||||
class RealChatDispatcher
|
||||
{
|
||||
public:
|
||||
struct Result
|
||||
{
|
||||
bool handled{false};
|
||||
bool success{false};
|
||||
std::string message;
|
||||
};
|
||||
|
||||
RealChatDispatcher();
|
||||
~RealChatDispatcher();
|
||||
RealChatDispatcher(const RealChatDispatcher&) = delete;
|
||||
RealChatDispatcher& operator=(const RealChatDispatcher&) = delete;
|
||||
|
||||
// Recognizes: SendChat <type 0-7> "message" [target SteamID64]
|
||||
// This function must be called on the game thread.
|
||||
Result dispatch_if_chat(std::string_view command);
|
||||
// Non-mutating game-thread diagnostic for the real-player chat path.
|
||||
Result probe();
|
||||
std::string status() const;
|
||||
|
||||
// Public only so the game-thread helper functions in the .cpp can
|
||||
// keep all Unreal implementation details out of this header.
|
||||
struct RuntimeState;
|
||||
|
||||
private:
|
||||
RuntimeState* m_state{nullptr};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "Config.hpp"
|
||||
#include "OfflineAdminExecutor.hpp"
|
||||
#include "RealChatDispatcher.hpp"
|
||||
|
||||
namespace RC::Unreal
|
||||
{
|
||||
class UObject;
|
||||
}
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
class ScumBridge
|
||||
{
|
||||
public:
|
||||
void configure(const Config& config, const std::filesystem::path& mod_directory);
|
||||
void on_unreal_init();
|
||||
std::string execute(const std::string& command);
|
||||
std::string admin_status();
|
||||
std::string dispatch_status();
|
||||
|
||||
private:
|
||||
RC::Unreal::UObject* resolve_server_context(std::string& detail);
|
||||
bool process_server_console(RC::Unreal::UObject* context, const std::wstring& command, std::string& detail);
|
||||
void refresh_admin_ids_locked();
|
||||
std::string selected_admin_id_locked() const;
|
||||
static std::filesystem::path resolve_admin_users_file(const Config& config, const std::filesystem::path& mod_directory);
|
||||
static std::wstring widen_utf8(const std::string& s);
|
||||
static std::string trim_command(std::string s);
|
||||
|
||||
std::mutex m_mutex;
|
||||
bool m_unreal_ready{false};
|
||||
bool m_require_configured_admin{true};
|
||||
std::string m_preferred_admin_steam_id;
|
||||
std::filesystem::path m_admin_users_file;
|
||||
std::filesystem::file_time_type m_admin_users_last_write{};
|
||||
bool m_admin_file_seen{false};
|
||||
std::unordered_set<std::string> m_admin_ids;
|
||||
OfflineAdminExecutor m_offline_executor;
|
||||
RealChatDispatcher m_real_chat;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <Mod/CppUserModBase.hpp>
|
||||
|
||||
#include "CommandQueue.hpp"
|
||||
#include "Config.hpp"
|
||||
#include "RconServer.hpp"
|
||||
#include "ScumBridge.hpp"
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
class SimpleRconMod final : public RC::CppUserModBase
|
||||
{
|
||||
public:
|
||||
SimpleRconMod();
|
||||
~SimpleRconMod() override;
|
||||
|
||||
auto on_unreal_init() -> void override;
|
||||
auto on_update() -> void override;
|
||||
|
||||
private:
|
||||
void log(const std::string& line);
|
||||
std::string handle_rcon_command(const std::string& command);
|
||||
void install_game_thread_drain();
|
||||
void drain_game_thread();
|
||||
|
||||
Config m_config;
|
||||
ScumBridge m_bridge;
|
||||
CommandQueue m_queue;
|
||||
std::unique_ptr<RconServer> m_server;
|
||||
bool m_hook_installed{false};
|
||||
uint64_t m_tick_callback_id{0};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#include "CommandQueue.hpp"
|
||||
|
||||
#include <exception>
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
CommandQueue::CommandQueue(Executor executor) : m_executor(std::move(executor)) {}
|
||||
|
||||
std::string CommandQueue::enqueue_and_wait(const std::string& command, std::chrono::milliseconds timeout)
|
||||
{
|
||||
auto task = std::make_shared<Task>();
|
||||
task->command = command;
|
||||
auto future = task->result.get_future();
|
||||
|
||||
{
|
||||
std::scoped_lock lock{m_mutex};
|
||||
if (m_stopping)
|
||||
{
|
||||
return "error: command queue is shutting down";
|
||||
}
|
||||
m_tasks.push(task);
|
||||
}
|
||||
|
||||
if (future.wait_for(timeout) == std::future_status::ready)
|
||||
{
|
||||
return future.get();
|
||||
}
|
||||
return "error: command timed out waiting for the game thread";
|
||||
}
|
||||
|
||||
int CommandQueue::drain(int max_items)
|
||||
{
|
||||
int drained = 0;
|
||||
while (drained < max_items)
|
||||
{
|
||||
std::shared_ptr<Task> task;
|
||||
{
|
||||
std::scoped_lock lock{m_mutex};
|
||||
if (m_tasks.empty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
task = m_tasks.front();
|
||||
m_tasks.pop();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
task->result.set_value(m_executor(task->command));
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
task->result.set_value(std::string{"error: exception while executing command: "} + e.what());
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
task->result.set_value("error: unknown exception while executing command");
|
||||
}
|
||||
++drained;
|
||||
}
|
||||
return drained;
|
||||
}
|
||||
|
||||
void CommandQueue::shutdown()
|
||||
{
|
||||
std::queue<std::shared_ptr<Task>> pending;
|
||||
{
|
||||
std::scoped_lock lock{m_mutex};
|
||||
m_stopping = true;
|
||||
std::swap(pending, m_tasks);
|
||||
}
|
||||
while (!pending.empty())
|
||||
{
|
||||
pending.front()->result.set_value("error: command queue stopped before execution");
|
||||
pending.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
#include "OfflineAdminExecutor.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <DynamicOutput/DynamicOutput.hpp>
|
||||
#include <UnrealDef.hpp>
|
||||
|
||||
namespace
|
||||
{
|
||||
using RC::Unreal::FProperty;
|
||||
using RC::Unreal::FString;
|
||||
using RC::Unreal::UClass;
|
||||
using RC::Unreal::UFunction;
|
||||
using RC::Unreal::UObject;
|
||||
|
||||
constexpr auto k_mark_as_root_set = static_cast<RC::Unreal::EObjectFlags>(0x80);
|
||||
constexpr size_t k_function_parameter_bytes = 0x200;
|
||||
|
||||
// This is the exact native AdminCommand executor signature recovered from
|
||||
// the reference SCUM-RCON build. It is intentionally not fuzzy/wildcard
|
||||
// matched: a game update must produce one exact hit or dispatch is refused.
|
||||
constexpr std::array<uint8_t, 24> k_executor_signature{
|
||||
0x10, 0x48, 0x89, 0x74, 0x24, 0x18, 0x57, 0x48,
|
||||
0x83, 0xEC, 0x30, 0x48, 0x8B, 0xF9, 0x0F, 0x29,
|
||||
0x74, 0x24, 0x20, 0x48, 0x8D, 0x4C, 0x24, 0x40,
|
||||
};
|
||||
|
||||
// The matched bytes begin four bytes after the native function entry.
|
||||
constexpr ptrdiff_t k_executor_entry_adjustment = -4;
|
||||
|
||||
struct alignas(16) IdentityTransform
|
||||
{
|
||||
float rotation[4]{0.0f, 0.0f, 0.0f, 1.0f};
|
||||
float translation[4]{0.0f, 0.0f, 0.0f, 0.0f};
|
||||
float scale[4]{1.0f, 1.0f, 1.0f, 0.0f};
|
||||
};
|
||||
|
||||
static_assert(sizeof(IdentityTransform) == 0x30);
|
||||
|
||||
auto narrow(const std::wstring& input) -> std::string
|
||||
{
|
||||
std::string output;
|
||||
output.reserve(input.size());
|
||||
for (wchar_t c : input)
|
||||
{
|
||||
output.push_back(c >= 0 && c <= 0x7F ? static_cast<char>(c) : '?');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
auto widen_utf8(std::string_view input) -> std::wstring
|
||||
{
|
||||
if (input.empty())
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
const int characters = ::MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast<int>(input.size()), nullptr, 0);
|
||||
if (characters <= 0)
|
||||
{
|
||||
return {input.begin(), input.end()};
|
||||
}
|
||||
|
||||
std::wstring output(static_cast<size_t>(characters), L'\0');
|
||||
::MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast<int>(input.size()), output.data(), characters);
|
||||
return output;
|
||||
}
|
||||
|
||||
auto lowercase(std::string value) -> std::string
|
||||
{
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
auto tokenize(std::string_view command) -> std::vector<std::string>
|
||||
{
|
||||
std::vector<std::string> result;
|
||||
std::string current;
|
||||
bool quoted = false;
|
||||
bool escaped = false;
|
||||
|
||||
for (char c : command)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
current.push_back(c);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '"')
|
||||
{
|
||||
quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (!quoted && std::isspace(static_cast<unsigned char>(c)))
|
||||
{
|
||||
if (!current.empty())
|
||||
{
|
||||
result.emplace_back(std::move(current));
|
||||
current.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current.push_back(c);
|
||||
}
|
||||
|
||||
if (escaped)
|
||||
{
|
||||
current.push_back('\\');
|
||||
}
|
||||
if (!current.empty())
|
||||
{
|
||||
result.emplace_back(std::move(current));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
auto find_object(const wchar_t* path) -> T*
|
||||
{
|
||||
return RC::Unreal::UObjectGlobals::StaticFindObject<T*>(nullptr, nullptr, path);
|
||||
}
|
||||
|
||||
auto find_property(UFunction* function, const wchar_t* name) -> FProperty*
|
||||
{
|
||||
if (!function)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
for (FProperty* property : RC::Unreal::TFieldRange<FProperty>(function, RC::Unreal::EFieldIterationFlags::IncludeDeprecated))
|
||||
{
|
||||
if (property && property->GetName() == name)
|
||||
{
|
||||
return property;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto find_property(UClass* klass, const wchar_t* name) -> FProperty*
|
||||
{
|
||||
if (!klass)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
for (FProperty* property : RC::Unreal::TFieldRange<FProperty>(klass, RC::Unreal::EFieldIterationFlags::IncludeDeprecated))
|
||||
{
|
||||
if (property && property->GetName() == name)
|
||||
{
|
||||
return property;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto property_offset(UFunction* function, const wchar_t* name) -> int32_t
|
||||
{
|
||||
const auto* property = find_property(function, name);
|
||||
return property ? property->GetOffset_Internal() : -1;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
auto write_parameter(std::array<uint8_t, k_function_parameter_bytes>& parameters, int32_t offset, const T& value) -> bool
|
||||
{
|
||||
if (offset < 0 || static_cast<size_t>(offset) + sizeof(T) > parameters.size())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
std::memcpy(parameters.data() + offset, &value, sizeof(T));
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
auto read_parameter(const std::array<uint8_t, k_function_parameter_bytes>& parameters, int32_t offset) -> std::optional<T>
|
||||
{
|
||||
if (offset < 0 || static_cast<size_t>(offset) + sizeof(T) > parameters.size())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
T value{};
|
||||
std::memcpy(&value, parameters.data() + offset, sizeof(T));
|
||||
return value;
|
||||
}
|
||||
|
||||
auto call_function(UObject* target, UFunction* function, void* parameters) -> bool
|
||||
{
|
||||
if (!target || !function)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
target->ProcessEvent(function, parameters);
|
||||
return true;
|
||||
}
|
||||
|
||||
auto class_is_child_of(UClass* candidate, UClass* expected_base) -> bool
|
||||
{
|
||||
for (auto* current = candidate; current; current = static_cast<UClass*>(current->GetSuperStruct()))
|
||||
{
|
||||
if (current == expected_base)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
auto looks_like_UClass(UObject* object) -> bool
|
||||
{
|
||||
if (!object || !object->GetClassPrivate())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const auto meta_name = lowercase(narrow(object->GetClassPrivate()->GetName()));
|
||||
return meta_name == "class" || meta_name == "blueprintgeneratedclass";
|
||||
}
|
||||
|
||||
auto class_verb(UClass* klass) -> std::string
|
||||
{
|
||||
if (!klass)
|
||||
{
|
||||
return {};
|
||||
}
|
||||
std::string name = lowercase(narrow(klass->GetName()));
|
||||
constexpr std::string_view marker{"admincommand_"};
|
||||
const auto marker_pos = name.find(marker);
|
||||
if (marker_pos == std::string::npos)
|
||||
{
|
||||
return {};
|
||||
}
|
||||
std::string verb = name.substr(marker_pos + marker.size());
|
||||
if (verb.ends_with("_c"))
|
||||
{
|
||||
verb.resize(verb.size() - 2);
|
||||
}
|
||||
return verb;
|
||||
}
|
||||
|
||||
auto scan_main_module_for_executor(std::string& detail) -> void*
|
||||
{
|
||||
const auto module = ::GetModuleHandleW(nullptr);
|
||||
if (!module)
|
||||
{
|
||||
detail = "GetModuleHandleW(nullptr) failed";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto* image = reinterpret_cast<const uint8_t*>(module);
|
||||
const auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(image);
|
||||
if (dos->e_magic != IMAGE_DOS_SIGNATURE)
|
||||
{
|
||||
detail = "main module has no DOS header";
|
||||
return nullptr;
|
||||
}
|
||||
const auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(image + dos->e_lfanew);
|
||||
if (nt->Signature != IMAGE_NT_SIGNATURE)
|
||||
{
|
||||
detail = "main module has no NT header";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const IMAGE_SECTION_HEADER* text_section = nullptr;
|
||||
const auto* sections = IMAGE_FIRST_SECTION(nt);
|
||||
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i)
|
||||
{
|
||||
const auto& section = sections[i];
|
||||
if (std::memcmp(section.Name, ".text", 5) == 0)
|
||||
{
|
||||
text_section = §ion;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!text_section || text_section->Misc.VirtualSize < k_executor_signature.size())
|
||||
{
|
||||
detail = "main module .text section is unavailable";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto* begin = image + text_section->VirtualAddress;
|
||||
const size_t length = text_section->Misc.VirtualSize;
|
||||
const uint8_t* match = nullptr;
|
||||
size_t matches = 0;
|
||||
for (size_t offset = 0; offset + k_executor_signature.size() <= length; ++offset)
|
||||
{
|
||||
if (std::memcmp(begin + offset, k_executor_signature.data(), k_executor_signature.size()) == 0)
|
||||
{
|
||||
match = begin + offset;
|
||||
++matches;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches != 1)
|
||||
{
|
||||
detail = matches == 0 ? "native AdminCommand executor signature not found" :
|
||||
"native AdminCommand executor signature is ambiguous (" + std::to_string(matches) + " matches)";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
detail = "native AdminCommand executor resolved by exact signature";
|
||||
return const_cast<uint8_t*>(match + k_executor_entry_adjustment);
|
||||
}
|
||||
|
||||
auto call_native_admin_executor(void* native_executor,
|
||||
UObject* command_instance,
|
||||
RC::Unreal::TArray<FString>* arguments,
|
||||
bool& faulted) -> int64_t
|
||||
{
|
||||
using NativeAdminExecutor = int64_t(__fastcall*)(UObject*, RC::Unreal::TArray<FString>*);
|
||||
const auto execute_native = reinterpret_cast<NativeAdminExecutor>(native_executor);
|
||||
#if defined(_MSC_VER)
|
||||
__try
|
||||
{
|
||||
return execute_native(command_instance, arguments);
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER)
|
||||
{
|
||||
faulted = true;
|
||||
return 0;
|
||||
}
|
||||
#else
|
||||
return execute_native(command_instance, arguments);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
struct OfflineAdminExecutor::RuntimeState
|
||||
{
|
||||
UObject* world{};
|
||||
UObject* controller{};
|
||||
UObject* pawn{};
|
||||
UObject* user_profile{};
|
||||
UClass* admin_command_base{};
|
||||
std::unordered_map<std::string, UClass*> verbs;
|
||||
void* native_executor{};
|
||||
std::string bound_admin_steam_id;
|
||||
std::string native_executor_detail{"not resolved yet"};
|
||||
std::string last_context_detail{"not built yet"};
|
||||
};
|
||||
|
||||
OfflineAdminExecutor::OfflineAdminExecutor() : m_state(new RuntimeState{}) {}
|
||||
|
||||
OfflineAdminExecutor::~OfflineAdminExecutor()
|
||||
{
|
||||
delete m_state;
|
||||
}
|
||||
|
||||
void OfflineAdminExecutor::configure(bool enabled, bool native_executor_enabled)
|
||||
{
|
||||
m_enabled = enabled;
|
||||
m_native_executor_enabled = native_executor_enabled;
|
||||
}
|
||||
|
||||
void OfflineAdminExecutor::on_unreal_init()
|
||||
{
|
||||
m_unreal_ready = true;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
auto make_identity_transform() -> IdentityTransform
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
auto spawn_actor(UObject* world, UClass* actor_class, std::string& detail) -> UObject*
|
||||
{
|
||||
auto* gameplay_statics = find_object<UObject>(STR("/Script/Engine.Default__GameplayStatics"));
|
||||
auto* begin_spawn = find_object<UFunction>(STR("/Script/Engine.GameplayStatics:BeginSpawningActorFromClass"));
|
||||
auto* finish_spawn = find_object<UFunction>(STR("/Script/Engine.GameplayStatics:FinishSpawningActor"));
|
||||
if (!world || !actor_class || !gameplay_statics || !begin_spawn || !finish_spawn)
|
||||
{
|
||||
detail = "GameplayStatics deferred-spawn objects are not available";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const int32_t world_offset = property_offset(begin_spawn, STR("WorldContextObject"));
|
||||
const int32_t class_offset = property_offset(begin_spawn, STR("ActorClass"));
|
||||
const int32_t transform_offset = property_offset(begin_spawn, STR("SpawnTransform"));
|
||||
const int32_t collision_fail_offset = property_offset(begin_spawn, STR("bNoCollisionFail"));
|
||||
const int32_t collision_mode_offset = property_offset(begin_spawn, STR("CollisionHandlingOverride"));
|
||||
const int32_t return_offset = property_offset(begin_spawn, STR("ReturnValue"));
|
||||
if (world_offset < 0 || class_offset < 0 || transform_offset < 0 || return_offset < 0)
|
||||
{
|
||||
detail = "BeginSpawningActorFromClass parameters are not compatible";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::array<uint8_t, k_function_parameter_bytes> begin_parameters{};
|
||||
const IdentityTransform transform = make_identity_transform();
|
||||
const uint8_t true_byte = 1;
|
||||
const uint8_t always_spawn = 1;
|
||||
if (!write_parameter(begin_parameters, world_offset, world) ||
|
||||
!write_parameter(begin_parameters, class_offset, actor_class) ||
|
||||
!write_parameter(begin_parameters, transform_offset, transform) ||
|
||||
!write_parameter(begin_parameters, return_offset, static_cast<UObject*>(nullptr)) ||
|
||||
(collision_fail_offset >= 0 && !write_parameter(begin_parameters, collision_fail_offset, true_byte)) ||
|
||||
(collision_mode_offset >= 0 && !write_parameter(begin_parameters, collision_mode_offset, always_spawn)))
|
||||
{
|
||||
detail = "BeginSpawningActorFromClass parameter layout is too large";
|
||||
return nullptr;
|
||||
}
|
||||
if (!call_function(gameplay_statics, begin_spawn, begin_parameters.data()))
|
||||
{
|
||||
detail = "BeginSpawningActorFromClass could not be called";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto deferred_actor = read_parameter<UObject*>(begin_parameters, return_offset).value_or(nullptr);
|
||||
if (!deferred_actor)
|
||||
{
|
||||
detail = "BeginSpawningActorFromClass returned null";
|
||||
return nullptr;
|
||||
}
|
||||
deferred_actor->SetFlags(k_mark_as_root_set);
|
||||
|
||||
const int32_t actor_offset = property_offset(finish_spawn, STR("Actor"));
|
||||
const int32_t finish_transform_offset = property_offset(finish_spawn, STR("SpawnTransform"));
|
||||
const int32_t finish_return_offset = property_offset(finish_spawn, STR("ReturnValue"));
|
||||
if (actor_offset < 0 || finish_transform_offset < 0 || finish_return_offset < 0)
|
||||
{
|
||||
detail = "FinishSpawningActor parameters are not compatible";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::array<uint8_t, k_function_parameter_bytes> finish_parameters{};
|
||||
if (!write_parameter(finish_parameters, actor_offset, deferred_actor) ||
|
||||
!write_parameter(finish_parameters, finish_transform_offset, transform) ||
|
||||
!write_parameter(finish_parameters, finish_return_offset, static_cast<UObject*>(nullptr)))
|
||||
{
|
||||
detail = "FinishSpawningActor parameter layout is too large";
|
||||
return nullptr;
|
||||
}
|
||||
if (!call_function(gameplay_statics, finish_spawn, finish_parameters.data()))
|
||||
{
|
||||
detail = "FinishSpawningActor could not be called";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto* actor = read_parameter<UObject*>(finish_parameters, finish_return_offset).value_or(deferred_actor);
|
||||
if (!actor)
|
||||
{
|
||||
detail = "FinishSpawningActor returned null";
|
||||
return nullptr;
|
||||
}
|
||||
actor->SetFlags(k_mark_as_root_set);
|
||||
return actor;
|
||||
}
|
||||
|
||||
void disable_actor_tick(UObject* actor)
|
||||
{
|
||||
auto* function = find_object<UFunction>(STR("/Script/Engine.Actor:SetActorTickEnabled"));
|
||||
if (!actor || !function)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const int32_t enabled_offset = property_offset(function, STR("bEnabled"));
|
||||
if (enabled_offset < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
std::array<uint8_t, k_function_parameter_bytes> parameters{};
|
||||
const uint8_t false_byte = 0;
|
||||
if (write_parameter(parameters, enabled_offset, false_byte))
|
||||
{
|
||||
call_function(actor, function, parameters.data());
|
||||
}
|
||||
}
|
||||
|
||||
void best_effort_bind_admin_identity(UObject* object, std::string_view steam_id)
|
||||
{
|
||||
if (!object || steam_id.empty() || !object->GetClassPrivate())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const std::wstring steam_id_wide = widen_utf8(steam_id);
|
||||
uint64_t numeric_id{};
|
||||
try
|
||||
{
|
||||
numeric_id = std::stoull(std::string{steam_id});
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (FProperty* property : RC::Unreal::TFieldRange<FProperty>(object->GetClassPrivate(), RC::Unreal::EFieldIterationFlags::IncludeDeprecated))
|
||||
{
|
||||
if (!property)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const std::string property_name = lowercase(narrow(property->GetName()));
|
||||
const bool looks_like_identity = property_name.find("steam") != std::string::npos ||
|
||||
property_name == "userid" || property_name == "_userid" ||
|
||||
property_name.find("onlineid") != std::string::npos;
|
||||
if (!looks_like_identity)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (property->IsA<RC::Unreal::FStrProperty>())
|
||||
{
|
||||
auto* value = property->ContainerPtrToValuePtr<FString>(object);
|
||||
if (value)
|
||||
{
|
||||
*value = FString{steam_id_wide.c_str()};
|
||||
}
|
||||
}
|
||||
else if (property->IsA<RC::Unreal::FUInt64Property>())
|
||||
{
|
||||
auto* value = property->ContainerPtrToValuePtr<uint64_t>(object);
|
||||
if (value)
|
||||
{
|
||||
*value = numeric_id;
|
||||
}
|
||||
}
|
||||
else if (property->IsA<RC::Unreal::FInt64Property>())
|
||||
{
|
||||
auto* value = property->ContainerPtrToValuePtr<int64_t>(object);
|
||||
if (value)
|
||||
{
|
||||
*value = static_cast<int64_t>(numeric_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
auto ensure_synthetic_caller(OfflineAdminExecutor::RuntimeState& state,
|
||||
std::string_view configured_admin_steam_id,
|
||||
std::string& detail) -> bool
|
||||
{
|
||||
auto* current_world = RC::Unreal::UObjectGlobals::FindFirstOf(STR("World"));
|
||||
if (!current_world)
|
||||
{
|
||||
detail = "no UWorld is available yet";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.world != current_world)
|
||||
{
|
||||
state.world = current_world;
|
||||
state.controller = nullptr;
|
||||
state.pawn = nullptr;
|
||||
state.user_profile = nullptr;
|
||||
state.bound_admin_steam_id.clear();
|
||||
state.last_context_detail = "world changed; synthetic caller will be rebuilt";
|
||||
}
|
||||
|
||||
if (state.controller && state.pawn)
|
||||
{
|
||||
// AdminUsers.ini is re-read by ScumBridge. Do not leave an
|
||||
// already-created synthetic caller bound to a SteamID that was
|
||||
// removed from the file or superseded through preferred_admin_steam_id.
|
||||
if (state.bound_admin_steam_id != configured_admin_steam_id)
|
||||
{
|
||||
best_effort_bind_admin_identity(state.user_profile, configured_admin_steam_id);
|
||||
best_effort_bind_admin_identity(state.controller, configured_admin_steam_id);
|
||||
state.bound_admin_steam_id = std::string{configured_admin_steam_id};
|
||||
detail = "reusing synthetic controller and pawn; AdminUsers identity refreshed";
|
||||
}
|
||||
else
|
||||
{
|
||||
detail = "reusing synthetic controller and pawn";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
auto* controller_class = find_object<UClass>(
|
||||
STR("/Game/ConZ_Files/Blueprints/PlayerControllers/BP_ConZPlayerController.BP_ConZPlayerController_C"));
|
||||
if (!controller_class)
|
||||
{
|
||||
detail = "BP_ConZPlayerController class is not loaded";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* controller = spawn_actor(current_world, controller_class, detail);
|
||||
if (!controller)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
disable_actor_tick(controller);
|
||||
|
||||
auto* profile_class = find_object<UClass>(STR("/Script/SCUM.UserProfile"));
|
||||
if (profile_class)
|
||||
{
|
||||
RC::Unreal::FStaticConstructObjectParameters parameters{profile_class, controller};
|
||||
auto* profile = RC::Unreal::UObjectGlobals::StaticConstructObject(parameters);
|
||||
if (profile)
|
||||
{
|
||||
profile->SetFlags(k_mark_as_root_set);
|
||||
if (auto* property = find_property(controller->GetClassPrivate(), STR("_userProfile")))
|
||||
{
|
||||
auto* value = property->ContainerPtrToValuePtr<UObject*>(controller);
|
||||
if (value)
|
||||
{
|
||||
*value = profile;
|
||||
}
|
||||
}
|
||||
best_effort_bind_admin_identity(profile, configured_admin_steam_id);
|
||||
state.user_profile = profile;
|
||||
}
|
||||
}
|
||||
best_effort_bind_admin_identity(controller, configured_admin_steam_id);
|
||||
|
||||
auto* character_class = find_object<UClass>(STR("/Script/SCUM.ConZCharacter"));
|
||||
if (!character_class)
|
||||
{
|
||||
character_class = find_object<UClass>(STR("/Script/ConZ.ConZCharacter"));
|
||||
}
|
||||
if (!character_class)
|
||||
{
|
||||
detail = "ConZCharacter class is not loaded";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* pawn = spawn_actor(current_world, character_class, detail);
|
||||
if (!pawn)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
disable_actor_tick(pawn);
|
||||
|
||||
auto* possess = find_object<UFunction>(STR("/Script/Engine.Controller:Possess"));
|
||||
const int32_t pawn_offset = property_offset(possess, STR("InPawn"));
|
||||
if (!possess || pawn_offset < 0)
|
||||
{
|
||||
detail = "Controller:Possess or its InPawn parameter is not available";
|
||||
return false;
|
||||
}
|
||||
std::array<uint8_t, k_function_parameter_bytes> parameters{};
|
||||
if (!write_parameter(parameters, pawn_offset, pawn) || !call_function(controller, possess, parameters.data()))
|
||||
{
|
||||
detail = "could not possess the synthetic ConZCharacter";
|
||||
return false;
|
||||
}
|
||||
|
||||
state.controller = controller;
|
||||
state.pawn = pawn;
|
||||
state.bound_admin_steam_id = std::string{configured_admin_steam_id};
|
||||
state.last_context_detail = "synthetic BP_ConZPlayerController + ConZCharacter ready";
|
||||
detail = state.last_context_detail;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto build_verb_map(OfflineAdminExecutor::RuntimeState& state, std::string& detail) -> bool
|
||||
{
|
||||
auto* base = find_object<UClass>(STR("/Script/SCUM.AdminCommand"));
|
||||
if (!base)
|
||||
{
|
||||
state.verbs.clear();
|
||||
detail = "SCUM.AdminCommand base class is not loaded yet";
|
||||
return false;
|
||||
}
|
||||
if (state.admin_command_base == base && !state.verbs.empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, UClass*> next;
|
||||
RC::Unreal::UObjectGlobals::ForEachUObject([&](UObject* object, int32_t, int32_t) {
|
||||
if (!looks_like_UClass(object))
|
||||
{
|
||||
return RC::LoopAction::Continue;
|
||||
}
|
||||
auto* candidate = static_cast<UClass*>(object);
|
||||
if (!class_is_child_of(candidate, base))
|
||||
{
|
||||
return RC::LoopAction::Continue;
|
||||
}
|
||||
const auto verb = class_verb(candidate);
|
||||
if (!verb.empty())
|
||||
{
|
||||
next.try_emplace(verb, candidate);
|
||||
}
|
||||
return RC::LoopAction::Continue;
|
||||
});
|
||||
|
||||
state.admin_command_base = base;
|
||||
state.verbs = std::move(next);
|
||||
if (state.verbs.empty())
|
||||
{
|
||||
detail = "AdminCommand subclasses were found but no command verbs could be extracted";
|
||||
return false;
|
||||
}
|
||||
detail = "discovered " + std::to_string(state.verbs.size()) + " AdminCommand verbs";
|
||||
return true;
|
||||
}
|
||||
|
||||
auto resolve_native_executor(OfflineAdminExecutor::RuntimeState& state, std::string& detail) -> void*
|
||||
{
|
||||
if (state.native_executor)
|
||||
{
|
||||
detail = state.native_executor_detail;
|
||||
return state.native_executor;
|
||||
}
|
||||
state.native_executor = scan_main_module_for_executor(state.native_executor_detail);
|
||||
detail = state.native_executor_detail;
|
||||
return state.native_executor;
|
||||
}
|
||||
}
|
||||
|
||||
OfflineAdminExecutor::Result OfflineAdminExecutor::execute(std::string_view command,
|
||||
std::string_view configured_admin_steam_id)
|
||||
{
|
||||
if (!m_enabled)
|
||||
{
|
||||
return {false, false, "offline AdminCommand dispatcher is disabled"};
|
||||
}
|
||||
if (!m_unreal_ready || !m_state)
|
||||
{
|
||||
return {true, false, "offline dispatcher is not ready yet"};
|
||||
}
|
||||
|
||||
const auto tokens = tokenize(command);
|
||||
if (tokens.empty())
|
||||
{
|
||||
return {false, false, "empty command"};
|
||||
}
|
||||
|
||||
std::string verb_detail;
|
||||
if (!build_verb_map(*m_state, verb_detail))
|
||||
{
|
||||
return {true, false, "offline AdminCommand dispatcher unavailable: " + verb_detail};
|
||||
}
|
||||
|
||||
const std::string verb = lowercase(tokens.front());
|
||||
const auto command_class_it = m_state->verbs.find(verb);
|
||||
if (command_class_it == m_state->verbs.end())
|
||||
{
|
||||
// The caller can still try a standard UE console command through
|
||||
// ScumBridge's server-console fallback.
|
||||
return {false, false, "no AdminCommand class for '" + tokens.front() + "'"};
|
||||
}
|
||||
|
||||
std::string caller_detail;
|
||||
if (!ensure_synthetic_caller(*m_state, configured_admin_steam_id, caller_detail))
|
||||
{
|
||||
return {true, false, "offline synthetic caller unavailable: " + caller_detail};
|
||||
}
|
||||
|
||||
if (!m_native_executor_enabled)
|
||||
{
|
||||
return {true, false, "offline native executor is disabled by config"};
|
||||
}
|
||||
|
||||
std::string executor_detail;
|
||||
auto* native_executor = resolve_native_executor(*m_state, executor_detail);
|
||||
if (!native_executor)
|
||||
{
|
||||
return {true, false, "offline native executor unavailable: " + executor_detail};
|
||||
}
|
||||
|
||||
RC::Unreal::FStaticConstructObjectParameters construction{command_class_it->second, m_state->controller};
|
||||
auto* command_instance = RC::Unreal::UObjectGlobals::StaticConstructObject(construction);
|
||||
if (!command_instance)
|
||||
{
|
||||
return {true, false, "could not construct AdminCommand for '" + tokens.front() + "'"};
|
||||
}
|
||||
|
||||
RC::Unreal::TArray<FString> arguments{};
|
||||
for (size_t index = 1; index < tokens.size(); ++index)
|
||||
{
|
||||
const auto argument_wide = widen_utf8(tokens[index]);
|
||||
arguments.Add(FString{argument_wide.c_str()});
|
||||
}
|
||||
|
||||
bool faulted = false;
|
||||
const int64_t native_result = call_native_admin_executor(native_executor, command_instance, &arguments, faulted);
|
||||
if (faulted)
|
||||
{
|
||||
return {true, false, "AdminCommand '" + tokens.front() + "' faulted inside the SCUM native executor"};
|
||||
}
|
||||
if (native_result == 0)
|
||||
{
|
||||
return {true, false, "AdminCommand '" + tokens.front() + "' ran but the SCUM executor returned 0"};
|
||||
}
|
||||
|
||||
return {true, true, "ok: offline AdminCommand '" + tokens.front() + "' executed via synthetic server caller"};
|
||||
}
|
||||
|
||||
OfflineAdminExecutor::Result OfflineAdminExecutor::probe(std::string_view configured_admin_steam_id)
|
||||
{
|
||||
if (!m_enabled)
|
||||
{
|
||||
return {true, false, "offline AdminCommand dispatcher is disabled"};
|
||||
}
|
||||
if (!m_unreal_ready || !m_state)
|
||||
{
|
||||
return {true, false, "offline dispatcher is not ready yet"};
|
||||
}
|
||||
|
||||
std::string verb_detail;
|
||||
if (!build_verb_map(*m_state, verb_detail))
|
||||
{
|
||||
return {true, false, "offline AdminCommand dispatcher unavailable: " + verb_detail};
|
||||
}
|
||||
|
||||
std::string caller_detail;
|
||||
if (!ensure_synthetic_caller(*m_state, configured_admin_steam_id, caller_detail))
|
||||
{
|
||||
return {true, false, "offline synthetic caller unavailable: " + caller_detail};
|
||||
}
|
||||
|
||||
if (!m_native_executor_enabled)
|
||||
{
|
||||
return {true, false, "offline native executor is disabled by config"};
|
||||
}
|
||||
|
||||
std::string executor_detail;
|
||||
if (!resolve_native_executor(*m_state, executor_detail))
|
||||
{
|
||||
return {true, false, "offline native executor unavailable: " + executor_detail};
|
||||
}
|
||||
|
||||
return {true, true, "ok: offline dispatcher ready; " + status()};
|
||||
}
|
||||
|
||||
std::string OfflineAdminExecutor::status() const
|
||||
{
|
||||
std::ostringstream output;
|
||||
output << "offline_dispatch=" << (m_enabled ? "enabled" : "disabled")
|
||||
<< "; native_executor=" << (m_native_executor_enabled ? "enabled" : "disabled");
|
||||
if (!m_state)
|
||||
{
|
||||
return output.str();
|
||||
}
|
||||
output << "; verbs=" << m_state->verbs.size()
|
||||
<< "; synthetic=" << (m_state->controller && m_state->pawn ? "ready" : "not-ready")
|
||||
<< "; context=" << m_state->last_context_detail
|
||||
<< "; executor=" << m_state->native_executor_detail;
|
||||
return output.str();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
#include "RconServer.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
#include <vector>
|
||||
|
||||
#include <ws2tcpip.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32_t SERVERDATA_RESPONSE_VALUE = 0;
|
||||
constexpr int32_t SERVERDATA_AUTH_RESPONSE = 2;
|
||||
constexpr int32_t SERVERDATA_EXECCOMMAND = 2;
|
||||
constexpr int32_t SERVERDATA_AUTH = 3;
|
||||
constexpr int32_t MAX_PACKET_SIZE = 4096;
|
||||
|
||||
bool send_all(SOCKET s, const char* data, int len)
|
||||
{
|
||||
int sent_total = 0;
|
||||
while (sent_total < len)
|
||||
{
|
||||
const int sent = ::send(s, data + sent_total, len - sent_total, 0);
|
||||
if (sent <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
sent_total += sent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool recv_all(SOCKET s, char* data, int len)
|
||||
{
|
||||
int got_total = 0;
|
||||
while (got_total < len)
|
||||
{
|
||||
const int got = ::recv(s, data + got_total, len - got_total, 0);
|
||||
if (got <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
got_total += got;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
T read_le(const char* p)
|
||||
{
|
||||
T value{};
|
||||
std::memcpy(&value, p, sizeof(T));
|
||||
return value;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
void append_le(std::vector<char>& out, T value)
|
||||
{
|
||||
const auto* p = reinterpret_cast<const char*>(&value);
|
||||
out.insert(out.end(), p, p + sizeof(T));
|
||||
}
|
||||
}
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
RconServer::RconServer(Config config, CommandHandler handler, Logger logger)
|
||||
: m_config(std::move(config)), m_handler(std::move(handler)), m_logger(std::move(logger))
|
||||
{
|
||||
}
|
||||
|
||||
RconServer::~RconServer()
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
bool RconServer::start()
|
||||
{
|
||||
if (m_running.exchange(true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
WSADATA wsa{};
|
||||
if (::WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
|
||||
{
|
||||
m_running = false;
|
||||
log("rcon: WSAStartup failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
m_listen_socket = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (m_listen_socket == INVALID_SOCKET)
|
||||
{
|
||||
m_running = false;
|
||||
log("rcon: socket() failed");
|
||||
::WSACleanup();
|
||||
return false;
|
||||
}
|
||||
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(m_config.port);
|
||||
if (::inet_pton(AF_INET, m_config.bind_address.c_str(), &addr.sin_addr) != 1)
|
||||
{
|
||||
log("rcon: invalid bind_address '" + m_config.bind_address + "'");
|
||||
stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
int yes = 1;
|
||||
::setsockopt(m_listen_socket, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char*>(&yes), sizeof(yes));
|
||||
|
||||
if (::bind(m_listen_socket, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == SOCKET_ERROR)
|
||||
{
|
||||
log("rcon: bind failed on " + m_config.bind_address + ":" + std::to_string(m_config.port));
|
||||
stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (::listen(m_listen_socket, SOMAXCONN) == SOCKET_ERROR)
|
||||
{
|
||||
log("rcon: listen() failed");
|
||||
stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
m_accept_thread = std::thread([this] { accept_loop(); });
|
||||
log("rcon: listening on " + m_config.bind_address + ":" + std::to_string(m_config.port));
|
||||
return true;
|
||||
}
|
||||
|
||||
void RconServer::stop()
|
||||
{
|
||||
if (!m_running.exchange(false) && m_listen_socket == INVALID_SOCKET)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_listen_socket != INVALID_SOCKET)
|
||||
{
|
||||
::shutdown(m_listen_socket, SD_BOTH);
|
||||
::closesocket(m_listen_socket);
|
||||
m_listen_socket = INVALID_SOCKET;
|
||||
}
|
||||
|
||||
if (m_accept_thread.joinable())
|
||||
{
|
||||
m_accept_thread.join();
|
||||
}
|
||||
|
||||
std::vector<std::thread> client_threads;
|
||||
{
|
||||
std::scoped_lock lock{m_clients_mutex};
|
||||
for (SOCKET client : m_clients)
|
||||
{
|
||||
// shutdown unblocks recv; the owner thread closes the socket.
|
||||
::shutdown(client, SD_BOTH);
|
||||
}
|
||||
std::swap(client_threads, m_client_threads);
|
||||
}
|
||||
|
||||
for (auto& client_thread : client_threads)
|
||||
{
|
||||
if (client_thread.joinable())
|
||||
{
|
||||
client_thread.join();
|
||||
}
|
||||
}
|
||||
|
||||
::WSACleanup();
|
||||
}
|
||||
|
||||
bool RconServer::running() const
|
||||
{
|
||||
return m_running.load();
|
||||
}
|
||||
|
||||
void RconServer::accept_loop()
|
||||
{
|
||||
while (m_running)
|
||||
{
|
||||
sockaddr_in remote_addr{};
|
||||
int remote_len = sizeof(remote_addr);
|
||||
SOCKET client = ::accept(m_listen_socket, reinterpret_cast<sockaddr*>(&remote_addr), &remote_len);
|
||||
if (client == INVALID_SOCKET)
|
||||
{
|
||||
if (m_running)
|
||||
{
|
||||
log("rcon: accept() failed");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
char ip[INET_ADDRSTRLEN]{};
|
||||
::inet_ntop(AF_INET, &remote_addr.sin_addr, ip, sizeof(ip));
|
||||
std::ostringstream remote;
|
||||
remote << ip << ":" << ntohs(remote_addr.sin_port);
|
||||
|
||||
if (m_active_clients.fetch_add(1) >= m_config.max_connections)
|
||||
{
|
||||
--m_active_clients;
|
||||
log("rcon: connection limit reached; refusing " + remote.str());
|
||||
::closesocket(client);
|
||||
continue;
|
||||
}
|
||||
|
||||
{
|
||||
std::scoped_lock lock{m_clients_mutex};
|
||||
m_clients.insert(client);
|
||||
}
|
||||
|
||||
std::scoped_lock lock{m_clients_mutex};
|
||||
m_client_threads.emplace_back([this, client, remote = remote.str()] {
|
||||
client_loop(client, remote);
|
||||
close_client_socket(client);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void RconServer::client_loop(SOCKET client, std::string remote)
|
||||
{
|
||||
bool authed = false;
|
||||
|
||||
int timeout_ms = 30000;
|
||||
::setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&timeout_ms), sizeof(timeout_ms));
|
||||
|
||||
Packet packet{};
|
||||
while (m_running && read_packet(client, packet))
|
||||
{
|
||||
if (!authed)
|
||||
{
|
||||
if (packet.type != SERVERDATA_AUTH)
|
||||
{
|
||||
log("rcon: " + remote + " sent command before auth");
|
||||
send_packet(client, -1, SERVERDATA_AUTH_RESPONSE, "");
|
||||
break;
|
||||
}
|
||||
|
||||
const bool ok = packet.body == m_config.password &&
|
||||
(m_config.allow_empty_password || !m_config.password.empty()) &&
|
||||
(m_config.allow_empty_password || m_config.password != "CHANGE_ME_BEFORE_USE");
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
if (m_config.auth_log)
|
||||
{
|
||||
log("rcon: auth FAILED from " + remote);
|
||||
}
|
||||
send_packet(client, -1, SERVERDATA_AUTH_RESPONSE, "");
|
||||
break;
|
||||
}
|
||||
|
||||
authed = true;
|
||||
if (m_config.auth_log)
|
||||
{
|
||||
log("rcon: auth succeeded from " + remote);
|
||||
}
|
||||
send_packet(client, packet.id, SERVERDATA_AUTH_RESPONSE, "");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (packet.type != SERVERDATA_EXECCOMMAND)
|
||||
{
|
||||
send_response_chunks(client, packet.id, "error: unsupported packet type " + std::to_string(packet.type));
|
||||
continue;
|
||||
}
|
||||
|
||||
log("rcon: " + remote + " -> " + packet.body);
|
||||
const std::string response = m_handler ? m_handler(packet.body) : "error: no command handler installed";
|
||||
if (!send_response_chunks(client, packet.id, response))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
--m_active_clients;
|
||||
}
|
||||
|
||||
bool RconServer::read_packet(SOCKET socket, Packet& out)
|
||||
{
|
||||
std::array<char, 4> size_buf{};
|
||||
if (!recv_all(socket, size_buf.data(), static_cast<int>(size_buf.size())))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const int32_t size = read_le<int32_t>(size_buf.data());
|
||||
if (size < 10 || size > MAX_PACKET_SIZE)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<char> buf(static_cast<size_t>(size));
|
||||
if (!recv_all(socket, buf.data(), size))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
out.id = read_le<int32_t>(buf.data());
|
||||
out.type = read_le<int32_t>(buf.data() + 4);
|
||||
const char* body = buf.data() + 8;
|
||||
const int body_capacity = size - 10;
|
||||
out.body.assign(body, body + std::max(0, body_capacity));
|
||||
const auto nul = out.body.find('\0');
|
||||
if (nul != std::string::npos)
|
||||
{
|
||||
out.body.resize(nul);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RconServer::send_packet(SOCKET socket, int32_t id, int32_t type, const std::string& body)
|
||||
{
|
||||
const int32_t size = static_cast<int32_t>(8 + body.size() + 2);
|
||||
std::vector<char> packet;
|
||||
packet.reserve(static_cast<size_t>(size) + 4);
|
||||
append_le<int32_t>(packet, size);
|
||||
append_le<int32_t>(packet, id);
|
||||
append_le<int32_t>(packet, type);
|
||||
packet.insert(packet.end(), body.begin(), body.end());
|
||||
packet.push_back('\0');
|
||||
packet.push_back('\0');
|
||||
return send_all(socket, packet.data(), static_cast<int>(packet.size()));
|
||||
}
|
||||
|
||||
bool RconServer::send_response_chunks(SOCKET socket, int32_t id, const std::string& body)
|
||||
{
|
||||
const int max_body = std::clamp(m_config.packet_body_bytes, 1, 4000);
|
||||
if (body.empty())
|
||||
{
|
||||
return send_packet(socket, id, SERVERDATA_RESPONSE_VALUE, "");
|
||||
}
|
||||
|
||||
size_t offset = 0;
|
||||
while (offset < body.size())
|
||||
{
|
||||
const size_t n = std::min<size_t>(static_cast<size_t>(max_body), body.size() - offset);
|
||||
if (!send_packet(socket, id, SERVERDATA_RESPONSE_VALUE, body.substr(offset, n)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
offset += n;
|
||||
}
|
||||
|
||||
// A final empty response packet is the common Source-RCON sentinel
|
||||
// that lets clients stop waiting after a multi-packet reply.
|
||||
return send_packet(socket, id, SERVERDATA_RESPONSE_VALUE, "");
|
||||
}
|
||||
|
||||
void RconServer::close_client_socket(SOCKET socket)
|
||||
{
|
||||
{
|
||||
std::scoped_lock lock{m_clients_mutex};
|
||||
m_clients.erase(socket);
|
||||
}
|
||||
::shutdown(socket, SD_BOTH);
|
||||
::closesocket(socket);
|
||||
}
|
||||
|
||||
void RconServer::log(const std::string& line)
|
||||
{
|
||||
if (m_logger)
|
||||
{
|
||||
m_logger(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
#include "RealChatDispatcher.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <UnrealDef.hpp>
|
||||
|
||||
namespace
|
||||
{
|
||||
using RC::Unreal::FProperty;
|
||||
using RC::Unreal::FString;
|
||||
using RC::Unreal::UClass;
|
||||
using RC::Unreal::UFunction;
|
||||
using RC::Unreal::UObject;
|
||||
|
||||
constexpr uint16_t k_no_return_value = 0xFFFF;
|
||||
constexpr size_t k_max_message_bytes = 1024;
|
||||
|
||||
auto lowercase(std::string value) -> std::string
|
||||
{
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
auto narrow(const std::wstring& input) -> std::string
|
||||
{
|
||||
std::string output;
|
||||
output.reserve(input.size());
|
||||
for (wchar_t c : input)
|
||||
{
|
||||
output.push_back(c >= 0 && c <= 0x7F ? static_cast<char>(c) : '?');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
auto fstring_to_utf8(FString& value) -> std::string
|
||||
{
|
||||
const auto* characters = *value;
|
||||
return characters ? narrow(std::wstring{characters}) : std::string{};
|
||||
}
|
||||
|
||||
auto widen_utf8(std::string_view input) -> std::wstring
|
||||
{
|
||||
if (input.empty())
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
const int characters = ::MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast<int>(input.size()), nullptr, 0);
|
||||
if (characters <= 0)
|
||||
{
|
||||
return {input.begin(), input.end()};
|
||||
}
|
||||
|
||||
std::wstring output(static_cast<size_t>(characters), L'\0');
|
||||
::MultiByteToWideChar(CP_UTF8, 0, input.data(), static_cast<int>(input.size()), output.data(), characters);
|
||||
return output;
|
||||
}
|
||||
|
||||
auto tokenize(std::string_view command) -> std::vector<std::string>
|
||||
{
|
||||
std::vector<std::string> result;
|
||||
std::string current;
|
||||
bool quoted = false;
|
||||
bool escaped = false;
|
||||
|
||||
for (char c : command)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
current.push_back(c);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '"')
|
||||
{
|
||||
quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (!quoted && std::isspace(static_cast<unsigned char>(c)))
|
||||
{
|
||||
if (!current.empty())
|
||||
{
|
||||
result.emplace_back(std::move(current));
|
||||
current.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current.push_back(c);
|
||||
}
|
||||
|
||||
if (escaped)
|
||||
{
|
||||
current.push_back('\\');
|
||||
}
|
||||
if (!current.empty())
|
||||
{
|
||||
result.emplace_back(std::move(current));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
auto is_steam_id64(std::string_view value) -> bool
|
||||
{
|
||||
return value.size() == 17 &&
|
||||
std::all_of(value.begin(), value.end(), [](unsigned char c) { return std::isdigit(c) != 0; });
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
auto find_object(const wchar_t* path) -> T*
|
||||
{
|
||||
return RC::Unreal::UObjectGlobals::StaticFindObject<T*>(nullptr, nullptr, path);
|
||||
}
|
||||
|
||||
auto class_is_child_of(UClass* candidate, UClass* expected_base) -> bool
|
||||
{
|
||||
for (auto* current = candidate; current; current = static_cast<UClass*>(current->GetSuperStruct()))
|
||||
{
|
||||
if (current == expected_base)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
auto find_property(UClass* klass, const wchar_t* name) -> FProperty*
|
||||
{
|
||||
for (auto* current = klass; current; current = static_cast<UClass*>(current->GetSuperStruct()))
|
||||
{
|
||||
for (FProperty* property : RC::Unreal::TFieldRange<FProperty>(current, RC::Unreal::EFieldIterationFlags::IncludeDeprecated))
|
||||
{
|
||||
if (property && property->GetName() == name)
|
||||
{
|
||||
return property;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto is_parameter(FProperty* property) -> bool
|
||||
{
|
||||
return property && property->HasAnyPropertyFlags(RC::Unreal::EPropertyFlags::CPF_Parm);
|
||||
}
|
||||
|
||||
auto property_name(FProperty* property) -> std::string
|
||||
{
|
||||
return property ? narrow(property->GetName()) : std::string{};
|
||||
}
|
||||
|
||||
auto property_description(FProperty* property) -> std::string
|
||||
{
|
||||
if (!property)
|
||||
{
|
||||
return "<null>";
|
||||
}
|
||||
return property_name(property) + ":" + narrow(property->GetClass().GetName());
|
||||
}
|
||||
|
||||
auto is_chat_type_property(FProperty* property) -> bool
|
||||
{
|
||||
return property &&
|
||||
(property->IsA<RC::Unreal::FByteProperty>() ||
|
||||
property->IsA<RC::Unreal::FIntProperty>() ||
|
||||
property->IsA<RC::Unreal::FInt64Property>() ||
|
||||
property->IsA<RC::Unreal::FUInt64Property>() ||
|
||||
property->IsA<RC::Unreal::FEnumProperty>());
|
||||
}
|
||||
|
||||
class FunctionParameterBuffer
|
||||
{
|
||||
public:
|
||||
explicit FunctionParameterBuffer(UFunction* function)
|
||||
{
|
||||
const size_t byte_count = function ? std::max<size_t>(function->GetPropertiesSize(), 1) : 0;
|
||||
m_byte_capacity = byte_count;
|
||||
if (byte_count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_storage = static_cast<uint8_t*>(::operator new(byte_count, std::align_val_t{alignof(std::max_align_t)}));
|
||||
std::memset(m_storage, 0, byte_count);
|
||||
|
||||
if (!function)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (FProperty* property : RC::Unreal::TFieldRange<FProperty>(function, RC::Unreal::EFieldIterationFlags::IncludeDeprecated))
|
||||
{
|
||||
if (!is_parameter(property) || !property->IsA<RC::Unreal::FStrProperty>())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const auto offset = property->GetOffset_Internal();
|
||||
if (!contains(offset, sizeof(FString)))
|
||||
{
|
||||
m_valid = false;
|
||||
continue;
|
||||
}
|
||||
auto* field = reinterpret_cast<FString*>(bytes() + offset);
|
||||
new (field) FString{};
|
||||
m_strings.emplace_back(field);
|
||||
}
|
||||
}
|
||||
|
||||
~FunctionParameterBuffer()
|
||||
{
|
||||
for (auto* value : m_strings)
|
||||
{
|
||||
value->~FString();
|
||||
}
|
||||
::operator delete(m_storage, std::align_val_t{alignof(std::max_align_t)});
|
||||
}
|
||||
|
||||
FunctionParameterBuffer(const FunctionParameterBuffer&) = delete;
|
||||
FunctionParameterBuffer& operator=(const FunctionParameterBuffer&) = delete;
|
||||
|
||||
auto valid() const -> bool { return m_valid && m_storage != nullptr; }
|
||||
auto data() -> void* { return m_storage; }
|
||||
|
||||
auto write_object(FProperty* property, UObject* value) -> bool
|
||||
{
|
||||
if (!property || !contains(property->GetOffset_Internal(), sizeof(value)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
std::memcpy(bytes() + property->GetOffset_Internal(), &value, sizeof(value));
|
||||
return true;
|
||||
}
|
||||
|
||||
auto write_string(FProperty* property, std::string_view value) -> bool
|
||||
{
|
||||
if (!property || !property->IsA<RC::Unreal::FStrProperty>() || !contains(property->GetOffset_Internal(), sizeof(FString)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
auto* field = reinterpret_cast<FString*>(bytes() + property->GetOffset_Internal());
|
||||
const auto wide = widen_utf8(value);
|
||||
*field = FString{wide.c_str()};
|
||||
return true;
|
||||
}
|
||||
|
||||
auto write_small_integer(FProperty* property, int64_t value) -> bool
|
||||
{
|
||||
if (!property || !is_chat_type_property(property))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto offset = property->GetOffset_Internal();
|
||||
switch (property->GetSize())
|
||||
{
|
||||
case 1: {
|
||||
const auto written = static_cast<uint8_t>(value);
|
||||
if (!contains(offset, sizeof(written))) return false;
|
||||
std::memcpy(bytes() + offset, &written, sizeof(written));
|
||||
return true;
|
||||
}
|
||||
case 2: {
|
||||
const auto written = static_cast<uint16_t>(value);
|
||||
if (!contains(offset, sizeof(written))) return false;
|
||||
std::memcpy(bytes() + offset, &written, sizeof(written));
|
||||
return true;
|
||||
}
|
||||
case 4: {
|
||||
const auto written = static_cast<uint32_t>(value);
|
||||
if (!contains(offset, sizeof(written))) return false;
|
||||
std::memcpy(bytes() + offset, &written, sizeof(written));
|
||||
return true;
|
||||
}
|
||||
case 8: {
|
||||
const auto written = static_cast<uint64_t>(value);
|
||||
if (!contains(offset, sizeof(written))) return false;
|
||||
std::memcpy(bytes() + offset, &written, sizeof(written));
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
auto read_string(FProperty* property) const -> std::optional<std::string>
|
||||
{
|
||||
if (!property || !property->IsA<RC::Unreal::FStrProperty>() || !contains(property->GetOffset_Internal(), sizeof(FString)))
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
auto* field = reinterpret_cast<FString*>(const_cast<uint8_t*>(bytes() + property->GetOffset_Internal()));
|
||||
return fstring_to_utf8(*field);
|
||||
}
|
||||
|
||||
private:
|
||||
auto bytes() -> uint8_t* { return m_storage; }
|
||||
auto bytes() const -> const uint8_t* { return m_storage; }
|
||||
|
||||
auto contains(int32_t offset, size_t size) const -> bool
|
||||
{
|
||||
return offset >= 0 && static_cast<size_t>(offset) + size <= m_byte_capacity;
|
||||
}
|
||||
|
||||
uint8_t* m_storage{};
|
||||
std::vector<FString*> m_strings;
|
||||
size_t m_byte_capacity{};
|
||||
bool m_valid{true};
|
||||
};
|
||||
|
||||
struct ChatSchema
|
||||
{
|
||||
FProperty* target_controller{};
|
||||
FProperty* world_context{};
|
||||
FProperty* message{};
|
||||
FProperty* message_type{};
|
||||
std::string detail;
|
||||
};
|
||||
|
||||
auto discover_chat_schema(UFunction* function, ChatSchema& schema) -> bool
|
||||
{
|
||||
if (!function)
|
||||
{
|
||||
schema.detail = "SendChatLineToPlayer UFunction is not available";
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint16_t return_offset = function->GetReturnValueOffset();
|
||||
std::vector<FProperty*> objects;
|
||||
std::vector<FProperty*> strings;
|
||||
std::vector<FProperty*> numeric;
|
||||
std::vector<std::string> unsupported;
|
||||
|
||||
for (FProperty* property : RC::Unreal::TFieldRange<FProperty>(function, RC::Unreal::EFieldIterationFlags::IncludeDeprecated))
|
||||
{
|
||||
if (!is_parameter(property) || property->GetOffset_Internal() == return_offset)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto name = lowercase(property_name(property));
|
||||
if (property->IsA<RC::Unreal::FObjectProperty>())
|
||||
{
|
||||
if (name.find("worldcontext") != std::string::npos)
|
||||
{
|
||||
if (schema.world_context)
|
||||
{
|
||||
unsupported.emplace_back(property_description(property));
|
||||
}
|
||||
else
|
||||
{
|
||||
schema.world_context = property;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
objects.emplace_back(property);
|
||||
}
|
||||
}
|
||||
else if (property->IsA<RC::Unreal::FStrProperty>())
|
||||
{
|
||||
strings.emplace_back(property);
|
||||
}
|
||||
else if (is_chat_type_property(property))
|
||||
{
|
||||
numeric.emplace_back(property);
|
||||
}
|
||||
else
|
||||
{
|
||||
unsupported.emplace_back(property_description(property));
|
||||
}
|
||||
}
|
||||
|
||||
if (objects.size() == 1) schema.target_controller = objects.front();
|
||||
if (strings.size() == 1) schema.message = strings.front();
|
||||
if (numeric.size() == 1) schema.message_type = numeric.front();
|
||||
|
||||
if (schema.target_controller && schema.message && schema.message_type && unsupported.empty() &&
|
||||
objects.size() == 1 && strings.size() == 1 && numeric.size() == 1)
|
||||
{
|
||||
schema.detail = "schema=" + property_description(schema.target_controller) + "," +
|
||||
property_description(schema.message) + "," +
|
||||
property_description(schema.message_type);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::ostringstream output;
|
||||
output << "unsupported SendChatLineToPlayer parameter schema";
|
||||
for (FProperty* property : objects) output << "; object=" << property_description(property);
|
||||
for (FProperty* property : strings) output << "; string=" << property_description(property);
|
||||
for (FProperty* property : numeric) output << "; numeric=" << property_description(property);
|
||||
for (const auto& property : unsupported) output << "; unsupported=" << property;
|
||||
schema.detail = output.str();
|
||||
return false;
|
||||
}
|
||||
|
||||
auto controller_has_live_network_player(UObject* controller, UClass* net_connection_base) -> bool
|
||||
{
|
||||
if (!controller || !controller->GetClassPrivate() || !net_connection_base)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A synthetic controller created for offline AdminCommand dispatch has
|
||||
// no live UNetConnection. Requiring the inherited Player property to
|
||||
// be an actual NetConnection prevents it (and CDOs) from ever becoming
|
||||
// a chat recipient.
|
||||
auto* player_property = find_property(controller->GetClassPrivate(), STR("Player"));
|
||||
if (!player_property || !player_property->IsA<RC::Unreal::FObjectProperty>())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto* player = player_property->ContainerPtrToValuePtr<UObject*>(controller);
|
||||
return player && *player && (*player)->GetClassPrivate() &&
|
||||
class_is_child_of((*player)->GetClassPrivate(), net_connection_base);
|
||||
}
|
||||
|
||||
auto call_get_user_id(UObject* controller, UFunction* function, std::string& steam_id) -> bool
|
||||
{
|
||||
if (!controller || !function || function->GetReturnValueOffset() == k_no_return_value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* return_property = function->GetReturnProperty();
|
||||
if (!return_property || !return_property->IsA<RC::Unreal::FStrProperty>())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
FunctionParameterBuffer parameters{function};
|
||||
if (!parameters.valid())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
controller->ProcessEvent(function, parameters.data());
|
||||
const auto result = parameters.read_string(return_property);
|
||||
if (!result || !is_steam_id64(*result))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
steam_id = *result;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
struct RealChatDispatcher::RuntimeState
|
||||
{
|
||||
UClass* controller_base{};
|
||||
UClass* net_connection_base{};
|
||||
UFunction* get_user_id{};
|
||||
UObject* misc_statics{};
|
||||
UFunction* send_chat_line{};
|
||||
std::string detail{"not resolved yet"};
|
||||
};
|
||||
|
||||
RealChatDispatcher::RealChatDispatcher() : m_state(new RuntimeState{}) {}
|
||||
|
||||
RealChatDispatcher::~RealChatDispatcher()
|
||||
{
|
||||
delete m_state;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
auto resolve_runtime(RealChatDispatcher::RuntimeState& state, std::string& detail) -> bool
|
||||
{
|
||||
if (!state.controller_base)
|
||||
{
|
||||
state.controller_base = find_object<UClass>(STR("/Script/SCUM.ConZPlayerController"));
|
||||
if (!state.controller_base)
|
||||
{
|
||||
state.controller_base = find_object<UClass>(STR("/Script/ConZ.ConZPlayerController"));
|
||||
}
|
||||
}
|
||||
if (!state.net_connection_base)
|
||||
{
|
||||
state.net_connection_base = find_object<UClass>(STR("/Script/Engine.NetConnection"));
|
||||
}
|
||||
if (!state.get_user_id)
|
||||
{
|
||||
state.get_user_id = find_object<UFunction>(STR("/Script/SCUM.ConZPlayerController:GetUserId"));
|
||||
if (!state.get_user_id)
|
||||
{
|
||||
state.get_user_id = find_object<UFunction>(STR("/Script/ConZ.ConZPlayerController:GetUserId"));
|
||||
}
|
||||
}
|
||||
if (!state.misc_statics)
|
||||
{
|
||||
state.misc_statics = find_object<UObject>(STR("/Script/SCUM.Default__MiscStatics"));
|
||||
}
|
||||
if (!state.send_chat_line)
|
||||
{
|
||||
state.send_chat_line = find_object<UFunction>(STR("/Script/SCUM.MiscStatics:SendChatLineToPlayer"));
|
||||
}
|
||||
|
||||
if (!state.controller_base || !state.net_connection_base || !state.get_user_id || !state.misc_statics || !state.send_chat_line)
|
||||
{
|
||||
std::ostringstream output;
|
||||
output << "chat runtime unavailable:";
|
||||
if (!state.controller_base) output << " ConZPlayerController";
|
||||
if (!state.net_connection_base) output << " NetConnection";
|
||||
if (!state.get_user_id) output << " GetUserId";
|
||||
if (!state.misc_statics) output << " Default__MiscStatics";
|
||||
if (!state.send_chat_line) output << " SendChatLineToPlayer";
|
||||
state.detail = output.str();
|
||||
detail = state.detail;
|
||||
return false;
|
||||
}
|
||||
|
||||
ChatSchema schema{};
|
||||
if (!discover_chat_schema(state.send_chat_line, schema))
|
||||
{
|
||||
state.detail = schema.detail;
|
||||
detail = state.detail;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.detail = "real controller lookup + " + schema.detail;
|
||||
detail = state.detail;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto send_chat_line(RealChatDispatcher::RuntimeState& state,
|
||||
UObject* target_controller,
|
||||
int type,
|
||||
std::string_view message,
|
||||
std::string& detail) -> bool
|
||||
{
|
||||
ChatSchema schema{};
|
||||
if (!discover_chat_schema(state.send_chat_line, schema))
|
||||
{
|
||||
detail = schema.detail;
|
||||
return false;
|
||||
}
|
||||
|
||||
FunctionParameterBuffer parameters{state.send_chat_line};
|
||||
if (!parameters.valid() ||
|
||||
!parameters.write_object(schema.target_controller, target_controller) ||
|
||||
(schema.world_context && !parameters.write_object(schema.world_context, target_controller)) ||
|
||||
!parameters.write_string(schema.message, message) ||
|
||||
!parameters.write_small_integer(schema.message_type, type))
|
||||
{
|
||||
detail = "could not populate SendChatLineToPlayer parameters; " + schema.detail;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.misc_statics->ProcessEvent(state.send_chat_line, parameters.data());
|
||||
detail = schema.detail;
|
||||
return true;
|
||||
}
|
||||
|
||||
auto find_live_controller_by_steam_id(RealChatDispatcher::RuntimeState& state,
|
||||
std::string_view steam_id) -> UObject*
|
||||
{
|
||||
UObject* result{};
|
||||
RC::Unreal::UObjectGlobals::ForEachUObject([&](UObject* object, int32_t, int32_t) {
|
||||
if (result || !object || !object->GetClassPrivate() ||
|
||||
!class_is_child_of(object->GetClassPrivate(), state.controller_base) ||
|
||||
!controller_has_live_network_player(object, state.net_connection_base))
|
||||
{
|
||||
return RC::LoopAction::Continue;
|
||||
}
|
||||
|
||||
std::string candidate_id;
|
||||
if (call_get_user_id(object, state.get_user_id, candidate_id) && candidate_id == steam_id)
|
||||
{
|
||||
result = object;
|
||||
return RC::LoopAction::Break;
|
||||
}
|
||||
return RC::LoopAction::Continue;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
auto enumerate_live_controllers(RealChatDispatcher::RuntimeState& state) -> std::vector<UObject*>
|
||||
{
|
||||
std::vector<UObject*> result;
|
||||
RC::Unreal::UObjectGlobals::ForEachUObject([&](UObject* object, int32_t, int32_t) {
|
||||
if (!object || !object->GetClassPrivate() ||
|
||||
!class_is_child_of(object->GetClassPrivate(), state.controller_base) ||
|
||||
!controller_has_live_network_player(object, state.net_connection_base))
|
||||
{
|
||||
return RC::LoopAction::Continue;
|
||||
}
|
||||
|
||||
std::string steam_id;
|
||||
if (call_get_user_id(object, state.get_user_id, steam_id))
|
||||
{
|
||||
result.emplace_back(object);
|
||||
}
|
||||
return RC::LoopAction::Continue;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
RealChatDispatcher::Result RealChatDispatcher::dispatch_if_chat(std::string_view command)
|
||||
{
|
||||
const auto tokens = tokenize(command);
|
||||
if (tokens.empty() || lowercase(tokens.front()) != "sendchat")
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
Result result{};
|
||||
result.handled = true;
|
||||
if (tokens.size() < 3 || tokens.size() > 4)
|
||||
{
|
||||
result.message = "usage: SendChat <type 0-7> \"message\" [target SteamID64]";
|
||||
return result;
|
||||
}
|
||||
|
||||
int type{};
|
||||
const auto [end, error] = std::from_chars(tokens[1].data(), tokens[1].data() + tokens[1].size(), type);
|
||||
if (error != std::errc{} || end != tokens[1].data() + tokens[1].size() || type < 0 || type > 7)
|
||||
{
|
||||
result.message = "SendChat type must be an integer from 0 through 7";
|
||||
return result;
|
||||
}
|
||||
if (tokens[2].empty() || tokens[2].size() > k_max_message_bytes)
|
||||
{
|
||||
result.message = "SendChat message must contain 1-1024 UTF-8 bytes";
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string target_steam_id;
|
||||
if (tokens.size() == 4)
|
||||
{
|
||||
target_steam_id = tokens[3];
|
||||
if (!is_steam_id64(target_steam_id))
|
||||
{
|
||||
result.message = "SendChat target must be a 17-digit SteamID64";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (!m_state)
|
||||
{
|
||||
result.message = "chat dispatcher has no runtime state";
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string detail;
|
||||
if (!resolve_runtime(*m_state, detail))
|
||||
{
|
||||
result.message = detail;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!target_steam_id.empty())
|
||||
{
|
||||
auto* target = find_live_controller_by_steam_id(*m_state, target_steam_id);
|
||||
if (!target)
|
||||
{
|
||||
result.message = "SendChat target " + target_steam_id + " is not online with a real network controller";
|
||||
return result;
|
||||
}
|
||||
if (!send_chat_line(*m_state, target, type, tokens[2], detail))
|
||||
{
|
||||
result.message = detail;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.message = "ok: SendChat delivered to online SteamID " + target_steam_id;
|
||||
return result;
|
||||
}
|
||||
|
||||
const auto targets = enumerate_live_controllers(*m_state);
|
||||
if (targets.empty())
|
||||
{
|
||||
result.message = "SendChat broadcast has no real online player controllers";
|
||||
return result;
|
||||
}
|
||||
|
||||
int sent{};
|
||||
for (auto* target : targets)
|
||||
{
|
||||
if (!send_chat_line(*m_state, target, type, tokens[2], detail))
|
||||
{
|
||||
result.message = detail;
|
||||
return result;
|
||||
}
|
||||
++sent;
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.message = "ok: SendChat broadcast delivered to " + std::to_string(sent) + " real online player(s)";
|
||||
return result;
|
||||
}
|
||||
|
||||
RealChatDispatcher::Result RealChatDispatcher::probe()
|
||||
{
|
||||
Result result{};
|
||||
result.handled = true;
|
||||
if (!m_state)
|
||||
{
|
||||
result.message = "chat dispatcher has no runtime state";
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string detail;
|
||||
if (!resolve_runtime(*m_state, detail))
|
||||
{
|
||||
result.message = detail;
|
||||
return result;
|
||||
}
|
||||
|
||||
const auto online = enumerate_live_controllers(*m_state);
|
||||
result.success = true;
|
||||
result.message = "ok: real chat dispatcher ready; online_real_controllers=" + std::to_string(online.size()) + "; " + detail;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string RealChatDispatcher::status() const
|
||||
{
|
||||
return m_state ? "real_chat=" + m_state->detail : "real_chat=no runtime state";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
#include "ScumBridge.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <codecvt>
|
||||
#include <fstream>
|
||||
#include <locale>
|
||||
#include <sstream>
|
||||
|
||||
#include <DynamicOutput/DynamicOutput.hpp>
|
||||
#include <Unreal/FOutputDevice.hpp>
|
||||
#include <Unreal/UObject.hpp>
|
||||
#include <Unreal/UObjectGlobals.hpp>
|
||||
|
||||
namespace
|
||||
{
|
||||
auto is_steam_id64(const std::string& value) -> bool
|
||||
{
|
||||
return value.size() == 17 &&
|
||||
std::all_of(value.begin(), value.end(), [](unsigned char c) { return std::isdigit(c) != 0; });
|
||||
}
|
||||
|
||||
auto find_steam_ids_in_line(const std::string& line) -> std::vector<std::string>
|
||||
{
|
||||
std::vector<std::string> ids;
|
||||
for (size_t begin = 0; begin < line.size();)
|
||||
{
|
||||
if (!std::isdigit(static_cast<unsigned char>(line[begin])))
|
||||
{
|
||||
++begin;
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t end = begin;
|
||||
while (end < line.size() && std::isdigit(static_cast<unsigned char>(line[end])))
|
||||
{
|
||||
++end;
|
||||
}
|
||||
if (end - begin == 17)
|
||||
{
|
||||
ids.emplace_back(line.substr(begin, 17));
|
||||
}
|
||||
begin = end;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
auto path_to_utf8(const std::filesystem::path& path) -> std::string
|
||||
{
|
||||
return path.string();
|
||||
}
|
||||
}
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
void ScumBridge::configure(const Config& config, const std::filesystem::path& mod_directory)
|
||||
{
|
||||
std::scoped_lock lock{m_mutex};
|
||||
m_require_configured_admin = config.require_configured_admin;
|
||||
m_preferred_admin_steam_id = config.preferred_admin_steam_id;
|
||||
m_admin_users_file = resolve_admin_users_file(config, mod_directory);
|
||||
m_admin_ids.clear();
|
||||
m_admin_file_seen = false;
|
||||
m_admin_users_last_write = {};
|
||||
m_offline_executor.configure(config.offline_admin_dispatch, config.native_admin_executor);
|
||||
}
|
||||
|
||||
void ScumBridge::on_unreal_init()
|
||||
{
|
||||
std::scoped_lock lock{m_mutex};
|
||||
m_unreal_ready = true;
|
||||
m_offline_executor.on_unreal_init();
|
||||
refresh_admin_ids_locked();
|
||||
RC::Output::send<RC::LogLevel::Verbose>(
|
||||
STR("[scum_simple_rcon] SCUM bridge ready; AdminUsers.ini: {} ({} IDs)\n"),
|
||||
m_admin_users_file.wstring(),
|
||||
m_admin_ids.size());
|
||||
}
|
||||
|
||||
std::string ScumBridge::execute(const std::string& command)
|
||||
{
|
||||
const auto cmd = trim_command(command);
|
||||
if (cmd.empty())
|
||||
{
|
||||
return "error: empty command";
|
||||
}
|
||||
|
||||
std::scoped_lock lock{m_mutex};
|
||||
if (!m_unreal_ready)
|
||||
{
|
||||
return "error: Unreal bridge not ready yet";
|
||||
}
|
||||
|
||||
if (cmd == "__scum_simple_rcon_chat_probe__")
|
||||
{
|
||||
const auto chat = m_real_chat.probe();
|
||||
return chat.success ? chat.message : "error: " + chat.message;
|
||||
}
|
||||
|
||||
// Ordinary SendChat is intentionally separate from admin-command
|
||||
// execution. It resolves only real, online target controllers and
|
||||
// does not borrow a PlayerRpcChannel or require an administrator ID.
|
||||
const auto chat = m_real_chat.dispatch_if_chat(cmd);
|
||||
if (chat.handled)
|
||||
{
|
||||
return chat.success ? chat.message : "error: " + chat.message;
|
||||
}
|
||||
|
||||
refresh_admin_ids_locked();
|
||||
const std::string admin_id = selected_admin_id_locked();
|
||||
if (m_require_configured_admin && admin_id.empty())
|
||||
{
|
||||
return "error: no valid SteamID64 found in AdminUsers.ini: " + path_to_utf8(m_admin_users_file);
|
||||
}
|
||||
|
||||
if (cmd == "__scum_simple_rcon_probe__")
|
||||
{
|
||||
const auto probe = m_offline_executor.probe(admin_id);
|
||||
return probe.success ? probe.message : "error: " + probe.message;
|
||||
}
|
||||
|
||||
const auto offline = m_offline_executor.execute(cmd, admin_id);
|
||||
if (offline.attempted)
|
||||
{
|
||||
return offline.success ? offline.message : "error: " + offline.message;
|
||||
}
|
||||
|
||||
std::string context_detail;
|
||||
auto* context = resolve_server_context(context_detail);
|
||||
if (!context)
|
||||
{
|
||||
return "error: offline dispatcher skipped (" + offline.message + ") and no server execution context: " + context_detail;
|
||||
}
|
||||
|
||||
std::string dispatch_detail;
|
||||
if (!process_server_console(context, widen_utf8(cmd), dispatch_detail))
|
||||
{
|
||||
return "error: offline dispatcher skipped (" + offline.message + ") and server console dispatch failed: " + dispatch_detail;
|
||||
}
|
||||
|
||||
return "ok: server-console-dispatched '" + cmd + "'" +
|
||||
(admin_id.empty() ? std::string{} : " (configured admin " + admin_id + ")");
|
||||
}
|
||||
|
||||
std::string ScumBridge::admin_status()
|
||||
{
|
||||
std::scoped_lock lock{m_mutex};
|
||||
refresh_admin_ids_locked();
|
||||
|
||||
std::ostringstream reply;
|
||||
reply << "AdminUsers.ini=" << path_to_utf8(m_admin_users_file)
|
||||
<< "; configured_admins=" << m_admin_ids.size();
|
||||
const auto selected = selected_admin_id_locked();
|
||||
if (!selected.empty())
|
||||
{
|
||||
reply << "; selected=" << selected;
|
||||
}
|
||||
return reply.str();
|
||||
}
|
||||
|
||||
std::string ScumBridge::dispatch_status()
|
||||
{
|
||||
std::scoped_lock lock{m_mutex};
|
||||
return m_offline_executor.status();
|
||||
}
|
||||
|
||||
RC::Unreal::UObject* ScumBridge::resolve_server_context(std::string& detail)
|
||||
{
|
||||
// These are server-owned objects and exist independently of connected
|
||||
// players. FindFirstOf deliberately ignores class-default objects.
|
||||
if (auto* game_mode = RC::Unreal::UObjectGlobals::FindFirstOf(STR("GameModeBase")))
|
||||
{
|
||||
detail = "GameModeBase";
|
||||
return game_mode;
|
||||
}
|
||||
if (auto* game_instance = RC::Unreal::UObjectGlobals::FindFirstOf(STR("GameInstance")))
|
||||
{
|
||||
detail = "GameInstance";
|
||||
return game_instance;
|
||||
}
|
||||
if (auto* world = RC::Unreal::UObjectGlobals::FindFirstOf(STR("World")))
|
||||
{
|
||||
detail = "World";
|
||||
return world;
|
||||
}
|
||||
|
||||
detail = "GameModeBase, GameInstance, and World are not available yet";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ScumBridge::process_server_console(RC::Unreal::UObject* context,
|
||||
const std::wstring& command,
|
||||
std::string& detail)
|
||||
{
|
||||
RC::Unreal::FOutputDevice output{};
|
||||
if (context->ProcessConsoleExec(command.c_str(), output, context))
|
||||
{
|
||||
detail = "ProcessConsoleExec returned true";
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: UE's string invocation path can resolve command-like UFUNCTIONs
|
||||
// that do not respond through ProcessConsoleExec on the first context.
|
||||
if (RC::Unreal::UObject::CallFunctionByNameWithArgumentsInternal(context, command.c_str(), output, context, true))
|
||||
{
|
||||
detail = "CallFunctionByNameWithArgumentsInternal returned true";
|
||||
return true;
|
||||
}
|
||||
|
||||
detail = "ProcessConsoleExec and CallFunctionByNameWithArgumentsInternal both returned false";
|
||||
return false;
|
||||
}
|
||||
|
||||
void ScumBridge::refresh_admin_ids_locked()
|
||||
{
|
||||
if (m_admin_users_file.empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
const bool exists = std::filesystem::exists(m_admin_users_file, ec);
|
||||
if (ec || !exists)
|
||||
{
|
||||
if (m_admin_file_seen)
|
||||
{
|
||||
m_admin_ids.clear();
|
||||
m_admin_file_seen = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const auto last_write = std::filesystem::last_write_time(m_admin_users_file, ec);
|
||||
if (ec || (m_admin_file_seen && last_write == m_admin_users_last_write))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
std::ifstream file{m_admin_users_file};
|
||||
if (!file)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
std::unordered_set<std::string> next;
|
||||
std::string line;
|
||||
while (std::getline(file, line))
|
||||
{
|
||||
const auto comment = line.find_first_of(";#");
|
||||
if (comment != std::string::npos)
|
||||
{
|
||||
line.resize(comment);
|
||||
}
|
||||
for (const auto& id : find_steam_ids_in_line(line))
|
||||
{
|
||||
next.insert(id);
|
||||
}
|
||||
}
|
||||
|
||||
m_admin_ids = std::move(next);
|
||||
m_admin_users_last_write = last_write;
|
||||
m_admin_file_seen = true;
|
||||
}
|
||||
|
||||
std::string ScumBridge::selected_admin_id_locked() const
|
||||
{
|
||||
if (is_steam_id64(m_preferred_admin_steam_id) && m_admin_ids.contains(m_preferred_admin_steam_id))
|
||||
{
|
||||
return m_preferred_admin_steam_id;
|
||||
}
|
||||
|
||||
if (m_admin_ids.empty())
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<std::string> ordered{m_admin_ids.begin(), m_admin_ids.end()};
|
||||
std::sort(ordered.begin(), ordered.end());
|
||||
return ordered.front();
|
||||
}
|
||||
|
||||
std::filesystem::path ScumBridge::resolve_admin_users_file(const Config& config,
|
||||
const std::filesystem::path& mod_directory)
|
||||
{
|
||||
if (!config.admin_users_file.empty() && config.admin_users_file != "auto")
|
||||
{
|
||||
const std::filesystem::path configured{config.admin_users_file};
|
||||
return configured.is_absolute() ? configured : mod_directory / configured;
|
||||
}
|
||||
|
||||
// <SCUM>/Binaries/Win64/ue4ss/Mods/scum_simple_rcon
|
||||
const auto win64 = mod_directory.parent_path().parent_path().parent_path();
|
||||
const auto scum_root = win64.parent_path().parent_path();
|
||||
return scum_root / "Saved" / "Config" / "WindowsServer" / "AdminUsers.ini";
|
||||
}
|
||||
|
||||
std::wstring ScumBridge::widen_utf8(const std::string& s)
|
||||
{
|
||||
if (s.empty())
|
||||
{
|
||||
return {};
|
||||
}
|
||||
std::wstring_convert<std::codecvt_utf8_utf16<wchar_t>> conv;
|
||||
return conv.from_bytes(s);
|
||||
}
|
||||
|
||||
std::string ScumBridge::trim_command(std::string s)
|
||||
{
|
||||
if (!s.empty() && s[0] == '#')
|
||||
{
|
||||
s.erase(s.begin());
|
||||
}
|
||||
const auto not_space = [](unsigned char c) { return c != ' ' && c != '\t' && c != '\r' && c != '\n'; };
|
||||
s.erase(s.begin(), std::find_if(s.begin(), s.end(), not_space));
|
||||
s.erase(std::find_if(s.rbegin(), s.rend(), not_space).base(), s.end());
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
#include "SimpleRconMod.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <exception>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <DynamicOutput/DynamicOutput.hpp>
|
||||
#include <UE4SSRuntime.hpp>
|
||||
#include <Unreal/Hooks/Hooks.hpp>
|
||||
|
||||
namespace
|
||||
{
|
||||
std::string trim(std::string s)
|
||||
{
|
||||
auto not_space = [](unsigned char c) { return c != ' ' && c != '\t' && c != '\r' && c != '\n'; };
|
||||
s.erase(s.begin(), std::find_if(s.begin(), s.end(), not_space));
|
||||
s.erase(std::find_if(s.rbegin(), s.rend(), not_space).base(), s.end());
|
||||
return s;
|
||||
}
|
||||
|
||||
std::filesystem::path mod_dir()
|
||||
{
|
||||
HMODULE module{};
|
||||
wchar_t path[MAX_PATH]{};
|
||||
if (::GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(&mod_dir),
|
||||
&module) &&
|
||||
::GetModuleFileNameW(module, path, MAX_PATH) > 0)
|
||||
{
|
||||
auto p = std::filesystem::path(path);
|
||||
// .../Mods/scum_simple_rcon/dlls/main.dll -> .../Mods/scum_simple_rcon
|
||||
return p.parent_path().parent_path();
|
||||
}
|
||||
return std::filesystem::current_path() / "ue4ss" / "Mods" / "scum_simple_rcon";
|
||||
}
|
||||
}
|
||||
|
||||
namespace simple_rcon
|
||||
{
|
||||
Config load_config(const std::filesystem::path& path)
|
||||
{
|
||||
Config cfg{};
|
||||
std::ifstream in(path);
|
||||
if (!in)
|
||||
{
|
||||
return cfg;
|
||||
}
|
||||
|
||||
std::string section;
|
||||
std::string line;
|
||||
while (std::getline(in, line))
|
||||
{
|
||||
line = trim(line);
|
||||
if (line.empty() || line[0] == ';' || line[0] == '#')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (line.front() == '[' && line.back() == ']')
|
||||
{
|
||||
section = line.substr(1, line.size() - 2);
|
||||
continue;
|
||||
}
|
||||
const auto eq = line.find('=');
|
||||
if (eq == std::string::npos)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const std::string key = trim(line.substr(0, eq));
|
||||
const std::string value = trim(line.substr(eq + 1));
|
||||
try
|
||||
{
|
||||
if (section == "rcon")
|
||||
{
|
||||
if (key == "bind_address") cfg.bind_address = value;
|
||||
else if (key == "port")
|
||||
{
|
||||
const int port = std::stoi(value);
|
||||
if (port >= 1 && port <= 65535) cfg.port = static_cast<uint16_t>(port);
|
||||
}
|
||||
else if (key == "password") cfg.password = value;
|
||||
else if (key == "auth_log") cfg.auth_log = value == "true" || value == "1";
|
||||
else if (key == "max_connections") cfg.max_connections = std::clamp(std::stoi(value), 1, 32);
|
||||
else if (key == "packet_body_bytes") cfg.packet_body_bytes = std::clamp(std::stoi(value), 1, 4000);
|
||||
else if (key == "command_timeout_ms")
|
||||
{
|
||||
cfg.command_timeout = std::chrono::milliseconds{std::clamp(std::stoi(value), 100, 60000)};
|
||||
}
|
||||
else if (key == "allow_empty_password") cfg.allow_empty_password = value == "true" || value == "1";
|
||||
}
|
||||
else if (section == "dispatch")
|
||||
{
|
||||
if (key == "context") cfg.dispatch_context = value;
|
||||
else if (key == "offline_admin_dispatch") cfg.offline_admin_dispatch = value == "true" || value == "1";
|
||||
else if (key == "native_admin_executor") cfg.native_admin_executor = value == "true" || value == "1";
|
||||
}
|
||||
else if (section == "admin")
|
||||
{
|
||||
if (key == "users_file") cfg.admin_users_file = value;
|
||||
else if (key == "preferred_steam_id") cfg.preferred_admin_steam_id = value;
|
||||
else if (key == "require_configured_admin") cfg.require_configured_admin = value == "true" || value == "1";
|
||||
}
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
// Ignore an invalid individual value and retain the safe default.
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
SimpleRconMod::SimpleRconMod()
|
||||
: m_config(load_config(mod_dir() / "config.ini")),
|
||||
m_queue([this](const std::string& command) { return m_bridge.execute(command); })
|
||||
{
|
||||
ModName = STR("scum_simple_rcon");
|
||||
ModVersion = STR("0.1.0");
|
||||
ModDescription = STR("Minimal Source RCON server for SCUM via UE4SS");
|
||||
ModAuthors = STR("Codex");
|
||||
m_bridge.configure(m_config, mod_dir());
|
||||
}
|
||||
|
||||
SimpleRconMod::~SimpleRconMod()
|
||||
{
|
||||
if (m_server)
|
||||
{
|
||||
m_server->stop();
|
||||
}
|
||||
m_queue.shutdown();
|
||||
if (m_tick_callback_id != 0)
|
||||
{
|
||||
RC::Unreal::Hook::UnregisterCallback(m_tick_callback_id);
|
||||
}
|
||||
}
|
||||
|
||||
auto SimpleRconMod::on_unreal_init() -> void
|
||||
{
|
||||
m_bridge.on_unreal_init();
|
||||
install_game_thread_drain();
|
||||
|
||||
if (!m_config.allow_empty_password && (m_config.password.empty() || m_config.password == "CHANGE_ME_BEFORE_USE"))
|
||||
{
|
||||
log("rcon: listener NOT started; set [rcon] password in config.ini");
|
||||
return;
|
||||
}
|
||||
|
||||
m_server = std::make_unique<RconServer>(
|
||||
m_config,
|
||||
[this](const std::string& command) { return handle_rcon_command(command); },
|
||||
[this](const std::string& line) { log(line); });
|
||||
|
||||
if (!m_server->start())
|
||||
{
|
||||
log("rcon: listener failed to start");
|
||||
}
|
||||
}
|
||||
|
||||
auto SimpleRconMod::on_update() -> void
|
||||
{
|
||||
// Fallback path if EngineTick hook is unavailable in a given UE4SS build.
|
||||
if (!m_hook_installed)
|
||||
{
|
||||
drain_game_thread();
|
||||
}
|
||||
}
|
||||
|
||||
void SimpleRconMod::install_game_thread_drain()
|
||||
{
|
||||
if (m_hook_installed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RC::UE4SSRuntime::IsEngineTickAvailable())
|
||||
{
|
||||
log("game-thread drain: EngineTick unavailable; falling back to on_update");
|
||||
return;
|
||||
}
|
||||
|
||||
m_tick_callback_id = RC::Unreal::Hook::RegisterEngineTickPreCallback(
|
||||
[this](auto&, RC::Unreal::UEngine*, float, bool) {
|
||||
drain_game_thread();
|
||||
},
|
||||
{true, true, STR("scum_simple_rcon"), STR("DrainCommands")});
|
||||
|
||||
m_hook_installed = m_tick_callback_id != 0;
|
||||
log(m_hook_installed ? "game-thread drain installed via EngineTick" : "game-thread drain install failed");
|
||||
}
|
||||
|
||||
void SimpleRconMod::drain_game_thread()
|
||||
{
|
||||
m_queue.drain(32);
|
||||
}
|
||||
|
||||
std::string SimpleRconMod::handle_rcon_command(const std::string& command)
|
||||
{
|
||||
const auto cmd = trim(command);
|
||||
if (cmd.empty())
|
||||
{
|
||||
return "error: empty command";
|
||||
}
|
||||
|
||||
if (cmd == "rcon.status")
|
||||
{
|
||||
return "scum_simple_rcon: ok; game-thread queue active";
|
||||
}
|
||||
if (cmd == "rcon.help")
|
||||
{
|
||||
return "internal: rcon.status, rcon.admins, rcon.dispatch, rcon.chat, rcon.help; SendChat uses real online controllers; otherwise send raw SCUM command text";
|
||||
}
|
||||
if (cmd == "rcon.admins")
|
||||
{
|
||||
return m_bridge.admin_status();
|
||||
}
|
||||
if (cmd == "rcon.dispatch")
|
||||
{
|
||||
// A dispatcher probe creates UObject state and scans the game
|
||||
// executable, so it must take the exact same game-thread path as
|
||||
// an actual RCON command.
|
||||
return m_queue.enqueue_and_wait("__scum_simple_rcon_probe__", m_config.command_timeout);
|
||||
}
|
||||
if (cmd == "rcon.chat")
|
||||
{
|
||||
return m_queue.enqueue_and_wait("__scum_simple_rcon_chat_probe__", m_config.command_timeout);
|
||||
}
|
||||
|
||||
return m_queue.enqueue_and_wait(cmd, m_config.command_timeout);
|
||||
}
|
||||
|
||||
void SimpleRconMod::log(const std::string& line)
|
||||
{
|
||||
const std::wstring wide(line.begin(), line.end());
|
||||
RC::Output::send<RC::LogLevel::Verbose>(STR("[scum_simple_rcon] {}\n"), wide);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#include "SimpleRconMod.hpp"
|
||||
|
||||
extern "C"
|
||||
{
|
||||
__declspec(dllexport) RC::CppUserModBase* start_mod()
|
||||
{
|
||||
return new simple_rcon::SimpleRconMod();
|
||||
}
|
||||
|
||||
__declspec(dllexport) void uninstall_mod(RC::CppUserModBase* mod)
|
||||
{
|
||||
delete mod;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
; Add this line to your server's existing ue4ss/Mods/mods.txt
|
||||
scum_simple_rcon : 1
|
||||
@@ -0,0 +1,36 @@
|
||||
; Minimal SCUM Source RCON over UE4SS.
|
||||
; Change password before starting the server.
|
||||
|
||||
[rcon]
|
||||
bind_address = 127.0.0.1
|
||||
port = 27015
|
||||
password = CHANGE_ME_BEFORE_USE
|
||||
auth_log = true
|
||||
max_connections = 4
|
||||
packet_body_bytes = 4000
|
||||
command_timeout_ms = 10000
|
||||
allow_empty_password = false
|
||||
|
||||
[dispatch]
|
||||
; Primary path: create a server-only BP_ConZPlayerController + ConZCharacter,
|
||||
; then invoke SCUM's AdminCommand executor. This does not require any player
|
||||
; (including an administrator) to be connected.
|
||||
offline_admin_dispatch = true
|
||||
; The executor is discovered by one exact SCUM build signature. If SCUM updates
|
||||
; and it no longer resolves uniquely, commands fail safely and rcon.dispatch
|
||||
; reports the reason instead of calling an untrusted address.
|
||||
native_admin_executor = true
|
||||
; Fallback only for non-AdminCommand Unreal console commands.
|
||||
context = auto
|
||||
|
||||
[admin]
|
||||
; auto = <SCUM>/Saved/Config/WindowsServer/AdminUsers.ini
|
||||
; This list is re-read when the file changes. It is never substituted with
|
||||
; the first online player.
|
||||
users_file = auto
|
||||
; Optional selection only: this ID must also already exist in AdminUsers.ini.
|
||||
; Leave empty to use the first configured admin ID deterministically. This is
|
||||
; not an authorization entry and never substitutes an online player.
|
||||
preferred_steam_id =
|
||||
; Refuse game commands if AdminUsers.ini is missing or has no valid SteamID64.
|
||||
require_configured_admin = true
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user