From d7bf16e9c266a683540cd5c3bce7258dd60dda74 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 15 Oct 2026 21:32:09 -0400 Subject: [PATCH] Add identity-safe player moderation runtimes for T5 and T6 --- CHANGELOG.md | 24 ++ README.md | 11 +- docs/moderation.md | 114 ++++++ .../src/component/moderation.cpp | 161 ++++++++ .../tws-t5-gsc-utils/src/utils/http.cpp | 12 +- .../tws-t5-gsc-utils/src/utils/http.hpp | 3 + .../tws-t5-gsc-utils/src/utils/moderation.cpp | 349 ++++++++++++++++++ .../tws-t5-gsc-utils/src/utils/moderation.hpp | 46 +++ t5/scripts/README.md | 13 +- t5/scripts/tws_t5_moderation.gsc | 63 ++++ .../src/component/moderation.cpp | 161 ++++++++ .../tws-t6-gsc-utils/src/utils/http.cpp | 12 +- .../tws-t6-gsc-utils/src/utils/http.hpp | 3 + .../tws-t6-gsc-utils/src/utils/moderation.cpp | 349 ++++++++++++++++++ .../tws-t6-gsc-utils/src/utils/moderation.hpp | 46 +++ t6/scripts/README.md | 18 +- t6/scripts/tools/check.ps1 | 6 +- t6/scripts/tws_t6_moderation.gsc | 66 ++++ tools/check_hud.mjs | 2 +- tools/check_moderation.mjs | 40 ++ tools/test_plugin_http.py | 9 +- tools/test_plugin_moderation.py | 33 ++ tools/tests/http_probe.cpp | 3 +- tools/tests/moderation_cases.cpp | 160 ++++++++ tools/tests/moderation_component_cases.cpp | 49 +++ tools/tests/moderation_component_stub.hpp | 50 +++ versions.json | 6 +- 27 files changed, 1784 insertions(+), 25 deletions(-) create mode 100644 docs/moderation.md create mode 100644 t5/plugins/tws-t5-gsc-utils/src/component/moderation.cpp create mode 100644 t5/plugins/tws-t5-gsc-utils/src/utils/moderation.cpp create mode 100644 t5/plugins/tws-t5-gsc-utils/src/utils/moderation.hpp create mode 100644 t5/scripts/tws_t5_moderation.gsc create mode 100644 t6/plugins/tws-t6-gsc-utils/src/component/moderation.cpp create mode 100644 t6/plugins/tws-t6-gsc-utils/src/utils/moderation.cpp create mode 100644 t6/plugins/tws-t6-gsc-utils/src/utils/moderation.hpp create mode 100644 t6/scripts/tws_t6_moderation.gsc create mode 100644 tools/check_moderation.mjs create mode 100644 tools/test_plugin_moderation.py create mode 100644 tools/tests/moderation_cases.cpp create mode 100644 tools/tests/moderation_component_cases.cpp create mode 100644 tools/tests/moderation_component_stub.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index ece9955..9a64e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ Changes are listed newest first, with an independent version for every individua T5/T6 script and each native plugin. Current versions are recorded in [versions.json](versions.json); see the [version policy](README.md#versioning). +## 2026-10-15 — Player moderation runtime + +- **tws_t5_moderation.gsc 1.0.0 / tws_t6_moderation.gsc 1.0.0**: new optional + paired scripts register current GUIDs and use the existing shared connection + dispatcher. Require common 1.2.0, the matching native plugin 1.3.0, and Electric + Cherry's instance-authenticated moderation endpoint/private configuration. +- **t5-plugin 1.3.0 / t6-plugin 1.3.0**: add the native moderation component and + policy/journal utilities, connection-bound synchronous actions, durable cached + bans/mutes and expiry/revocation, and an opt-in bounded HTTP response limit. + Existing HTTP callers keep their previous limit behavior. Sources are each + plugin's component/moderation.cpp, utils/moderation.*, and utils/http.*. + Both games change for matching functionality; their chat opcodes remain distinct. +- Deploy the receiver first; build and install both matching DLL/script pairs, + supply separate instance credentials, and fully restart game-server processes. + Persistent tws-moderation directories must survive restart. No deployment or + live Windows/Plutonium verification is claimed. See [runtime contract and + validation](docs/moderation.md). +- Validation: paired native moderation policy/adapter checks passed with ASan and + UBSan, including separate-process journal and session-sequence recovery. The + GSC identity and retry/disable/resume checks, native HTTP cap/error checks, + existing HTTP component, bank and telemetry checks, metadata/link validation, + and `git diff --check` passed. Native adapter tests use engine shims; no DLL + build, GSC compilation or live game validation was performed. + ## 2026-10-15 ### Replace the dashboard panels with unboxed HUD overlays diff --git a/README.md b/README.md index 5a6a9c3..537813a 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Versions use `MAJOR.MINOR.PATCH`: increase the patch for fixes and internal changes, the minor for compatible features, and the major for breaking changes to commands, configuration, native APIs, or data contracts. Reset lower fields when increasing the minor or major. Bump once per logical change; documentation-only -and test-only changes are exempt. All 16 scripts and both plugins advance independently. +and test-only changes are exempt. All 18 scripts and both plugins advance independently. The initial `1.0.0` versions document the existing source baseline. These TWS versions are separate from upstream revisions in each plugin's `upstream.json` @@ -591,3 +591,12 @@ powershell -File t6/scripts/tools/check.ps1 -CompilerPath C:\path\to\gsc-tool.ex ``` Requires PowerShell and `gsc-tool` (T6 PC). + +## Player moderation (T5 and T6) + +See [Moderation runtime](docs/moderation.md) for the instance credential contract, +policy persistence, command-result limits and deployment validation. New +`tws_t5_moderation.gsc` / `tws_t6_moderation.gsc` 1.0.0 require the corresponding +TWS plugin 1.3.0 and common script 1.2.0. Moderation is optional; without private +configuration it stays disabled. Existing bank and telemetry credentials are +never reused. Native plugin updates require a full game-server process restart. diff --git a/docs/moderation.md b/docs/moderation.md new file mode 100644 index 0000000..b4b1254 --- /dev/null +++ b/docs/moderation.md @@ -0,0 +1,114 @@ +# Moderation runtime + +T5 and T6 use the same protocol and native policy core, with separate native +chat opcodes and existing engine symbols. Install the game's new moderation +script 1.0.0, common 1.2.0, and native plugin 1.3.0. The controller must implement +`POST /api/moderation/runtime` before enabling the scripts. + +## Private configuration + +Each instance receives its own non-shared credential. Keep this configuration +out of shared scripts, release artifacts, ordinary config exports, and logs: + +```cfg +set tws_server_id "registered-server" +set tws_moderation_url "https://controller.example/api/moderation/runtime" +set tws_moderation_token "ec_moderation_REPLACE_WITH_INSTANCE_SECRET" +set tws_moderation_generation "1" +``` + +Set `tws_moderation_enabled "0"` to disable the runtime. Missing or invalid +configuration leaves it waiting; it retries every three seconds so private configuration can be installed without a map restart. Disabling stops synchronization and enforcement; it does not revoke controller sanctions or promise to restore existing engine mutes. HTTPS is mandatory; redirects are not followed. +The generation must match the controller's live instance. Only that instance's +credential authorizes its roster, receipts and policy; the bank and telemetry +credentials cannot authorize moderation. + +## Synchronization + +Native HTTP runs outside the game thread with 3-second connection and 5-second +transfer deadlines. Polls start at most once per 3 seconds; only one poll is in +flight. Responses are limited to 1 MiB at the transport (oversize fails without +truncation), 1000 sanctions and 100 actions. The native cache never crosses a GSC +string boundary. Requests include protocol 1, server, generation, runtime session, sessionSequence, +players `{guid,name,connection}`, capabilities, appliedRevision and up to 256 +receipts. Sessions change on map/process startup. A positive safe-integer sessionSequence is incremented and atomically persisted before each new runtime starts; the controller rejects older sequences and conflicting sessions at the same sequence. Repeated unchanged configuration preserves the current sequence. Never share an instance credential or its cache directory between simultaneous game-server processes. Connections change on every +native connect/disconnect event, even when the same GUID reuses the same slot. +GUIDs remain strings; pure decimal IDs have leading zeroes removed without a numeric conversion; the T5 script reads getGuid and the T6 script reads the +stock persistent guid field. GUIDs are limited to 128 ASCII identifier bytes. Roster names are bounded to 255 UTF-8 bytes; malformed name bytes become question marks and ASCII controls become spaces. Blank names, including Unicode whitespace-only names, become Unknown player. Names never select an action target. + +Responses contain `{ok,revision,serverTime,sanctions,actions,pollAfterMs}`. +Sanctions contain `{id,revision,guid,kind,reason,expiresAt}`, with `kind` ban/mute +and nullable expiry. Actions contain `{id,guid,connection,kind,reason,expiresAt}`; +kind is warn/message/kick/announce. Only announce uses null GUID and connection. +Deadlines and policy expiry are UTC ISO timestamps. Clock skew over two minutes +rejects an update. Received server time is anchored to monotonic elapsed time without moving backward between polls, conservatively including the full transfer age and a one-second timestamp rounding margin, so a slow or backward-jumping host clock cannot extend one-time deadlines. This may expire an action or timed policy slightly early. Deadlines are checked again after writing the action journal and before dispatch. Revision rollback, inconsistent policy at the same revision, +malformed data, duplicate IDs and changed action content reject an update. +Removing sanctions already expired according to the validated serverTime is permitted without a revision increment. +The current client uses its fixed 3-second poll interval after validating the +server's pollAfterMs bounds (1000–60000). + +## Enforcement and recovery + +Keep `tws-moderation/t5/` or `tws-moderation/t6/`, relative to the game process +working directory, on persistent writable storage. Files are scoped to server, +endpoint, instance generation and SHA-256 credential fingerprint and do not contain the credential. Complete +snapshots replace files atomically. Every write verifies ownership of the current session sequence so a superseded runtime cannot roll the counter or policy backward. Corrupt cache disables startup and preserves +the file; storage failures are logged and block new effectful actions. Cache loss must be recovered by rebuilding the controller instance generation and issuing a new private credential; resetting or deleting a cache under an existing credential cannot reset the controller's highest session sequence. Retired +instance generations cannot inherit a newer generation's policy cache. + +Cached bans and mutes continue during controller outages and expire using the +host wall clock until synchronized, then the later of host time and the monotonic server-time estimate. After a restart without connectivity, host clock drift remains an operator responsibility. Ban enforcement is a kick after connection, checked through the +shared connected dispatcher and a 500 ms loop; it is not a pre-authentication +admission hook. Still-connected banned players are checked again every five +seconds. Removing the last applicable mute restores clients muted by this +runtime; unrelated manually applied mutes are not deliberately cleared. Managed +mute ownership survives process/map restart so revoked cached mutes can be +removed when the affected player returns. No native ban.txt is written. + +One-time actions require the exact current connection and unexpired deadline. +The native game-thread adapter validates the target and executes immediately; +GSC's buffered executeCommand is never used. Only fixed kick/mute/unmute command +templates are available; untrusted text cannot add delimiters, quotes, line +breaks or backslash escapes. Visible text is bounded to 240 UTF-8 bytes without splitting codepoints. Messages use direct per-game server message calls. + +Before one-time dispatch, the action and an unknown receipt are saved durably. +Duplicate IDs never execute again, including after restart. A crash between +journal write and dispatch may therefore lose a one-time action; its result +stays unknown instead of risking a duplicate kick. Journal entries expire from +storage only after receipt acknowledgment and at least a day past their action +deadline. The journal is bounded at 10000 entries; saturation stops new actions +and requires controller delivery/storage investigation. + +A confirmed private-message receipt means the game server dispatched it, not +that the player read it. A kick is confirmed only if its synchronous dispatch +observes the native disconnect callback; otherwise the result is unknown. +Native mute commands have no readback, so appliedRevision means the policy is +cached and being enforced, not independently verified voice/text suppression. +Do not describe a cached policy revision as proven enforcement. + +## Validation and rollout + +Portable checks: + +```sh +node tools/check_moderation.mjs +python3 tools/test_plugin_moderation.py +python3 tools/test_plugin_http.py +python3 tools/test_plugin_component.py +``` + +The native moderation runner accepts `--json-header '/path/{game}-json.hpp'` +when dependencies have not been restored. Use each plugin's pinned JSON revision. +It compiles the actual core and registration/HTTP bridge against minimal engine +shims with ASan/UBSan, including separate process persistence tests. These checks +do not validate Windows linking or the Plutonium ABI. + +Build both x86 DLLs using the existing Windows build workflow. Deploy the new +plugins and scripts together, then fully restart each game-server process; a map +restart alone cannot load the native methods. Test in isolated T5 Zombies and T6 +Zombies instances before fleet rollout: duplicate names, same-account reconnect, +slot reuse, kick command/reason behavior, text and voice mute/unmute, reconnect +and map/process restart, expiry, revocation, controller outage, credential +revocation, truncated/oversized responses and disk failure. Verify clientkick and +muteclient command support in the deployed Plutonium revisions. If unsupported, +keep moderation disabled; this change has no automatic production deployment. diff --git a/t5/plugins/tws-t5-gsc-utils/src/component/moderation.cpp b/t5/plugins/tws-t5-gsc-utils/src/component/moderation.cpp new file mode 100644 index 0000000..fe67834 --- /dev/null +++ b/t5/plugins/tws-t5-gsc-utils/src/component/moderation.cpp @@ -0,0 +1,161 @@ +#include +#include "loader/component_loader.hpp" +#include "gsc.hpp" +#include "scripting.hpp" +#include "scheduler.hpp" +#include "command.hpp" +#include "game/game.hpp" +#include "game/scripting/array.hpp" +#include +#include +#include +#include +#include +#include + +namespace moderation +{ + namespace + { + using runtime = utils::moderation::runtime; + struct transfer + { + std::string session, response; + std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now(); + std::atomic done{false}; + bool success = false; + }; + std::shared_ptr current; + std::shared_ptr pending; + std::string endpoint, credential, configured_server; + int configured_generation = 0; + std::chrono::steady_clock::time_point next_poll{}, next_log{}; + bool fault = false; + long long now() { return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); } + void failure() + { + if (std::chrono::steady_clock::now() >= next_log) + { + printf("[tws_moderation] Runtime unavailable: verify credentials, policy, clock and persistent storage.\n"); + next_log = std::chrono::steady_clock::now() + std::chrono::seconds(30); + } + } + void disconnected(unsigned int slot) { if (current) current->disconnect(static_cast(slot)); } + std::string execute(const std::string& kind, int slot, const std::string& reason) + { + auto active = current; + if (!active) return "failed"; + // This function is called only by the server-thread GSC pump. No queue/yield + // separates runtime target validation from synchronous engine dispatch. + if (kind == "announce" || kind == "warn" || kind == "message") + { + const auto text = std::string("h \"") + reason + "\""; + game::SV_GameSendServerCommand(slot, 0, text.c_str()); + return "confirmed"; + } + if (slot < 0 || slot >= 64) return "failed"; + if (kind == "mute" || kind == "unmute") + { + command::execute((kind == "mute" ? "muteclient " : "unmuteclient ") + std::to_string(slot), true); + return "unknown"; // Engine has no mute-state readback in this adapter. + } + if (kind != "kick") return "failed"; + const auto connection = active->players.contains(slot) ? active->players.at(slot).connection : std::string(); + command::execute("clientkick " + std::to_string(slot) + " \"" + reason + "\"", true); + return !active->players.contains(slot) || active->players.at(slot).connection != connection ? "confirmed" : "unknown"; + } + void pump() + { + if (!current || fault) return; + try + { + if (pending && pending->done.load()) + { + auto completed = pending; pending.reset(); + if (completed->session == current->session) + { + if (completed->success) + { + const auto age = std::chrono::duration_cast(std::chrono::steady_clock::now() - completed->started).count(); + current->accept(completed->response, now(), age); + } + else failure(); + } + } + auto active = current; + active->enforce(now(), [active](const auto& kind, int slot, const auto& reason) + { + return current == active ? execute(kind, slot, reason) : std::string("failed"); + }); + if (current != active) return; + if (!pending && std::chrono::steady_clock::now() >= next_poll) + { + auto request = current->request().dump(); auto target = endpoint; auto token = credential; + pending = std::make_shared(); pending->session = current->session; auto work = pending; + next_poll = std::chrono::steady_clock::now() + std::chrono::seconds(3); + scheduler::thread_pool.push([work, request, target, token] + { + try + { + utils::http::timeouts limits; limits.connect_timeout_ms = 3000; limits.timeout_ms = 5000; + limits.max_response_bytes = 1024 * 1024; + const auto response = utils::http::get_data(target, request, + {{"Authorization", "Bearer " + token}, {"Content-Type", "application/json"}}, "POST", limits); + if (response && response->code == CURLE_OK && response->status == 200) + { work->response = response->buffer; work->success = true; } + } + catch (...) { work->success = false; } + work->done.store(true); + }); + } + } + catch (...) { failure(); } + } + } + class component final : public component_interface + { + public: + void on_startup(plugin::plugin* plugin) override + { + plugin->get_interface()->callbacks()->on_player_connect(disconnected); + plugin->get_interface()->callbacks()->on_player_disconnect(disconnected); + scripting::on_shutdown([] + { + // In-flight requests own only native data. Results from the old VM are abandoned. + current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false; + }); + gsc::function::add("moderationversion", [] { return std::string("1.0.0"); }); + gsc::function::add("moderationconfigure", [](const std::string& server, const std::string& url, const std::string& token, int generation) + { + if (current && !fault && endpoint == url && credential == token && configured_server == server && configured_generation == generation) return true; + // Credentials cannot leave the explicitly configured HTTPS origin. Curl does not follow redirects. + if (url.size() > 2048 || url.rfind("https://", 0) != 0 || url.find_first_of("\r\n?#@") != std::string::npos || + url.size() < 8 || url.find("/api/moderation/runtime") != url.size() - 23 || + token.rfind("ec_moderation_", 0) != 0 || token.size() < 40 || token.size() > 256 || + token.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != std::string::npos) return false; + try + { + auto configured = std::make_shared("tws-moderation/t5", server, url, generation, + utils::cryptography::sha256::compute(token, true)); + current = std::move(configured); pending.reset(); configured_server = server; configured_generation = generation; + endpoint = url; credential = token; next_poll = {}; fault = false; return true; + } + catch (...) { fault = true; failure(); return false; } + }); + gsc::method::add("moderationobserve", [](const scripting::entity& entity, const std::string& guid, const std::string& name) + { + if (!current || fault) return false; + const auto ref = entity.get_entity_reference(); + if (ref.classnum != 0 || ref.entnum >= 64) return false; + try { current->observe(ref.entnum, guid, name); return true; } + catch (...) { failure(); return false; } + }); + gsc::function::add("moderationstop", [] + { + current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false; + }); + gsc::function::add("moderationpump", pump); + } + }; +} +REGISTER_COMPONENT(moderation::component) diff --git a/t5/plugins/tws-t5-gsc-utils/src/utils/http.cpp b/t5/plugins/tws-t5-gsc-utils/src/utils/http.cpp index 42f8269..aca0760 100644 --- a/t5/plugins/tws-t5-gsc-utils/src/utils/http.cpp +++ b/t5/plugins/tws-t5-gsc-utils/src/utils/http.cpp @@ -14,13 +14,17 @@ namespace utils::http { namespace { + struct response_buffer { std::string bytes; size_t limit; }; + size_t write_callback(void* contents, const size_t size, const size_t nmemb, void* userp) noexcept { - const auto buffer = static_cast(userp); + const auto buffer = static_cast(userp); + if (size && nmemb > static_cast(-1) / size) return 0; const auto total_size = size * nmemb; + if (buffer->limit && (buffer->bytes.size() > buffer->limit || total_size > buffer->limit - buffer->bytes.size())) return 0; try { - buffer->append(static_cast(contents), total_size); + buffer->bytes.append(static_cast(contents), total_size); } catch (...) { @@ -68,7 +72,7 @@ namespace utils::http header_list.reset(appended); } - std::string buffer{}; + response_buffer buffer{{}, limits.max_response_bytes}; curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list.get()); curl_easy_setopt(curl, CURLOPT_URL, url.data()); @@ -106,7 +110,7 @@ namespace utils::http response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME); response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME); if (response.code == CURLE_OK) - response.buffer = std::move(buffer); + response.buffer = std::move(buffer.bytes); return response; } } diff --git a/t5/plugins/tws-t5-gsc-utils/src/utils/http.hpp b/t5/plugins/tws-t5-gsc-utils/src/utils/http.hpp index c7d91f1..13000ea 100644 --- a/t5/plugins/tws-t5-gsc-utils/src/utils/http.hpp +++ b/t5/plugins/tws-t5-gsc-utils/src/utils/http.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -25,6 +26,8 @@ namespace utils::http { long connect_timeout_ms = 5000; long timeout_ms = 15000; + // Opt-in transport cap. Zero preserves existing callers; oversized transfers fail, never truncate. + size_t max_response_bytes = 0; }; using headers = std::unordered_map; diff --git a/t5/plugins/tws-t5-gsc-utils/src/utils/moderation.cpp b/t5/plugins/tws-t5-gsc-utils/src/utils/moderation.cpp new file mode 100644 index 0000000..9233b55 --- /dev/null +++ b/t5/plugins/tws-t5-gsc-utils/src/utils/moderation.cpp @@ -0,0 +1,349 @@ +#include +#include "moderation.hpp" +#include +#include +#include +#include +#include +#include + +namespace utils::moderation +{ + namespace + { + constexpr size_t max_file = 16 * 1024 * 1024; + constexpr size_t max_response = 1024 * 1024; + bool identifier(const std::string& value, size_t limit = 128) + { + return !value.empty() && value.size() <= limit && + std::all_of(value.begin(), value.end(), [](unsigned char ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.' || ch == ':'; + }); + } + std::string bounded_text(const std::string& value, size_t limit) + { + // Engine names may contain malformed bytes. Preserve valid UTF-8 and never + // split a codepoint or let one bad name poison the complete JSON roster. + std::string result; + for (size_t i = 0; i < value.size() && result.size() < limit;) + { + const auto ch = static_cast(value[i]); + size_t width = ch < 0x80 ? 1 : ch >= 0xc2 && ch <= 0xdf ? 2 : + ch >= 0xe0 && ch <= 0xef ? 3 : ch >= 0xf0 && ch <= 0xf4 ? 4 : 0; + bool valid = width && i + width <= value.size(); + for (size_t j = 1; valid && j < width; ++j) + valid = (static_cast(value[i + j]) & 0xc0) == 0x80; + if (valid && width >= 3) + { + const auto second = static_cast(value[i + 1]); + valid = !(ch == 0xe0 && second < 0xa0) && !(ch == 0xed && second >= 0xa0) && + !(ch == 0xf0 && second < 0x90) && !(ch == 0xf4 && second >= 0x90); + } + if (!valid) { result += '?'; ++i; continue; } + if (result.size() + width > limit) break; + result.append(value, i, width); i += width; + } + return result; + } + std::string player_name(const std::string& value) + { + auto name = bounded_text(value, 255); + for (auto& ch : name) if (static_cast(ch) < 32 || ch == 127) ch = ' '; + auto visible = name; + // Match ECMAScript trim whitespace so the controller cannot reject an + // otherwise complete roster because one display name is blank. + for (const std::string space : {" ", "\xc2\xa0", "\xe1\x9a\x80", "\xe2\x80\x80", "\xe2\x80\x81", + "\xe2\x80\x82", "\xe2\x80\x83", "\xe2\x80\x84", "\xe2\x80\x85", "\xe2\x80\x86", "\xe2\x80\x87", + "\xe2\x80\x88", "\xe2\x80\x89", "\xe2\x80\x8a", "\xe2\x80\xa8", "\xe2\x80\xa9", "\xe2\x80\xaf", + "\xe2\x81\x9f", "\xe3\x80\x80", "\xef\xbb\xbf"}) + for (auto pos = visible.find(space); pos != std::string::npos; pos = visible.find(space)) visible.erase(pos, space.size()); + return visible.empty() ? "Unknown player" : name; + } + bool guid_identifier(const std::string& value) { return identifier(value, 128) && value.find('.') == std::string::npos; } + std::string hash_scope(const std::string& text) + { + unsigned long long hash = 14695981039346656037ull; + for (unsigned char ch : text) hash = (hash ^ ch) * 1099511628211ull; + std::ostringstream out; out << std::hex << hash; return out.str(); + } + void require(bool condition) { if (!condition) throw std::runtime_error("invalid moderation data"); } + std::string field(const json& value, const char* key, size_t max, bool empty = false) + { + require(value.contains(key) && value.at(key).is_string()); + auto result = value.at(key).get(); + require((empty || !result.empty()) && result.size() <= max); + return result; + } + void reason(const json& value) { field(value, "reason", 500, true); } + void sanction(const json& value, long long revision) + { + require(value.is_object()); + require(identifier(field(value, "id", 128)) && guid_identifier(field(value, "guid", 128))); + require(value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= revision); + auto kind = field(value, "kind", 16); require(kind == "ban" || kind == "mute"); reason(value); + if (!value.at("expiresAt").is_null()) timestamp(field(value, "expiresAt", 32)); + } + void action(const json& value) + { + require(value.is_object() && identifier(field(value, "id", 128))); + auto kind = field(value, "kind", 16); + require(kind == "kick" || kind == "warn" || kind == "message" || kind == "announce"); + if (kind == "announce") require(value.at("guid").is_null() && value.at("connection").is_null()); + else require(guid_identifier(field(value, "guid", 128)) && identifier(field(value, "connection", 128))); + reason(value); timestamp(field(value, "expiresAt", 32)); + } + } + long long timestamp(const std::string& value) + { + require(value.size() == 20 || value.size() == 24); + require(value.back() == 'Z' && value[10] == 'T'); + if (value.size() == 24) require(value[19] == '.' && std::isdigit(value[20]) && std::isdigit(value[21]) && std::isdigit(value[22])); + std::tm tm{}; std::istringstream input(value.substr(0, 19)); input >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); + require(!input.fail() && input.peek() == EOF && tm.tm_year >= 100 && tm.tm_year <= 200); + const auto original = tm; +#ifdef _WIN32 + auto result = _mkgmtime64(&tm); +#else + auto result = timegm(&tm); +#endif + require(result >= 0 && original.tm_year == tm.tm_year && original.tm_mon == tm.tm_mon && original.tm_mday == tm.tm_mday && + original.tm_hour == tm.tm_hour && original.tm_min == tm.tm_min && original.tm_sec == tm.tm_sec); + return result; + } + std::string command_text(const std::string& value) + { + // Untrusted text is never a console delimiter or a quoted command escape. + std::string result; + for (unsigned char ch : bounded_text(value, 240)) + { + result += (ch < 32 || ch == 127 || ch == '"' || ch == '\\' || ch == ';' || ch == '^') ? ' ' : static_cast(ch); + } + return result; + } + std::string canonical_guid(const std::string& value) + { + if (!value.empty() && std::all_of(value.begin(), value.end(), [](char ch) { return ch >= '0' && ch <= '9'; })) + { + const auto first = value.find_first_not_of('0'); + return first == std::string::npos ? "0" : value.substr(first); + } + return value; + } + std::string new_session() + { + std::random_device random; std::ostringstream out; out << std::hex << std::setfill('0'); + for (int i = 0; i < 4; ++i) out << std::setw(8) << static_cast(random()); + return out.str(); + } + runtime::runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope) + : server_(std::move(server)), endpoint_(std::move(endpoint)), credential_scope_(std::move(credential_scope)), generation_(generation) + { + require(!server_.empty() && server_.size() <= 255 && generation_ > 0 && identifier(credential_scope_)); + file_ = root / (hash_scope(server_ + '\0' + endpoint_ + '\0' + std::to_string(generation_) + '\0' + credential_scope_) + ".json"); + saved_ = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_}, {"generation", generation_}, {"credentialScope", credential_scope_}, + {"revision", 0}, {"sessionSequence", 0}, {"sanctions", json::array()}, {"journal", json::object()}, {"ownedMutes", json::array()}}; + if (std::filesystem::exists(file_)) + { + require(std::filesystem::file_size(file_) < max_file); + std::ifstream stream(file_, std::ios::binary); json value; stream >> value; + require(!stream.bad() && value.at("version") == 1 && value.at("server") == server_ && value.at("endpoint") == endpoint_ && + value.at("generation") == generation_ && value.at("credentialScope") == credential_scope_ && value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= 9007199254740991LL && + value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 && value.at("journal").is_object() && value.at("journal").size() <= 10000); + for (const auto& policy : value.at("sanctions")) sanction(policy, value.at("revision").get()); + for (auto it = value.at("journal").begin(); it != value.at("journal").end(); ++it) + { + require(identifier(it.key())); action(it.value().at("action")); + auto state = field(it.value(), "state", 16); + require(state == "confirmed" || state == "failed" || state == "unknown" || state == "expired"); + require(it.value().at("reported").is_boolean() && it.value().at("action").at("id") == it.key()); + } + require(value.at("ownedMutes").is_array() && value.at("ownedMutes").size() <= 10000); + for (const auto& guid : value.at("ownedMutes")) require(guid.is_string() && guid_identifier(guid.get())); + saved_ = std::move(value); + } + require(saved_.at("sessionSequence").is_number_integer() && saved_.at("sessionSequence") >= 0 && saved_.at("sessionSequence") < 9007199254740991LL); + auto next = saved_; next["sessionSequence"] = saved_.at("sessionSequence").get() + 1; + save(next); // Allocate this boot/map sequence durably before exposing its session. + } + void runtime::save(const json& next) + { + const auto bytes = next.dump(); require(bytes.size() < max_file); + const auto exists = std::filesystem::exists(file_); + require(exists || !allocated_); + if (exists) + { + // A synchronous engine command may replace the map/runtime before the + // older call frame returns. It must never overwrite the new sequence. + require(std::filesystem::file_size(file_) < max_file); + std::ifstream source(file_, std::ios::binary); json checkpoint; source >> checkpoint; + require(!source.bad() && checkpoint.at("version") == 1 && checkpoint.at("server") == server_ && + checkpoint.at("endpoint") == endpoint_ && checkpoint.at("generation") == generation_ && + checkpoint.at("credentialScope") == credential_scope_ && checkpoint.at("sessionSequence") == saved_.at("sessionSequence")); + } + std::filesystem::create_directories(file_.parent_path()); auto temp = file_; temp += ".tmp"; + std::ofstream stream(temp, std::ios::binary | std::ios::trunc); stream.write(bytes.data(), bytes.size()); stream.flush(); + require(stream.good()); stream.close(); require(!stream.fail()); +#ifdef _WIN32 + require(MoveFileExW(temp.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0); +#else + std::filesystem::rename(temp, file_); +#endif + saved_ = next; allocated_ = true; + } + void runtime::observe(int slot, const std::string& guid, const std::string& name) + { + require(slot >= 0 && slot < 64 && guid_identifier(guid)); + auto found = players.find(slot); + if (found == players.end() || found->second.guid != canonical_guid(guid)) + { + disconnect(slot); + players.emplace(slot, player{slot, canonical_guid(guid), player_name(name), session + ":" + std::to_string(++serial_)}); + } + else found->second.name = player_name(name); + } + void runtime::disconnect(int slot) + { + auto found = players.find(slot); + if (found != players.end()) { muted_.erase(found->second.connection); kicked_.erase(found->second.connection); players.erase(found); } + } + json runtime::request() + { + json roster = json::array(), receipts = json::array(); sent_.clear(); + for (const auto& [slot, value] : players) roster.push_back({{"guid", value.guid}, {"name", value.name}, {"connection", value.connection}}); + for (auto it = saved_["journal"].begin(); it != saved_["journal"].end(); ++it) + if (!it.value().at("reported").get() && receipts.size() < 256) + { + receipts.push_back({{"id", it.key()}, {"state", it.value().at("state")}, {"detail", it.value().value("detail", std::string())}}); + sent_.push_back(it.key()); + } + return {{"protocol", 1}, {"server", server_}, {"generation", generation_}, {"session", session}, {"sessionSequence", saved_.at("sessionSequence")}, {"players", roster}, + {"capabilities", {"warn", "message", "kick", "announce", "ban", "mute"}}, {"appliedRevision", revision()}, {"receipts", receipts}}; + } + void runtime::accept(const std::string& response, long long now, long long transfer_age_ms) + { + const auto received = std::chrono::steady_clock::now(); + require(!response.empty() && response.size() <= max_response && transfer_age_ms >= 0 && transfer_age_ms <= 86400000); + auto value = json::parse(response); require(value.is_object() && value.at("ok").is_boolean() && value.at("ok").get()); + require(value.at("revision").is_number_integer() && value.at("revision") >= revision() && value.at("revision") <= 9007199254740991LL); + auto revision = value.at("revision").get(); + auto server_now = timestamp(field(value, "serverTime", 32)); require(std::abs(now - server_now) <= 120); + require(value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 && + value.at("actions").is_array() && value.at("actions").size() <= 100 && + value.at("pollAfterMs").is_number_integer() && value.at("pollAfterMs") >= 1000 && value.at("pollAfterMs") <= 60000); + std::set ids; + for (const auto& entry : value.at("sanctions")) { sanction(entry, revision); require(ids.emplace(entry.at("id").get()).second); } + ids.clear(); + for (const auto& entry : value.at("actions")) { action(entry); auto id = entry.at("id").get(); require(ids.emplace(id).second); + if (saved_["journal"].contains(id)) require(saved_["journal"][id].at("action") == entry); } + if (revision == this->revision() && revision != 0) + { + auto previous = saved_.at("sanctions"); + for (auto it = previous.begin(); it != previous.end(); ) + if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get()) <= server_now) it = previous.erase(it); else ++it; + auto incoming = value.at("sanctions"); + for (auto it = incoming.begin(); it != incoming.end(); ) + if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get()) <= server_now) it = incoming.erase(it); else ++it; + auto by_id = [](const json& a, const json& b) { return a.at("id").get() < b.at("id").get(); }; + std::sort(previous.begin(), previous.end(), by_id); std::sort(incoming.begin(), incoming.end(), by_id); + require(incoming == previous); + } + auto next = saved_; next["revision"] = revision; next["sanctions"] = value.at("sanctions"); + for (const auto& id : sent_) if (next["journal"].contains(id)) next["journal"][id]["reported"] = true; + // Expired entries are safe to prune only after acknowledgment. An old action still fails its deadline. + for (auto it = next["journal"].begin(); it != next["journal"].end(); ) + if (it.value().at("reported").get() && timestamp(it.value().at("action").at("expiresAt").get()) < now - 86400) it = next["journal"].erase(it); else ++it; + save(next); pending_ = value.at("actions"); + const auto accepted = std::chrono::steady_clock::now(); + const auto processing = std::chrono::duration_cast(accepted - received); + const auto estimate = std::chrono::system_clock::from_time_t(server_now) + std::chrono::seconds(1) + + std::chrono::milliseconds(transfer_age_ms) + processing; + const auto previous = server_anchor_ + std::chrono::duration_cast(accepted - clock_anchor_); + server_anchor_ = server_anchor_.time_since_epoch().count() ? std::max(previous, estimate) : estimate; + clock_anchor_ = accepted; + } + long long runtime::effective_time(long long local) const + { + if (!server_anchor_.time_since_epoch().count()) return local; + // Never extend deadlines when the host clock lags or jumps backward. The + // full request age is conservative because serverTime is generated during it. + const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - clock_anchor_); + const auto estimate = std::chrono::duration_cast((server_anchor_ + elapsed).time_since_epoch()).count(); + return std::max(local, static_cast(estimate)); + } + void runtime::enforce(long long now, const executor& execute) + { + now = effective_time(now); + auto pending = pending_; pending_ = json::array(); + for (const auto& item : pending) + { + auto id = item.at("id").get(); + if (saved_["journal"].contains(id)) + { + // A duplicate immutable action is acknowledged, never executed twice. + require(saved_["journal"][id].at("action") == item); + auto next = saved_; next["journal"][id]["reported"] = false; save(next); continue; + } + require(saved_["journal"].size() < 10000); + auto kind = item.at("kind").get(); int slot = -1; + auto state = std::string("unknown"), detail = std::string("Execution recorded before dispatch; outcome uncertain."); + if (timestamp(item.at("expiresAt").get()) <= now) { state = "expired"; detail = "Action deadline elapsed."; } + else if (kind != "announce") + { + for (const auto& [number, target] : players) + if (item.at("guid") == target.guid && item.at("connection") == target.connection) slot = number; + if (slot < 0) { state = "failed"; detail = "Player connection no longer matches."; } + } + auto next = saved_; next["journal"][id] = {{"action", item}, {"state", state}, {"detail", detail}, {"reported", false}}; save(next); + if (state == "unknown") + { + // All target validation and dispatch occur on the calling game thread, without yielding. + if (timestamp(item.at("expiresAt").get()) <= effective_time(now)) state = "expired"; + else + { + try { state = execute(kind, slot, command_text(item.at("reason").get())); } + catch (...) { state = "unknown"; } + if (state != "confirmed" && state != "failed") state = "unknown"; + } + next = saved_; next["journal"][id]["state"] = state; + next["journal"][id]["detail"] = state == "confirmed" ? "Game dispatch completed." : state == "expired" ? "Action deadline elapsed before dispatch." : "Game dispatch outcome could not be verified."; save(next); + } + } + // Snapshot is copied because kicking can synchronously invoke a disconnect callback. + const auto roster = players; + for (const auto& [slot, target] : roster) + { + now = effective_time(now); + if (!players.contains(slot) || players.at(slot).connection != target.connection) continue; + bool ban = false, mute = false; std::string why; + for (const auto& policy : saved_.at("sanctions")) + { + if (policy.at("guid") != target.guid || (!policy.at("expiresAt").is_null() && timestamp(policy.at("expiresAt").get()) <= now)) continue; + if (policy.at("kind") == "ban") { ban = true; why = policy.at("reason").get(); } + if (policy.at("kind") == "mute") mute = true; + } + if (ban) + { + if (!kicked_.contains(target.connection) || kicked_[target.connection] + 5 <= now) + { kicked_[target.connection] = now; execute("kick", slot, command_text(why)); } + continue; + } + bool was_muted = muted_.contains(target.connection) && muted_.at(target.connection); + auto owned = saved_.at("ownedMutes"); + bool managed = std::find(owned.begin(), owned.end(), target.guid) != owned.end(); + if (mute != was_muted || (!mute && managed)) + { + if (mute && !managed) + { + require(owned.size() < 10000); owned.push_back(target.guid); + auto next = saved_; next["ownedMutes"] = owned; save(next); + } + execute(mute ? "mute" : "unmute", slot, ""); muted_[target.connection] = mute; + if (!mute && managed) + { + owned.erase(std::remove(owned.begin(), owned.end(), json(target.guid)), owned.end()); + auto next = saved_; next["ownedMutes"] = owned; save(next); + } + } + } + } +} diff --git a/t5/plugins/tws-t5-gsc-utils/src/utils/moderation.hpp b/t5/plugins/tws-t5-gsc-utils/src/utils/moderation.hpp new file mode 100644 index 0000000..a7ae35a --- /dev/null +++ b/t5/plugins/tws-t5-gsc-utils/src/utils/moderation.hpp @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace utils::moderation +{ + using json = nlohmann::json; + struct player { int slot; std::string guid, name, connection; }; + using executor = std::function; + long long timestamp(const std::string& value); + std::string command_text(const std::string& value); + std::string new_session(); + std::string canonical_guid(const std::string& value); + class runtime + { + public: + runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope); + std::string session = new_session(); + std::map players; + void observe(int slot, const std::string& guid, const std::string& name); + void disconnect(int slot); + json request(); + void accept(const std::string& response, long long now, long long transfer_age_ms = 0); + void enforce(long long now, const executor& execute); + long long revision() const { return saved_.at("revision").get(); } + private: + std::filesystem::path file_; + std::string server_, endpoint_, credential_scope_; + int generation_; + unsigned long long serial_ = 0; + bool allocated_ = false; + std::chrono::system_clock::time_point server_anchor_{}; + std::chrono::steady_clock::time_point clock_anchor_{}; + json saved_, pending_ = json::array(); + std::vector sent_; + std::map muted_; + std::map kicked_; + void save(const json& next); + long long effective_time(long long local) const; + }; +} diff --git a/t5/scripts/README.md b/t5/scripts/README.md index 6c53997..bcee2c2 100644 --- a/t5/scripts/README.md +++ b/t5/scripts/README.md @@ -1,7 +1,7 @@ # TWS T5 Zombies scripts Custom HUD + AFK toggle + immediate join + lobby lock + game telemetry + map records for Plutonium T5 -(Black Ops 1) zombies dedicated servers. Split across eight `.gsc` files, +(Black Ops 1) zombies dedicated servers. Split across nine `.gsc` files, with `tws_t5_common` as the shared hub. Lobby lock also requires the HUD module. Platform quirks and deploy commands: [NOTES.md](NOTES.md). @@ -18,10 +18,13 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md). | [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports | | [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map high-round record and all tied holders on connection | | [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands | +| [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ | ## Install / upgrade -Copy `tws_t5_*.gsc` to `storage/t5/scripts/sp/`, then restart the map. +Copy the desired `tws_t5_*.gsc` modules to `storage/t5/scripts/sp/`, then restart the map. +Before adding the optional moderation module, install native plugin 1.3.0+ and +restart the full game-server process; see [moderation setup](../../docs/moderation.md). When upgrading an existing installation, first remove these old scripts from that directory: @@ -595,3 +598,9 @@ and [score tracking](https://github.com/plutoniummod/t5-scripts/blob/main/ZM/Com and leaderboard HTTP, JSON, type and string helpers. Install a build exposing the natives listed in each module's documentation, then restart the server process. No database is required. + +## Moderation + +Optional `tws_t5_moderation.gsc` 1.0.0 requires common 1.2.0 and the matching +plugin 1.3.0. See the [moderation contract](../../docs/moderation.md) for private +configuration, persistence, action results and deployment checks. diff --git a/t5/scripts/tws_t5_moderation.gsc b/t5/scripts/tws_t5_moderation.gsc new file mode 100644 index 0000000..fe26e65 --- /dev/null +++ b/t5/scripts/tws_t5_moderation.gsc @@ -0,0 +1,63 @@ +/* TWS moderation 1.0.0: native durable policy, connection-safe actions, HTTPS synchronization. */ +#include maps\_utility; +#include common_scripts\utility; + +init() +{ + if (GetDvar(#"zombiemode") != "1") + return; + scripts\sp\tws_t5_common::ensure_config(); + level.moderation_ready = false; + scripts\sp\tws_t5_common::register_connect_handler(::moderation_connected); + level thread moderation_loop(); +} + +moderation_connected() +{ + self endon("disconnect"); + if (!level.moderation_ready) + return; + moderation_observe(self); + moderationPump(); +} + +moderation_observe(player) +{ + if (!isDefined(player)) + return; + guid = player getGuid(); + if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int")) + return; + // Preserve large identifiers as strings; native validation rejects ambiguous inputs. + guid = "" + guid; + name = player.name; + if (!isDefined(name)) + name = "Unknown player"; + player moderationObserve(guid, name); +} + +moderation_loop() +{ + for (;;) + { + if (GetDvar("tws_moderation_enabled") == "0") + { + moderationStop(); + level.moderation_ready = false; + wait 3; + continue; + } + level.moderation_ready = moderationConfigure(GetDvar("tws_server_id"), GetDvar("tws_moderation_url"), + GetDvar("tws_moderation_token"), GetDvarInt("tws_moderation_generation")); + if (!level.moderation_ready) + { + wait 3; + continue; + } + players = getPlayers(); + for (i = 0; i < players.size; i++) + moderation_observe(players[i]); + moderationPump(); + wait 0.5; + } +} diff --git a/t6/plugins/tws-t6-gsc-utils/src/component/moderation.cpp b/t6/plugins/tws-t6-gsc-utils/src/component/moderation.cpp new file mode 100644 index 0000000..b3f5070 --- /dev/null +++ b/t6/plugins/tws-t6-gsc-utils/src/component/moderation.cpp @@ -0,0 +1,161 @@ +#include +#include "loader/component_loader.hpp" +#include "gsc.hpp" +#include "scripting.hpp" +#include "scheduler.hpp" +#include "command.hpp" +#include "game/game.hpp" +#include "game/scripting/array.hpp" +#include +#include +#include +#include +#include +#include + +namespace moderation +{ + namespace + { + using runtime = utils::moderation::runtime; + struct transfer + { + std::string session, response; + std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now(); + std::atomic done{false}; + bool success = false; + }; + std::shared_ptr current; + std::shared_ptr pending; + std::string endpoint, credential, configured_server; + int configured_generation = 0; + std::chrono::steady_clock::time_point next_poll{}, next_log{}; + bool fault = false; + long long now() { return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); } + void failure() + { + if (std::chrono::steady_clock::now() >= next_log) + { + printf("[tws_moderation] Runtime unavailable: verify credentials, policy, clock and persistent storage.\n"); + next_log = std::chrono::steady_clock::now() + std::chrono::seconds(30); + } + } + void disconnected(unsigned int slot) { if (current) current->disconnect(static_cast(slot)); } + std::string execute(const std::string& kind, int slot, const std::string& reason) + { + auto active = current; + if (!active) return "failed"; + // This function is called only by the server-thread GSC pump. No queue/yield + // separates runtime target validation from synchronous engine dispatch. + if (kind == "announce" || kind == "warn" || kind == "message") + { + const auto text = std::string("j \"") + reason + "\""; + game::SV_GameSendServerCommand(slot, 0, text.c_str()); + return "confirmed"; + } + if (slot < 0 || slot >= 64) return "failed"; + if (kind == "mute" || kind == "unmute") + { + command::execute((kind == "mute" ? "muteclient " : "unmuteclient ") + std::to_string(slot), true); + return "unknown"; // Engine has no mute-state readback in this adapter. + } + if (kind != "kick") return "failed"; + const auto connection = active->players.contains(slot) ? active->players.at(slot).connection : std::string(); + command::execute("clientkick " + std::to_string(slot) + " \"" + reason + "\"", true); + return !active->players.contains(slot) || active->players.at(slot).connection != connection ? "confirmed" : "unknown"; + } + void pump() + { + if (!current || fault) return; + try + { + if (pending && pending->done.load()) + { + auto completed = pending; pending.reset(); + if (completed->session == current->session) + { + if (completed->success) + { + const auto age = std::chrono::duration_cast(std::chrono::steady_clock::now() - completed->started).count(); + current->accept(completed->response, now(), age); + } + else failure(); + } + } + auto active = current; + active->enforce(now(), [active](const auto& kind, int slot, const auto& reason) + { + return current == active ? execute(kind, slot, reason) : std::string("failed"); + }); + if (current != active) return; + if (!pending && std::chrono::steady_clock::now() >= next_poll) + { + auto request = current->request().dump(); auto target = endpoint; auto token = credential; + pending = std::make_shared(); pending->session = current->session; auto work = pending; + next_poll = std::chrono::steady_clock::now() + std::chrono::seconds(3); + scheduler::thread_pool.push([work, request, target, token] + { + try + { + utils::http::timeouts limits; limits.connect_timeout_ms = 3000; limits.timeout_ms = 5000; + limits.max_response_bytes = 1024 * 1024; + const auto response = utils::http::get_data(target, request, + {{"Authorization", "Bearer " + token}, {"Content-Type", "application/json"}}, "POST", limits); + if (response && response->code == CURLE_OK && response->status == 200) + { work->response = response->buffer; work->success = true; } + } + catch (...) { work->success = false; } + work->done.store(true); + }); + } + } + catch (...) { failure(); } + } + } + class component final : public component_interface + { + public: + void on_startup(plugin::plugin* plugin) override + { + plugin->get_interface()->callbacks()->on_player_connect(disconnected); + plugin->get_interface()->callbacks()->on_player_disconnect(disconnected); + scripting::on_shutdown([] + { + // In-flight requests own only native data. Results from the old VM are abandoned. + current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false; + }); + gsc::function::add("moderationversion", [] { return std::string("1.0.0"); }); + gsc::function::add("moderationconfigure", [](const std::string& server, const std::string& url, const std::string& token, int generation) + { + if (current && !fault && endpoint == url && credential == token && configured_server == server && configured_generation == generation) return true; + // Credentials cannot leave the explicitly configured HTTPS origin. Curl does not follow redirects. + if (url.size() > 2048 || url.rfind("https://", 0) != 0 || url.find_first_of("\r\n?#@") != std::string::npos || + url.size() < 8 || url.find("/api/moderation/runtime") != url.size() - 23 || + token.rfind("ec_moderation_", 0) != 0 || token.size() < 40 || token.size() > 256 || + token.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != std::string::npos) return false; + try + { + auto configured = std::make_shared("tws-moderation/t6", server, url, generation, + utils::cryptography::sha256::compute(token, true)); + current = std::move(configured); pending.reset(); configured_server = server; configured_generation = generation; + endpoint = url; credential = token; next_poll = {}; fault = false; return true; + } + catch (...) { fault = true; failure(); return false; } + }); + gsc::method::add("moderationobserve", [](const scripting::entity& entity, const std::string& guid, const std::string& name) + { + if (!current || fault) return false; + const auto ref = entity.get_entity_reference(); + if (ref.classnum != 0 || ref.entnum >= 64) return false; + try { current->observe(ref.entnum, guid, name); return true; } + catch (...) { failure(); return false; } + }); + gsc::function::add("moderationstop", [] + { + current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false; + }); + gsc::function::add("moderationpump", pump); + } + }; +} +REGISTER_COMPONENT(moderation::component) diff --git a/t6/plugins/tws-t6-gsc-utils/src/utils/http.cpp b/t6/plugins/tws-t6-gsc-utils/src/utils/http.cpp index 42f8269..aca0760 100644 --- a/t6/plugins/tws-t6-gsc-utils/src/utils/http.cpp +++ b/t6/plugins/tws-t6-gsc-utils/src/utils/http.cpp @@ -14,13 +14,17 @@ namespace utils::http { namespace { + struct response_buffer { std::string bytes; size_t limit; }; + size_t write_callback(void* contents, const size_t size, const size_t nmemb, void* userp) noexcept { - const auto buffer = static_cast(userp); + const auto buffer = static_cast(userp); + if (size && nmemb > static_cast(-1) / size) return 0; const auto total_size = size * nmemb; + if (buffer->limit && (buffer->bytes.size() > buffer->limit || total_size > buffer->limit - buffer->bytes.size())) return 0; try { - buffer->append(static_cast(contents), total_size); + buffer->bytes.append(static_cast(contents), total_size); } catch (...) { @@ -68,7 +72,7 @@ namespace utils::http header_list.reset(appended); } - std::string buffer{}; + response_buffer buffer{{}, limits.max_response_bytes}; curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list.get()); curl_easy_setopt(curl, CURLOPT_URL, url.data()); @@ -106,7 +110,7 @@ namespace utils::http response.first_byte_ms = timing_ms(curl, CURLINFO_STARTTRANSFER_TIME); response.total_ms = timing_ms(curl, CURLINFO_TOTAL_TIME); if (response.code == CURLE_OK) - response.buffer = std::move(buffer); + response.buffer = std::move(buffer.bytes); return response; } } diff --git a/t6/plugins/tws-t6-gsc-utils/src/utils/http.hpp b/t6/plugins/tws-t6-gsc-utils/src/utils/http.hpp index c7d91f1..13000ea 100644 --- a/t6/plugins/tws-t6-gsc-utils/src/utils/http.hpp +++ b/t6/plugins/tws-t6-gsc-utils/src/utils/http.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -25,6 +26,8 @@ namespace utils::http { long connect_timeout_ms = 5000; long timeout_ms = 15000; + // Opt-in transport cap. Zero preserves existing callers; oversized transfers fail, never truncate. + size_t max_response_bytes = 0; }; using headers = std::unordered_map; diff --git a/t6/plugins/tws-t6-gsc-utils/src/utils/moderation.cpp b/t6/plugins/tws-t6-gsc-utils/src/utils/moderation.cpp new file mode 100644 index 0000000..9233b55 --- /dev/null +++ b/t6/plugins/tws-t6-gsc-utils/src/utils/moderation.cpp @@ -0,0 +1,349 @@ +#include +#include "moderation.hpp" +#include +#include +#include +#include +#include +#include + +namespace utils::moderation +{ + namespace + { + constexpr size_t max_file = 16 * 1024 * 1024; + constexpr size_t max_response = 1024 * 1024; + bool identifier(const std::string& value, size_t limit = 128) + { + return !value.empty() && value.size() <= limit && + std::all_of(value.begin(), value.end(), [](unsigned char ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.' || ch == ':'; + }); + } + std::string bounded_text(const std::string& value, size_t limit) + { + // Engine names may contain malformed bytes. Preserve valid UTF-8 and never + // split a codepoint or let one bad name poison the complete JSON roster. + std::string result; + for (size_t i = 0; i < value.size() && result.size() < limit;) + { + const auto ch = static_cast(value[i]); + size_t width = ch < 0x80 ? 1 : ch >= 0xc2 && ch <= 0xdf ? 2 : + ch >= 0xe0 && ch <= 0xef ? 3 : ch >= 0xf0 && ch <= 0xf4 ? 4 : 0; + bool valid = width && i + width <= value.size(); + for (size_t j = 1; valid && j < width; ++j) + valid = (static_cast(value[i + j]) & 0xc0) == 0x80; + if (valid && width >= 3) + { + const auto second = static_cast(value[i + 1]); + valid = !(ch == 0xe0 && second < 0xa0) && !(ch == 0xed && second >= 0xa0) && + !(ch == 0xf0 && second < 0x90) && !(ch == 0xf4 && second >= 0x90); + } + if (!valid) { result += '?'; ++i; continue; } + if (result.size() + width > limit) break; + result.append(value, i, width); i += width; + } + return result; + } + std::string player_name(const std::string& value) + { + auto name = bounded_text(value, 255); + for (auto& ch : name) if (static_cast(ch) < 32 || ch == 127) ch = ' '; + auto visible = name; + // Match ECMAScript trim whitespace so the controller cannot reject an + // otherwise complete roster because one display name is blank. + for (const std::string space : {" ", "\xc2\xa0", "\xe1\x9a\x80", "\xe2\x80\x80", "\xe2\x80\x81", + "\xe2\x80\x82", "\xe2\x80\x83", "\xe2\x80\x84", "\xe2\x80\x85", "\xe2\x80\x86", "\xe2\x80\x87", + "\xe2\x80\x88", "\xe2\x80\x89", "\xe2\x80\x8a", "\xe2\x80\xa8", "\xe2\x80\xa9", "\xe2\x80\xaf", + "\xe2\x81\x9f", "\xe3\x80\x80", "\xef\xbb\xbf"}) + for (auto pos = visible.find(space); pos != std::string::npos; pos = visible.find(space)) visible.erase(pos, space.size()); + return visible.empty() ? "Unknown player" : name; + } + bool guid_identifier(const std::string& value) { return identifier(value, 128) && value.find('.') == std::string::npos; } + std::string hash_scope(const std::string& text) + { + unsigned long long hash = 14695981039346656037ull; + for (unsigned char ch : text) hash = (hash ^ ch) * 1099511628211ull; + std::ostringstream out; out << std::hex << hash; return out.str(); + } + void require(bool condition) { if (!condition) throw std::runtime_error("invalid moderation data"); } + std::string field(const json& value, const char* key, size_t max, bool empty = false) + { + require(value.contains(key) && value.at(key).is_string()); + auto result = value.at(key).get(); + require((empty || !result.empty()) && result.size() <= max); + return result; + } + void reason(const json& value) { field(value, "reason", 500, true); } + void sanction(const json& value, long long revision) + { + require(value.is_object()); + require(identifier(field(value, "id", 128)) && guid_identifier(field(value, "guid", 128))); + require(value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= revision); + auto kind = field(value, "kind", 16); require(kind == "ban" || kind == "mute"); reason(value); + if (!value.at("expiresAt").is_null()) timestamp(field(value, "expiresAt", 32)); + } + void action(const json& value) + { + require(value.is_object() && identifier(field(value, "id", 128))); + auto kind = field(value, "kind", 16); + require(kind == "kick" || kind == "warn" || kind == "message" || kind == "announce"); + if (kind == "announce") require(value.at("guid").is_null() && value.at("connection").is_null()); + else require(guid_identifier(field(value, "guid", 128)) && identifier(field(value, "connection", 128))); + reason(value); timestamp(field(value, "expiresAt", 32)); + } + } + long long timestamp(const std::string& value) + { + require(value.size() == 20 || value.size() == 24); + require(value.back() == 'Z' && value[10] == 'T'); + if (value.size() == 24) require(value[19] == '.' && std::isdigit(value[20]) && std::isdigit(value[21]) && std::isdigit(value[22])); + std::tm tm{}; std::istringstream input(value.substr(0, 19)); input >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); + require(!input.fail() && input.peek() == EOF && tm.tm_year >= 100 && tm.tm_year <= 200); + const auto original = tm; +#ifdef _WIN32 + auto result = _mkgmtime64(&tm); +#else + auto result = timegm(&tm); +#endif + require(result >= 0 && original.tm_year == tm.tm_year && original.tm_mon == tm.tm_mon && original.tm_mday == tm.tm_mday && + original.tm_hour == tm.tm_hour && original.tm_min == tm.tm_min && original.tm_sec == tm.tm_sec); + return result; + } + std::string command_text(const std::string& value) + { + // Untrusted text is never a console delimiter or a quoted command escape. + std::string result; + for (unsigned char ch : bounded_text(value, 240)) + { + result += (ch < 32 || ch == 127 || ch == '"' || ch == '\\' || ch == ';' || ch == '^') ? ' ' : static_cast(ch); + } + return result; + } + std::string canonical_guid(const std::string& value) + { + if (!value.empty() && std::all_of(value.begin(), value.end(), [](char ch) { return ch >= '0' && ch <= '9'; })) + { + const auto first = value.find_first_not_of('0'); + return first == std::string::npos ? "0" : value.substr(first); + } + return value; + } + std::string new_session() + { + std::random_device random; std::ostringstream out; out << std::hex << std::setfill('0'); + for (int i = 0; i < 4; ++i) out << std::setw(8) << static_cast(random()); + return out.str(); + } + runtime::runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope) + : server_(std::move(server)), endpoint_(std::move(endpoint)), credential_scope_(std::move(credential_scope)), generation_(generation) + { + require(!server_.empty() && server_.size() <= 255 && generation_ > 0 && identifier(credential_scope_)); + file_ = root / (hash_scope(server_ + '\0' + endpoint_ + '\0' + std::to_string(generation_) + '\0' + credential_scope_) + ".json"); + saved_ = {{"version", 1}, {"server", server_}, {"endpoint", endpoint_}, {"generation", generation_}, {"credentialScope", credential_scope_}, + {"revision", 0}, {"sessionSequence", 0}, {"sanctions", json::array()}, {"journal", json::object()}, {"ownedMutes", json::array()}}; + if (std::filesystem::exists(file_)) + { + require(std::filesystem::file_size(file_) < max_file); + std::ifstream stream(file_, std::ios::binary); json value; stream >> value; + require(!stream.bad() && value.at("version") == 1 && value.at("server") == server_ && value.at("endpoint") == endpoint_ && + value.at("generation") == generation_ && value.at("credentialScope") == credential_scope_ && value.at("revision").is_number_integer() && value.at("revision") >= 0 && value.at("revision") <= 9007199254740991LL && + value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 && value.at("journal").is_object() && value.at("journal").size() <= 10000); + for (const auto& policy : value.at("sanctions")) sanction(policy, value.at("revision").get()); + for (auto it = value.at("journal").begin(); it != value.at("journal").end(); ++it) + { + require(identifier(it.key())); action(it.value().at("action")); + auto state = field(it.value(), "state", 16); + require(state == "confirmed" || state == "failed" || state == "unknown" || state == "expired"); + require(it.value().at("reported").is_boolean() && it.value().at("action").at("id") == it.key()); + } + require(value.at("ownedMutes").is_array() && value.at("ownedMutes").size() <= 10000); + for (const auto& guid : value.at("ownedMutes")) require(guid.is_string() && guid_identifier(guid.get())); + saved_ = std::move(value); + } + require(saved_.at("sessionSequence").is_number_integer() && saved_.at("sessionSequence") >= 0 && saved_.at("sessionSequence") < 9007199254740991LL); + auto next = saved_; next["sessionSequence"] = saved_.at("sessionSequence").get() + 1; + save(next); // Allocate this boot/map sequence durably before exposing its session. + } + void runtime::save(const json& next) + { + const auto bytes = next.dump(); require(bytes.size() < max_file); + const auto exists = std::filesystem::exists(file_); + require(exists || !allocated_); + if (exists) + { + // A synchronous engine command may replace the map/runtime before the + // older call frame returns. It must never overwrite the new sequence. + require(std::filesystem::file_size(file_) < max_file); + std::ifstream source(file_, std::ios::binary); json checkpoint; source >> checkpoint; + require(!source.bad() && checkpoint.at("version") == 1 && checkpoint.at("server") == server_ && + checkpoint.at("endpoint") == endpoint_ && checkpoint.at("generation") == generation_ && + checkpoint.at("credentialScope") == credential_scope_ && checkpoint.at("sessionSequence") == saved_.at("sessionSequence")); + } + std::filesystem::create_directories(file_.parent_path()); auto temp = file_; temp += ".tmp"; + std::ofstream stream(temp, std::ios::binary | std::ios::trunc); stream.write(bytes.data(), bytes.size()); stream.flush(); + require(stream.good()); stream.close(); require(!stream.fail()); +#ifdef _WIN32 + require(MoveFileExW(temp.c_str(), file_.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0); +#else + std::filesystem::rename(temp, file_); +#endif + saved_ = next; allocated_ = true; + } + void runtime::observe(int slot, const std::string& guid, const std::string& name) + { + require(slot >= 0 && slot < 64 && guid_identifier(guid)); + auto found = players.find(slot); + if (found == players.end() || found->second.guid != canonical_guid(guid)) + { + disconnect(slot); + players.emplace(slot, player{slot, canonical_guid(guid), player_name(name), session + ":" + std::to_string(++serial_)}); + } + else found->second.name = player_name(name); + } + void runtime::disconnect(int slot) + { + auto found = players.find(slot); + if (found != players.end()) { muted_.erase(found->second.connection); kicked_.erase(found->second.connection); players.erase(found); } + } + json runtime::request() + { + json roster = json::array(), receipts = json::array(); sent_.clear(); + for (const auto& [slot, value] : players) roster.push_back({{"guid", value.guid}, {"name", value.name}, {"connection", value.connection}}); + for (auto it = saved_["journal"].begin(); it != saved_["journal"].end(); ++it) + if (!it.value().at("reported").get() && receipts.size() < 256) + { + receipts.push_back({{"id", it.key()}, {"state", it.value().at("state")}, {"detail", it.value().value("detail", std::string())}}); + sent_.push_back(it.key()); + } + return {{"protocol", 1}, {"server", server_}, {"generation", generation_}, {"session", session}, {"sessionSequence", saved_.at("sessionSequence")}, {"players", roster}, + {"capabilities", {"warn", "message", "kick", "announce", "ban", "mute"}}, {"appliedRevision", revision()}, {"receipts", receipts}}; + } + void runtime::accept(const std::string& response, long long now, long long transfer_age_ms) + { + const auto received = std::chrono::steady_clock::now(); + require(!response.empty() && response.size() <= max_response && transfer_age_ms >= 0 && transfer_age_ms <= 86400000); + auto value = json::parse(response); require(value.is_object() && value.at("ok").is_boolean() && value.at("ok").get()); + require(value.at("revision").is_number_integer() && value.at("revision") >= revision() && value.at("revision") <= 9007199254740991LL); + auto revision = value.at("revision").get(); + auto server_now = timestamp(field(value, "serverTime", 32)); require(std::abs(now - server_now) <= 120); + require(value.at("sanctions").is_array() && value.at("sanctions").size() <= 1000 && + value.at("actions").is_array() && value.at("actions").size() <= 100 && + value.at("pollAfterMs").is_number_integer() && value.at("pollAfterMs") >= 1000 && value.at("pollAfterMs") <= 60000); + std::set ids; + for (const auto& entry : value.at("sanctions")) { sanction(entry, revision); require(ids.emplace(entry.at("id").get()).second); } + ids.clear(); + for (const auto& entry : value.at("actions")) { action(entry); auto id = entry.at("id").get(); require(ids.emplace(id).second); + if (saved_["journal"].contains(id)) require(saved_["journal"][id].at("action") == entry); } + if (revision == this->revision() && revision != 0) + { + auto previous = saved_.at("sanctions"); + for (auto it = previous.begin(); it != previous.end(); ) + if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get()) <= server_now) it = previous.erase(it); else ++it; + auto incoming = value.at("sanctions"); + for (auto it = incoming.begin(); it != incoming.end(); ) + if (!it->at("expiresAt").is_null() && timestamp(it->at("expiresAt").get()) <= server_now) it = incoming.erase(it); else ++it; + auto by_id = [](const json& a, const json& b) { return a.at("id").get() < b.at("id").get(); }; + std::sort(previous.begin(), previous.end(), by_id); std::sort(incoming.begin(), incoming.end(), by_id); + require(incoming == previous); + } + auto next = saved_; next["revision"] = revision; next["sanctions"] = value.at("sanctions"); + for (const auto& id : sent_) if (next["journal"].contains(id)) next["journal"][id]["reported"] = true; + // Expired entries are safe to prune only after acknowledgment. An old action still fails its deadline. + for (auto it = next["journal"].begin(); it != next["journal"].end(); ) + if (it.value().at("reported").get() && timestamp(it.value().at("action").at("expiresAt").get()) < now - 86400) it = next["journal"].erase(it); else ++it; + save(next); pending_ = value.at("actions"); + const auto accepted = std::chrono::steady_clock::now(); + const auto processing = std::chrono::duration_cast(accepted - received); + const auto estimate = std::chrono::system_clock::from_time_t(server_now) + std::chrono::seconds(1) + + std::chrono::milliseconds(transfer_age_ms) + processing; + const auto previous = server_anchor_ + std::chrono::duration_cast(accepted - clock_anchor_); + server_anchor_ = server_anchor_.time_since_epoch().count() ? std::max(previous, estimate) : estimate; + clock_anchor_ = accepted; + } + long long runtime::effective_time(long long local) const + { + if (!server_anchor_.time_since_epoch().count()) return local; + // Never extend deadlines when the host clock lags or jumps backward. The + // full request age is conservative because serverTime is generated during it. + const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - clock_anchor_); + const auto estimate = std::chrono::duration_cast((server_anchor_ + elapsed).time_since_epoch()).count(); + return std::max(local, static_cast(estimate)); + } + void runtime::enforce(long long now, const executor& execute) + { + now = effective_time(now); + auto pending = pending_; pending_ = json::array(); + for (const auto& item : pending) + { + auto id = item.at("id").get(); + if (saved_["journal"].contains(id)) + { + // A duplicate immutable action is acknowledged, never executed twice. + require(saved_["journal"][id].at("action") == item); + auto next = saved_; next["journal"][id]["reported"] = false; save(next); continue; + } + require(saved_["journal"].size() < 10000); + auto kind = item.at("kind").get(); int slot = -1; + auto state = std::string("unknown"), detail = std::string("Execution recorded before dispatch; outcome uncertain."); + if (timestamp(item.at("expiresAt").get()) <= now) { state = "expired"; detail = "Action deadline elapsed."; } + else if (kind != "announce") + { + for (const auto& [number, target] : players) + if (item.at("guid") == target.guid && item.at("connection") == target.connection) slot = number; + if (slot < 0) { state = "failed"; detail = "Player connection no longer matches."; } + } + auto next = saved_; next["journal"][id] = {{"action", item}, {"state", state}, {"detail", detail}, {"reported", false}}; save(next); + if (state == "unknown") + { + // All target validation and dispatch occur on the calling game thread, without yielding. + if (timestamp(item.at("expiresAt").get()) <= effective_time(now)) state = "expired"; + else + { + try { state = execute(kind, slot, command_text(item.at("reason").get())); } + catch (...) { state = "unknown"; } + if (state != "confirmed" && state != "failed") state = "unknown"; + } + next = saved_; next["journal"][id]["state"] = state; + next["journal"][id]["detail"] = state == "confirmed" ? "Game dispatch completed." : state == "expired" ? "Action deadline elapsed before dispatch." : "Game dispatch outcome could not be verified."; save(next); + } + } + // Snapshot is copied because kicking can synchronously invoke a disconnect callback. + const auto roster = players; + for (const auto& [slot, target] : roster) + { + now = effective_time(now); + if (!players.contains(slot) || players.at(slot).connection != target.connection) continue; + bool ban = false, mute = false; std::string why; + for (const auto& policy : saved_.at("sanctions")) + { + if (policy.at("guid") != target.guid || (!policy.at("expiresAt").is_null() && timestamp(policy.at("expiresAt").get()) <= now)) continue; + if (policy.at("kind") == "ban") { ban = true; why = policy.at("reason").get(); } + if (policy.at("kind") == "mute") mute = true; + } + if (ban) + { + if (!kicked_.contains(target.connection) || kicked_[target.connection] + 5 <= now) + { kicked_[target.connection] = now; execute("kick", slot, command_text(why)); } + continue; + } + bool was_muted = muted_.contains(target.connection) && muted_.at(target.connection); + auto owned = saved_.at("ownedMutes"); + bool managed = std::find(owned.begin(), owned.end(), target.guid) != owned.end(); + if (mute != was_muted || (!mute && managed)) + { + if (mute && !managed) + { + require(owned.size() < 10000); owned.push_back(target.guid); + auto next = saved_; next["ownedMutes"] = owned; save(next); + } + execute(mute ? "mute" : "unmute", slot, ""); muted_[target.connection] = mute; + if (!mute && managed) + { + owned.erase(std::remove(owned.begin(), owned.end(), json(target.guid)), owned.end()); + auto next = saved_; next["ownedMutes"] = owned; save(next); + } + } + } + } +} diff --git a/t6/plugins/tws-t6-gsc-utils/src/utils/moderation.hpp b/t6/plugins/tws-t6-gsc-utils/src/utils/moderation.hpp new file mode 100644 index 0000000..a7ae35a --- /dev/null +++ b/t6/plugins/tws-t6-gsc-utils/src/utils/moderation.hpp @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace utils::moderation +{ + using json = nlohmann::json; + struct player { int slot; std::string guid, name, connection; }; + using executor = std::function; + long long timestamp(const std::string& value); + std::string command_text(const std::string& value); + std::string new_session(); + std::string canonical_guid(const std::string& value); + class runtime + { + public: + runtime(std::filesystem::path root, std::string server, std::string endpoint, int generation, std::string credential_scope); + std::string session = new_session(); + std::map players; + void observe(int slot, const std::string& guid, const std::string& name); + void disconnect(int slot); + json request(); + void accept(const std::string& response, long long now, long long transfer_age_ms = 0); + void enforce(long long now, const executor& execute); + long long revision() const { return saved_.at("revision").get(); } + private: + std::filesystem::path file_; + std::string server_, endpoint_, credential_scope_; + int generation_; + unsigned long long serial_ = 0; + bool allocated_ = false; + std::chrono::system_clock::time_point server_anchor_{}; + std::chrono::steady_clock::time_point clock_anchor_{}; + json saved_, pending_ = json::array(); + std::vector sent_; + std::map muted_; + std::map kicked_; + void save(const json& next); + long long effective_time(long long local) const; + }; +} diff --git a/t6/scripts/README.md b/t6/scripts/README.md index 6509ade..34e727d 100644 --- a/t6/scripts/README.md +++ b/t6/scripts/README.md @@ -2,7 +2,7 @@ Plutonium T6 versions of the T5 server scripts. Defaults live in the scripts, just like T5. The telemetry token is embedded in the reporter script; no -separate telemetry token config is required. The optional bank needs its own +separate telemetry token config is required. The optional bank and moderation modules each need their own private server token. | File | Role | Dependencies | @@ -15,14 +15,16 @@ private server token. | `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common | | `tws_t6_leaderboard.gsc` | Private map high-round record and every tied holder on connection | Common 1.2.0+, tws-t6-gsc-utils | | `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 | +| `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ | ## Install -1. Copy all eight `tws_t6_*.gsc` files into +1. Copy the desired modules from the nine `tws_t6_*.gsc` files into `Plutonium/storage/t6/scripts/zm/`. Update common along with HUD, leaderboard and AFK when those modules are installed. -2. For telemetry, leaderboard or bank, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1, - in `Plutonium/plugins/` and restart the server process. The other five scripts +2. For telemetry, leaderboard, bank or moderation, install the [tws-t6-gsc-utils build](../plugins/README.md), based on upstream v3.1.1, + in `Plutonium/plugins/` and restart the server process. Moderation requires + plugin 1.3.0+ before its script is installed; see [moderation setup](../../docs/moderation.md). The other five scripts do not require that plugin. 3. Ensure the API service's `EC_TELEMETRY_TOKEN` matches the token embedded in the reporter; see [authentication setup](../../README.md#telemetry-authentication-t5-and-t6). @@ -260,7 +262,7 @@ another mod installed one first. Run the shared [portable and live HUD checks](../../README.md#hud-validation) for core placement, service pagination, record interruptions and command regressions. -To compile all eight scripts for T6 PC with gsc-tool: +To compile all nine scripts for T6 PC with gsc-tool: ```powershell .\tools\check.ps1 -CompilerPath C:\path\to\gsc-tool.exe @@ -268,3 +270,9 @@ To compile all eight scripts for T6 PC with gsc-tool: Compilation does not verify engine/plugin linking or gameplay. T6 differences, research sources and live checks are in [NOTES.md](NOTES.md). + +## Moderation + +Optional `tws_t6_moderation.gsc` 1.0.0 requires common 1.2.0 and the matching +plugin 1.3.0. See the [moderation contract](../../docs/moderation.md) for private +configuration, persistence, action results and deployment checks. diff --git a/t6/scripts/tools/check.ps1 b/t6/scripts/tools/check.ps1 index f094623..788edee 100644 --- a/t6/scripts/tools/check.ps1 +++ b/t6/scripts/tools/check.ps1 @@ -6,8 +6,8 @@ $ErrorActionPreference = 'Stop' $compiler = (Resolve-Path -LiteralPath $CompilerPath).Path $repo = Split-Path -Parent $PSScriptRoot $files = @(Get-ChildItem -LiteralPath $repo -Filter 'tws_*.gsc' | Sort-Object Name) -$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc', 'tws_t6_bank.gsc') -if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the eight TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank.' } +$expected = @('tws_t6_common.gsc', 'tws_t6_hud.gsc', 'tws_t6_afk.gsc', 'tws_t6_lock.gsc', 'tws_t6_telemetry.gsc', 'tws_t6_join.gsc', 'tws_t6_leaderboard.gsc', 'tws_t6_bank.gsc', 'tws_t6_moderation.gsc') +if (@(Compare-Object $expected @($files.Name)).Count -ne 0) { throw 'Expected the nine TWS modules: common, HUD, AFK, lock, telemetry, join, leaderboard, bank, moderation.' } $common = Get-Content -LiteralPath (Join-Path $repo 'tws_t6_common.gsc') -Raw $functions = @([regex]::Matches($common, '(?m)^([a-zA-Z_][a-zA-Z_0-9]*)\([^\r\n]*\)\s*\r?\n\{') | ForEach-Object { $_.Groups[1].Value }) foreach ($file in $files) { @@ -32,4 +32,4 @@ foreach ($file in $files) { } Write-Output "PASS $($file.Name)" } -Write-Output 'All eight T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.' +Write-Output 'All nine T6 compile checks and common references passed. Runtime linking/gameplay require Plutonium.' diff --git a/t6/scripts/tws_t6_moderation.gsc b/t6/scripts/tws_t6_moderation.gsc new file mode 100644 index 0000000..bc4c873 --- /dev/null +++ b/t6/scripts/tws_t6_moderation.gsc @@ -0,0 +1,66 @@ +/* TWS moderation 1.0.0: native durable policy, connection-safe actions, HTTPS synchronization. */ +#include maps\mp\_utility; +#include common_scripts\utility; +// T6 loader probes main and init; initialize only once. +main() +{ +} + +init() +{ + + scripts\zm\tws_t6_common::ensure_config(); + level.moderation_ready = false; + scripts\zm\tws_t6_common::register_connect_handler(::moderation_connected); + level thread moderation_loop(); +} + +moderation_connected() +{ + self endon("disconnect"); + if (!level.moderation_ready) + return; + moderation_observe(self); + moderationPump(); +} + +moderation_observe(player) +{ + if (!isDefined(player)) + return; + guid = player.guid; + if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int")) + return; + // Preserve large identifiers as strings; native validation rejects ambiguous inputs. + guid = "" + guid; + name = player.name; + if (!isDefined(name)) + name = "Unknown player"; + player moderationObserve(guid, name); +} + +moderation_loop() +{ + for (;;) + { + if (GetDvar("tws_moderation_enabled") == "0") + { + moderationStop(); + level.moderation_ready = false; + wait 3; + continue; + } + level.moderation_ready = moderationConfigure(GetDvar("tws_server_id"), GetDvar("tws_moderation_url"), + GetDvar("tws_moderation_token"), GetDvarInt("tws_moderation_generation")); + if (!level.moderation_ready) + { + wait 3; + continue; + } + players = getPlayers(); + for (i = 0; i < players.size; i++) + moderation_observe(players[i]); + moderationPump(); + wait 0.5; + } +} diff --git a/tools/check_hud.mjs b/tools/check_hud.mjs index e01648f..0227709 100644 --- a/tools/check_hud.mjs +++ b/tools/check_hud.mjs @@ -502,7 +502,7 @@ for (const game of ['t5', 't6']) { const functions = new Set([...eSource(game).matchAll(/^(\w+)\([^\n]*\)\s*\n\{/gm)].map(m => m[1])); const folder = new URL(`${game}/scripts/`, root); const scripts = readdirSync(folder).filter(file => file.endsWith('.gsc')); - assert.equal(scripts.length, 8); + assert.equal(scripts.length, 9); let connected = 0; for (const file of scripts) { const source = readFileSync(new URL(file, folder), 'utf8'); diff --git a/tools/check_moderation.mjs b/tools/check_moderation.mjs new file mode 100644 index 0000000..09a42c9 --- /dev/null +++ b/tools/check_moderation.mjs @@ -0,0 +1,40 @@ +// Exercise the real GSC identity adapter under a small VM shim; native policy tests are separate. +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import vm from 'node:vm'; +for (const game of ['t5','t6']) { + const source=readFileSync(new URL(`../${game}/scripts/tws_${game}_moderation.gsc`,import.meta.url),'utf8'); + assert.equal((source.match(/level waittill\("connected"/g)??[]).length,0,'must use the shared connection dispatcher'); + assert(source.includes('register_connect_handler(::moderation_connected)')); + assert(!source.includes('executeCommand'),'all effects belong to the native synchronous adapter'); + const start=source.indexOf('moderation_observe(player)\n{'); + const end=source.indexOf('\nmoderation_loop()',start); + let body=source.slice(start,end).replace('moderation_observe(player)','function observe(player)'); + body=body.replace(' guid =',' var guid =').replace(' name =',' var name ='); + body=body.replace('player getGuid()','getGuid(player)').replaceAll('typeof(', 'kind(').replace('player moderationObserve(guid, name)', 'record(player,guid,name)'); + const calls=[]; + const c=vm.createContext({isDefined:v=>v!==null&&v!==undefined,kind:v=>typeof v==='number'?(Number.isInteger(v)?'int':'float'):typeof v, + getGuid:p=>p.identity,record:(...args)=>calls.push(args)}); + vm.runInContext(body,c); + const player=identity=>({identity,guid:identity,name:'same name'}); + c.observe(player('18446744073709551615')); assert.equal(calls.at(-1)[1],'18446744073709551615'); + c.observe(player(2741626)); assert.equal(calls.at(-1)[1],'2741626'); + c.observe(player('0000123')); assert.equal(calls.at(-1)[1],'0000123'); + const count=calls.length; for(const value of [undefined,null,1.5,{},[]])c.observe(player(value)); assert.equal(calls.length,count); + c.observe(undefined); assert.equal(calls.length,count); + c.observe({...player('abc'),name:undefined}); assert.equal(calls.at(-1)[2],'Unknown player'); + let loop=source.slice(source.indexOf('moderation_loop()\n{')) + .replace('moderation_loop()', 'function* loop()').replaceAll(/wait ([0-9.]+);/g, 'yield $1;') + .replace('players = getPlayers();', 'var players = getPlayers();').replace('for (i = 0;', 'for (var i = 0;').replace('players.size', 'players.length'); + let enabled=true, configured=false, stopped=0, pumped=0, observed=0; + const level={moderation_ready:false}; + const runtime=vm.createContext({level,GetDvar:key=>key==='tws_moderation_enabled'?(enabled?'1':'0'):'configured',GetDvarInt:()=>1, + moderationConfigure:()=>configured,moderationStop:()=>++stopped,moderationPump:()=>++pumped,getPlayers:()=>[{guid:'123'}],moderation_observe:()=>++observed}); + vm.runInContext(loop,runtime); const poll=runtime.loop(); + assert.equal(poll.next().value,3); assert.equal(level.moderation_ready,false); + configured=true; assert.equal(poll.next().value,0.5); assert.equal(level.moderation_ready,true); + assert.equal(pumped,1); assert.equal(observed,1); + enabled=false; assert.equal(poll.next().value,3); assert.equal(level.moderation_ready,false); assert.equal(stopped,1); + enabled=true; assert.equal(poll.next().value,0.5); assert.equal(pumped,2); + console.log(`PASS ${game}: actual moderation identity, retry/disable/resume loop and shared dispatcher integration`); +} diff --git a/tools/test_plugin_http.py b/tools/test_plugin_http.py index abbff91..d10831d 100644 --- a/tools/test_plugin_http.py +++ b/tools/test_plugin_http.py @@ -66,9 +66,9 @@ def main(): base = f"http://127.0.0.1:{http.server_address[1]}" env = {**os.environ, "NO_PROXY": "*", "no_proxy": "*"} - def probe(path, connect="1000", total="1000"): + def probe(path, connect="1000", total="1000", cap=None): url = path if "://" in path else base + path - completed = subprocess.run([str(executable), url, connect, total], env=env, capture_output=True, text=True, check=True, timeout=8) + completed = subprocess.run([str(executable), url, connect, total] + ([] if cap is None else [str(cap)]), env=env, capture_output=True, text=True, check=True, timeout=8) return json.loads(completed.stdout) normal = probe("/200", "-", "-") @@ -84,6 +84,11 @@ def main(): assert timed_out["body_size"] == 0, timed_out if status: assert timed_out["first_byte_ms"] > 0, timed_out assert probe("/200")["curl_code"] == 0 # The next request completes after the stall. + exact = probe("/200", cap=11) + assert exact["curl_code"] == 0 and exact["body_size"] == 11, exact + bounded = probe("/200", cap=10) + assert bounded["curl_code"] == 23 and bounded["body_size"] == 0, bounded + assert probe("/200")["body_size"] == 11 slow = probe("/slow") assert slow["curl_code"] == 0 and slow["first_byte_ms"] >= 90, slow tls_url = f"https://127.0.0.1:{tls.server_address[1]}/" diff --git a/tools/test_plugin_moderation.py b/tools/test_plugin_moderation.py new file mode 100644 index 0000000..51a6431 --- /dev/null +++ b/tools/test_plugin_moderation.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Compile both real native moderation cores with ASan/UBSan (no Windows/VM emulation).""" +import argparse +import os +import re +from pathlib import Path +import shutil +import subprocess +import tempfile +ROOT=Path(__file__).resolve().parents[1] +p=argparse.ArgumentParser(description=__doc__) +p.add_argument('--json-header', help='Pinned single header; {game} expands to t5/t6') +a=p.parse_args() +with tempfile.TemporaryDirectory(prefix='tws-moderation-tests-') as tmp: + temp=Path(tmp) + for game in ('t5','t6'): + plugin=ROOT/game/'plugins'/f'tws-{game}-gsc-utils' + header=Path(a.json_header.format(game=game)) if a.json_header else plugin/'deps/json/single_include/nlohmann/json.hpp' + if not header.is_file(): p.error(f'Missing {header}; restore pinned dependencies or pass --json-header') + shutil.copyfile(header,temp/'json.hpp') + (temp/'stdinc.hpp').write_text('#include \n#include \n#include \n#include \n') + exe=temp/game + subprocess.run([os.environ.get('CXX','c++'),'-std=c++20','-Wall','-Wextra','-Werror','-fsanitize=address,undefined','-I',str(temp),'-I',str(plugin/'src/utils'),str(plugin/'src/utils/moderation.cpp'),str(ROOT/'tools/tests/moderation_cases.cpp'),'-o',str(exe)],check=True) + subprocess.run([str(exe),str(temp/f'{game}-data')],check=True) + subprocess.run([str(exe),str(temp/f'{game}-restart'),'write'],check=True) + subprocess.run([str(exe),str(temp/f'{game}-restart'),'read'],check=True) + component=re.sub(r'^#include[^\n]*\n','',(plugin/'src/component/moderation.cpp').read_text(),flags=re.MULTILINE) + (temp/'component.cpp').write_text('#include "moderation_component_stub.hpp"\n#include "moderation.hpp"\n#include \n'+component+(ROOT/'tools/tests/moderation_component_cases.cpp').read_text()) + shutil.copyfile(ROOT/'tools/tests/moderation_component_stub.hpp',temp/'moderation_component_stub.hpp') + subprocess.run([os.environ.get('CXX','c++'),'-std=c++20','-Wall','-Wextra','-Werror','-fsanitize=address,undefined','-I',str(temp),'-I',str(plugin/'src/utils'),str(plugin/'src/utils/moderation.cpp'),str(temp/'component.cpp'),'-o',str(temp/'component')],check=True) + component_dir=temp/f'{game}-component'; component_dir.mkdir() + subprocess.run([str(temp/'component')],cwd=component_dir,check=True) + print(f'PASS {game}: moderation native policy, journal and process restart',flush=True) diff --git a/tools/tests/http_probe.cpp b/tools/tests/http_probe.cpp index 3217d3f..b6452c4 100644 --- a/tools/tests/http_probe.cpp +++ b/tools/tests/http_probe.cpp @@ -6,12 +6,13 @@ int main(int argc, char** argv) { - if (argc != 4 || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) + if ((argc != 4 && argc != 5) || curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return 2; utils::http::timeouts limits; // A dash exercises the defaults compiled into the actual plugin source. if (std::string(argv[2]) != "-") limits.connect_timeout_ms = std::stol(argv[2]); if (std::string(argv[3]) != "-") limits.timeout_ms = std::stol(argv[3]); + if (argc == 5) limits.max_response_bytes = std::stoul(argv[4]); const auto started = std::chrono::steady_clock::now(); const auto result = utils::http::get_data(argv[1], "{\"event\":\"heartbeat\"}", {{"Content-Type", "application/json"}, {"Authorization", "Bearer test-only"}}, "POST", limits); diff --git a/tools/tests/moderation_cases.cpp b/tools/tests/moderation_cases.cpp new file mode 100644 index 0000000..7ac921d --- /dev/null +++ b/tools/tests/moderation_cases.cpp @@ -0,0 +1,160 @@ +#include "moderation.hpp" +#include +#include +#include +using namespace utils::moderation; +static const long long NOW = timestamp("2026-10-15T12:00:00.000Z"); +static const std::string END = "2026-10-15T12:00:30.000Z"; +static json reply(int revision, json sanctions = json::array(), json actions = json::array()) +{ + if (sanctions.is_null()) sanctions = json::array(); + if (sanctions.is_object()) sanctions = json::array({sanctions}); + if (actions.is_object()) actions = json::array({actions}); + return {{"ok", true}, {"revision", revision}, {"serverTime", "2026-10-15T12:00:00.000Z"}, {"sanctions", sanctions}, {"actions", actions}, {"pollAfterMs", 3000}}; +} +static json policy(std::string kind, std::string expiry = END) +{ + return {{"id", "policy"}, {"revision", 1}, {"guid", "18446744073709551615"}, {"kind", kind}, {"reason", "rule"}, {"expiresAt", expiry.empty() ? json(nullptr) : json(expiry)}}; +} +static json act(std::string id, std::string connection, std::string kind = "kick") +{ + return {{"id", id}, {"guid", "18446744073709551615"}, {"connection", connection}, {"kind", kind}, {"reason", "rule; say bad\n\"bad\\^1"}, {"expiresAt", END}}; +} +template void rejects(F f) { bool threw = false; try { f(); } catch (...) { threw = true; } assert(threw); } +int main(int argc, char** argv) +{ + assert(argc >= 2); auto root = std::filesystem::path(argv[1]); + runtime game(root, "server", "https://controller/api/moderation/runtime", 1, "credential-a"); + if (argc == 3) + { + if (std::string(argv[2]) == "write") + { + assert(game.request()["sessionSequence"] == 1); + game.observe(0, "18446744073709551615", "one"); auto id = game.players.at(0).connection; + game.accept(reply(1, {policy("ban", "")}, {act("crash", id)}).dump(), NOW); + game.enforce(NOW, [](const auto&, int, const auto&) -> std::string { return "unknown"; }); + } + else + { + assert(game.request()["sessionSequence"] == 2); + assert(game.revision() == 1); assert(game.request()["receipts"][0]["id"] == "crash"); + assert(game.request()["receipts"][0]["state"] == "unknown"); + int kicks = 0; game.observe(0, "18446744073709551615", "one"); + game.enforce(NOW + 10, [&](const auto& kind, int, const auto&) { if (kind == "kick") ++kicks; return "confirmed"; }); + assert(kicks == 1); // cached permanent policy enforces without a network response + } + return 0; + } + assert(canonical_guid("000123") == "123" && canonical_guid("000") == "0"); + assert(canonical_guid("18446744073709551615") == "18446744073709551615" && canonical_guid("abc001") == "abc001"); + assert(timestamp("2026-10-15T12:00:00Z") == NOW); + for (const auto* invalid : {"garbage", "2026-02-31T12:00:00Z", "2026-10-15T12:00:00+01:00", "2026-10-15T12:00:00.abcZ"}) rejects([&] { timestamp(invalid); }); + assert(command_text("a\";\\\n\r^1b") == "a 1b"); + assert(command_text(std::string(239, 'a') + "é") == std::string(239, 'a')); + assert(command_text(std::string(238, 'a') + "é") == std::string(238, 'a') + "é"); + game.observe(1, "000123", std::string(254, 'a') + "é"); + assert(game.players.at(1).guid == "123" && game.players.at(1).name == std::string(254, 'a')); + game.observe(1, "123", std::string("bad") + char(0xff)); assert(game.players.at(1).name == "bad?"); + game.observe(1, "123", std::string("bad") + char(1) + "name"); assert(game.players.at(1).name == "bad name"); + for (const std::string blank : {"", " ", "\t\r\n\x01\x7f", "\xc2\xa0\xe2\x80\x83\xef\xbb\xbf"}) + { + game.observe(1, "123", blank); assert(game.players.at(1).name == "Unknown player"); + assert(game.request()["players"][0]["name"] == "Unknown player"); + } + rejects([&] { game.observe(1, "abc.001", "name"); }); + game.request().dump(); game.disconnect(1); + game.observe(0, "18446744073709551615", "same name"); auto first = game.players.at(0).connection; + game.observe(0, "18446744073709551615", "new name"); assert(game.players.at(0).connection == first); + game.disconnect(0); game.observe(0, "18446744073709551615", "same name"); assert(game.players.at(0).connection != first); + int executed = 0; + auto execute = [&](const std::string&, int, const std::string& text) { ++executed; assert(text.find_first_of("\r\n;\"\\^") == std::string::npos); return "confirmed"; }; + game.accept(reply(1, {}, {act("stale", first)}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 0); + assert(game.request()["receipts"][0]["state"] == "failed"); + const auto valid = act("once", game.players.at(0).connection); + game.accept(reply(1, {}, {valid}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 1); + game.accept(reply(1, {}, {valid}).dump(), NOW); game.enforce(NOW, execute); assert(executed == 1); + auto changed = valid; changed["reason"] = "different action"; + rejects([&] { game.accept(reply(1, {}, {changed}).dump(), NOW); }); + auto expired = act("expired", game.players.at(0).connection); game.accept(reply(1, {}, {expired}).dump(), NOW + 31); game.enforce(NOW + 31, execute); assert(executed == 1); + auto future = reply(2); future["serverTime"] = "2026-10-16T12:00:00Z"; rejects([&] { game.accept(future.dump(), NOW); }); + rejects([&] { game.accept(reply(0).dump(), NOW); }); + auto invalid = reply(2, {policy("mute")}); invalid["sanctions"][0]["guid"] = "bad guid"; rejects([&] { game.accept(invalid.dump(), NOW); }); assert(game.revision() == 1); + for (const auto* malformed : {"{}", "[]", "not json", "{\"ok\":false}"}) rejects([&] { game.accept(malformed, NOW); }); + auto too_many = reply(2); for (int i = 0; i < 1001; ++i) too_many["sanctions"].push_back(policy("mute")); rejects([&] { game.accept(too_many.dump(), NOW); }); + rejects([&] { game.accept(std::string(1024 * 1024 + 1, ' '), NOW); }); + rejects([&] { game.accept(reply(1, {policy("mute")}).dump(), NOW); }); + std::vector effects; + auto record = [&](const auto& kind, int, const auto&) { effects.push_back(kind); return "unknown"; }; + game.accept(reply(2, {policy("mute")}).dump(), NOW); game.enforce(NOW, record); game.enforce(NOW + 1, record); assert(effects == std::vector{"mute"}); + game.enforce(NOW + 31, record); assert(effects == (std::vector{"mute", "unmute"})); + auto expired_snapshot = reply(2); expired_snapshot["serverTime"] = "2026-10-15T12:00:31.000Z"; + game.accept(expired_snapshot.dump(), NOW); // server expiry wins even when local clock lags within allowed skew + game.accept(reply(3, {policy("mute", "")}).dump(), NOW); game.enforce(NOW, record); + game.accept(reply(4).dump(), NOW); game.enforce(NOW, record); assert(effects.back() == "unmute"); + game.accept(reply(5, {policy("ban", "")}).dump(), NOW); + auto disconnecting = [&](const auto& kind, int slot, const auto&) { assert(kind == "kick"); game.disconnect(slot); return "confirmed"; }; + game.enforce(NOW, disconnecting); assert(game.players.empty()); + // Slow host time and buffered responses cannot extend a one-time deadline. + { + runtime slow(root / "slow-clock", "server", "endpoint", 1, "credential-a"); + slow.observe(0, "18446744073709551615", "one"); + slow.accept(reply(1, {}, {act("slow", slow.players.at(0).connection)}).dump(), NOW - 60, 31000); + const auto count = executed; slow.enforce(NOW - 60, execute); assert(executed == count); + assert(slow.request()["receipts"][0]["state"] == "expired"); + } + // A faster next response cannot roll the conservative server-time anchor back. + { + runtime monotonic(root / "monotonic-clock", "server", "endpoint", 1, "credential-a"); + monotonic.observe(0, "18446744073709551615", "one"); + auto timed = reply(1, {policy("mute", "2026-10-15T12:00:07.000Z")}); + const auto count = effects.size(); + monotonic.accept(timed.dump(), NOW, 8000); monotonic.enforce(NOW, record); + monotonic.accept(timed.dump(), NOW, 0); monotonic.enforce(NOW, record); + assert(effects.size() == count); + } + // A superseded runtime cannot overwrite a newer session counter or execute. + { + const auto replacement_root = root / "replacement"; + runtime older(replacement_root, "server", "endpoint", 1, "credential-a"); + older.observe(0, "18446744073709551615", "one"); + older.accept(reply(1, {}, {act("superseded", older.players.at(0).connection)}).dump(), NOW); + runtime newer(replacement_root, "server", "endpoint", 1, "credential-a"); + const auto count = executed; rejects([&] { older.enforce(NOW, execute); }); assert(executed == count); + newer.accept(reply(2).dump(), NOW); assert(newer.request()["sessionSequence"] == 2); + runtime recovered(replacement_root, "server", "endpoint", 1, "credential-a"); + assert(recovered.request()["sessionSequence"] == 3 && recovered.revision() == 2); + for (const auto& file : std::filesystem::directory_iterator(replacement_root)) std::filesystem::remove(file.path()); + rejects([&] { recovered.accept(reply(3).dump(), NOW); }); // cache deletion cannot silently reset its sequence + } + // Managed mute ownership survives restart and allows revocation without clearing unrelated mutes. + const auto mute_root = root / "mute-restart"; + { + runtime original(mute_root, "server", "endpoint", 1, "credential-a"); + original.observe(0, "18446744073709551615", "one"); + original.accept(reply(1, {policy("mute", "")}).dump(), NOW); + original.enforce(NOW, record); + } + { + runtime recovered(mute_root, "server", "endpoint", 1, "credential-a"); + recovered.accept(reply(2).dump(), NOW); + recovered.observe(1, "18446744073709551615", "one"); + const auto before = effects.size(); recovered.enforce(NOW, record); + assert(effects.size() == before + 1 && effects.back() == "unmute"); + recovered.observe(2, "someone-else", "two"); recovered.enforce(NOW, record); + assert(effects.size() == before + 1); + } + // Pre-dispatch disk failure must prevent the irreversible effect. + game.observe(0, "18446744073709551615", "one"); + game.accept(reply(6, {}, {act("disk", game.players.at(0).connection)}).dump(), NOW); + std::filesystem::path journal; + for (const auto& file : std::filesystem::directory_iterator(root)) if (file.path().extension() == ".json") journal = file.path(); + auto temporary = journal; temporary += ".tmp"; std::filesystem::create_directory(temporary); + const auto before_disk = executed; rejects([&] { game.enforce(NOW, execute); }); assert(executed == before_disk); + std::filesystem::remove(temporary); + runtime different_credential(root, "server", "https://controller/api/moderation/runtime", 1, "credential-b"); + assert(different_credential.revision() == 0 && different_credential.request()["receipts"].empty()); + // Corruption is visible: runtime never replaces a bad persistent cache with empty policy. + { std::ofstream file(journal); file << "corrupt"; } + rejects([&] { runtime broken(root, "server", "https://controller/api/moderation/runtime", 1, "credential-a"); }); + std::cout << "moderation policy, identity, expiry, revocation, journal and schema checks passed\n"; +} diff --git a/tools/tests/moderation_component_cases.cpp b/tools/tests/moderation_component_cases.cpp new file mode 100644 index 0000000..1a36e57 --- /dev/null +++ b/tools/tests/moderation_component_cases.cpp @@ -0,0 +1,49 @@ +int main() +{ + plugin::plugin plugin; moderation::component component; component.on_startup(&plugin); + const std::string url="https://controller/api/moderation/runtime", token="ec_moderation_"+std::string(43,'a'); + assert(!gsc::function::configure("server","http://controller/api/moderation/runtime",token,1)); + assert(!gsc::function::configure("server","https://controller@evil/api/moderation/runtime",token,1)); + assert(!gsc::function::configure("server",url,token+"\r\nX: leak",1)); + assert(gsc::function::configure("server",url,token,1)); + // A failed replacement config must not permanently poison the valid old config. + assert(!gsc::function::configure("server",url,token,0)); + assert(gsc::function::configure("server",url,token,1)); + assert(!moderation::fault); + const auto initial_session=moderation::current->session; + const auto initial_sequence=moderation::current->request()["sessionSequence"]; + assert(gsc::function::configure("server",url,token,1)); + assert(moderation::current->session==initial_session && moderation::current->request()["sessionSequence"] == initial_sequence); // periodic configuration cannot invalidate connections + scripting::entity player{{0,0}}, wrong{{0,1}}; + assert(!gsc::method::observe(wrong,"123","one")); + assert(!gsc::method::observe(player,"123; kick all","one")); + assert(gsc::method::observe(player,"123","one")); + const auto connection=moderation::current->players.at(0).connection; + plugin.i.c.disconnect(0); assert(gsc::method::observe(player,"123","two")); + assert(moderation::current->players.at(0).connection!=connection); + auto now=moderation::now(); + auto iso=[](long long seconds) {time_t time=seconds; auto tm=*gmtime(&time); std::ostringstream out; out<request()["receipts"][0]["state"]=="failed"); + reply["actions"][0]["id"]="valid";reply["actions"][0]["connection"]=moderation::current->players.at(0).connection; + reply["actions"][0]["kind"]="message";reply["actions"][0]["reason"]="a\";\\\r\nsay"; + moderation::current->accept(reply.dump(),now); gsc::function::pump(); + assert(game::messages.size()==1); assert(game::messages[0].first==0); assert(game::messages[0].second.find(';')==std::string::npos); + assert(game::messages[0].second.find('\n')==std::string::npos); + moderation::current->accept(reply.dump(),now); gsc::function::pump(); assert(game::messages.size()==1); + reply["actions"][0]["id"]="shutdown"; reply["actions"][0]["kind"]="kick"; + moderation::current->accept(reply.dump(),now); + command::on_execute=[] { scripting::shutdown(); }; + gsc::function::pump(); assert(!moderation::current); // callback cannot free the active frame + command::on_execute={}; + assert(gsc::function::configure("server",url,token,1)); + assert(moderation::current->request()["receipts"].size()>=1); + const auto old_session=moderation::current->session; + assert(gsc::function::configure("server",url,"ec_moderation_"+std::string(43,'b'),1)); + assert(moderation::current->session!=old_session && moderation::current->revision()==0); + assert(moderation::current->request()["receipts"].empty()); + scripting::shutdown(); +} diff --git a/tools/tests/moderation_component_stub.hpp b/tools/tests/moderation_component_stub.hpp new file mode 100644 index 0000000..c2b7b55 --- /dev/null +++ b/tools/tests/moderation_component_stub.hpp @@ -0,0 +1,50 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "http.hpp" +namespace game { +struct scr_entref_t { unsigned short entnum = 0, classnum = 0; }; +inline std::vector> messages; +inline void SV_GameSendServerCommand(int slot, int, const char* message) { messages.emplace_back(slot,message); } +} +namespace scripting { +struct entity { game::scr_entref_t ref; game::scr_entref_t get_entity_reference() const { return ref; } }; +struct array {}; +inline std::function shutdown; +inline void on_shutdown(std::function callback) { shutdown=callback; } +} +namespace plugin { +struct callbacks { std::function connect, disconnect; void on_player_connect(std::function fn) {connect=fn;} void on_player_disconnect(std::function fn) {disconnect=fn;} }; +struct iface { plugin::callbacks c; plugin::callbacks* callbacks() {return &c;} }; +struct plugin { iface i; iface* get_interface() {return &i;} }; +} +struct component_interface { virtual ~component_interface()=default; virtual void on_startup(plugin::plugin*) {} }; +#define REGISTER_COMPONENT(x) +namespace command { inline std::function on_execute; inline std::vector commands; inline void execute(std::string command,bool sync) {assert(sync); commands.push_back(command); if(on_execute)on_execute();} } +namespace scheduler { struct pool { std::vector> tasks; void push(std::function fn) {tasks.push_back(fn);} void flush() {auto copy=tasks; tasks.clear(); for(auto& fn:copy)fn();} }; inline pool thread_pool; } +namespace gsc::function { +inline std::function configure; +inline std::function pump; +template void add(const char*,F fn) { + if constexpr(std::is_invocable_r_v)configure=fn; + else if constexpr(std::is_invocable_r_v) { if constexpr(std::is_same_v,void>)pump=fn; } +} +} +namespace gsc::method { inline std::function observe; template void add(const char*,F fn) {observe=fn;} } +namespace utils::cryptography::sha256 { inline std::string compute(const std::string& input, bool hex) { assert(hex); return std::to_string(std::hash{}(input)); } } +namespace utils::http { +inline std::string response, request; +inline std::optional get_data(const std::string& url,const std::string& fields,const headers& hdrs,const std::string& method,const timeouts& limits) { + assert(url=="https://controller/api/moderation/runtime"); assert(method=="POST"); assert(hdrs.at("Authorization").starts_with("Bearer ec_moderation_")); assert(limits.max_response_bytes==1024*1024); + request=fields; result out; out.status=200; out.code=CURLE_OK; out.buffer=response; return out; +} +} diff --git a/versions.json b/versions.json index eaf1c4f..3536cb9 100644 --- a/versions.json +++ b/versions.json @@ -15,6 +15,8 @@ "tws_t6_leaderboard.gsc": "1.1.0", "tws_t6_lock.gsc": "1.0.0", "tws_t6_telemetry.gsc": "1.0.0", - "t5-plugin": "1.2.0", - "t6-plugin": "1.2.0" + "t5-plugin": "1.3.0", + "t6-plugin": "1.3.0", + "tws_t5_moderation.gsc": "1.0.0", + "tws_t6_moderation.gsc": "1.0.0" }