Add identity-safe player moderation runtimes for T5 and T6

This commit is contained in:
2026-10-15 21:35:19 -04:00
parent 2b5b1955bc
commit d7bf16e9c2
27 changed files with 1784 additions and 25 deletions
@@ -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<std::string*>(userp);
const auto buffer = static_cast<response_buffer*>(userp);
if (size && nmemb > static_cast<size_t>(-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<char*>(contents), total_size);
buffer->bytes.append(static_cast<char*>(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;
}
}
@@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <cstddef>
#include <optional>
#include <unordered_map>
@@ -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<std::string, std::string>;
@@ -0,0 +1,349 @@
#include <stdinc.hpp>
#include "moderation.hpp"
#include <chrono>
#include <cctype>
#include <ctime>
#include <iomanip>
#include <random>
#include <set>
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<unsigned char>(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<unsigned char>(value[i + j]) & 0xc0) == 0x80;
if (valid && width >= 3)
{
const auto second = static_cast<unsigned char>(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<unsigned char>(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<std::string>();
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<char>(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<unsigned>(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<long long>());
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<std::string>()));
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<long long>() + 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<bool>() && 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<bool>());
require(value.at("revision").is_number_integer() && value.at("revision") >= revision() && value.at("revision") <= 9007199254740991LL);
auto revision = value.at("revision").get<long long>();
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<std::string> ids;
for (const auto& entry : value.at("sanctions")) { sanction(entry, revision); require(ids.emplace(entry.at("id").get<std::string>()).second); }
ids.clear();
for (const auto& entry : value.at("actions")) { action(entry); auto id = entry.at("id").get<std::string>(); 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<std::string>()) <= 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<std::string>()) <= server_now) it = incoming.erase(it); else ++it;
auto by_id = [](const json& a, const json& b) { return a.at("id").get<std::string>() < b.at("id").get<std::string>(); };
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<bool>() && timestamp(it.value().at("action").at("expiresAt").get<std::string>()) < 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<std::chrono::system_clock::duration>(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<std::chrono::system_clock::duration>(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::system_clock::duration>(std::chrono::steady_clock::now() - clock_anchor_);
const auto estimate = std::chrono::duration_cast<std::chrono::seconds>((server_anchor_ + elapsed).time_since_epoch()).count();
return std::max(local, static_cast<long long>(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<std::string>();
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<std::string>(); int slot = -1;
auto state = std::string("unknown"), detail = std::string("Execution recorded before dispatch; outcome uncertain.");
if (timestamp(item.at("expiresAt").get<std::string>()) <= 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<std::string>()) <= effective_time(now)) state = "expired";
else
{
try { state = execute(kind, slot, command_text(item.at("reason").get<std::string>())); }
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<std::string>()) <= now)) continue;
if (policy.at("kind") == "ban") { ban = true; why = policy.at("reason").get<std::string>(); }
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);
}
}
}
}
}
@@ -0,0 +1,46 @@
#pragma once
#include <filesystem>
#include <chrono>
#include <functional>
#include <map>
#include <string>
#include <vector>
#include <json.hpp>
namespace utils::moderation
{
using json = nlohmann::json;
struct player { int slot; std::string guid, name, connection; };
using executor = std::function<std::string(const std::string&, int, const std::string&)>;
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<int, player> 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<long long>(); }
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<std::string> sent_;
std::map<std::string, bool> muted_;
std::map<std::string, long long> kicked_;
void save(const json& next);
long long effective_time(long long local) const;
};
}