Add identity-safe player moderation runtimes for T5 and T6
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
#include "moderation.hpp"
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
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<class F> 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<std::string> 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<std::string>{"mute"});
|
||||
game.enforce(NOW + 31, record); assert(effects == (std::vector<std::string>{"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";
|
||||
}
|
||||
@@ -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<<std::put_time(&tm,"%Y-%m-%dT%H:%M:%SZ"); return out.str();};
|
||||
utils::moderation::json reply={{"ok",true},{"revision",1},{"serverTime",iso(now)},{"sanctions",utils::moderation::json::array()},
|
||||
{"actions",{{{"id","stale"},{"guid","123"},{"connection",connection},{"kind","kick"},{"reason","reason"},{"expiresAt",iso(now+30)}}}},{"pollAfterMs",3000}};
|
||||
if (reply["actions"].is_object()) reply["actions"] = utils::moderation::json::array({reply["actions"]});
|
||||
utils::http::response=reply.dump(); gsc::function::pump(); scheduler::thread_pool.flush(); gsc::function::pump();
|
||||
assert(command::commands.empty()); assert(moderation::current->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();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <json.hpp>
|
||||
#include "http.hpp"
|
||||
namespace game {
|
||||
struct scr_entref_t { unsigned short entnum = 0, classnum = 0; };
|
||||
inline std::vector<std::pair<int,std::string>> 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<void()> shutdown;
|
||||
inline void on_shutdown(std::function<void()> callback) { shutdown=callback; }
|
||||
}
|
||||
namespace plugin {
|
||||
struct callbacks { std::function<void(unsigned)> connect, disconnect; void on_player_connect(std::function<void(unsigned)> fn) {connect=fn;} void on_player_disconnect(std::function<void(unsigned)> 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<void()> on_execute; inline std::vector<std::string> 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<std::function<void()>> tasks; void push(std::function<void()> 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<bool(std::string,std::string,std::string,int)> configure;
|
||||
inline std::function<void()> pump;
|
||||
template<typename F> void add(const char*,F fn) {
|
||||
if constexpr(std::is_invocable_r_v<bool,F,std::string,std::string,std::string,int>)configure=fn;
|
||||
else if constexpr(std::is_invocable_r_v<void,F>) { if constexpr(std::is_same_v<std::invoke_result_t<F>,void>)pump=fn; }
|
||||
}
|
||||
}
|
||||
namespace gsc::method { inline std::function<bool(scripting::entity,std::string,std::string)> observe; template<typename F> 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<std::string>{}(input)); } }
|
||||
namespace utils::http {
|
||||
inline std::string response, request;
|
||||
inline std::optional<result> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user