skate3recomp-multiplayer/tools/ghost_relay_main.cpp
MiTHRAL 7a48eb9f35 ghost multiplayer: beta hardening - version handshake, crash-proofing, logging, persistence
Production-readiness pass toward 1.0:

- Protocol handshake: JOIN carries kGhostVersion; a mismatched client is
  REFUSED by the relay (HELLO session 0) and its poses are never forwarded,
  so two builds can never silently corrupt each other's pose stream. The
  client surfaces the refusal in the menu and skips refused LAN hosts during
  discovery.
- Crash-proofing: every received payload is bounds-checked against its
  packet size before parsing (HELLO / PoseChunk / JOIN); both the client
  network thread and the relay thread are exception-guarded (a throw logs
  and keeps the loop alive instead of hitting std::terminate). The relay no
  longer creates client records for unknown-address POSE/LEAVE floods and
  enforces a hard client cap (--max-clients, default 64).
- Logging: rate-limited error logging throughout (send failures, resolve
  failures, refusals, thread exceptions, client-cap drops). The relay core
  uses a build-flag shim (REXLOG in-game, a tiny {} formatter in the
  dependency-free standalone), with --verbose join/leave lines for the
  server. Game crashes themselves are already handled by the SDK's crash
  handlers + file logs; this layer now avoids triggering them and explains
  itself when it can't connect.
- Settings persistence: server/room/name/port/rate save into the app's cvar
  config on change, so a player's multiplayer setup survives a restart.
- Menu: status now shows protocol refusals and the last error.

Tests updated for the handshake (version-refusal case added); all suites
pass.
2026-08-26 22:07:09 -04:00

114 lines
No EOL
3.8 KiB
C++

// ghost_relay: standalone ghost multiplayer relay server.
//
// Run on a public VPS (or your own machine behind a free tunnel like
// playit.gg / ngrok / Tailscale Funnel) so players connect OUTBOUND to it
// and need no port forwarding:
//
// ghost_relay [--port 40001] [--room CODE]
//
// --room optionally locks every client into one room (ignores JOIN rooms).
// The server is stateless across restarts: clients re-send their JOIN every
// ~5 s, so a redeployed relay re-gathers the room automatically.
//
// Build: cmake target `ghost_relay` (no rexglue dependency; plain sockets).
#include <chrono>
#include <cstdio>
#include <cstring>
#include <string>
#include <thread>
#include "skate3_native_ghost_relay.h"
#if !defined(_WIN32)
#include <csignal>
namespace {
volatile std::sig_atomic_t g_stop = 0;
void OnSignal(int) { g_stop = 1; }
} // namespace
#endif
int main(int argc, char** argv) {
uint16_t port = skate3::native_ghost::kGhostDefaultPort;
std::string locked_room;
uint32_t max_clients = 64;
bool verbose = false;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
auto next = [&](const char* name) -> std::string {
if (i + 1 < argc) {
return argv[++i];
}
std::fprintf(stderr, "ghost_relay: %s requires a value\n", name);
std::exit(2);
};
if (arg == "--port") {
port = uint16_t(std::stoi(next("--port")));
} else if (arg == "--room") {
locked_room = next("--room");
} else if (arg == "--max-clients") {
max_clients = uint32_t(std::stoul(next("--max-clients")));
} else if (arg == "--verbose") {
verbose = true;
} else if (arg == "--help" || arg == "-h") {
std::printf(
"ghost_relay - ghost multiplayer UDP relay\n"
"usage: ghost_relay [--port 40001] [--room CODE] [--max-clients 64] "
"[--verbose]\n"
" --port UDP port to listen on (clients connect here; allow\n"
" this port through any firewall, e.g.\n"
" `ufw allow 40001/udp`)\n"
" --room optional: force every client into one room code\n"
" --max-clients hard cap on simultaneously tracked clients (default 64)\n"
" --verbose log every join/leave to stdout\n");
return 0;
} else {
std::fprintf(stderr, "ghost_relay: unknown argument '%s'\n", arg.c_str());
return 2;
}
}
#if !defined(_WIN32)
std::signal(SIGINT, OnSignal);
std::signal(SIGTERM, OnSignal);
#endif
skate3::native_ghost::GhostRelay relay(port);
if (!locked_room.empty()) {
relay.SetLockedRoom(locked_room.c_str());
}
relay.SetMaxClients(max_clients);
relay.SetVerbose(verbose);
if (!relay.Start()) {
std::fprintf(stderr, "ghost_relay: failed to bind UDP port %u\n", port);
return 1;
}
const std::string lock_note =
locked_room.empty() ? "" : (" (locked room: " + locked_room + ")");
std::printf("ghost_relay: listening on UDP %u%s\n", port, lock_note.c_str());
std::printf("ghost_relay: clients connect outbound; no client-side port "
"forwarding needed (max clients %u)\n",
max_clients);
std::printf("ghost_relay: clients connect outbound; no client-side port "
"forwarding needed\n");
// Status line every 5 s; on POSIX exit on SIGINT/SIGTERM.
auto last_log = std::chrono::steady_clock::now();
while (true) {
#if !defined(_WIN32)
if (g_stop != 0) {
break;
}
#endif
const auto now = std::chrono::steady_clock::now();
if (now - last_log >= std::chrono::seconds(5)) {
std::printf("ghost_relay: %zu client(s)\n", relay.ClientCount());
std::fflush(stdout);
last_log = now;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
relay.Stop();
std::printf("ghost_relay: stopped\n");
return 0;
}