diff --git a/.gitignore b/.gitignore index 80dfd3861..835bb4630 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,8 @@ dashboard_oauth2_token.json CGC_REPORT.md __pycache__/ *.pyc + +# Packet captures, bundles and fixtures are player data: never committed (docs/CaptureReplay.md) +captures/ +*.bundle +tests/fixtures-local/ diff --git a/dAuthServer/AuthServer.cpp b/dAuthServer/AuthServer.cpp index 128fb43bf..e1c82ce96 100644 --- a/dAuthServer/AuthServer.cpp +++ b/dAuthServer/AuthServer.cpp @@ -8,6 +8,7 @@ //DLU Includes: #include "Profiler.h" #include "dCommonVars.h" +#include "PacketCapture.h" #include "ConfigSync.h" #include "dServer.h" #include "Logger.h" @@ -136,6 +137,7 @@ int main(int argc, char** argv) { Profiler::Scope scope("Master packets", Profiler::Phase::PACKETS); Game::server->ReceiveFromMaster(); //ReceiveFromMaster also handles the master packets if needed. } + PacketCapture::Update(); packet = Game::server->Receive(); if (packet) { Profiler::PacketScope scope(packet->data, packet->length); diff --git a/dChatServer/ChatServer.cpp b/dChatServer/ChatServer.cpp index c9d683c09..e7eb4fe0d 100644 --- a/dChatServer/ChatServer.cpp +++ b/dChatServer/ChatServer.cpp @@ -7,6 +7,7 @@ #include "Profiler.h" #include #include "dCommonVars.h" +#include "PacketCapture.h" #include "ConfigSync.h" #include "dServer.h" #include "Logger.h" @@ -181,6 +182,7 @@ int main(int argc, char** argv) { Game::server->DeallocateMasterPacket(masterPacket); } masterScope.reset(); + PacketCapture::Update(); packet = Game::server->Receive(); if (packet) { Profiler::PacketScope scope(packet->data, packet->length); diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index cbd5c70e7..45c6648d0 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -76,6 +76,7 @@ #include "Inspector.h" #include "CDClientBrowser.h" #include "master/MessageCapture.h" +#include "CaptureReplay.h" #include "PublicRoutes.h" #include "Showcase.h" #include "ContrabandRoutes.h" @@ -341,7 +342,10 @@ namespace { handlers.On(Master::PLAYER_ADDED, OnPlayerAdded); handlers.On(Master::PLAYER_REMOVED, OnPlayerRemoved); handlers.On(Master::PLAYER_POSITIONS, [](const PlayerPositions& positions, const SystemAddress&) { LiveWorld::HandlePlayerPositions(positions); }); - handlers.On(Master::MESSAGE_CAPTURE_DATA, [](const MessageCaptureData& data, const SystemAddress&) { Inspector::HandleData(data); }); + handlers.On(Master::MESSAGE_CAPTURE_DATA, [](const MessageCaptureData& data, const SystemAddress&) { + if (data.status == eMessageCaptureStatus::PACKETS) CaptureReplay::HandleData(data); + else Inspector::HandleData(data); + }); handlers.On(Master::DATA_CHANGED, [](const DataChanged& changed, const SystemAddress&) { BroadcastDataChanged(changed); }); handlers.On(Master::PLAYER_ACTION_RESULT, [](const PlayerActionResult& result, const SystemAddress&) { PlayerActions::HandleResult(result); }); handlers.On(Master::SHUTDOWN_RESPONSE, OnWorldShutDown); @@ -512,6 +516,7 @@ int main(int argc, char** argv) { RegisterReportViewTask(); RegisterCharacterTasks(); Inspector::Initialize(); + CaptureReplay::Initialize(); Scheduler::Initialize(); // Register global middleware @@ -555,6 +560,7 @@ int main(int argc, char** argv) { LiveWorld::RegisterRoutes(); LiveUpdateRoutes::RegisterRoutes(); Inspector::RegisterRoutes(); + CaptureReplay::RegisterRoutes(); RegisterCDClientBrowserRoutes(); RegisterSettingsRoutes(); SettingsHistory::RegisterRoutes(); @@ -630,6 +636,7 @@ int main(int argc, char** argv) { { Profiler::Scope scope("LiveWorld::Update"); LiveWorld::Update(); } { Profiler::Scope scope("LiveUpdateRoutes::Update"); LiveUpdateRoutes::Update(); } { Profiler::Scope scope("Inspector::Update"); Inspector::Update(); } + { Profiler::Scope scope("CaptureReplay::Update"); CaptureReplay::Update(); } { Profiler::Scope scope("Announcements::Update"); Announcements::Update(); } { Profiler::Scope scope("EventsCalendar::Update"); EventsCalendar::Update(); } { Profiler::Scope scope("LiveEventRoutes::Update"); LiveEventRoutes::Update(); } @@ -655,6 +662,7 @@ int main(int argc, char** argv) { Game::web.Shutdown(); ApiKeyService::Flush(); Inspector::Shutdown(); + CaptureReplay::Shutdown(); EmailService::Shutdown(); ModeratorHelper::Shutdown(); Background::Shutdown(); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index 71fae6f53..b914c7123 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -47,6 +47,7 @@ set(DASHBOARDROUTES_SOURCES "ClientSysInfoView.cpp" "Strikes.cpp" "Inspector.cpp" + "CaptureReplay.cpp" "CDClientBrowser.cpp" "SettingsHistory.cpp" "Announcements.cpp" @@ -109,5 +110,9 @@ target_include_directories(DashboardRoutes PRIVATE "${PROJECT_SOURCE_DIR}/dGame/dUtilities" # ObjectIDManager.h ) -target_compile_definitions(DashboardRoutes PRIVATE PROJECT_VERSION="\"${PROJECT_VERSION}\"") +execute_process(COMMAND git rev-parse --short HEAD WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} OUTPUT_VARIABLE DLU_GIT_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) +if(NOT DLU_GIT_COMMIT) + set(DLU_GIT_COMMIT "unknown") +endif() +target_compile_definitions(DashboardRoutes PRIVATE PROJECT_VERSION="\"${PROJECT_VERSION}\"" DLU_GIT_COMMIT="${DLU_GIT_COMMIT}") target_link_libraries(DashboardRoutes PRIVATE ${COMMON_LIBRARIES} dWeb dServer dUgc sqlite3 OpenSSL::Crypto CURL::libcurl) diff --git a/dDashboardServer/routes/CaptureReplay.cpp b/dDashboardServer/routes/CaptureReplay.cpp new file mode 100644 index 000000000..19d30bd84 --- /dev/null +++ b/dDashboardServer/routes/CaptureReplay.cpp @@ -0,0 +1,665 @@ +#include "CaptureReplay.h" +#include "MasterPackets.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "BinaryPathFinder.h" +#include "CaptureBundle.h" +#include "CaptureTools.h" +#include "Database.h" +#include "DashboardRoutes.h" +#include "Game.h" +#include "LiveWorld.h" +#include "Logger.h" +#include "master/MessageCapture.h" +#include "PacketDecoder.h" +#include "Permissions.h" +#include "RouteUtils.h" +#include "Web.h" +#include "dConfig.h" +#include "dServer.h" +#include "eHTTPMethod.h" + +#ifndef DLU_GIT_COMMIT +#define DLU_GIT_COMMIT "unknown" +#endif + +using namespace RouteUtils; +namespace fs = std::filesystem; + +namespace { + using Clock = std::chrono::steady_clock; + using Session = IMessageCaptures::MessageCaptureSession; + using json = nlohmann::json; + + constexpr const char* TOPIC = "packet_capture"; + constexpr const char* PERMISSION = "dev_message_inspector"; + constexpr uint32_t DEFAULT_SECONDS = 300; + // Captures of everything at once (each gets every packet of every server) + constexpr size_t MAX_EVERYTHING = 1; + // Every server is told again this often (servers that started since, characters made since) + constexpr auto REARM_INTERVAL = std::chrono::seconds(10); + constexpr auto PUSH_INTERVAL = std::chrono::milliseconds(500); + constexpr auto SAVE_INTERVAL = std::chrono::seconds(5); + // Batches still on their way when a capture ends are kept this long + constexpr auto LATE_GRACE = std::chrono::seconds(15); + constexpr auto KEEP_ENDED = std::chrono::hours(1); + // Most recent packets pushed to open pages + constexpr size_t RECENT = 50; + constexpr uint32_t PAGE_DEFAULT = 500; + constexpr uint32_t PAGE_MAX = 2000; + + struct Capture { + Session session; + MessageCaptureControl control; + Clock::time_point until; + bool running{ true }; + Clock::time_point endedAt{}; + std::ofstream file; + std::string pending; // records waiting to be written + Clock::time_point lastWrite{}; + Clock::time_point nextArm{}; + Clock::time_point lastSave{}; + std::deque recent; + std::map perSource; + bool changed{}; + bool dirty{}; + }; + + std::map g_Captures; + Clock::time_point g_NextPush{}; + + // The last bundle read for the viewer (a running capture's file grows: read again when its size changed) + struct Cached { + uint64_t id{}; + uintmax_t size{}; + CaptureBundle::Bundle bundle; + int64_t startUs{}; + }; + Cached g_Cache; + + bool Connected() { return Game::server && Game::server->GetIsConnectedToMaster(); } + + int64_t Setting(const std::string& key, int64_t fallback) { + if (!Game::config) return fallback; + return GeneralUtils::TryParse(Game::config->GetValue(key)).value_or(fallback); + } + + const char* TargetName(eCaptureTarget target) { + switch (target) { + case eCaptureTarget::CHARACTER: return "character"; + case eCaptureTarget::ACCOUNT: return "account"; + default: return "everything"; + } + } + + std::string Describe(const Session& s) { + if (s.target == "everything") return "everything"; + if (s.target == "character") return s.characterName + " (character " + std::to_string(s.characterId) + ")"; + return s.accountName + " (account " + std::to_string(s.accountId) + ")"; + } + + json SessionJson(const Capture& c) { + const auto& s = c.session; + return { + {"id", s.id}, {"kind", 1}, {"target", s.target}, {"describe", Describe(s)}, + {"characterId", std::to_string(s.characterId)}, {"characterName", s.characterName}, + {"accountId", s.accountId}, {"accountName", s.accountName}, + {"startedBy", s.startedBy}, {"startedAt", s.startedAt}, {"endsAt", s.endsAt}, {"endedAt", s.endedAt}, + {"state", c.running ? "capturing" : "ended"}, {"endReason", s.endReason}, + {"received", s.messageCount}, {"bytes", s.byteCount}, {"dropped", s.dropped}, {"perSource", c.perSource}, + }; + } + + void SendControl(const MessageCaptureControl& control) { + if (Connected()) MasterPackets::SendToMaster(control); + } + + // Tell every server (again) what to record, for the time that is left + void Arm(Capture& c) { + const auto left = std::chrono::duration_cast(c.until - Clock::now()).count(); + if (left <= 0) return; + c.control.action = eMessageCaptureControl::ARM; + c.control.seconds = static_cast(left); + // Characters made since the last time belong to the account too + if (c.control.target == eCaptureTarget::ACCOUNT) { + try { + c.control.characterIds = Database::Get()->GetAccountCharacterIds(c.control.accountId); + } catch (const std::exception&) {} + } + SendControl(c.control); + c.nextArm = Clock::now() + REARM_INTERVAL; + } + + void Write(Capture& c) { + if (c.pending.empty()) return; + if (c.file.is_open()) { + c.file.write(c.pending.data(), static_cast(c.pending.size())); + c.file.flush(); + } + c.pending.clear(); + c.lastWrite = Clock::now(); + } + + void Save(Capture& c) { + if (!c.dirty) return; + try { + Database::Get()->UpdateMessageCaptureSession(c.session); + } catch (const std::exception& e) { + LOG("Couldn't save packet capture %llu: %s", c.session.id, e.what()); + } + c.dirty = false; + c.lastSave = Clock::now(); + } + + void End(Capture& c, const std::string& reason) { + if (!c.running) return; + c.running = false; + c.endedAt = Clock::now(); + c.session.endedAt = std::time(nullptr); + c.session.endReason = reason; + c.changed = c.dirty = true; + c.control.action = eMessageCaptureControl::DISARM; + SendControl(c.control); + Write(c); + Save(c); + LOG("Packet capture %llu of %s ended: %s", c.session.id, Describe(c.session).c_str(), reason.c_str()); + } + + void Close(Capture& c) { + Write(c); + if (c.file.is_open()) c.file.close(); + Save(c); + } + + void Push(Capture& c) { + if (!c.changed) return; + c.changed = false; + json recent = json::array(); + const auto start = c.session.startedAt * 1000000; + size_t i = c.session.messageCount - c.recent.size(); + for (const auto& record : c.recent) recent.push_back(CaptureTools::RecordJson(record, i++, start, false)); + c.recent.clear(); + json message{ {"capture", SessionJson(c)}, {"recent", recent} }; + Game::web.SendWSMessage(TOPIC, message); + } + + void Note(Capture& c, const PacketRecordHeader& header, std::string_view bytes) { + PacketRecord::Append(c.pending, header, bytes.data()); + c.session.messageCount++; + c.session.byteCount += sizeof(header) + header.length; + c.recent.push_back({ header, std::string(bytes) }); + if (c.recent.size() > RECENT) c.recent.pop_front(); + c.changed = c.dirty = true; + } + + const char* SourceKey(uint8_t source) { + switch (static_cast(source)) { + case eCaptureSource::AUTH: return "auth"; + case eCaptureSource::CHAT: return "chat"; + case eCaptureSource::WORLD: return "world"; + case eCaptureSource::MASTER: return "master"; + default: return "other"; + } + } + + Capture* Live(uint64_t id) { + const auto it = id <= UINT32_MAX ? g_Captures.find(static_cast(id)) : g_Captures.end(); + return it == g_Captures.end() ? nullptr : &it->second; + } + + // A packet capture's session from the path; writes the error when there is none + std::optional FindSession(const HTTPContext& context, HTTPReply& reply) { + const auto id = PathId(context.path, 3); + std::optional session; + if (id) { + if (auto* live = Live(*id)) { + Write(*live); + session = live->session; + } else { + session = Database::Get()->GetMessageCaptureSession(*id); + } + } + if (!session || session->kind != 1) { + JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such packet capture"); + return std::nullopt; + } + return session; + } + + // The capture's records on one timeline (read again when the file grew) + const Cached* Load(const Session& session, HTTPReply& reply) { + const auto path = CaptureReplay::FileOf(session.id); + std::error_code ec; + const auto size = fs::file_size(path, ec); + if (ec) { + JsonError(reply, eHTTPStatusCode::NOT_FOUND, "The capture's file is gone (deleted, or kept on another machine)"); + return nullptr; + } + if (g_Cache.id == session.id && g_Cache.size == size) return &g_Cache; + Cached cached; + std::string error; + if (!CaptureBundle::Load(path, cached.bundle, error)) { + JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, error); + return nullptr; + } + CaptureTools::SortTimeline(cached.bundle.records); + cached.id = session.id; + cached.size = size; + cached.startUs = cached.bundle.records.empty() ? session.startedAt * 1000000 : std::min(session.startedAt * 1000000, cached.bundle.records.front().header.timeUs); + g_Cache = std::move(cached); + return &g_Cache; + } + + json StoredSessionJson(const Session& s) { + if (const auto* live = Live(s.id)) return SessionJson(*live); + Capture view; + view.session = s; + view.running = false; + return SessionJson(view); + } + + // Checks and fills a new capture from the request; the error is written when it can't + bool Prepare(const json& body, Capture& c, HTTPReply& reply) { + const auto target = body.value("target", std::string{}); + auto& s = c.session; + auto& control = c.control; + if (target == "everything") { + control.target = eCaptureTarget::EVERYTHING; + } else if (target == "account") { + control.target = eCaptureTarget::ACCOUNT; + const auto text = body.value("account", std::string{}); + const auto number = GeneralUtils::TryParse(text); + json account = number ? Database::Get()->GetAccountById(*number) : json(); + if (!number) { + if (const auto info = Database::Get()->GetAccountInfo(text)) account = Database::Get()->GetAccountById(info->id); + } + if (!account.is_object() || !account.contains("id")) { + JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such account"); + return false; + } + control.accountId = account["id"].is_string() ? GeneralUtils::TryParse(account["id"].get()).value_or(0) : account["id"].get(); + control.accountName = account.value("name", std::string{}); + control.characterIds = Database::Get()->GetAccountCharacterIds(control.accountId); + } else if (target == "character") { + control.target = eCaptureTarget::CHARACTER; + const auto id = ResolveCharacter(body.value("character", std::string{})); + const auto info = id ? Database::Get()->GetCharacterInfo(*id) : std::nullopt; + if (!info) { + JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such character"); + return false; + } + control.characterIds = { info->id }; + control.accountId = info->accountId; + s.characterId = info->id; + s.characterName = info->name; + } else { + JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "target must be account, character or everything"); + return false; + } + if (control.accountId) { + s.accountId = control.accountId; + const auto account = Database::Get()->GetAccountById(control.accountId); + s.accountName = account.value("name", std::string{}); + // Account names are how logins and session keys are matched on the servers + control.accountName = s.accountName; + } + s.target = TargetName(control.target); + return true; + } +} + +namespace CaptureReplay { + fs::path Folder() { + auto folder = fs::path(Game::config ? Game::config->GetValue("capture_dir") : ""); + if (folder.empty()) folder = "captures"; + if (folder.is_relative()) folder = BinaryPathFinder::GetBinaryDir() / folder; + return folder; + } + + bool IsRunning(uint64_t sessionId) { + const auto* c = Live(sessionId); + return c && c->running; + } + + fs::path FileOf(uint64_t sessionId) { return Folder() / (std::to_string(sessionId) + ".bundle"); } + + void Initialize() { + // Packet captures don't carry on across a dashboard restart: the servers stop them at their time limit + try { + for (auto& session : Database::Get()->GetMessageCaptureSessions({ .unfinishedOnly = true, .ascending = true, .limit = 1000 })) { + if (session.kind != 1) continue; + session.endedAt = std::time(nullptr); + session.endReason = "The dashboard stopped while it ran"; + Database::Get()->UpdateMessageCaptureSession(session); + } + } catch (const std::exception& e) { + LOG("Couldn't end unfinished packet captures: %s", e.what()); + } + } + + void HandleData(const MessageCaptureData& data) { + std::set unknown; + const auto accept = [&](uint32_t id) -> Capture* { + auto* c = id ? Live(id) : nullptr; + if (c && (c->running || Clock::now() - c->endedAt < LATE_GRACE)) return c; + if (id) unknown.insert(id); + return nullptr; + }; + PacketRecord::ForEach(data.packets, [&](const PacketRecordHeader& header, std::string_view bytes) { + for (uint8_t bit = 0; bit < MessageCapture::MAX_SLOTS; bit++) { + if (!(header.mask & (1 << bit))) continue; + auto* c = accept(data.slots[bit]); + if (!c) continue; + auto own = header; + own.mask = 1; // one capture per file + Note(*c, own, bytes); + c->perSource[SourceKey(header.source)]++; + } + }); + // Lost on a server (its buffer was full): a gap in every capture it was recording for + if (data.packetsDropped) { + for (const auto id : data.slots) { + auto* c = accept(id); + if (!c) continue; + PacketRecordHeader gap; + gap.timeUs = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + gap.source = data.source; + gap.flags = PacketRecordFlags::GAP; + gap.bits = data.packetsDropped; + gap.zoneId = static_cast(data.zoneId); + gap.instanceId = static_cast(data.instanceId); + PacketRecord::Append(c->pending, gap, nullptr); + c->session.dropped += data.packetsDropped; + c->changed = c->dirty = true; + } + } + // A server still recording for a capture that ended here (it missed the disarm): tell it again + for (const auto id : unknown) { + for (uint8_t slot = 0; slot < MessageCapture::MAX_SLOTS; slot++) { + if (data.slots[slot] != id) continue; + MessageCaptureControl control; + control.action = eMessageCaptureControl::DISARM; + control.captureId = id; + control.slot = slot; + SendControl(control); + } + } + } + + void Update() { + if (g_Captures.empty()) return; + const auto now = Clock::now(); + const auto flushBytes = static_cast(Setting("capture_flush_bytes", 256 * 1024)); + const auto flushInterval = std::chrono::milliseconds(Setting("capture_flush_interval_ms", 1000)); + const bool push = now >= g_NextPush; + if (push) g_NextPush = now + PUSH_INTERVAL; + for (auto it = g_Captures.begin(); it != g_Captures.end();) { + auto& c = it->second; + if (c.running) { + if (now >= c.until) End(c, "Time limit reached"); + else if (now >= c.nextArm) Arm(c); + } + // One write per capture when enough is waiting or it waited long enough; never per packet + if (c.pending.size() >= flushBytes || (!c.pending.empty() && now - c.lastWrite >= flushInterval)) Write(c); + if (c.dirty && now - c.lastSave >= SAVE_INTERVAL) Save(c); + if (push) Push(c); + if (!c.running && c.file.is_open() && now - c.endedAt >= LATE_GRACE) Close(c); + if (!c.running && now - c.endedAt >= KEEP_ENDED) it = g_Captures.erase(it); + else ++it; + } + } + + void Shutdown() { + for (auto& [id, c] : g_Captures) { + if (c.running) End(c, "The dashboard stopped"); + Close(c); + } + } + + void RegisterRoutes() { + Game::web.RegisterWSSubscription(TOPIC, std::function([] { return Permissions::Level(PERMISSION); })); + + Route(eHTTPMethod::GET, "/api/inspector/targets", Perm(PERMISSION), + "Accounts and characters to capture, by part of an account or character name, or an account or character ID. Query: ?q=", + [](HTTPReply& reply, const HTTPContext& context) { + const auto q = QueryValue(context.queryString, "q"); + std::set online; + for (const auto& player : LiveWorld::OnlinePlayers()) online.insert(player.value("id", std::string{})); + json accounts = json::array(); + const auto add = [&](uint32_t accountId, const std::string& name) { + json characters = json::array(); + bool anyOnline = false; + for (const auto& character : Database::Get()->GetAccountCharacters(accountId)) { + const auto id = character.value("id", std::string{}); + const bool on = online.contains(id); + anyOnline |= on; + characters.push_back({ {"id", id}, {"name", character.value("name", std::string{})}, {"online", on} }); + } + accounts.push_back({ {"id", accountId}, {"name", name}, {"online", anyOnline}, {"characters", characters} }); + }; + // A pasted character object ID finds its account + const auto characterId = GeneralUtils::TryParse(q); + if (characterId && *characterId > UINT32_MAX) { + if (const auto info = Database::Get()->GetCharacterInfo(*characterId)) { + add(info->accountId, Database::Get()->GetAccountById(info->accountId).value("name", std::string{})); + } + } else { + const auto table = Database::Get()->GetAccountsTable(0, 10, q, 1, true); + for (const auto& row : table.value("data", json::array())) { + const auto id = row["id"].is_number() ? row["id"].get() : GeneralUtils::TryParse(row["id"].get()).value_or(0); + add(id, row.value("name", std::string{})); + } + } + JsonSuccess(reply, { {"accounts", accounts} }); + }); + + Route(eHTTPMethod::GET, "/api/inspector/packet-captures", Perm(PERMISSION), "Running and recently finished packet captures", + [](HTTPReply& reply, const HTTPContext&) { + json captures = json::array(); + for (auto it = g_Captures.rbegin(); it != g_Captures.rend(); ++it) captures.push_back(SessionJson(it->second)); + JsonSuccess(reply, { {"captures", captures}, {"maxSeconds", MessageCapture::MAX_SECONDS}, {"defaultSeconds", DEFAULT_SECONDS}, + {"slots", MessageCapture::MAX_SLOTS} }); + }); + + Route(eHTTPMethod::POST, "/api/inspector/packet-captures", Perm(PERMISSION), + "Arm a packet capture on every server (audited). Body: {target: account|character|everything, account (name or ID), character (name or ID), seconds}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto body = ParseBody(context); + if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); + const auto seconds = body->value("seconds", DEFAULT_SECONDS); + if (seconds < 1 || seconds > MessageCapture::MAX_SECONDS) { + return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "A capture can run from 1 second to " + std::to_string(MessageCapture::MAX_SECONDS / 60) + " minutes"); + } + Capture c; + if (!Prepare(*body, c, reply)) return; + + // A free slot (each running capture has one bit in the servers' records) + std::set taken; + size_t everything = 0; + for (const auto& [id, other] : g_Captures) { + if (!other.running) continue; + taken.insert(other.control.slot); + if (other.control.target == eCaptureTarget::EVERYTHING) everything++; + const bool same = other.control.target == c.control.target && other.control.accountId == c.control.accountId && + other.session.characterId == c.session.characterId; + if (same) return JsonError(reply, eHTTPStatusCode::CONFLICT, "That is already being captured (capture " + std::to_string(id) + ")"); + } + if (c.control.target == eCaptureTarget::EVERYTHING && everything >= MAX_EVERYTHING) { + return JsonError(reply, eHTTPStatusCode::CONFLICT, "Everything is already being captured"); + } + uint8_t slot = 0; + while (slot < MessageCapture::MAX_SLOTS && taken.contains(slot)) slot++; + if (slot == MessageCapture::MAX_SLOTS) return JsonError(reply, eHTTPStatusCode::CONFLICT, "At most " + std::to_string(MessageCapture::MAX_SLOTS) + " packet captures can run at once"); + if (!Connected()) return JsonError(reply, eHTTPStatusCode::SERVICE_UNAVAILABLE, "Not connected to the master server"); + + auto& s = c.session; + s.kind = 1; + s.startedById = context.accountId; + s.startedBy = context.authenticatedUser; + s.startedAt = std::time(nullptr); + s.endsAt = s.startedAt + seconds; + s.id = Database::Get()->InsertMessageCaptureSession(s); + if (s.id == 0 || s.id > UINT32_MAX) return JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Couldn't save the capture"); + + std::error_code ec; + fs::create_directories(CaptureReplay::Folder(), ec); + c.file.open(CaptureReplay::FileOf(s.id), std::ios::binary | std::ios::trunc); + if (!c.file) { + Database::Get()->DeleteMessageCaptureSession(s.id); + return JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Couldn't create the capture file in " + CaptureReplay::Folder().string()); + } + const json meta{ {"format", CaptureBundle::FORMAT_VERSION}, {"origin", "dlu-capture"}, {"captureId", s.id}, {"target", s.target}, + {"server", { {"version", PROJECT_VERSION}, {"commit", DLU_GIT_COMMIT} }}, {"startedAt", s.startedAt}, {"portable", false} }; + const auto header = CaptureBundle::Header(meta); + c.file.write(header.data(), static_cast(header.size())); + c.file.flush(); + + c.control.captureId = static_cast(s.id); + c.control.slot = slot; + c.until = Clock::now() + std::chrono::seconds(seconds); + c.lastWrite = c.lastSave = Clock::now(); + Arm(c); + const auto summary = SessionJson(c); + const auto target = s.characterId ? AuditTarget::Character(s.characterId) : AuditTarget::Account(s.accountId); + Audit(context, "arm_packet_capture", "Armed a packet capture of " + Describe(s) + " for " + std::to_string(seconds) + " s (capture " + + std::to_string(s.id) + ")", target); + g_Captures.emplace(c.control.captureId, std::move(c)); + JsonSuccess(reply, { {"capture", summary} }); + }); + + Route(eHTTPMethod::POST, "/api/inspector/packet-captures/:id/stop", Perm(PERMISSION), "Stop a running packet capture", + [](HTTPReply& reply, const HTTPContext& context) { + const auto id = PathId(context.path, 3); + auto* c = id ? Live(*id) : nullptr; + if (!c || !c->running) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such running packet capture"); + End(*c, "Stopped by " + context.authenticatedUser); + Push(*c); + Audit(context, "stop_packet_capture", "Stopped the packet capture of " + Describe(c->session) + " (capture " + std::to_string(c->session.id) + ")"); + JsonSuccess(reply, { {"capture", SessionJson(*c)} }); + }); + + Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/packets", Perm(PERMISSION), + "A packet capture's packets on one timeline, decoded. Query: ?offset=&limit=<1-2000>&q=&source=auth|chat|world|master&fields=0", + [](HTTPReply& reply, const HTTPContext& context) { + const auto session = FindSession(context, reply); + if (!session) return; + const auto* cached = Load(*session, reply); + if (!cached) return; + const auto offset = GeneralUtils::TryParse(QueryValue(context.queryString, "offset")).value_or(0); + const auto limit = std::clamp(GeneralUtils::TryParse(QueryValue(context.queryString, "limit")).value_or(PAGE_DEFAULT), 1, PAGE_MAX); + auto q = QueryValue(context.queryString, "q"); + for (auto& ch : q) ch = static_cast(std::toupper(static_cast(ch))); + const auto source = QueryValue(context.queryString, "source"); + const bool fields = QueryValue(context.queryString, "fields") != "0"; + json records = json::array(); + size_t matched = 0; + const auto& all = cached->bundle.records; + for (size_t i = 0; i < all.size(); i++) { + if (q.empty() && source.empty()) { + if (i < offset) continue; + if (records.size() >= limit) break; + records.push_back(CaptureTools::RecordJson(all[i], i, cached->startUs, fields)); + continue; + } + auto record = CaptureTools::RecordJson(all[i], i, cached->startUs, false); + if (!source.empty() && record.value("source", std::string{}) != source) continue; + if (!q.empty() && record.value("name", std::string{}).find(q) == std::string::npos) continue; + if (matched++ < offset || records.size() >= limit) continue; + records.push_back(fields ? CaptureTools::RecordJson(all[i], i, cached->startUs, true) : record); + } + JsonSuccess(reply, { {"capture", StoredSessionJson(*session)}, {"total", all.size()}, {"matched", q.empty() && source.empty() ? all.size() : matched}, + {"start", cached->startUs / 1000}, {"duration", all.empty() ? 0.0 : static_cast(all.back().header.timeUs - cached->startUs) / 1000.0}, + {"records", records} }); + }); + + Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/packets/:index", Perm(PERMISSION), "One captured packet with its bytes", + [](HTTPReply& reply, const HTTPContext& context) { + const auto session = FindSession(context, reply); + if (!session) return; + const auto* cached = Load(*session, reply); + if (!cached) return; + const auto index = PathId(context.path, 5); + if (!index || *index >= cached->bundle.records.size()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such packet"); + const auto& record = cached->bundle.records[*index]; + auto out = CaptureTools::RecordJson(record, *index, cached->startUs, true); + out["hex"] = MessageCapture::ToHex(record.bytes); + JsonSuccess(reply, { {"record", out} }); + }); + + Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/positions", Perm(PERMISSION), + "Where the captured characters moved, in the World 3D replay's shape. Query: ?zone= (default: the first zone with movement)", + [](HTTPReply& reply, const HTTPContext& context) { + const auto session = FindSession(context, reply); + if (!session) return; + const auto* cached = Load(*session, reply); + if (!cached) return; + const auto tracks = CaptureTools::Tracks(cached->bundle.records, cached->startUs); + std::set zones; + for (const auto& t : tracks) zones.insert(t.zoneId); + auto zone = GeneralUtils::TryParse(QueryValue(context.queryString, "zone")).value_or(zones.empty() ? 0 : *zones.begin()); + json players = json::array(); + for (const auto& t : tracks) { + if (t.zoneId != zone) continue; + const auto info = t.characterId ? Database::Get()->GetCharacterInfo(t.characterId) : std::nullopt; + players.push_back({ {"id", std::to_string(t.characterId) + ":" + std::to_string(t.instanceId)}, {"name", info ? info->name : "Character " + std::to_string(t.characterId)}, + {"instances", json::array({ t.instanceId })}, {"samples", t.samples} }); + } + const auto from = cached->startUs / 1000000; + const auto to = cached->bundle.records.empty() ? from + 1 : cached->bundle.records.back().header.timeUs / 1000000 + 1; + JsonSuccess(reply, { {"zone", zone}, {"zones", zones}, {"from", from}, {"to", to}, {"players", players}, + {"idleSeconds", 3}, {"bucket", 1}, {"interval", 1}, {"truncated", false} }); + }); + + Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/bundle", Perm(PERMISSION), + "Export a packet capture as a portable bundle for the capture tool (audited). Query: ?anonymise=1 also blanks character names and chat", + [](HTTPReply& reply, const HTTPContext& context) { + const auto session = FindSession(context, reply); + if (!session) return; + const auto* cached = Load(*session, reply); + if (!cached) return; + const bool anonymise = QueryValue(context.queryString, "anonymise") == "1"; + auto bundle = cached->bundle; + bundle.meta["origin"] = "dlu-capture"; + bundle.meta["exportedAt"] = std::time(nullptr); + bundle.meta["server"] = { {"version", PROJECT_VERSION}, {"commit", DLU_GIT_COMMIT} }; + // Zone and client data checksums seen in the capture, so a replay on other data is reported, not diffed + json zones = json::object(); + for (const auto& record : bundle.records) { + const auto decoded = PacketDecoder::Decode(record.bytes, CaptureTools::FromClient(record.header)); + if (!decoded.fields) continue; + if (decoded.name == "LOAD_STATIC_ZONE") zones[std::to_string((*decoded.fields)["mapID"].get())] = (*decoded.fields)["mapChecksum"]; + if (decoded.name == "VALIDATION") bundle.meta["fdbChecksum"] = (*decoded.fields)["fdbChecksum"]; + } + bundle.meta["zones"] = zones; + const auto characters = CaptureTools::MakePortable(bundle); + if (anonymise) CaptureTools::Anonymise(bundle); + // The setup section: what a replay needs to make these characters on another server (their saved data, + // without the account it belonged to) + json setup = json::array(); + static const std::regex account(R"( acct="[0-9]+")"); + for (const auto& [symbol, id] : characters) { + const auto info = Database::Get()->GetCharacterInfo(id); + if (!info) continue; + auto xml = Database::Get()->GetCharacterXml(id); + xml = std::regex_replace(xml, account, ""); + setup.push_back({ {"symbol", symbol}, {"placeholder", bundle.meta["ids"][symbol]["placeholder"]}, {"name", anonymise ? symbol : info->name}, {"xml", xml} }); + } + bundle.meta["setup"] = { {"characters", setup} }; + std::string out = CaptureBundle::Header(bundle.meta); + for (const auto& record : bundle.records) CaptureBundle::AppendRecord(out, record); + Audit(context, "export_packet_capture", "Exported the packet capture of " + Describe(*session) + (anonymise ? " (anonymised)" : "") + " as a bundle", + session->characterId ? AuditTarget::Character(session->characterId) : AuditTarget::Account(session->accountId)); + reply.status = eHTTPStatusCode::OK; + reply.message = std::move(out); + reply.contentType = eContentType::APPLICATION_OCTET_STREAM; + reply.headers.push_back("Content-Disposition: attachment; filename=\"capture_" + std::to_string(session->id) + (anonymise ? "_anonymised" : "") + ".bundle\""); + }); + } +} diff --git a/dDashboardServer/routes/CaptureReplay.h b/dDashboardServer/routes/CaptureReplay.h new file mode 100644 index 000000000..076e02d63 --- /dev/null +++ b/dDashboardServer/routes/CaptureReplay.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +struct MessageCaptureData; + +/** + * Packet captures and their replay on the dashboard (docs/CaptureReplay.md), next to the game message inspector + * (Inspector.h) and under the same permission (dev_message_inspector). + * + * Staff arm a capture for an account (from its next login, or at once if it is online), one character, or + * everything; every server records its part (PacketCapture.h) and sends batches through master. The dashboard keeps + * each capture's session in message_capture_sessions (capture_kind 1) and its packets in a bundle file under + * capture_dir (CaptureBundle.h), written once per batch, never per packet. Viewing decodes the saved bytes with the + * server's packet structs (PacketDecoder.h); positions feed the World 3D replay; a capture exports as a portable + * bundle for the capture tool's replay. + */ +namespace CaptureReplay { + // After the database is up: captures that were running when the dashboard stopped are marked ended + void Initialize(); + + void RegisterRoutes(); + + // MESSAGE_CAPTURE_DATA with status PACKETS, via master + void HandleData(const MessageCaptureData& data); + + // Main loop: writes batches, pushes to browsers, re-arms and ends captures on time + void Update(); + + // Writes what is waiting and closes the files + void Shutdown(); + + // Whether a packet capture is still recording + bool IsRunning(uint64_t sessionId); + + // Where capture files are kept (capture_dir, relative to the server's folder), and one capture's file + std::filesystem::path Folder(); + std::filesystem::path FileOf(uint64_t sessionId); +} diff --git a/dDashboardServer/routes/Inspector.cpp b/dDashboardServer/routes/Inspector.cpp index a28fdfdba..660458fb8 100644 --- a/dDashboardServer/routes/Inspector.cpp +++ b/dDashboardServer/routes/Inspector.cpp @@ -12,6 +12,8 @@ #include "DashboardRoutes.h" #include "Game.h" #include "InspectorFormat.h" +#include "CaptureReplay.h" +#include #include "Logger.h" #include "master/MessageCapture.h" #include "MessageType/Master.h" @@ -152,7 +154,9 @@ namespace { {"toClient", session.toClient}, {"only", InspectorFormat::MessageNames(InspectorFormat::ParseIds(session.onlyMessages))}, {"skip", InspectorFormat::MessageNames(InspectorFormat::ParseIds(session.skipMessages))}, - {"lastSeq", session.messageCount} + {"lastSeq", session.messageCount}, + {"kind", session.kind}, + {"target", session.target} }; } @@ -263,6 +267,7 @@ namespace { } const char* StateOf(const Session& session) { + if (session.kind == 1) return CaptureReplay::IsRunning(session.id) ? "capturing" : "ended"; const auto* capture = Live(session.id); return capture ? StateName(capture->state) : "ended"; } @@ -278,7 +283,8 @@ namespace { const auto maxMb = Setting("inspector_max_mb", DEFAULT_MAX_MB); run->Log("inspector_session_days = " + (days > 0 ? std::to_string(days) : "0 (no age limit)")); run->Log("inspector_max_mb = " + (maxMb > 0 ? std::to_string(maxMb) : "0 (no size limit)")); - const bool queued = Background::Run("message_capture_pruning", [days, maxMb](GameDatabase& db) -> nlohmann::json { + const auto folder = CaptureReplay::Folder(); + const bool queued = Background::Run("message_capture_pruning", [days, maxMb, folder](GameDatabase& db) -> nlohmann::json { std::vector stored; uint64_t total = 0; for (uint32_t offset = 0;; offset += 1000) { @@ -295,6 +301,9 @@ namespace { const auto it = std::ranges::find(stored, id, &InspectorFormat::StoredSession::id); if (it != stored.end()) freed += it->bytes; db.DeleteMessageCaptureSession(id); + // A packet capture's packets are in its file (nothing there for a game message capture) + std::error_code ec; + std::filesystem::remove(folder / (std::to_string(id) + ".bundle"), ec); } return { {"sessions", stored.size()}, {"deleted", expired.size()}, {"total", total}, {"freed", freed} }; }, [run](nlohmann::json result, const std::string& error) { @@ -360,6 +369,7 @@ namespace Inspector { LOG("Couldn't read unfinished message captures: %s", e.what()); } for (auto& session : unfinished) { + if (session.kind != 0) continue; // packet captures: CaptureReplay const bool taken = std::ranges::any_of(g_Captures, [&](const auto& entry) { return entry.second.session.characterId == session.characterId; }); if (session.endsAt > now && session.id <= UINT32_MAX && g_Captures.size() < MAX_RUNNING && !taken) { Capture capture; @@ -659,7 +669,12 @@ namespace Inspector { if (const auto* capture = Live(session->id); capture && capture->state != eState::ENDED) { return JsonError(reply, eHTTPStatusCode::CONFLICT, "Stop the capture before deleting it"); } + if (session->kind == 1 && CaptureReplay::IsRunning(session->id)) return JsonError(reply, eHTTPStatusCode::CONFLICT, "Stop the capture before deleting it"); Database::Get()->DeleteMessageCaptureSession(session->id); + if (session->kind == 1) { + std::error_code ec; + std::filesystem::remove(CaptureReplay::FileOf(session->id), ec); + } g_Captures.erase(static_cast(session->id)); const auto reason = body->value("reason", std::string{}); Audit(context, "delete_message_capture", "Deleted the saved game messages of " + Describe(*session) + (reason.empty() ? "" : ": " + reason), diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index bf47b4009..0548d0851 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -429,6 +429,13 @@ namespace { c.Add(Days("inspector_session_days", "Keep captures for", "0: no age limit.", "30")); c.Add(Unit(Int(DASHBOARD, "inspector_max_mb", "At most", "When all saved captures together take more, the oldest are deleted. A busy 15 minute capture can take 50 MB. 0: no size limit.", "1024", 0, 1000000), "MB")); + c.AddSection("Packet capture", "How servers record packets for the dashboard's packet captures (docs/CaptureReplay.md). Nothing is written to disk per packet: " + "each server buffers records in memory and sends a batch when either limit below is reached; the dashboard writes one batch at a time to the capture's file."); + c.Add(Unit(Int(SHARED, "capture_flush_interval_ms", "Send at least every", "A server sends what it recorded this often, even when the batch is small.", "1000", 50, 60000), "ms")); + c.Add(Unit(Int(SHARED, "capture_flush_bytes", "Or when a batch reaches", "A server sends a batch as soon as it is this large.", "262144", 4096, 4194304), "bytes")); + c.Add(Unit(Int(SHARED, "capture_buffer_max_mb", "Keep at most", "Batches a server keeps while master can't take them; past this the oldest are dropped and the capture shows a gap.", "16", 1, 1024), "MB")); + c.Add(Format(Text(DASHBOARD, "capture_dir", "Capture files", "Folder for packet capture files, relative to the server binaries. Captures are player data: keep it out of any repository.", "captures"), eFormat::PATH)); + c.AddSection("Economy history"); c.Add(Days("economy_detail_days", "Daily detail", "Older daily rows are merged into months.", "180", 31)); c.Add(Days("economy_map_days", "Map detail", "", "90", 31)); diff --git a/dDatabase/GameDatabase/ITables/IMessageCaptures.h b/dDatabase/GameDatabase/ITables/IMessageCaptures.h index 94da96fa4..c84d902c5 100644 --- a/dDatabase/GameDatabase/ITables/IMessageCaptures.h +++ b/dDatabase/GameDatabase/ITables/IMessageCaptures.h @@ -38,6 +38,8 @@ public: uint64_t messageCount{}; uint64_t byteCount{}; // stored bytes (raw bytes plus decoded fields) uint64_t dropped{}; // messages the worlds left out (too many at once) + uint8_t kind{}; // 0: game messages (message_capture_entries); 1: packets (a capture file, docs/CaptureReplay.md) + std::string target; // packets: "character", "account" or "everything" }; // One captured message diff --git a/dDatabase/GameDatabase/MySQL/Tables/MessageCaptures.cpp b/dDatabase/GameDatabase/MySQL/Tables/MessageCaptures.cpp index 000bb5e07..14ed46950 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/MessageCaptures.cpp +++ b/dDatabase/GameDatabase/MySQL/Tables/MessageCaptures.cpp @@ -55,16 +55,18 @@ namespace { s.messageCount = r->getUInt64("message_count"); s.byteCount = r->getUInt64("byte_count"); s.dropped = r->getUInt64("dropped"); + s.kind = static_cast(r->getUInt("capture_kind")); + s.target = r->getString("capture_target").c_str(); return s; } } uint64_t MySQLDatabase::InsertMessageCaptureSession(const MessageCaptureSession& s) { ExecuteInsert("INSERT INTO message_capture_sessions (character_id, character_name, account_id, account_name, started_by_id, started_by, started_at, ends_at, " - "ended_at, end_reason, to_server, to_client, only_messages, skip_messages, zone_id, instance_id, clone_id, zones, message_count, byte_count, dropped) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + "ended_at, end_reason, to_server, to_client, only_messages, skip_messages, zone_id, instance_id, clone_id, zones, message_count, byte_count, dropped, capture_kind, capture_target) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", s.characterId, s.characterName, s.accountId, s.accountName, s.startedById, s.startedBy, s.startedAt, s.endsAt, s.endedAt, s.endReason, - s.toServer, s.toClient, s.onlyMessages, s.skipMessages, s.zoneId, s.instanceId, s.cloneId, s.zones, s.messageCount, s.byteCount, s.dropped); + s.toServer, s.toClient, s.onlyMessages, s.skipMessages, s.zoneId, s.instanceId, s.cloneId, s.zones, s.messageCount, s.byteCount, s.dropped, static_cast(s.kind), s.target); auto last = ExecuteSelect("SELECT LAST_INSERT_ID() AS id;"); // this connection's insert return last->next() ? last->getUInt64("id") : 0; } diff --git a/dDatabase/GameDatabase/SQLite/Tables/MessageCaptures.cpp b/dDatabase/GameDatabase/SQLite/Tables/MessageCaptures.cpp index a0c54e392..6d7cc11a7 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/MessageCaptures.cpp +++ b/dDatabase/GameDatabase/SQLite/Tables/MessageCaptures.cpp @@ -56,16 +56,18 @@ namespace { s.messageCount = static_cast(r.getInt64Field("message_count")); s.byteCount = static_cast(r.getInt64Field("byte_count")); s.dropped = static_cast(r.getInt64Field("dropped")); + s.kind = static_cast(r.getIntField("capture_kind")); + s.target = r.getStringField("capture_target"); return s; } } uint64_t SQLiteDatabase::InsertMessageCaptureSession(const MessageCaptureSession& s) { ExecuteInsert("INSERT INTO message_capture_sessions (character_id, character_name, account_id, account_name, started_by_id, started_by, started_at, ends_at, " - "ended_at, end_reason, to_server, to_client, only_messages, skip_messages, zone_id, instance_id, clone_id, zones, message_count, byte_count, dropped) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + "ended_at, end_reason, to_server, to_client, only_messages, skip_messages, zone_id, instance_id, clone_id, zones, message_count, byte_count, dropped, capture_kind, capture_target) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", s.characterId, s.characterName, s.accountId, s.accountName, s.startedById, s.startedBy, s.startedAt, s.endsAt, s.endedAt, s.endReason, - s.toServer, s.toClient, s.onlyMessages, s.skipMessages, s.zoneId, s.instanceId, s.cloneId, s.zones, s.messageCount, s.byteCount, s.dropped); + s.toServer, s.toClient, s.onlyMessages, s.skipMessages, s.zoneId, s.instanceId, s.cloneId, s.zones, s.messageCount, s.byteCount, s.dropped, static_cast(s.kind), s.target); auto [_, last] = ExecuteSelect("SELECT last_insert_rowid() AS id;"); // this connection's insert return last.eof() ? 0 : static_cast(last.getInt64Field("id")); } diff --git a/dGame/UserManager.cpp b/dGame/UserManager.cpp index d60022e0e..0a339e7d0 100644 --- a/dGame/UserManager.cpp +++ b/dGame/UserManager.cpp @@ -1,4 +1,5 @@ #include "UserManager.h" +#include "PacketCapture.h" #include "ChatServerLink.h" #include "ChatPackets.h" #include "DashboardNotify.h" @@ -123,8 +124,13 @@ UserManager::~UserManager() { User* UserManager::CreateUser(const SystemAddress& sysAddr, const std::string& username, const std::string& sessionKey) { User* user = new User(sysAddr, username, sessionKey); - if (user && Game::server->IsConnected(sysAddr)) + if (user && Game::server->IsConnected(sysAddr)) { m_Users.insert(std::make_pair(sysAddr, user)); + PacketCapture::Bind(sysAddr, user->GetAccountID(), username); + // In a zone the user comes with the character they play + const auto* character = Game::server->GetZoneID() != 0 ? user->GetLastUsedChar() : nullptr; + if (character) PacketCapture::BindCharacter(sysAddr, character->GetID()); + } else { if (user) { delete user; @@ -576,6 +582,7 @@ void UserManager::LoginCharacter(const SystemAddress& sysAddr, LWOOBJID playerID if (hasCharacter && character) { Database::Get()->UpdateLastLoggedInCharacter(playerID); + PacketCapture::BindCharacter(sysAddr, playerID); uint32_t zoneID = character->GetZoneID(); if (zoneID == LWOZONEID_INVALID) zoneID = 1000; //Send char to VE diff --git a/dGame/dUtilities/MessageInspector.cpp b/dGame/dUtilities/MessageInspector.cpp index 67e39839c..291232b64 100644 --- a/dGame/dUtilities/MessageInspector.cpp +++ b/dGame/dUtilities/MessageInspector.cpp @@ -165,6 +165,7 @@ namespace MessageInspector { bool g_Capturing = false; void Control(const MessageCaptureControl& control) { + if (control.action != eMessageCaptureControl::START && control.action != eMessageCaptureControl::STOP) return; const auto existing = std::ranges::find_if(g_Captures, [&](const Capture& c) { return c.control.captureId == control.captureId; }); if (control.action == eMessageCaptureControl::STOP) { if (existing != g_Captures.end()) End(existing - g_Captures.begin(), eMessageCaptureEnd::STOPPED); diff --git a/dMasterServer/MasterServer.cpp b/dMasterServer/MasterServer.cpp index e2827079f..96645fb6a 100644 --- a/dMasterServer/MasterServer.cpp +++ b/dMasterServer/MasterServer.cpp @@ -1,5 +1,6 @@ #include "Profiler.h" #include "master/PlayerAction.h" +#include "PacketCapture.h" #include "master/DashboardMessages.h" #include #include @@ -559,10 +560,18 @@ int main(int argc, char** argv) { constexpr uint32_t spareCheckTime = 10 * masterFramerate; uint32_t framesSinceSpareCheck = 0; + // Master's own packet captures go straight to the dashboard + PacketCapture::SetSink([](MessageCaptureData& data) { + if (dashboardServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS) return false; + MasterPackets::SendTo(dashboardServerMasterPeerSysAddr, data); + return true; + }); + Game::logger->Flush(); while (!Game::ShouldShutdown()) { Profiler::BeginFrame(); //In world we'd update our other systems here. + PacketCapture::Update(); //Check for packets here: packet = Game::server->Receive(); @@ -747,6 +756,8 @@ namespace { case ServiceType::DASHBOARD: dashboardServerMasterPeerSysAddr = sysAddr; g_DashboardConnects++; + // Its traffic isn't a player's; packet captures leave it out + PacketCapture::IgnorePeer(sysAddr); break; case ServiceType::UGC: ugcServerMasterPeerSysAddr = sysAddr; @@ -1129,6 +1140,20 @@ namespace { for (const auto& instance : Game::im->GetInstances()) { if (instance && instance->GetIsReady() && !instance->GetIsShuttingDown()) MasterPackets::SendTo(instance->GetSysAddr(), control); } + // Packet captures (ARM, DISARM) run on every server, master included + if (control.action == eMessageCaptureControl::ARM || control.action == eMessageCaptureControl::DISARM) { + for (const auto& peer : { authServerMasterPeerSysAddr, chatServerMasterPeerSysAddr }) { + if (peer != UNASSIGNED_SYSTEM_ADDRESS) MasterPackets::SendTo(peer, control); + } + PacketCapture::Control(control); + } + } + + // Captured messages and packets: from worlds, and (packet captures) from auth and chat + void OnMessageCaptureData(const MessageCaptureData& data, const SystemAddress& sysAddr) { + if (dashboardServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS) return; + const bool known = Game::im->GetInstanceBySysAddr(sysAddr) || sysAddr == authServerMasterPeerSysAddr || sysAddr == chatServerMasterPeerSysAddr; + if (known) MasterPackets::SendTo(dashboardServerMasterPeerSysAddr, data); } void OnRequestServerList(const RequestServerList& request, const SystemAddress& sysAddr) { @@ -1168,7 +1193,7 @@ namespace { // Only world servers report game writes; pass them on unchanged handlers.On(Master::DATA_CHANGED, ForwardWorldToDashboard); handlers.On(Master::MESSAGE_CAPTURE_CONTROL, OnMessageCaptureControl); - handlers.On(Master::MESSAGE_CAPTURE_DATA, ForwardWorldToDashboard); + handlers.On(Master::MESSAGE_CAPTURE_DATA, OnMessageCaptureData); handlers.On(Master::REQUEST_SERVER_LIST, OnRequestServerList); handlers.On(Master::SERVER_TRAFFIC, OnServerTraffic); handlers.On(Master::PROFILE_REQUEST, OnProfileRequest); diff --git a/dNet/AuthPackets.cpp b/dNet/AuthPackets.cpp index 3843c1667..199fe9014 100644 --- a/dNet/AuthPackets.cpp +++ b/dNet/AuthPackets.cpp @@ -1,4 +1,5 @@ #include "AuthPackets.h" +#include "PacketCapture.h" #include "MasterPackets.h" #include @@ -144,6 +145,8 @@ void AuthPackets::LoginRequest::Handle() { stamps.Add(eStamps::PASSPORT_AUTH_DB_SELECT_START); auto accountInfo = Database::Get()->GetAccountInfo(username); stamps.Add(eStamps::PASSPORT_AUTH_DB_SELECT_FINISH, accountInfo ? 1 : 0); + // The dashboard's packet capture: this connection is this account's (failed logins included) + PacketCapture::Bind(sysAddr, accountInfo ? accountInfo->id : 0, username); if (!accountInfo) { LOG("No user by name %s found!", username.c_str()); diff --git a/dNet/CMakeLists.txt b/dNet/CMakeLists.txt index df29ff401..179c940a2 100644 --- a/dNet/CMakeLists.txt +++ b/dNet/CMakeLists.txt @@ -6,6 +6,9 @@ set(DNET_SOURCES "AuthPackets.cpp" "dServer.cpp" "MailInfo.cpp" "MasterPackets.cpp" + "PacketCapture.cpp" + "PacketDecoder.cpp" + "CaptureTools.cpp" "Stamps.cpp" "WorldPackets.cpp" "WorldRoutePacket.cpp" diff --git a/dNet/CaptureBundle.h b/dNet/CaptureBundle.h new file mode 100644 index 000000000..3b7b45093 --- /dev/null +++ b/dNet/CaptureBundle.h @@ -0,0 +1,104 @@ +#ifndef __CAPTUREBUNDLE__H__ +#define __CAPTUREBUNDLE__H__ + +#include +#include +#include +#include +#include +#include +#include + +#include "json.hpp" +#include "PacketRecord.h" + +/** + * The packet bundle file format (docs/CaptureReplay.md), shared by the dashboard's capture files, exported bundles, + * bundles converted from other captures, and the capture tool: + * + * "DLUBNDL1" 8 bytes, the format and its version + * u32 metadata length little endian + * metadata UTF-8 JSON (see docs/CaptureReplay.md: origin, server version, zones and checksums, + * the setup section, how ids are written) + * records PacketRecordHeader + bytes, one after another to the end of the file + * + * The dashboard appends records to a capture's file while it runs (one write per batch), so the metadata is written + * once, when the file is made. + */ +namespace CaptureBundle { + constexpr std::string_view MAGIC{ "DLUBNDL1", 8 }; + constexpr uint32_t FORMAT_VERSION = 1; + constexpr uint32_t MAX_METADATA = 64 * 1024 * 1024; + + struct Record { + PacketRecordHeader header; + std::string bytes; + }; + + struct Bundle { + nlohmann::json meta = nlohmann::json::object(); + std::vector records; + }; + + inline std::string Header(const nlohmann::json& meta) { + const auto text = meta.dump(); + std::string out(MAGIC); + const auto length = static_cast(text.size()); + out.append(reinterpret_cast(&length), sizeof(length)); + out += text; + return out; + } + + inline void AppendRecord(std::string& out, const Record& record) { + auto header = record.header; + header.length = static_cast(record.bytes.size()); + PacketRecord::Append(out, header, record.bytes.data()); + } + + // Parses a whole file's contents. A file cut short in its last record (a capture still being written) keeps the + // records before it; `error` is set for anything else. + inline bool Parse(std::string_view data, Bundle& bundle, std::string& error, bool* truncated = nullptr) { + if (data.size() < MAGIC.size() + 4 || data.substr(0, MAGIC.size()) != MAGIC) { + error = "Not a packet bundle (DLUBNDL1)"; + return false; + } + uint32_t length{}; + std::memcpy(&length, data.data() + MAGIC.size(), sizeof(length)); + const size_t start = MAGIC.size() + 4; + if (length > MAX_METADATA || data.size() - start < length) { + error = "The bundle's metadata is cut short"; + return false; + } + bundle.meta = nlohmann::json::parse(data.substr(start, length), nullptr, false); + if (bundle.meta.is_discarded() || !bundle.meta.is_object()) { + error = "The bundle's metadata isn't JSON"; + return false; + } + bundle.records.clear(); + const bool whole = PacketRecord::ForEach(data.substr(start + length), [&](const PacketRecordHeader& header, std::string_view bytes) { + bundle.records.push_back({ header, std::string(bytes) }); + }); + if (truncated) *truncated = !whole; + return true; + } + + inline bool Load(const std::filesystem::path& path, Bundle& bundle, std::string& error, bool* truncated = nullptr) { + std::ifstream file(path, std::ios::binary); + if (!file) { + error = "Can't open " + path.string(); + return false; + } + std::string data((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + return Parse(data, bundle, error, truncated); + } + + inline bool Save(const std::filesystem::path& path, const Bundle& bundle) { + std::string out = Header(bundle.meta); + for (const auto& record : bundle.records) AppendRecord(out, record); + std::ofstream file(path, std::ios::binary | std::ios::trunc); + file.write(out.data(), static_cast(out.size())); + return static_cast(file); + } +} + +#endif //!__CAPTUREBUNDLE__H__ diff --git a/dNet/CaptureTools.cpp b/dNet/CaptureTools.cpp new file mode 100644 index 000000000..1cb42d185 --- /dev/null +++ b/dNet/CaptureTools.cpp @@ -0,0 +1,253 @@ +#include "CaptureTools.h" + +#include +#include +#include + +#include "PacketDecoder.h" +#include "ServiceType.h" + +namespace { + using json = nlohmann::json; + using Record = CaptureBundle::Record; + + const char* SourceName(uint8_t source) { + switch (static_cast(source)) { + case eCaptureSource::AUTH: return "auth"; + case eCaptureSource::CHAT: return "chat"; + case eCaptureSource::WORLD: return "world"; + case eCaptureSource::MASTER: return "master"; + default: return "?"; + } + } + + // Who is on each end, as the viewer shows it + std::pair Ends(const PacketRecordHeader& h) { + std::string server = SourceName(h.source); + if (h.source == static_cast(eCaptureSource::WORLD) && h.zoneId) server += " " + std::to_string(h.zoneId) + ":" + std::to_string(h.instanceId); + std::string other; + if (h.flags & PacketRecordFlags::MASTER_LINK) other = "master"; + else if (h.source == static_cast(eCaptureSource::CHAT) || h.source == static_cast(eCaptureSource::MASTER)) other = "server"; + else other = "client"; + if (h.flags & PacketRecordFlags::BROADCAST) other = "everyone"; + return h.direction == static_cast(ePacketDirection::RECEIVED) ? std::pair{ other, server } : std::pair{ server, other }; + } + + void ReplaceAll(std::string& bytes, int64_t from, int64_t to) { + if (from == 0 || from == to) return; + char a[8], b[8]; + std::memcpy(a, &from, 8); + std::memcpy(b, &to, 8); + const std::string_view needle(a, 8); + for (size_t at = bytes.find(needle); at != std::string::npos; at = bytes.find(needle, at + 8)) std::memcpy(bytes.data() + at, b, 8); + } + + // Remove volatile fields from decoded JSON, recursively + void Strip(json& value) { + if (value.is_object()) { + for (auto it = value.begin(); it != value.end();) { + if (CaptureTools::IsVolatileField(it.key())) it = value.erase(it); + else { + Strip(it.value()); + ++it; + } + } + } else if (value.is_array()) { + for (auto& item : value) Strip(item); + } + } + + std::string NameOf(const Record& record) { + return PacketDecoder::Decode(record.bytes, CaptureTools::FromClient(record.header)).name; + } +} + +namespace CaptureTools { + void SortTimeline(std::vector& records) { + std::stable_sort(records.begin(), records.end(), [](const Record& a, const Record& b) { + if (a.header.timeUs != b.header.timeUs) return a.header.timeUs < b.header.timeUs; + if (a.header.source != b.header.source) return a.header.source < b.header.source; + return a.header.seq < b.header.seq; + }); + } + + bool FromClient(const PacketRecordHeader& h) { + const auto source = static_cast(h.source); + return h.direction == static_cast(ePacketDirection::RECEIVED) && !(h.flags & PacketRecordFlags::MASTER_LINK) && + (source == eCaptureSource::AUTH || source == eCaptureSource::WORLD); + } + + bool IsVolatileField(const std::string& name) { + static const std::set fields{ + // Made by the server each run + "objectID", "objectId", "lootID", "lootOwnerID", "requestID", "i64LocalID", "uiSkillHandle", "uiBehaviorHandle", + // Time and where things run + "timestamp", "stamps", "instanceID", "instanceId", "zoneInstance", "cloneID", "zoneClone", "serverIP", "serverPort", + "worldServerIP", "worldServerPort", "processID", "port", + // Per account on each server + "playerID", "targetID", "senderID", "username", + }; + return fields.contains(name); + } + + json RecordJson(const Record& record, size_t index, int64_t startUs, bool fields) { + const auto& h = record.header; + json out{ + {"i", index}, + {"t", static_cast(h.timeUs - startUs) / 1000.0}, + {"time", h.timeUs / 1000}, + {"source", SourceName(h.source)}, + {"seq", h.seq}, + {"zone", h.zoneId}, {"instance", h.instanceId}, {"clone", h.cloneId}, + {"account", h.accountId}, + {"character", std::to_string(h.characterId)}, + {"peer", PacketRecord::PeerText(h.peer)}, + {"bits", h.bits}, + {"bytes", record.bytes.size()}, + {"cut", (h.flags & PacketRecordFlags::CUT) != 0}, + }; + if (h.flags & PacketRecordFlags::GAP) { + out["gap"] = h.bits; + out["name"] = "(" + std::to_string(h.bits) + " packets lost)"; + return out; + } + const auto [from, to] = Ends(h); + out["from"] = from; + out["to"] = to; + out["toServer"] = FromClient(h); + const auto decoded = PacketDecoder::Decode(record.bytes, FromClient(h)); + out["service"] = decoded.service; + out["name"] = decoded.name; + if (decoded.gameMessageId >= 0) { + out["gameMessage"] = decoded.gameMessageId; + out["object"] = std::to_string(decoded.objectId); + } + if (decoded.failed) out["unreadable"] = true; + if (fields && decoded.fields) out["fields"] = *decoded.fields; + return out; + } + + std::vector Tracks(const std::vector& records, int64_t startUs) { + std::vector tracks; + for (const auto& record : records) { + if (!FromClient(record.header) || record.header.source != static_cast(eCaptureSource::WORLD)) continue; + const auto position = PacketDecoder::Position(record.bytes); + if (!position) continue; + const auto& h = record.header; + auto it = std::ranges::find_if(tracks, [&](const Track& t) { return t.characterId == h.characterId && t.zoneId == h.zoneId && t.instanceId == h.instanceId; }); + if (it == tracks.end()) { + tracks.push_back({ h.characterId, h.zoneId, h.instanceId, {} }); + it = tracks.end() - 1; + } + it->samples.insert(it->samples.end(), { static_cast(h.timeUs - startUs) / 1e6f, position->x, position->y, position->z }); + } + return tracks; + } + + std::map MakePortable(CaptureBundle::Bundle& bundle) { + std::map characters; + std::map accounts; + for (const auto& record : bundle.records) { + if (record.header.characterId && !characters.contains(record.header.characterId)) { + characters[record.header.characterId] = "char#" + std::to_string(characters.size() + 1); + } + if (record.header.accountId && !accounts.contains(record.header.accountId)) accounts[record.header.accountId] = static_cast(accounts.size() + 1); + } + json ids = json::object(); + std::map found; + std::map placeholders; + size_t n = 0; + for (const auto& [id, symbol] : characters) { + const auto placeholder = PLACEHOLDER_BASE + static_cast(++n); + placeholders[id] = placeholder; + ids[symbol] = { {"kind", "character"}, {"placeholder", std::to_string(placeholder)} }; + found[symbol] = id; + } + for (const auto& [id, number] : accounts) ids["account#" + std::to_string(number)] = { {"kind", "account"}, {"placeholder", number} }; + for (auto& record : bundle.records) { + for (const auto& [id, placeholder] : placeholders) ReplaceAll(record.bytes, id, placeholder); + if (record.header.characterId) record.header.characterId = placeholders[record.header.characterId]; + if (record.header.accountId) record.header.accountId = accounts[record.header.accountId]; + record.header.peer = 0; + PacketDecoder::Scrub(record.bytes, false); + record.header.length = static_cast(record.bytes.size()); + } + bundle.meta["ids"] = ids; + bundle.meta["portable"] = true; + return found; + } + + size_t Anonymise(CaptureBundle::Bundle& bundle) { + size_t changed = 0; + for (auto& record : bundle.records) { + if (PacketDecoder::Scrub(record.bytes, true)) changed++; + record.header.length = static_cast(record.bytes.size()); + record.header.peer = 0; + } + bundle.meta["anonymised"] = true; + return changed; + } + + json DiffReport::ToJson() const { + return { {"expected", expected}, {"matched", matched}, {"differing", differing}, {"missing", missing}, {"extra", extra}, + {"differingByName", differingByName}, {"missingByName", missingByName}, {"extraByName", extraByName}, {"examples", examples} }; + } + + DiffReport Diff(const std::vector& expected, const std::vector& actual) { + DiffReport report; + // Server -> client packets only (what the server answered) + const auto answers = [](const std::vector& records) { + std::vector out; + for (const auto& r : records) { + if (r.header.flags & (PacketRecordFlags::GAP | PacketRecordFlags::MASTER_LINK)) continue; + const auto source = static_cast(r.header.source); + if ((source == eCaptureSource::AUTH || source == eCaptureSource::WORLD) && r.header.direction == static_cast(ePacketDirection::SENT)) out.push_back(&r); + } + return out; + }; + const auto want = answers(expected), got = answers(actual); + report.expected = want.size(); + std::vector used(got.size()); + size_t from = 0; + for (const auto* w : want) { + const auto name = NameOf(*w); + // The next unused answer with the same name, looking a little ahead so one missing packet doesn't shift everything + size_t found = got.size(); + for (size_t i = from; i < got.size() && i < from + 200; i++) { + if (!used[i] && NameOf(*got[i]) == name) { + found = i; + break; + } + } + if (found == got.size()) { + report.missing++; + report.missingByName[name]++; + continue; + } + used[found] = true; + while (from < used.size() && used[from]) from++; + auto a = PacketDecoder::Decode(w->bytes, false).fields.value_or(json()); + auto b = PacketDecoder::Decode(got[found]->bytes, false).fields.value_or(json()); + Strip(a); + Strip(b); + // Packets without decoded fields compare by size + const bool same = a.is_null() && b.is_null() ? w->bytes.size() == got[found]->bytes.size() : a == b; + if (same) { + report.matched++; + } else { + report.differing++; + report.differingByName[name]++; + if (report.examples.size() < 20) { + report.examples.push_back(name + ": recorded " + (a.is_null() ? std::to_string(w->bytes.size()) + " bytes" : a.dump()).substr(0, 300) + + " / replayed " + (b.is_null() ? std::to_string(got[found]->bytes.size()) + " bytes" : b.dump()).substr(0, 300)); + } + } + } + for (size_t i = 0; i < got.size(); i++) { + if (used[i]) continue; + report.extra++; + report.extraByName[NameOf(*got[i])]++; + } + return report; + } +} diff --git a/dNet/CaptureTools.h b/dNet/CaptureTools.h new file mode 100644 index 000000000..f810d087b --- /dev/null +++ b/dNet/CaptureTools.h @@ -0,0 +1,74 @@ +#ifndef __CAPTURETOOLS__H__ +#define __CAPTURETOOLS__H__ + +#include +#include +#include +#include +#include + +#include "CaptureBundle.h" +#include "dCommonVars.h" +#include "json.hpp" + +/** + * What the dashboard's capture viewer and the capture tool do with recorded packets (docs/CaptureReplay.md): + * describe them, order them on one timeline, pull the player's movement out, make a bundle portable or anonymous, + * and compare a replay's answers with the recorded ones. Pure functions over records, so they are unit tested. + */ +namespace CaptureTools { + // The records of all servers on one timeline: by time, then by server and its sequence + void SortTimeline(std::vector& records); + + // Whether a record went from a game client to a server + bool FromClient(const PacketRecordHeader& header); + + // One record for the viewer: where it went, its name and, when `fields`, its decoded fields + nlohmann::json RecordJson(const CaptureBundle::Record& record, size_t index, int64_t startUs, bool fields); + + struct Track { + LWOOBJID characterId{}; + uint32_t zoneId{}; + uint32_t instanceId{}; + std::vector samples; // t (seconds from the capture's start), x, y, z, ... + }; + // Where each captured character moved (their POSITION_UPDATEs), per zone and instance + std::vector Tracks(const std::vector& records, int64_t startUs); + + /** + * Makes a bundle portable: the source server's character and account IDs are replaced by placeholders + * (PLACEHOLDER_BASE + n, written in the records' bytes and headers), listed in meta.ids as "char#n" / "account#n"; + * account names and session fields are already blank (they are never recorded). Returns the characters found, by + * symbol, with their source ID, for the setup section. + */ + constexpr int64_t PLACEHOLDER_BASE = 0x1FEDC00000000000LL; + std::map MakePortable(CaptureBundle::Bundle& bundle); + + // Blanks what players typed and names (chat text, character and account names) in every packet whose struct is + // known, so a bundle can be kept as a test fixture. Returns how many packets were changed. + size_t Anonymise(CaptureBundle::Bundle& bundle); + + /** + * A replay's answers against the recorded ones. Server->client packets are paired in order by name; paired + * packets are compared by their decoded fields, leaving out what legitimately differs between runs (object IDs + * the server makes, timestamps, session keys, instance and clone IDs, server addresses). + */ + struct DiffReport { + size_t expected{}; + size_t matched{}; // same fields + size_t differing{}; // same packet, different fields + size_t missing{}; // recorded, not answered in the replay + size_t extra{}; // answered in the replay, not recorded + std::map differingByName; + std::map missingByName; + std::map extraByName; + std::vector examples; // the first few differences, readable + nlohmann::json ToJson() const; + }; + DiffReport Diff(const std::vector& expected, const std::vector& actual); + + // Fields left out of comparisons (by name, in any packet) + bool IsVolatileField(const std::string& name); +} + +#endif //!__CAPTURETOOLS__H__ diff --git a/dNet/PacketCapture.cpp b/dNet/PacketCapture.cpp new file mode 100644 index 000000000..55f1937c0 --- /dev/null +++ b/dNet/PacketCapture.cpp @@ -0,0 +1,613 @@ +#include "PacketCapture.h" + +#include +#include +#include +#include +#include +#include + +#include "BitStream.h" +#include "BitStreamUtils.h" +#include "dConfig.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "Logger.h" +#include "MasterPackets.h" +#include "PacketDecoder.h" +#include "master/InstanceMigration.h" +#include "master/MessageCapture.h" +#include "MessageIdentifiers.h" +#include "MessageType/Master.h" +#include "MessageType/Auth.h" +#include "MessageType/Client.h" +#include "RakPeer.h" +#include "RakPeerInterface.h" +#include "ServiceType.h" +#include "dServer.h" + +// Servers always have a logger; tests may not +#define CAPTURE_LOG(...) do { if (Game::logger) LOG(__VA_ARGS__); } while (0) + +namespace { + using Clock = std::chrono::steady_clock; + using Master = MessageType::Master; + + // Packets kept per connection before it is known whose it is (a login, a world's session check), for account + // captures only: when the account turns out to be captured they are added in front + constexpr size_t PENDING_RECORDS = 32; + constexpr size_t PENDING_BYTES = 64 * 1024; + constexpr auto PENDING_FOR = std::chrono::seconds(60); + // Zone transfer requests remembered to tie their answer to the player + constexpr size_t MAX_REQUESTS = 256; + + struct Slot { + bool armed{}; + uint32_t captureId{}; + eCaptureTarget target{}; + uint32_t accountId{}; + std::string accountName; + std::vector characterIds; + Clock::time_point until{}; + }; + + struct Binding { + uint32_t accountId{}; + std::string accountName; + LWOOBJID characterId{}; + uint8_t mask{}; // account and character slots this connection belongs to + }; + + struct Pending { + std::string records; + size_t count{}; + Clock::time_point since{}; + }; + + struct Chunk { + std::string records; + std::array slots{}; + uint32_t count{}; + }; + + ServiceType g_ServerType{}; + eCaptureSource g_Source{}; + RakPeerInterface* g_Peer{}; + RakPeerInterface* g_MasterLink{}; + uint16_t g_Zone{}, g_Instance{}; + uint32_t g_Clone{}; + std::vector g_Ignored; + + std::array g_Slots; + uint8_t g_EverythingMask{}; // slots capturing everything + uint8_t g_SubjectMask{}; // slots capturing an account or a character + uint8_t g_AccountMask{}; // slots capturing an account + + std::unordered_map g_Bindings; + std::vector g_Unbind; // connections that closed; forgotten at the next receive + std::unordered_map g_Pending; + std::map g_Requests; // zone transfer request -> mask + uint64_t g_Scope{}; // connection whose packet is being handled + + PacketCapture::Settings g_Settings; + Chunk g_Chunk; + std::deque g_Sealed; + uint64_t g_SealedBytes{}; + uint32_t g_Dropped{}; // since the last batch + uint32_t g_Seq{}; + Clock::time_point g_ChunkStarted{}; + PacketCapture::Sink g_Sink; + PacketCapture::Stats g_Stats; + + uint64_t Key(const SystemAddress& address) { + return (static_cast(address.binaryAddress) << 16) | address.port; + } + + int64_t NowUs() { + return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + } + + bool SameName(const std::string& a, const std::string& b) { + return !a.empty() && GeneralUtils::CaseInsensitiveStringCompare(a, b); + } + + // The account and character slots a connection's owner belongs to + uint8_t MaskFor(uint32_t accountId, const std::string& accountName, LWOOBJID characterId) { + uint8_t mask = 0; + for (uint8_t i = 0; i < g_Slots.size(); i++) { + const auto& slot = g_Slots[i]; + if (!slot.armed || slot.target == eCaptureTarget::EVERYTHING) continue; + const bool character = characterId != 0 && std::ranges::find(slot.characterIds, characterId) != slot.characterIds.end(); + const bool account = (accountId != 0 && accountId == slot.accountId) || SameName(accountName, slot.accountName); + if (slot.target == eCaptureTarget::CHARACTER ? character : (account || character)) mask |= 1 << i; + } + return mask; + } + + void Remask() { + g_EverythingMask = g_SubjectMask = g_AccountMask = 0; + for (uint8_t i = 0; i < g_Slots.size(); i++) { + const auto& slot = g_Slots[i]; + if (!slot.armed) continue; + if (slot.target == eCaptureTarget::EVERYTHING) g_EverythingMask |= 1 << i; + else g_SubjectMask |= 1 << i; + if (slot.target == eCaptureTarget::ACCOUNT) g_AccountMask |= 1 << i; + } + for (auto& [key, binding] : g_Bindings) binding.mask = MaskFor(binding.accountId, binding.accountName, binding.characterId); + } + + void Seal() { + if (g_Chunk.count == 0) return; + for (size_t i = 0; i < g_Slots.size(); i++) g_Chunk.slots[i] = g_Slots[i].armed ? g_Slots[i].captureId : 0; + g_SealedBytes += g_Chunk.records.size(); + g_Sealed.push_back(std::move(g_Chunk)); + g_Chunk = Chunk{}; + g_Chunk.records.reserve(g_Settings.flushBytes + 4096); + g_ChunkStarted = Clock::now(); + // Over the cap: the oldest go first, and the dashboard is told how many + while (g_SealedBytes > g_Settings.maxBufferBytes && g_Sealed.size() > 1) { + g_SealedBytes -= g_Sealed.front().records.size(); + g_Dropped += g_Sealed.front().count; + g_Stats.dropped += g_Sealed.front().count; + g_Sealed.pop_front(); + } + } + + void Append(const PacketRecordHeader& header, const unsigned char* data) { + PacketRecord::Append(g_Chunk.records, header, data); + g_Chunk.count++; + g_Stats.recorded++; + g_Stats.recordedBytes += sizeof(header) + header.length; + if (g_Chunk.records.size() >= g_Settings.flushBytes) Seal(); + } + + PacketRecordHeader Header(ePacketDirection direction, uint8_t flags, uint64_t peer, uint32_t bits) { + PacketRecordHeader header; + header.timeUs = NowUs(); + header.seq = ++g_Seq; + header.source = static_cast(g_Source); + header.direction = static_cast(direction); + header.flags = flags; + header.peer = peer; + header.zoneId = g_Zone; + header.instanceId = g_Instance; + header.cloneId = g_Clone; + header.bits = bits; + const auto bytes = (bits + 7) / 8; + header.length = std::min(bytes, PacketRecord::MAX_BYTES); + if (header.length < bytes) header.flags |= PacketRecordFlags::CUT; + return header; + } + + // An LU packet's service and message ID, if it is one + bool LuHeader(const unsigned char* data, uint32_t bits, ServiceType& service, uint32_t& id) { + if (bits < 64 || data[0] != ID_USER_PACKET_ENUM) return false; + uint16_t rawService; + std::memcpy(&rawService, data + 1, sizeof(rawService)); + std::memcpy(&id, data + 3, sizeof(id)); + service = static_cast(rawService); + return true; + } + + // Never record the capture's own traffic + bool IsCaptureTraffic(const unsigned char* data, uint32_t bits) { + ServiceType service; + uint32_t id; + return LuHeader(data, bits, service, id) && service == ServiceType::MASTER && + (id == static_cast(Master::MESSAGE_CAPTURE_CONTROL) || id == static_cast(Master::MESSAGE_CAPTURE_DATA)); + } + + // Chat: every chat packet between worlds and chat starts with the player's object ID + LWOOBJID ChatSubject(const unsigned char* data, uint32_t bits) { + ServiceType service; + uint32_t id; + if (!LuHeader(data, bits, service, id) || bits < 128) return 0; + LWOOBJID subject; + std::memcpy(&subject, data + 8, sizeof(subject)); + return subject; + } + + /** + * Secrets never reach a record (PacketDecoder::Redact): packets that carry them are rewritten with them blanked, + * into `scratch`, and dropped if they don't read. Auth keeps only the handshake and the login request and + * response (both redacted). False: don't record it. + */ + bool Prepare(const unsigned char*& data, uint32_t& bits, std::string& scratch) { + ServiceType service; + uint32_t id; + if (!LuHeader(data, bits, service, id)) return true; + if (g_Source == eCaptureSource::AUTH && service != ServiceType::COMMON && + !(service == ServiceType::AUTH && id == static_cast(MessageType::Auth::LOGIN_REQUEST)) && + !(service == ServiceType::CLIENT && id == static_cast(MessageType::Client::LOGIN_RESPONSE))) return false; + if (!PacketDecoder::HasSecrets(service, id)) return true; + scratch.assign(reinterpret_cast(data), (bits + 7) / 8); + if (!PacketDecoder::Redact(scratch)) return false; + data = reinterpret_cast(scratch.data()); + bits = static_cast(scratch.size() * 8); + return true; + } + + // A packet on the listening peer: who it belongs to, and so which captures keep it + void RecordMain(const SystemAddress& address, ePacketDirection direction, bool broadcast, const unsigned char* data, uint32_t bits) { + if (bits < 8 || IsCaptureTraffic(data, bits)) return; + std::string scratch; + if (!Prepare(data, bits, scratch)) return; + const auto key = Key(address); + if (!g_Ignored.empty() && std::ranges::find(g_Ignored, key) != g_Ignored.end()) return; + + // Master: server-to-server traffic belongs to EVERYTHING captures only (the other servers record what belongs to a player) + if (g_Source == eCaptureSource::MASTER) { + if (!g_EverythingMask) return; + auto header = Header(direction, broadcast ? PacketRecordFlags::BROADCAST : 0, key, bits); + header.mask = g_EverythingMask; + Append(header, data); + return; + } + + uint8_t mask = g_EverythingMask; + uint32_t accountId = 0; + LWOOBJID characterId = 0; + if (g_SubjectMask) { + if (g_Source == eCaptureSource::CHAT) { + characterId = ChatSubject(data, bits); + if (characterId) mask |= MaskFor(0, "", characterId); + } else if (broadcast) { + // Everyone but `address`: the captured players it reaches + for (const auto& [bound, binding] : g_Bindings) { + if (binding.mask && bound != key) { + mask |= binding.mask; + accountId = binding.accountId; + characterId = binding.characterId; + } + } + } else if (const auto it = g_Bindings.find(key); it != g_Bindings.end()) { + mask |= it->second.mask; + accountId = it->second.accountId; + characterId = it->second.characterId; + } else if (g_AccountMask && g_Source != eCaptureSource::UNKNOWN) { + // Not known yet whose it is: keep a few until a login says + auto& pending = g_Pending[key]; + if (pending.count == 0) pending.since = Clock::now(); + auto header = Header(direction, 0, key, bits); + if (pending.count < PENDING_RECORDS && pending.records.size() + sizeof(header) + header.length <= PENDING_BYTES) { + PacketRecord::Append(pending.records, header, data); + pending.count++; + } + } + } + if (!mask) return; + auto header = Header(direction, broadcast ? PacketRecordFlags::BROADCAST : 0, key, bits); + header.mask = mask; + header.accountId = accountId; + header.characterId = characterId; + Append(header, data); + } + + // A master link message: which captured players it belongs to + uint8_t MasterLinkMask(ePacketDirection direction, const unsigned char* data, uint32_t bits, uint32_t& accountId, LWOOBJID& characterId) { + ServiceType service; + uint32_t id; + if (!LuHeader(data, bits, service, id) || service != ServiceType::MASTER) return 0; + RakNet::BitStream stream(const_cast(data), (bits + 7) / 8, false); + LUBitStream header; + if (!header.ReadHeader(stream)) return 0; + + const auto scope = g_Bindings.find(g_Scope); + const auto* scoped = g_Scope && scope != g_Bindings.end() && scope->second.mask ? &scope->second : nullptr; + const auto byName = [&](const std::string& name) { return MaskFor(0, name, 0); }; + uint8_t everyone = 0; + for (const auto& [key, binding] : g_Bindings) everyone |= binding.mask; + + switch (static_cast(id)) { + case Master::MESSAGE_CAPTURE_CONTROL: + case Master::MESSAGE_CAPTURE_DATA: + case Master::PLAYER_POSITIONS: + case Master::DATA_CHANGED: + case Master::PLAYER_ACTION_RESULT: + return 0; + case Master::REQUEST_SESSION_KEY: { + MasterPackets::RequestSessionKey msg; + return msg.Deserialize(stream) ? byName(msg.username.GetAsString()) : 0; + } + case Master::SESSION_KEY_RESPONSE: { + MasterPackets::SessionKeyResponse msg; + return msg.Deserialize(stream) ? byName(msg.username.GetAsString()) : 0; + } + case Master::SET_SESSION_KEY: { + MasterPackets::SetSessionKey msg; + return msg.Deserialize(stream) ? byName(msg.username.string) : 0; + } + case Master::NEW_SESSION_ALERT: { + MasterPackets::NewSessionAlert msg; + return msg.Deserialize(stream) ? byName(msg.username.string) : 0; + } + case Master::REQUEST_ZONE_TRANSFER: { + MasterPackets::RequestZoneTransfer msg; + if (!scoped || !msg.Deserialize(stream)) return 0; + if (g_Requests.size() >= MAX_REQUESTS) g_Requests.erase(g_Requests.begin()); + g_Requests[msg.requestID] = scoped->mask; + accountId = scoped->accountId; + characterId = scoped->characterId; + return scoped->mask; + } + case Master::REQUEST_ZONE_TRANSFER_RESPONSE: { + MasterPackets::RequestZoneTransferResponse msg; + if (!msg.Deserialize(stream)) return 0; + const auto it = g_Requests.find(msg.requestID); + if (it == g_Requests.end()) return 0; + const auto mask = it->second & (g_SubjectMask); + g_Requests.erase(it); + return mask; + } + // Instance-wide: they concern every captured player in this world + case Master::INSTANCE_MIGRATE: + case Master::MIGRATE_PLAYERS: + case Master::MIGRATE_STATUS: + case Master::MIGRATE_PLAYER_STATE: + case Master::SHUTDOWN: + case Master::AFFIRM_TRANSFER_REQUEST: + case Master::AFFIRM_TRANSFER_RESPONSE: + return everyone; + default: + // Anything else sent while handling a captured player's packet (player added or removed, ...) + if (direction == ePacketDirection::SENT && scoped) { + accountId = scoped->accountId; + characterId = scoped->characterId; + return scoped->mask; + } + return 0; + } + } + + void RecordMasterLink(ePacketDirection direction, const unsigned char* data, uint32_t bits) { + if (!g_SubjectMask || bits < 64) return; + uint32_t accountId = 0; + LWOOBJID characterId = 0; + const auto mask = MasterLinkMask(direction, data, bits, accountId, characterId); + if (!mask) return; + std::string scratch; + if (!Prepare(data, bits, scratch)) return; + auto header = Header(direction, PacketRecordFlags::MASTER_LINK, 0, bits); + header.mask = mask; + header.accountId = accountId; + header.characterId = characterId; + Append(header, data); + } + + void OnSend(RakPeerInterface* peer, const unsigned char* data, BitSize_t bits, SystemAddress address, bool broadcast) { + if (!PacketCapture::g_Armed || !data) return; + if (peer == g_Peer) RecordMain(address, ePacketDirection::SENT, broadcast, data, static_cast(bits)); + else if (peer == g_MasterLink && g_MasterLink) RecordMasterLink(ePacketDirection::SENT, data, static_cast(bits)); + } + + void SetArmed(bool armed) { + PacketCapture::g_Armed = armed; + g_RakPeerSendHook = armed ? &OnSend : nullptr; + if (!armed) { + g_Pending.clear(); + g_Requests.clear(); + } + } + + void ReadSettings() { + if (!Game::config) return; + PacketCapture::Settings settings; + settings.flushIntervalMs = std::clamp(GeneralUtils::TryParse(Game::config->GetValue("capture_flush_interval_ms")).value_or(1000), 50, 60000); + settings.flushBytes = std::clamp(GeneralUtils::TryParse(Game::config->GetValue("capture_flush_bytes")).value_or(256 * 1024), 4096, 4 * 1024 * 1024); + settings.maxBufferBytes = std::clamp(GeneralUtils::TryParse(Game::config->GetValue("capture_buffer_max_mb")).value_or(16), 1, 1024) * 1024 * 1024; + PacketCapture::SetSettings(settings); + } + + bool SendToMaster(MessageCaptureData& data) { + if (!Game::server || !Game::server->GetIsConnectedToMaster()) return false; + MasterPackets::SendToMaster(data); + return true; + } +} + +namespace PacketCapture { + bool g_Armed = false; + bool g_Tracking = false; + + void Attach(ServiceType serverType, RakPeerInterface* peer, RakPeerInterface* masterLink, uint32_t zoneId, uint32_t instanceId) { + g_ServerType = serverType; + switch (serverType) { + case ServiceType::AUTH: g_Source = eCaptureSource::AUTH; break; + case ServiceType::CHAT: g_Source = eCaptureSource::CHAT; break; + case ServiceType::WORLD: g_Source = eCaptureSource::WORLD; break; + case ServiceType::MASTER: g_Source = eCaptureSource::MASTER; break; + default: g_Source = eCaptureSource::UNKNOWN; break; + } + g_Peer = peer; + g_MasterLink = serverType == ServiceType::MASTER ? nullptr : masterLink; + g_Zone = static_cast(zoneId); + g_Instance = static_cast(instanceId); + } + + void Detach() { + Reset(); + g_Peer = g_MasterLink = nullptr; + } + + void SetClone(uint32_t cloneId) { g_Clone = cloneId; } + + void IgnorePeer(const SystemAddress& address) { + const auto key = Key(address); + if (std::ranges::find(g_Ignored, key) == g_Ignored.end()) g_Ignored.push_back(key); + } + + void SetSink(Sink sink) { g_Sink = std::move(sink); } + + void SetSettings(const Settings& settings) { + g_Settings = settings; + if (g_Chunk.records.capacity() < g_Settings.flushBytes) g_Chunk.records.reserve(g_Settings.flushBytes + 4096); + } + + const Stats& GetStats() { return g_Stats; } + + void Control(const MessageCaptureControl& control) { + if (control.action != eMessageCaptureControl::ARM && control.action != eMessageCaptureControl::DISARM) return; + if (control.slot >= g_Slots.size()) return; + auto& slot = g_Slots[control.slot]; + const bool sameCapture = slot.armed && slot.captureId == control.captureId; + if (control.action == eMessageCaptureControl::DISARM) { + if (!sameCapture) return; + Seal(); + slot = Slot{}; + CAPTURE_LOG("Packet capture %u ended here", control.captureId); + } else { + if (control.seconds == 0) return; + // The slot changes hands: what was recorded so far goes out under the old capture + if (!sameCapture) Seal(); + if (!sameCapture) CAPTURE_LOG("Packet capture %u armed here (%s) for %u seconds", control.captureId, + control.target == eCaptureTarget::EVERYTHING ? "everything" : control.target == eCaptureTarget::ACCOUNT ? "an account" : "a character", control.seconds); + slot.armed = true; + slot.captureId = control.captureId; + slot.target = control.target; + slot.accountId = control.accountId; + slot.accountName = control.accountName; + slot.characterIds = control.characterIds; + slot.until = Clock::now() + std::chrono::seconds(std::min(control.seconds, MessageCapture::MAX_SECONDS)); + ReadSettings(); + if (g_ChunkStarted == Clock::time_point{}) g_ChunkStarted = Clock::now(); + } + Remask(); + // Stays armed while chunks wait to be sent, so Update keeps sending them + SetArmed(std::ranges::any_of(g_Slots, &Slot::armed) || !g_Sealed.empty() || g_Chunk.count); + } + + void OnReceiveImpl(const Packet* packet) { + // Closed connections are forgotten once their disconnect was handled (a world still names the player then) + if (!g_Unbind.empty()) { + for (const auto key : g_Unbind) { + g_Bindings.erase(key); + g_Pending.erase(key); + } + g_Unbind.clear(); + g_Tracking = !g_Bindings.empty(); + } + if (!packet) { + g_Scope = 0; + return; + } + g_Scope = Key(packet->systemAddress); + if (packet->length >= 1 && (packet->data[0] == ID_DISCONNECTION_NOTIFICATION || packet->data[0] == ID_CONNECTION_LOST)) g_Unbind.push_back(g_Scope); + if (!g_Armed) return; + RecordMain(packet->systemAddress, ePacketDirection::RECEIVED, false, packet->data, static_cast(packet->bitSize)); + } + + void OnReceiveFromMasterImpl(const Packet* packet) { + if (!packet || !g_MasterLink) return; + RecordMasterLink(ePacketDirection::RECEIVED, packet->data, static_cast(packet->bitSize)); + } + + void Bind(const SystemAddress& address, uint32_t accountId, const std::string& accountName) { + const auto key = Key(address); + auto& binding = g_Bindings[key]; + g_Tracking = true; + binding.accountId = accountId; + binding.accountName = accountName; + binding.mask = MaskFor(accountId, accountName, binding.characterId); + + // What came before the login, now that it is known to be a captured account's + const auto pending = g_Pending.find(key); + if (pending == g_Pending.end()) return; + const auto mask = binding.mask & g_AccountMask; + if (mask) { + PacketRecord::ForEach(pending->second.records, [&](PacketRecordHeader header, std::string_view bytes) { + header.mask = mask; + header.accountId = accountId; + Append(header, reinterpret_cast(bytes.data())); + }); + } + g_Pending.erase(pending); + } + + void BindCharacter(const SystemAddress& address, LWOOBJID characterId) { + auto& binding = g_Bindings[Key(address)]; + g_Tracking = true; + binding.characterId = characterId; + binding.mask = MaskFor(binding.accountId, binding.accountName, characterId); + } + + void Update() { + if (!g_Armed) return; + const auto now = Clock::now(); + + // Captures past their time end here on their own + bool changed = false; + for (auto& slot : g_Slots) { + if (slot.armed && now >= slot.until) { + Seal(); + CAPTURE_LOG("Packet capture %u reached its time limit here", slot.captureId); + slot = Slot{}; + changed = true; + } + } + if (changed) Remask(); + + std::erase_if(g_Pending, [now](const auto& entry) { return now - entry.second.since > PENDING_FOR; }); + + if (g_Chunk.count && (g_Chunk.records.size() >= g_Settings.flushBytes || now - g_ChunkStarted >= std::chrono::milliseconds(g_Settings.flushIntervalMs))) Seal(); + if (g_Chunk.count == 0) g_ChunkStarted = now; + + while (!g_Sealed.empty() || g_Dropped) { + MessageCaptureData data; + data.status = eMessageCaptureStatus::PACKETS; + data.source = static_cast(g_Source); + data.zoneId = g_Zone; + data.instanceId = g_Instance; + data.cloneId = g_Clone; + data.packetsDropped = g_Dropped; + if (!g_Sealed.empty()) { + auto& chunk = g_Sealed.front(); + data.slots = chunk.slots; + data.packetCount = chunk.count; + data.packets = std::move(chunk.records); + } else { + for (size_t i = 0; i < g_Slots.size(); i++) data.slots[i] = g_Slots[i].armed ? g_Slots[i].captureId : 0; + } + const bool sent = g_Sink ? g_Sink(data) : SendToMaster(data); + if (!sent) { + // Put it back and try again next time + if (!g_Sealed.empty()) g_Sealed.front().records = std::move(data.packets); + break; + } + g_Stats.batches++; + g_Stats.sentBytes += data.packets.size(); + g_Dropped = 0; + if (!g_Sealed.empty()) { + g_SealedBytes -= std::min(g_SealedBytes, data.packets.size()); + g_Sealed.pop_front(); + } + } + + if (!std::ranges::any_of(g_Slots, &Slot::armed) && g_Sealed.empty() && g_Chunk.count == 0) SetArmed(false); + } + + void Reset() { + g_Slots = {}; + g_Bindings.clear(); + g_Tracking = false; + g_Unbind.clear(); + g_Pending.clear(); + g_Requests.clear(); + g_Ignored.clear(); + g_Scope = 0; + g_Chunk = Chunk{}; + g_Sealed.clear(); + g_SealedBytes = 0; + g_Dropped = 0; + g_Seq = 0; + g_Stats = {}; + g_Sink = nullptr; + g_Settings = {}; + Remask(); + SetArmed(false); + } + + void RecordForTest(const SystemAddress& address, bool sent, bool broadcast, const unsigned char* data, uint32_t bits) { + if (!g_Armed) return; + RecordMain(address, sent ? ePacketDirection::SENT : ePacketDirection::RECEIVED, broadcast, data, bits); + } +} diff --git a/dNet/PacketCapture.h b/dNet/PacketCapture.h new file mode 100644 index 000000000..3864ae3f9 --- /dev/null +++ b/dNet/PacketCapture.h @@ -0,0 +1,104 @@ +#ifndef __PACKETCAPTURE__H__ +#define __PACKETCAPTURE__H__ + +#include +#include +#include +#include + +#include "dCommonVars.h" +#include "PacketRecord.h" + +class RakPeerInterface; +struct MessageCaptureControl; +struct MessageCaptureData; +struct Packet; +struct SystemAddress; +enum class ServiceType : uint16_t; + +/** + * This server's side of the dashboard's packet capture (docs/CaptureReplay.md): records whole packets, as they went + * over RakNet, for up to MessageCapture::MAX_SLOTS armed captures at once, and ships them to the dashboard in batches. + * + * Every server runs one (auth, chat, world and master): the dashboard arms a capture with MESSAGE_CAPTURE_CONTROL + * ARM, master passes it to every server, and each records its part: + * - auth and world: what their clients send and receive (world: everything, replica constructions included, since + * the tap sits in RakPeer::Send), plus their own master link messages that belong to a captured player + * (session keys, zone transfers, player added/removed, instance migration); + * - chat: what worlds route to and from it (the player is the object ID each chat packet starts with); + * - master: for an EVERYTHING capture, all server-to-server traffic except the dashboard's. + * + * Nothing is written to disk here and nothing is sent per packet: records are appended to one preallocated chunk, + * which is sealed when it reaches capture_flush_bytes or capture_flush_interval_ms has passed, and sent from the + * main loop (Update). Sealed chunks waiting to be sent (master unreachable) are capped at capture_buffer_max_mb; the + * oldest are dropped past that and counted, so the dashboard shows a gap. With nothing armed the cost is one flag + * check per received packet and one null check per sent one. + * + * RakNet isn't thread safe: everything here runs on the server's main thread (sends from the main thread, receives + * where the main loop reads them). + */ +namespace PacketCapture { + extern bool g_Armed; + extern bool g_Tracking; // connections are bound (they are forgotten when they close, armed or not) + + // dServer: which peer is the listening one and which the link to master (nullptr on master itself) + void Attach(ServiceType serverType, RakPeerInterface* peer, RakPeerInterface* masterLink, uint32_t zoneId, uint32_t instanceId); + void Detach(); + + // Worlds: their clone (a property's owner), once known + void SetClone(uint32_t cloneId); + + // Master: packets to and from the dashboard aren't recorded + void IgnorePeer(const SystemAddress& address); + + // ARM or DISARM from the dashboard (START/STOP are the game message inspector's) + void Control(const MessageCaptureControl& control); + + // A packet the listening peer received / nullptr when there are no more this frame (dServer::Receive) + void OnReceiveImpl(const Packet* packet); + inline void OnReceive(const Packet* packet) { if (g_Armed || g_Tracking) OnReceiveImpl(packet); } + + // A packet from master (dServer::ReceiveFromMaster) + void OnReceiveFromMasterImpl(const Packet* packet); + inline void OnReceiveFromMaster(const Packet* packet) { if (g_Armed) OnReceiveFromMasterImpl(packet); } + + // Who is on the other end of a connection: auth when an account logs in (the account may not exist: 0), worlds + // when a session is validated, and the character when one is picked + void Bind(const SystemAddress& address, uint32_t accountId, const std::string& accountName); + void BindCharacter(const SystemAddress& address, LWOOBJID characterId); + + // Main loop: seals the chunk when it is due, sends sealed chunks, ends captures past their time + void Update(); + + /** + * Where sealed chunks go; returns false when they can't be sent right now (they are kept, up to the cap). The + * default sends them to master; master sends them straight to the dashboard. + */ + using Sink = std::function; + void SetSink(Sink sink); + + struct Settings { + uint32_t flushIntervalMs{ 1000 }; + uint32_t flushBytes{ 256 * 1024 }; + uint64_t maxBufferBytes{ 16ull * 1024 * 1024 }; + }; + // Read from capture_flush_interval_ms, capture_flush_bytes and capture_buffer_max_mb when a capture is armed + void SetSettings(const Settings& settings); + + struct Stats { + uint64_t recorded{}; // records kept + uint64_t recordedBytes{}; // their size, headers included + uint64_t dropped{}; // records dropped over the buffer cap + uint64_t batches{}; // chunks sent + uint64_t sentBytes{}; + }; + const Stats& GetStats(); + + // Tests: drop every capture, binding and buffer (the attachment stays) + void Reset(); + + // Tests and tools: record as if the listening peer had sent (broadcast: to all but `address`) or received this + void RecordForTest(const SystemAddress& address, bool sent, bool broadcast, const unsigned char* data, uint32_t bits); +} + +#endif //!__PACKETCAPTURE__H__ diff --git a/dNet/PacketDecoder.cpp b/dNet/PacketDecoder.cpp new file mode 100644 index 000000000..29954f15a --- /dev/null +++ b/dNet/PacketDecoder.cpp @@ -0,0 +1,419 @@ +#include "PacketDecoder.h" + +#include +#include +#include + +#include "AuthPackets.h" +#include "BitStreamUtils.h" +#include "ChatPackets.h" +#include "ClientPackets.h" +#include "CommonPackets.h" +#include "MasterPackets.h" +#include "MessageIdentifiers.h" +#include "MessageType/Auth.h" +#include "MessageType/Chat.h" +#include "MessageType/Client.h" +#include "MessageType/Master.h" +#include "MessageType/Server.h" +#include "MessageType/World.h" +#include "ServiceType.h" +#include "WorldPackets.h" +#include "WorldRoutePacket.h" +#include "magic_enum.hpp" + +namespace { + using json = nlohmann::json; + using Fields = std::function(RakNet::BitStream&, bool fromClient)>; + using Redactor = std::function; + + using Scrubber = std::function; + + struct Entry { + Fields fields; + Redactor redact; // set for structs with secret fields + Scrubber scrub; // set for structs with account names, character names or typed text + }; + + void X(std::u16string& text) { for (auto& c : text) c = u'x'; } + void X(std::string& text) { for (auto& c : text) c = 'x'; } + void X(LUWString& text) { X(text.string); } + void X(LUString& text) { X(text.string); } + + // Like Blank, for Scrub: `names(packet)` replaces account names, `anonymous(packet)` the rest + template + Scrubber Scrub(std::function names, std::function anonymous = nullptr) { + return [names, anonymous](std::string& bytes, bool anonymise) { + RakNet::BitStream in(reinterpret_cast(bytes.data()), static_cast(bytes.size()), false); + T packet; + if (!packet.ReadHeader(in) || !packet.Deserialize(in)) return false; + if (names) names(packet); + if (anonymise && anonymous) anonymous(packet); + RakNet::BitStream out; + packet.WritePacket(out); + std::string written(reinterpret_cast(out.GetData()), out.GetNumberOfBytesUsed()); + if (written == bytes) return false; + bytes = std::move(written); + return true; + }; + } + + PacketDecoder::GameMessageFields g_GameMessages; + + using Rewriter = std::function(std::string_view)>; + template + Rewriter Rewrite() { + return [](std::string_view bytes) -> std::optional { + RakNet::BitStream in(reinterpret_cast(const_cast(bytes.data())), static_cast(bytes.size()), false); + T packet; + if (!packet.ReadHeader(in) || !packet.Deserialize(in)) return std::nullopt; + RakNet::BitStream out; + packet.WritePacket(out); + return std::string(reinterpret_cast(out.GetData()), out.GetNumberOfBytesUsed()); + }; + } + + std::string Id(LWOOBJID id) { return std::to_string(id); } + json Point(const NiPoint3& p) { return json::array({ p.x, p.y, p.z }); } + json Rotation(const NiQuaternion& q) { return json::array({ q.x, q.y, q.z, q.w }); } + + template + Fields Make(std::function fill) { + return [fill](RakNet::BitStream& stream, bool) -> std::optional { + T packet; + if (!packet.Deserialize(stream)) return std::nullopt; + json out = json::object(); + fill(packet, out); + return out; + }; + } + + // Reads T from a whole packet, lets `blank` clear its secrets, and writes it back in place + template + Redactor Blank(std::function blank) { + return [blank](std::string& bytes) { + RakNet::BitStream in(reinterpret_cast(bytes.data()), static_cast(bytes.size()), false); + T packet; + if (!packet.ReadHeader(in) || !packet.Deserialize(in)) return false; + blank(packet); + RakNet::BitStream out; + packet.WritePacket(out); + bytes.assign(reinterpret_cast(out.GetData()), out.GetNumberOfBytesUsed()); + return true; + }; + } + + using Key = std::pair; + template Key K(ServiceType service, E id) { return { service, static_cast(id) }; } + + const std::map& Registry() { + using S = ServiceType; + static const std::map registry{ + // Handshake: one ID, a struct per direction + { K(S::COMMON, MessageType::Server::VERSION_CONFIRM), { [](RakNet::BitStream& stream, bool fromClient) -> std::optional { + if (fromClient) { + CommonPackets::ClientVersionConfirm packet; + if (!packet.Deserialize(stream)) return std::nullopt; + return json{ {"netVersion", packet.netVersion}, {"serviceType", static_cast(packet.serviceType)}, {"processID", packet.processID}, {"port", packet.port} }; + } + CommonPackets::ServerVersionConfirm packet; + if (!packet.Deserialize(stream)) return std::nullopt; + return json{ {"netVersion", packet.netVersion}, {"serviceType", static_cast(packet.serviceType)} }; + } } }, + + // Auth: the username and password are blanked when recorded + { K(S::AUTH, MessageType::Auth::LOGIN_REQUEST), { + Make([](const auto& p, json& j) { + j = { {"username", p.username.GetAsString()}, {"localeID", static_cast(p.localeID)}, {"clientOS", static_cast(p.clientOS)}, + {"memoryStats", p.memoryStats.GetAsString()}, {"videoCard", p.videoCard.GetAsString()} }; + }), + Blank([](auto& p) { p.username.string.clear(); p.password.string.clear(); }), + Scrub([](auto& p) { X(p.username); }) } }, + { K(S::CLIENT, MessageType::Client::LOGIN_RESPONSE), { + Make([](const auto& p, json& j) { + json stamps = json::array(); + for (const auto& stamp : p.stamps.list) stamps.push_back({ {"type", static_cast(stamp.type)}, {"value", stamp.value}, {"timestamp", stamp.timestamp} }); + j = { {"responseCode", static_cast(p.responseCode)}, {"worldServerIP", p.worldServerIP.string}, {"worldServerPort", p.worldServerPort}, + {"errorMessage", p.errorMessage}, {"stamps", stamps} }; + }), + Blank([](auto& p) { p.userKey.string.clear(); p.cdnKey.string.clear(); }) } }, + + // World + { K(S::WORLD, MessageType::World::VALIDATION), { + Make([](const auto& p, json& j) { j = { {"username", p.username.GetAsString()}, {"fdbChecksum", p.fdbChecksum.string} }; }), + Blank([](auto& p) { p.sessionKey.string.clear(); }), + Scrub([](auto& p) { X(p.username); }) } }, + { K(S::WORLD, MessageType::World::CHARACTER_CREATE_REQUEST), { Make([](const auto& p, json& j) { + j = { {"name", p.name.GetAsString()}, {"firstNameIndex", p.firstNameIndex}, {"middleNameIndex", p.middleNameIndex}, {"lastNameIndex", p.lastNameIndex}, + {"shirtColor", p.shirtColor}, {"shirtStyle", p.shirtStyle}, {"pantsColor", p.pantsColor}, {"hairStyle", p.hairStyle}, {"hairColor", p.hairColor}, + {"eyebrows", p.eyebrows}, {"eyes", p.eyes}, {"mouth", p.mouth} }; + }), + nullptr, Scrub(nullptr, [](auto& p) { X(p.name); }) } }, + { K(S::WORLD, MessageType::World::LOGIN_REQUEST), { Make([](const auto& p, json& j) { j = { {"playerID", Id(p.playerID)} }; }) } }, + { K(S::WORLD, MessageType::World::CHARACTER_DELETE_REQUEST), { Make([](const auto& p, json& j) { j = { {"objectID", Id(p.objectID)} }; }) } }, + { K(S::WORLD, MessageType::World::CHARACTER_RENAME_REQUEST), { Make([](const auto& p, json& j) { + j = { {"objectID", Id(p.objectID)}, {"name", p.name.GetAsString()} }; + }), + nullptr, Scrub(nullptr, [](auto& p) { X(p.name); }) } }, + { K(S::WORLD, MessageType::World::LEVEL_LOAD_COMPLETE), { Make([](const auto& p, json& j) { + j = { {"mapID", p.mapID}, {"instanceID", p.instanceID}, {"cloneID", p.cloneID} }; + }) } }, + { K(S::WORLD, MessageType::World::POSITION_UPDATE), { Make([](const auto& p, json& j) { + j = { {"position", Point(p.update.position)}, {"rotation", Rotation(p.update.rotation)}, {"onGround", p.update.onGround}, {"onRail", p.update.onRail} }; + if (p.hasVelocity) j["velocity"] = Point(p.update.velocity); + if (p.hasLocalSpaceInfo) j["platform"] = Id(p.update.localSpaceInfo.objectId); + }) } }, + { K(S::WORLD, MessageType::World::GENERAL_CHAT_MESSAGE), { Make([](const auto& p, json& j) { + j = { {"chatChannel", p.chatChannel}, {"message", GeneralUtils::UTF16ToWTF8(p.message)} }; + }), + nullptr, Scrub(nullptr, [](auto& p) { X(p.message); }) } }, + { K(S::WORLD, MessageType::World::ROUTE_PACKET), { Make([](const auto& p, json& j) { + j = { {"routed", PacketDecoder::Name(p.routedService, p.routedMessageID)}, {"size", p.size} }; + }) } }, + + // To the client + { K(S::CLIENT, MessageType::Client::LOAD_STATIC_ZONE), { Make([](const auto& p, json& j) { + j = { {"mapID", p.mapID}, {"instanceID", p.instanceID}, {"cloneID", p.cloneID}, {"mapChecksum", p.mapChecksum}, {"playerPosition", Point(p.playerPosition)}, + {"instanceType", p.instanceType} }; + }) } }, + { K(S::CLIENT, MessageType::Client::CHARACTER_LIST_RESPONSE), { Make([](const auto& p, json& j) { + json characters = json::array(); + for (const auto& c : p.characters) { + characters.push_back({ {"objectID", Id(c.objectID)}, {"name", c.name.GetAsString()}, {"zoneID", c.zoneID}, {"equippedItems", c.equippedItems} }); + } + j = { {"selectedCharacterIndex", p.selectedCharacterIndex}, {"characters", characters} }; + }), + nullptr, Scrub(nullptr, [](auto& p) { for (auto& c : p.characters) { X(c.name); X(c.unapprovedName); } }) } }, + { K(S::CLIENT, MessageType::Client::CHARACTER_CREATE_RESPONSE), { Make([](const auto& p, json& j) { + j = { {"response", static_cast(p.response)} }; + }) } }, + { K(S::CLIENT, MessageType::Client::TRANSFER_TO_WORLD), { Make([](const auto& p, json& j) { + j = { {"serverIP", p.serverIP.string}, {"serverPort", p.serverPort}, {"mythranShift", p.mythranShift} }; + }) } }, + + // Chat + { K(S::CHAT, MessageType::Chat::GENERAL_CHAT_MESSAGE), { Make([](const auto& p, json& j) { + j = { {"playerID", Id(p.playerID)}, {"chatChannel", static_cast(p.chatChannel)}, {"message", p.message.GetAsString()} }; + }), + nullptr, Scrub(nullptr, [](auto& p) { X(p.senderName); X(p.message); }) } }, + { K(S::CHAT, MessageType::Chat::PRIVATE_CHAT_MESSAGE), { Make([](const auto& p, json& j) { + j = { {"playerID", Id(p.playerID)}, {"senderName", p.senderName.GetAsString()}, {"receiverName", p.receiverName.GetAsString()}, + {"responseCode", p.responseCode}, {"message", p.message.GetAsString()} }; + }), + nullptr, Scrub(nullptr, [](auto& p) { X(p.senderName); X(p.receiverName); X(p.message); }) } }, + { K(S::CHAT, MessageType::Chat::WORLD_ROUTE_PACKET), { Make([](const auto& p, json& j) { + j = { {"targetID", Id(p.targetID)}, {"bytes", p.routedData.size()} }; + if (p.routedData.size() >= 8 && p.routedData[0] == ID_USER_PACKET_ENUM) { + uint16_t service; + uint32_t id; + std::memcpy(&service, p.routedData.data() + 1, sizeof(service)); + std::memcpy(&id, p.routedData.data() + 3, sizeof(id)); + j["routed"] = PacketDecoder::Name(static_cast(service), id); + } + }) } }, + + // Between servers: session keys are blanked when recorded + { K(S::MASTER, MessageType::Master::REQUEST_SESSION_KEY), { Make([](const auto& p, json& j) { j = { {"username", p.username.GetAsString()} }; }), + nullptr, Scrub([](auto& p) { X(p.username); }) } }, + { K(S::MASTER, MessageType::Master::SESSION_KEY_RESPONSE), { + Make([](const auto& p, json& j) { j = { {"username", p.username.GetAsString()} }; }), + Blank([](auto& p) { p.sessionKey = 0; }), + Scrub([](auto& p) { X(p.username); }) } }, + { K(S::MASTER, MessageType::Master::SET_SESSION_KEY), { + Make([](const auto& p, json& j) { j = { {"username", p.username.string} }; }), + Blank([](auto& p) { p.sessionKey = 0; }), + Scrub([](auto& p) { X(p.username); }) } }, + { K(S::MASTER, MessageType::Master::NEW_SESSION_ALERT), { + Make([](const auto& p, json& j) { j = { {"username", p.username.string} }; }), + Blank([](auto& p) { p.sessionKey = 0; }), + Scrub([](auto& p) { X(p.username); }) } }, + { K(S::MASTER, MessageType::Master::REQUEST_ZONE_TRANSFER), { Make([](const auto& p, json& j) { + j = { {"requestID", Id(p.requestID)}, {"zoneID", p.zoneID}, {"cloneID", p.cloneID}, {"mythranShift", p.mythranShift}, {"stamps", p.stamps.size()} }; + }) } }, + { K(S::MASTER, MessageType::Master::REQUEST_ZONE_TRANSFER_RESPONSE), { Make([](const auto& p, json& j) { + j = { {"requestID", Id(p.requestID)}, {"zoneID", p.zoneID}, {"zoneInstance", p.zoneInstance}, {"zoneClone", p.zoneClone}, {"serverPort", p.serverPort}, + {"stamps", p.stamps.size()} }; + }) } }, + { K(S::MASTER, MessageType::Master::PLAYER_ADDED), { Make([](const auto& p, json& j) { j = { {"zoneID", p.zoneID}, {"instanceID", p.instanceID} }; }) } }, + { K(S::MASTER, MessageType::Master::PLAYER_REMOVED), { Make([](const auto& p, json& j) { j = { {"zoneID", p.zoneID}, {"instanceID", p.instanceID} }; }) } }, + }; + return registry; + } + + const std::map& Rewriters() { + using S = ServiceType; + static const std::map rewriters{ + { K(S::AUTH, MessageType::Auth::LOGIN_REQUEST), Rewrite() }, + { K(S::CLIENT, MessageType::Client::LOGIN_RESPONSE), Rewrite() }, + { K(S::WORLD, MessageType::World::VALIDATION), Rewrite() }, + { K(S::WORLD, MessageType::World::CHARACTER_CREATE_REQUEST), Rewrite() }, + { K(S::WORLD, MessageType::World::LOGIN_REQUEST), Rewrite() }, + { K(S::WORLD, MessageType::World::CHARACTER_DELETE_REQUEST), Rewrite() }, + { K(S::WORLD, MessageType::World::CHARACTER_RENAME_REQUEST), Rewrite() }, + { K(S::WORLD, MessageType::World::LEVEL_LOAD_COMPLETE), Rewrite() }, + { K(S::WORLD, MessageType::World::POSITION_UPDATE), Rewrite() }, + { K(S::WORLD, MessageType::World::GENERAL_CHAT_MESSAGE), Rewrite() }, + { K(S::WORLD, MessageType::World::ROUTE_PACKET), Rewrite() }, + { K(S::CLIENT, MessageType::Client::LOAD_STATIC_ZONE), Rewrite() }, + { K(S::CLIENT, MessageType::Client::CHARACTER_LIST_RESPONSE), Rewrite() }, + { K(S::CLIENT, MessageType::Client::CHARACTER_CREATE_RESPONSE), Rewrite() }, + { K(S::CLIENT, MessageType::Client::TRANSFER_TO_WORLD), Rewrite() }, + { K(S::CHAT, MessageType::Chat::GENERAL_CHAT_MESSAGE), Rewrite() }, + { K(S::CHAT, MessageType::Chat::PRIVATE_CHAT_MESSAGE), Rewrite() }, + { K(S::CHAT, MessageType::Chat::WORLD_ROUTE_PACKET), Rewrite() }, + { K(S::MASTER, MessageType::Master::REQUEST_SESSION_KEY), Rewrite() }, + { K(S::MASTER, MessageType::Master::SESSION_KEY_RESPONSE), Rewrite() }, + { K(S::MASTER, MessageType::Master::SET_SESSION_KEY), Rewrite() }, + { K(S::MASTER, MessageType::Master::NEW_SESSION_ALERT), Rewrite() }, + { K(S::MASTER, MessageType::Master::REQUEST_ZONE_TRANSFER), Rewrite() }, + { K(S::MASTER, MessageType::Master::REQUEST_ZONE_TRANSFER_RESPONSE), Rewrite() }, + { K(S::MASTER, MessageType::Master::PLAYER_ADDED), Rewrite() }, + { K(S::MASTER, MessageType::Master::PLAYER_REMOVED), Rewrite() }, + }; + return rewriters; + } + + template + std::string EnumName(uint32_t id) { + const auto name = magic_enum::enum_name(static_cast(id)); + return name.empty() ? std::to_string(id) : std::string(name); + } + + std::string RakNetName(uint8_t id) { + switch (id) { + case ID_CONNECTION_REQUEST_ACCEPTED: return "ID_CONNECTION_REQUEST_ACCEPTED"; + case ID_NEW_INCOMING_CONNECTION: return "ID_NEW_INCOMING_CONNECTION"; + case ID_DISCONNECTION_NOTIFICATION: return "ID_DISCONNECTION_NOTIFICATION"; + case ID_CONNECTION_LOST: return "ID_CONNECTION_LOST"; + case ID_TIMESTAMP: return "ID_TIMESTAMP"; + case ID_REPLICA_MANAGER_CONSTRUCTION: return "ID_REPLICA_MANAGER_CONSTRUCTION"; + case ID_REPLICA_MANAGER_SCOPE_CHANGE: return "ID_REPLICA_MANAGER_SCOPE_CHANGE"; + case ID_REPLICA_MANAGER_SERIALIZE: return "ID_REPLICA_MANAGER_SERIALIZE"; + case ID_REPLICA_MANAGER_DESTRUCTION: return "ID_REPLICA_MANAGER_DESTRUCTION"; + case ID_REPLICA_MANAGER_DOWNLOAD_STARTED: return "ID_REPLICA_MANAGER_DOWNLOAD_STARTED"; + case ID_REPLICA_MANAGER_DOWNLOAD_COMPLETE: return "ID_REPLICA_MANAGER_DOWNLOAD_COMPLETE"; + default: return "RAKNET_" + std::to_string(id); + } + } + + bool ReadLuHeader(std::string_view bytes, ServiceType& service, uint32_t& id) { + if (bytes.size() < 8 || static_cast(bytes[0]) != ID_USER_PACKET_ENUM) return false; + uint16_t raw; + std::memcpy(&raw, bytes.data() + 1, sizeof(raw)); + std::memcpy(&id, bytes.data() + 3, sizeof(id)); + service = static_cast(raw); + return true; + } +} + +namespace PacketDecoder { + std::string Name(ServiceType service, uint32_t messageId) { + switch (service) { + case ServiceType::COMMON: return EnumName(messageId); + case ServiceType::AUTH: return EnumName(messageId); + case ServiceType::CHAT: return EnumName(messageId); + case ServiceType::WORLD: return EnumName(messageId); + case ServiceType::CLIENT: return EnumName(messageId); + case ServiceType::MASTER: return EnumName(messageId); + default: return std::to_string(messageId); + } + } + + Decoded Decode(std::string_view bytes, bool fromClient) { + Decoded out; + if (bytes.empty()) return out; + ServiceType service{}; + uint32_t id{}; + if (!ReadLuHeader(bytes, service, id)) { + out.service = "RAKNET"; + out.messageId = static_cast(bytes[0]); + out.name = RakNetName(static_cast(bytes[0])); + return out; + } + out.lu = true; + out.serviceId = static_cast(service); + out.messageId = id; + out.service = std::string(magic_enum::enum_name(service)); + if (out.service.empty()) out.service = std::to_string(out.serviceId); + out.name = Name(service, id); + + RakNet::BitStream stream(reinterpret_cast(const_cast(bytes.data())), static_cast(bytes.size()), false); + stream.IgnoreBytes(8); + + // Game messages: the object, the message ID, then its fields + const bool gameMessage = (service == ServiceType::WORLD && id == static_cast(MessageType::World::GAME_MSG)) || + (service == ServiceType::CLIENT && id == static_cast(MessageType::Client::GAME_MSG)); + if (gameMessage) { + uint16_t messageId{}; + if (!stream.Read(out.objectId) || !stream.Read(messageId)) { + out.failed = true; + return out; + } + out.gameMessageId = messageId; + const auto name = magic_enum::enum_name(static_cast(messageId)); + out.name += " " + (name.empty() ? std::to_string(messageId) : std::string(name)); + if (g_GameMessages) { + // What follows the header, as a stream of its own + const auto offset = stream.GetReadOffset() / 8; + RakNet::BitStream payload(reinterpret_cast(const_cast(bytes.data())) + offset, static_cast(bytes.size() - offset), false); + out.fields = g_GameMessages(static_cast(messageId), service == ServiceType::WORLD, payload); + } + return out; + } + + const auto it = Registry().find({ service, id }); + if (it == Registry().end()) return out; + out.fields = it->second.fields(stream, fromClient); + out.failed = !out.fields.has_value(); + return out; + } + + std::optional Position(std::string_view bytes) { + ServiceType service{}; + uint32_t id{}; + if (!ReadLuHeader(bytes, service, id) || service != ServiceType::WORLD || id != static_cast(MessageType::World::POSITION_UPDATE)) return std::nullopt; + RakNet::BitStream stream(reinterpret_cast(const_cast(bytes.data())), static_cast(bytes.size()), false); + stream.IgnoreBytes(8); + WorldPackets::PositionUpdate update; + if (!update.Deserialize(stream)) return std::nullopt; + return update.update.position; + } + + void SetGameMessageDecoder(GameMessageFields decoder) { g_GameMessages = std::move(decoder); } + + bool Redact(std::string& bytes) { + ServiceType service{}; + uint32_t id{}; + if (!ReadLuHeader(bytes, service, id)) return true; + const auto it = Registry().find({ service, id }); + if (it == Registry().end() || !it->second.redact) return true; + return it->second.redact(bytes); + } + + bool Scrub(std::string& bytes, bool anonymise) { + ServiceType service{}; + uint32_t id{}; + if (!ReadLuHeader(bytes, service, id)) return false; + const auto it = Registry().find({ service, id }); + if (it == Registry().end() || !it->second.scrub) return false; + return it->second.scrub(bytes, anonymise); + } + + std::optional RoundTrip(std::string_view bytes) { + ServiceType service{}; + uint32_t id{}; + if (!ReadLuHeader(bytes, service, id)) return std::nullopt; + const auto it = Rewriters().find({ service, id }); + if (it == Rewriters().end()) return std::nullopt; + const auto written = it->second(bytes); + if (!written) return std::nullopt; + return *written == bytes; + } + + bool HasSecrets(ServiceType service, uint32_t messageId) { + const auto it = Registry().find({ service, messageId }); + return it != Registry().end() && it->second.redact; + } + + size_t RegisteredCount() { return Registry().size() + 1; } +} diff --git a/dNet/PacketDecoder.h b/dNet/PacketDecoder.h new file mode 100644 index 000000000..e2bde47ff --- /dev/null +++ b/dNet/PacketDecoder.h @@ -0,0 +1,79 @@ +#ifndef __PACKETDECODER__H__ +#define __PACKETDECODER__H__ + +#include +#include +#include +#include +#include + +#include "BitStream.h" +#include "dCommonVars.h" +#include "json.hpp" +#include "MessageType/Game.h" +#include "NiPoint3.h" + +enum class ServiceType : uint16_t; + +/** + * Reads recorded packets (PacketRecord.h) for the dashboard's capture viewer and the capture tool, with the + * server's own packet structs and their Deserialize: a registry keyed by (service, message ID). Every packet gets a + * name (RakNet's own messages, LU packets by their MessageType enum); the ones registered here also get their fields. + * Game messages (WORLD and CLIENT GAME_MSG) are named by MessageType::Game; their fields come from the decoder a + * program that links the game sets with SetGameMessageDecoder (the capture tool does; the dashboard doesn't link + * the game, so it shows their bytes). + */ +namespace PacketDecoder { + struct Decoded { + std::string service; // "WORLD", "CLIENT", ... or "RAKNET" for RakNet's own messages + std::string name; // e.g. "POSITION_UPDATE", "GAME_MSG REQUEST_USE", "ID_REPLICA_MANAGER_CONSTRUCTION" + uint16_t serviceId{}; + uint32_t messageId{}; // the LU message ID, or the RakNet message ID + bool lu{}; // an LU packet (8 byte header) + int32_t gameMessageId{ -1 }; + LWOOBJID objectId{}; // game messages: the object it is for + std::optional fields; // set when a registered struct read it cleanly + bool failed{}; // a registered struct did not read it + }; + + // `fromClient`: the packet came from a game client (worlds and auth receiving on their listening peer) + Decoded Decode(std::string_view bytes, bool fromClient); + + // The name alone + std::string Name(ServiceType service, uint32_t messageId); + + // Where a client's POSITION_UPDATE says the player is; nullopt for any other packet + std::optional Position(std::string_view bytes); + + using GameMessageFields = std::function(MessageType::Game messageId, bool toServer, RakNet::BitStream& payload)>; + void SetGameMessageDecoder(GameMessageFields decoder); + + /** + * Secrets never reach a capture: packets whose struct declares secret fields (passwords, session and user keys) + * are read, those fields blanked (strings emptied, keys 0), and written again before they are recorded. Returns + * false when the packet declares secrets but doesn't read cleanly: then it must not be recorded at all. + * Packets without secrets are left as they are. + */ + bool Redact(std::string& bytes); + + /** + * For bundles that leave the server: account names are always replaced (portable bundles), and with `anonymise` + * also character names and what players typed (test fixtures). Text is replaced with as many 'x' as it had, so + * the packet keeps its size. Returns true if the packet changed. + */ + bool Scrub(std::string& bytes, bool anonymise); + + // Whether a packet's struct declares secret fields (they are blanked when recorded) + bool HasSecrets(ServiceType service, uint32_t messageId); + + /** + * Reads a packet with its struct and writes it again. nullopt: no struct for it (or it doesn't read); otherwise + * whether the bytes came back the same (the local fixture tests' byte-equality check). + */ + std::optional RoundTrip(std::string_view bytes); + + // How many (service, message) pairs have typed decoders, for tests + size_t RegisteredCount(); +} + +#endif //!__PACKETDECODER__H__ diff --git a/dNet/PacketRecord.h b/dNet/PacketRecord.h new file mode 100644 index 000000000..f30b07da8 --- /dev/null +++ b/dNet/PacketRecord.h @@ -0,0 +1,96 @@ +#ifndef __PACKETRECORD__H__ +#define __PACKETRECORD__H__ + +#include +#include +#include +#include + +/** + * One recorded packet of a packet capture (PacketCapture.h), as the servers pack them into batches and as the + * dashboard appends them to a capture file: a fixed little-endian header, then the packet's bytes exactly as they + * went over RakNet (the RakNet message ID first; for LU packets the 8 byte header follows). + * + * Files of records (a capture on the dashboard's disk, an exported bundle) are described in CaptureBundle.h. + */ +enum class eCaptureSource : uint8_t { + UNKNOWN, + AUTH, + CHAT, + WORLD, + MASTER, +}; + +enum class ePacketDirection : uint8_t { + RECEIVED, // by the server that recorded it + SENT, // by the server that recorded it +}; + +namespace PacketRecordFlags { + constexpr uint8_t MASTER_LINK = 1 << 0; // on a server's own link to master (not its listening socket) + constexpr uint8_t BROADCAST = 1 << 1; // sent to everyone connected (peer is who it skipped) + constexpr uint8_t CUT = 1 << 2; // longer than the server keeps; `bits` is the full size + constexpr uint8_t GAP = 1 << 3; // not a packet: `bits` packets were lost here (a server's buffer was full) +} + +#pragma pack(push, 1) +struct PacketRecordHeader { + int64_t timeUs{}; // Unix time in microseconds + uint32_t seq{}; // per server, from 1 when it first armed a capture; a jump means records were dropped + uint8_t mask{}; // the capture slots this record belongs to (one bit each) + uint8_t source{}; // eCaptureSource + uint8_t direction{}; // ePacketDirection + uint8_t flags{}; // PacketRecordFlags + uint64_t peer{}; // the other end: IPv4 address << 16 | port (0: none) + uint32_t accountId{}; // whose it is, when known + int64_t characterId{}; + uint16_t zoneId{}; // the recording server's zone (worlds), else 0 + uint16_t instanceId{}; + uint32_t cloneId{}; + uint32_t bits{}; // the packet's full size in bits + uint32_t length{}; // bytes of the packet stored after this header +}; +#pragma pack(pop) +static_assert(sizeof(PacketRecordHeader) == 52, "The record header is part of the capture file format"); + +namespace PacketRecord { + // Bytes of one packet kept; longer ones are cut (large replica constructions and character data fit) + constexpr uint32_t MAX_BYTES = 256 * 1024; + + inline void Append(std::string& out, const PacketRecordHeader& header, const void* data) { + const auto at = out.size(); + out.resize(at + sizeof(header) + header.length); + std::memcpy(out.data() + at, &header, sizeof(header)); + if (header.length) std::memcpy(out.data() + at + sizeof(header), data, header.length); + } + + /** + * Calls fn(header, bytes) for every record in `blob`, in order. Returns false if the blob ends in the middle of a + * record or a record is larger than records can be (what came before was still passed to fn). + */ + template + bool ForEach(std::string_view blob, Fn&& fn) { + size_t at = 0; + while (at < blob.size()) { + if (blob.size() - at < sizeof(PacketRecordHeader)) return false; + PacketRecordHeader header; + std::memcpy(&header, blob.data() + at, sizeof(header)); + at += sizeof(header); + if (header.length > MAX_BYTES || blob.size() - at < header.length) return false; + fn(header, blob.substr(at, header.length)); + at += header.length; + } + return true; + } + + // "a.b.c.d:port" of a record's peer + inline std::string PeerText(uint64_t peer) { + if (peer == 0) return ""; + const auto address = static_cast(peer >> 16); + // RakNet keeps IPv4 addresses in network order + return std::to_string(address & 0xff) + "." + std::to_string((address >> 8) & 0xff) + "." + std::to_string((address >> 16) & 0xff) + "." + + std::to_string(address >> 24) + ":" + std::to_string(peer & 0xffff); + } +} + +#endif //!__PACKETRECORD__H__ diff --git a/dNet/dServer.cpp b/dNet/dServer.cpp index 0eb0db44f..3fc5f9515 100644 --- a/dNet/dServer.cpp +++ b/dNet/dServer.cpp @@ -15,6 +15,8 @@ #include "CommonPackets.h" #include "MasterPackets.h" #include "ZoneInstanceManager.h" +#include "PacketCapture.h" +#include "master/MessageCapture.h" #include "StringifiedEnum.h" #include "GeneralUtils.h" #include "TrafficStats.h" @@ -134,6 +136,9 @@ dServer::dServer( mPeer->AttachPlugin(mReplicaManager); mPeer->SetNetworkIDManager(mNetIDManager); } + + // The dashboard's packet capture records what goes through the listening peer and, for a captured player, the master link + PacketCapture::Attach(serverType, mPeer, mMasterPeer, zoneID, static_cast(instanceID)); } dServer::~dServer() { @@ -148,6 +153,7 @@ Packet* dServer::ReceiveFromMaster() { CountTraffic(packet, TrafficStats::Peer::MASTER); if (packet) { if (packet->length < 1) { mMasterPeer->DeallocatePacket(packet); return nullptr; } + PacketCapture::OnReceiveFromMaster(packet); switch (packet->data[0]) { case ID_DISCONNECTION_NOTIFICATION: @@ -201,6 +207,15 @@ Packet* dServer::ReceiveFromMaster() { break; } + case MessageType::Master::MESSAGE_CAPTURE_CONTROL: { + // Packet captures run on every server; the game message inspector's start and stop go to the world's own handler + MessageCaptureControl control; + if (!control.Deserialize(inStream)) break; + if (control.action != eMessageCaptureControl::ARM && control.action != eMessageCaptureControl::DISARM) return packet; + PacketCapture::Control(control); + break; + } + // When we handle these packets in World instead dServer, we just return the packet's pointer. default: return packet; @@ -219,6 +234,7 @@ Packet* dServer::ReceiveFromMaster() { Packet* dServer::Receive() { Packet* packet = mPeer->Receive(); CountTraffic(packet, PeerOfConnections()); + PacketCapture::OnReceive(packet); return packet; } @@ -297,6 +313,7 @@ void dServer::UpdateBandwidthLimit() { } void dServer::Shutdown() { + PacketCapture::Detach(); if (mPeer) { mPeer->Shutdown(1000); RakNetworkFactory::DestroyRakPeerInterface(mPeer); diff --git a/dNet/master/MessageCapture.h b/dNet/master/MessageCapture.h index d1c7e6920..b9d268f97 100644 --- a/dNet/master/MessageCapture.h +++ b/dNet/master/MessageCapture.h @@ -2,6 +2,7 @@ #define __MESSAGECAPTURE__H__ #include +#include #include #include #include @@ -30,12 +31,22 @@ enum class eMessageDirection : uint8_t { enum class eMessageCaptureControl : uint8_t { START, STOP, + ARM, // packet capture (PacketCapture.h): every server records the target's packets into `slot` + DISARM, // packet capture: stop recording into `slot` +}; + +// What a packet capture (ARM) records +enum class eCaptureTarget : uint8_t { + CHARACTER, // one character, from the moment it is picked in a world + ACCOUNT, // everything of one account, from its login (every character) + EVERYTHING, // all traffic on all servers }; enum class eMessageCaptureStatus : uint8_t { STARTED, // the world holding the character started capturing ENTRIES, // captured messages (also sent empty every few seconds while capturing) ENDED, // the world stopped capturing; `reason` says why + PACKETS, // a batch of recorded packets (PacketCapture.h) from any server: `packets`, `slots`, `source` }; enum class eMessageCaptureEnd : uint8_t { @@ -53,6 +64,12 @@ namespace MessageCapture { constexpr uint16_t MAX_DECODED = 8192; // Message IDs in a filter list constexpr uint16_t MAX_FILTER = 256; + // Packet captures armed at once (one bit each in a packet record's mask) + constexpr uint8_t MAX_SLOTS = 8; + // Characters of an account a packet capture follows + constexpr uint16_t MAX_CHARACTERS = 64; + // One batch of packet records + constexpr uint32_t MAX_PACKET_BATCH = 8 * 1024 * 1024; // Lowercase hex, two digits per byte inline std::string ToHex(std::string_view bytes) { @@ -106,6 +123,12 @@ struct MessageCaptureControl : public LUBitStream { bool toClient{ true }; // START: capture what the client receives std::vector only; // START: capture only these message IDs (empty: all) std::vector skip; // START: never capture these message IDs + // ARM / DISARM (packet capture); `seconds` is its time limit and captureId its id + uint8_t slot{}; // 0 to MAX_SLOTS - 1: the bit this capture has in packet records + eCaptureTarget target{}; + uint32_t accountId{}; // ACCOUNT, CHARACTER: the account + std::string accountName; // its name, for packets that name the account instead (logins, session keys) + std::vector characterIds; // ACCOUNT: its characters; CHARACTER: the one void Serialize(RakNet::BitStream& stream) const override { stream.Write(captureId); @@ -116,6 +139,13 @@ struct MessageCaptureControl : public LUBitStream { stream.Write(toClient); MessageCapture::WriteIds(stream, only); MessageCapture::WriteIds(stream, skip); + stream.Write(slot); + stream.Write(target); + stream.Write(accountId); + MessageCapture::WriteString(stream, accountName, 64); + const auto count = static_cast(std::min(characterIds.size(), MessageCapture::MAX_CHARACTERS)); + stream.Write(count); + for (uint16_t i = 0; i < count; i++) stream.Write(characterIds[i]); } bool Deserialize(RakNet::BitStream& stream) override { @@ -125,7 +155,13 @@ struct MessageCaptureControl : public LUBitStream { seconds = std::min(seconds, MessageCapture::MAX_SECONDS); toServer = server != 0; toClient = client != 0; - return MessageCapture::ReadIds(stream, only) && MessageCapture::ReadIds(stream, skip); + if (!MessageCapture::ReadIds(stream, only) || !MessageCapture::ReadIds(stream, skip)) return false; + uint16_t count{}; + if (!stream.Read(slot) || slot >= MessageCapture::MAX_SLOTS || !stream.Read(target) || target > eCaptureTarget::EVERYTHING || + !stream.Read(accountId) || !MessageCapture::ReadString(stream, accountName, 64) || !stream.Read(count) || count > MessageCapture::MAX_CHARACTERS) return false; + characterIds.resize(count); + for (auto& id : characterIds) if (!stream.Read(id)) return false; + return true; } // Whether a message passes this capture's filters @@ -184,6 +220,13 @@ struct MessageCaptureData : public LUBitStream { uint32_t dropped{}; // messages left out since the last batch (over the rate or buffer limit) std::vector entries; uint32_t cloneId{}; // the world's clone (a property's owner), 0 elsewhere + // PACKETS: records (PacketRecord.h) packed one after another, the capture id of each mask bit when they were + // recorded, which server recorded them, how many there are and how many that server left out since its last batch + uint8_t source{}; // eCaptureSource + std::array slots{}; + uint32_t packetCount{}; + uint32_t packetsDropped{}; + std::string packets; void Serialize(RakNet::BitStream& stream) const override { stream.Write(captureId); @@ -197,6 +240,13 @@ struct MessageCaptureData : public LUBitStream { stream.Write(count); for (uint16_t i = 0; i < count; i++) entries[i].Serialize(stream); stream.Write(cloneId); + stream.Write(source); + for (const auto id : slots) stream.Write(id); + stream.Write(packetCount); + stream.Write(packetsDropped); + const auto length = static_cast(std::min(packets.size(), MessageCapture::MAX_PACKET_BATCH)); + stream.Write(length); + if (length) stream.Write(packets.data(), length); } bool Deserialize(RakNet::BitStream& stream) override { @@ -205,7 +255,12 @@ struct MessageCaptureData : public LUBitStream { !stream.Read(instanceId) || !stream.Read(reason) || !stream.Read(dropped) || !stream.Read(count) || count > MAX_ENTRIES) return false; entries.resize(count); for (auto& entry : entries) if (!entry.Deserialize(stream)) return false; - return stream.Read(cloneId); + uint32_t length{}; + if (!stream.Read(cloneId) || !stream.Read(source)) return false; + for (auto& id : slots) if (!stream.Read(id)) return false; + if (!stream.Read(packetCount) || !stream.Read(packetsDropped) || !stream.Read(length) || length > MessageCapture::MAX_PACKET_BATCH) return false; + packets.resize(length); + return length == 0 || stream.Read(packets.data(), length); } }; diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index 0e28f4aab..49f719e65 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -1,6 +1,7 @@ #include "DashboardActions.h" #include "Profiler.h" #include +#include "PacketCapture.h" #include "ConfigSync.h" #include "EconomyLedger.h" #include "DashboardNotify.h" @@ -391,6 +392,7 @@ int main(int argc, char** argv) { dpWorld::Initialize(zoneID); } Game::zoneManager->Initialize(LWOZONEID(zoneID, g_InstanceID, cloneID)); + PacketCapture::SetClone(cloneID); g_CloneID = cloneID; } else { Game::entityManager->Initialize(); @@ -561,6 +563,8 @@ int main(int argc, char** argv) { Metrics::StartMeasurement(MetricVariable::PacketHandling); + PacketCapture::Update(); + //Check for packets here: std::optional packetScope; packetScope.emplace("Master packets", Profiler::Phase::PACKETS); diff --git a/migrations/dlu/mysql/103_packet_captures.sql b/migrations/dlu/mysql/103_packet_captures.sql new file mode 100644 index 000000000..18258e2fb --- /dev/null +++ b/migrations/dlu/mysql/103_packet_captures.sql @@ -0,0 +1,6 @@ +/* Packet captures (docs/CaptureReplay.md) are saved alongside the game message inspector's captures: capture_kind 0 is + a game message capture (its messages in message_capture_entries), 1 a packet capture (its packets in a capture file + on the dashboard's disk, named by the id). capture_target is what a packet capture records: character, account or + everything. */ +ALTER TABLE message_capture_sessions ADD COLUMN capture_kind TINYINT NOT NULL DEFAULT 0; +ALTER TABLE message_capture_sessions ADD COLUMN capture_target VARCHAR(16) NOT NULL DEFAULT ''; diff --git a/migrations/dlu/sqlite/86_packet_captures.sql b/migrations/dlu/sqlite/86_packet_captures.sql new file mode 100644 index 000000000..236e9f5e4 --- /dev/null +++ b/migrations/dlu/sqlite/86_packet_captures.sql @@ -0,0 +1,3 @@ +/* capture_kind and capture_target: see the MySQL migration. */ +ALTER TABLE message_capture_sessions ADD COLUMN capture_kind INTEGER NOT NULL DEFAULT 0; +ALTER TABLE message_capture_sessions ADD COLUMN capture_target TEXT NOT NULL DEFAULT ''; diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index dca6375c0..78343fd10 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -56,7 +56,9 @@ if(APPLE) add_dependencies(dGameTests dGameTestsLink) endif() -target_link_libraries(dGameTests ${COMMON_LIBRARIES} GTest::gtest_main +# Local capture fixtures (tests/fixtures-local, never committed) are found from the source folder +target_compile_definitions(dGameTests PRIVATE DLU_SOURCE_DIR="${PROJECT_SOURCE_DIR}") +target_link_libraries(dGameTests ${COMMON_LIBRARIES} sqlite3 GTest::gtest_main dGame dScripts dPhysics Detour Recast tinyxml2 dWorldServer dZoneManager dChatFilter dChatServer dNavigation bcrypt MD5) # Discover the tests diff --git a/tests/dGameTests/dNetTests/CMakeLists.txt b/tests/dGameTests/dNetTests/CMakeLists.txt index c6fe0d086..2052bf240 100644 --- a/tests/dGameTests/dNetTests/CMakeLists.txt +++ b/tests/dGameTests/dNetTests/CMakeLists.txt @@ -3,6 +3,7 @@ SET(DNET_TESTS "GuildPacketsTests.cpp" "CommonAuthPacketsTests.cpp" "MasterPacketsTests.cpp" + "PacketCaptureTests.cpp" "ServerTrafficTests.cpp" "WorldPacketsTests.cpp") diff --git a/tests/dGameTests/dNetTests/PacketCaptureTests.cpp b/tests/dGameTests/dNetTests/PacketCaptureTests.cpp new file mode 100644 index 000000000..0b3eff186 --- /dev/null +++ b/tests/dGameTests/dNetTests/PacketCaptureTests.cpp @@ -0,0 +1,555 @@ +#include "PacketCapture.h" +#include "PacketDecoder.h" +#include "CaptureBundle.h" +#include "CaptureTools.h" +#include "AuthPackets.h" +#include "ChatPackets.h" +#include "ClientPackets.h" +#include "MasterPackets.h" +#include "WorldPackets.h" +#include "master/MessageCapture.h" +#include "MessageIdentifiers.h" +#include "RakNetTypes.h" +#include "ServiceType.h" +#include "sqlite3.h" + +#include +#include +#include +#include + +#include + +namespace { + std::string Bytes(const LUBitStream& packet) { + RakNet::BitStream stream; + packet.WritePacket(stream); + return std::string(reinterpret_cast(stream.GetData()), stream.GetNumberOfBytesUsed()); + } + + SystemAddress Address(uint32_t ip, uint16_t port) { + SystemAddress address; + address.binaryAddress = ip; + address.port = port; + return address; + } + + // UTF-16 LE, as LUWString writes text + std::string Wide(const std::string& text) { + std::string out; + for (const char c : text) { + out += c; + out += '\0'; + } + return out; + } + + struct Captured { + std::vector records; + std::vector batches; + std::string all; // every record's bytes, to look for secrets + }; + + class PacketCaptureTest : public ::testing::Test { + protected: + Captured captured; + bool connected = true; + + void Start(ServiceType server) { + PacketCapture::Reset(); + PacketCapture::Attach(server, nullptr, nullptr, 1100, 3); + PacketCapture::SetSettings({ .flushIntervalMs = 1000, .flushBytes = 1, .maxBufferBytes = 1024 * 1024 }); + PacketCapture::SetSink([this](MessageCaptureData& data) { + if (!connected) return false; + captured.batches.push_back(data); + PacketRecord::ForEach(data.packets, [this](const PacketRecordHeader& header, std::string_view bytes) { + captured.records.push_back({ header, std::string(bytes) }); + captured.all += bytes; + }); + return true; + }); + } + + void TearDown() override { PacketCapture::Reset(); } + + void Arm(uint8_t slot, uint32_t captureId, eCaptureTarget target, uint32_t accountId = 0, const std::string& name = "", std::vector characters = {}) { + MessageCaptureControl control; + control.action = eMessageCaptureControl::ARM; + control.captureId = captureId; + control.slot = slot; + control.seconds = 60; + control.target = target; + control.accountId = accountId; + control.accountName = name; + control.characterIds = std::move(characters); + PacketCapture::Control(control); + // Arming reads the settings; keep one record per batch for the tests + PacketCapture::SetSettings({ .flushIntervalMs = 1000, .flushBytes = 1, .maxBufferBytes = 1024 * 1024 }); + } + + void Receive(const SystemAddress& from, const std::string& bytes) { + Packet packet{}; + packet.systemAddress = from; + packet.length = static_cast(bytes.size()); + packet.bitSize = static_cast(bytes.size() * 8); + packet.data = reinterpret_cast(const_cast(bytes.data())); + PacketCapture::OnReceive(&packet); + PacketCapture::OnReceive(nullptr); + } + + void Send(const SystemAddress& to, const std::string& bytes, bool broadcast = false) { + PacketCapture::RecordForTest(to, true, broadcast, reinterpret_cast(bytes.data()), static_cast(bytes.size() * 8)); + } + }; +} + +TEST(MessageCaptureTests, PacketCaptureControlRoundTrips) { + MessageCaptureControl control; + control.action = eMessageCaptureControl::ARM; + control.captureId = 12; + control.slot = 5; + control.seconds = 120; + control.target = eCaptureTarget::ACCOUNT; + control.accountId = 44; + control.accountName = "Alice"; + control.characterIds = { 1152921510436607007LL, 1152921510436607008LL }; + RakNet::BitStream stream; + control.Serialize(stream); + MessageCaptureControl read; + ASSERT_TRUE(read.Deserialize(stream)); + EXPECT_EQ(read.action, eMessageCaptureControl::ARM); + EXPECT_EQ(read.slot, 5); + EXPECT_EQ(read.target, eCaptureTarget::ACCOUNT); + EXPECT_EQ(read.accountId, 44u); + EXPECT_EQ(read.accountName, "Alice"); + EXPECT_EQ(read.characterIds, control.characterIds); + + // A slot past the last is refused + RakNet::BitStream bad; + control.slot = MessageCapture::MAX_SLOTS; + control.Serialize(bad); + EXPECT_FALSE(read.Deserialize(bad)); +} + +TEST(MessageCaptureTests, PacketBatchRoundTrips) { + MessageCaptureData data; + data.status = eMessageCaptureStatus::PACKETS; + data.source = static_cast(eCaptureSource::CHAT); + data.slots[2] = 99; + data.packetCount = 1; + data.packetsDropped = 7; + PacketRecordHeader header; + header.length = 3; + PacketRecord::Append(data.packets, header, "abc"); + RakNet::BitStream stream; + data.Serialize(stream); + MessageCaptureData read; + ASSERT_TRUE(read.Deserialize(stream)); + EXPECT_EQ(read.status, eMessageCaptureStatus::PACKETS); + EXPECT_EQ(read.source, data.source); + EXPECT_EQ(read.slots[2], 99u); + EXPECT_EQ(read.packetsDropped, 7u); + EXPECT_EQ(read.packets, data.packets); +} + +TEST_F(PacketCaptureTest, NothingRecordedWhenNotArmed) { + Start(ServiceType::WORLD); + EXPECT_FALSE(PacketCapture::g_Armed); + WorldPackets::CharacterListRequest request; + Receive(Address(1, 1000), Bytes(request)); + PacketCapture::Update(); + EXPECT_TRUE(captured.records.empty()); +} + +TEST_F(PacketCaptureTest, AccountCaptureKeepsWhatCameBeforeTheLoginAndRedactsSecrets) { + Start(ServiceType::WORLD); + Arm(0, 10, eCaptureTarget::ACCOUNT, 7, "Alice"); + const auto alice = Address(0x0100007f, 50000), bob = Address(0x0200007f, 50001); + + WorldPackets::Validation validation; + validation.username = LUWString(std::string("alice")); + validation.sessionKey = LUWString(std::string("SECRETSESSIONKEY")); + Receive(alice, Bytes(validation)); + Receive(bob, Bytes(validation)); + PacketCapture::Update(); + EXPECT_TRUE(captured.records.empty()) << "nothing is known to be the account's yet"; + + PacketCapture::Bind(alice, 7, "alice"); + ClientPackets::LoadStaticZone zone; + zone.mapID = 1100; + Send(alice, Bytes(zone)); + Send(bob, Bytes(zone)); + WorldPackets::CharacterListRequest list; + Receive(bob, Bytes(list)); + PacketCapture::Update(); + + ASSERT_EQ(captured.records.size(), 2u); + EXPECT_EQ(PacketDecoder::Decode(captured.records[0].bytes, true).name, "VALIDATION"); + EXPECT_EQ(PacketDecoder::Decode(captured.records[1].bytes, false).name, "LOAD_STATIC_ZONE"); + for (const auto& record : captured.records) { + EXPECT_EQ(record.header.mask, 1); + EXPECT_EQ(record.header.accountId, 7u); + EXPECT_EQ(record.header.source, static_cast(eCaptureSource::WORLD)); + EXPECT_EQ(record.header.zoneId, 1100); + } + EXPECT_EQ(captured.batches.front().slots[0], 10u); + EXPECT_EQ(captured.all.find(Wide("SECRETSESSIONKEY")), std::string::npos); + EXPECT_EQ(captured.all.find("SECRETSESSIONKEY"), std::string::npos); +} + +TEST_F(PacketCaptureTest, AuthNeverStoresPasswordsOrUserKeys) { + Start(ServiceType::AUTH); + Arm(0, 11, eCaptureTarget::ACCOUNT, 7, "alice"); + const auto client = Address(0x0100007f, 50000); + + AuthPackets::LoginRequest login; + login.username = LUWString(std::string("alice")); + login.password = LUWString(std::string("hunter2password"), 41); + Receive(client, Bytes(login)); + PacketCapture::Bind(client, 7, "alice"); + + ClientPackets::LoginResponse response; + response.userKey = LUWString(std::string("USERKEY0123456789")); + Send(client, Bytes(response)); + // Anything else on auth isn't kept + MasterPackets::SetSessionKey other; + Send(client, Bytes(other)); + PacketCapture::Update(); + + ASSERT_EQ(captured.records.size(), 2u); + for (const auto& secret : { std::string("hunter2password"), std::string("USERKEY0123456789"), std::string("alice") }) { + EXPECT_EQ(captured.all.find(Wide(secret)), std::string::npos) << secret; + EXPECT_EQ(captured.all.find(secret), std::string::npos) << secret; + } + // Still readable, with the secrets blank + const auto decoded = PacketDecoder::Decode(captured.records[0].bytes, true); + ASSERT_TRUE(decoded.fields); + EXPECT_EQ((*decoded.fields)["username"], ""); +} + +TEST_F(PacketCaptureTest, CharacterCaptureStartsWhenTheCharacterIsPicked) { + Start(ServiceType::WORLD); + constexpr LWOOBJID character = 1152921510436607007LL; + Arm(3, 12, eCaptureTarget::CHARACTER, 7, "alice", { character }); + const auto alice = Address(0x0100007f, 50000); + PacketCapture::Bind(alice, 7, "alice"); + WorldPackets::CharacterListRequest list; + Receive(alice, Bytes(list)); + PacketCapture::BindCharacter(alice, character); + WorldPackets::LevelLoadComplete loaded; + Receive(alice, Bytes(loaded)); + PacketCapture::Update(); + ASSERT_EQ(captured.records.size(), 1u); + EXPECT_EQ(captured.records[0].header.mask, 1 << 3); + EXPECT_EQ(captured.records[0].header.characterId, character); +} + +TEST_F(PacketCaptureTest, BroadcastsReachCapturedPlayers) { + Start(ServiceType::WORLD); + Arm(0, 13, eCaptureTarget::ACCOUNT, 7, "alice"); + const auto alice = Address(1, 1), bob = Address(2, 2); + PacketCapture::Bind(alice, 7, "alice"); + PacketCapture::Bind(bob, 8, "bob"); + ClientPackets::LoadStaticZone zone; + Send(bob, Bytes(zone), true); // everyone but Bob: Alice gets it + Send(alice, Bytes(zone), true); // everyone but Alice + PacketCapture::Update(); + ASSERT_EQ(captured.records.size(), 1u); + EXPECT_TRUE(captured.records[0].header.flags & PacketRecordFlags::BROADCAST); +} + +TEST_F(PacketCaptureTest, EverythingRecordsAllButTheCapturesOwnTraffic) { + Start(ServiceType::MASTER); + Arm(1, 14, eCaptureTarget::EVERYTHING); + MasterPackets::PlayerAdded added; + Receive(Address(1, 1), Bytes(added)); + MessageCaptureData data; + Receive(Address(1, 1), Bytes(data)); + PacketCapture::IgnorePeer(Address(9, 9)); + Receive(Address(9, 9), Bytes(added)); + PacketCapture::Update(); + ASSERT_EQ(captured.records.size(), 1u); + EXPECT_EQ(captured.records[0].header.mask, 1 << 1); + EXPECT_EQ(captured.records[0].header.source, static_cast(eCaptureSource::MASTER)); +} + +TEST_F(PacketCaptureTest, ChatFindsThePlayerInThePacket) { + Start(ServiceType::CHAT); + constexpr LWOOBJID character = 1152921510436607007LL; + Arm(0, 15, eCaptureTarget::ACCOUNT, 7, "alice", { character }); + ChatPackets::GeneralChatMessage mine, theirs; + mine.playerID = character; + theirs.playerID = character + 1; + Receive(Address(1, 1), Bytes(mine)); + Receive(Address(1, 1), Bytes(theirs)); + PacketCapture::Update(); + ASSERT_EQ(captured.records.size(), 1u); + EXPECT_EQ(captured.records[0].header.characterId, character); +} + +TEST_F(PacketCaptureTest, BatchesWaitForMasterAndDropTheOldestPastTheCap) { + Start(ServiceType::WORLD); + Arm(0, 16, eCaptureTarget::EVERYTHING); + PacketCapture::SetSettings({ .flushIntervalMs = 1000, .flushBytes = 1, .maxBufferBytes = 2000 }); + connected = false; + ClientPackets::LoadStaticZone zone; + const auto bytes = Bytes(zone); + for (int i = 0; i < 100; i++) Send(Address(1, 1), bytes); + PacketCapture::Update(); + EXPECT_TRUE(captured.batches.empty()); + EXPECT_GT(PacketCapture::GetStats().dropped, 0u); + + connected = true; + PacketCapture::Update(); + ASSERT_FALSE(captured.batches.empty()); + EXPECT_EQ(captured.batches.front().packetsDropped, PacketCapture::GetStats().dropped); + EXPECT_EQ(captured.records.size() + PacketCapture::GetStats().dropped, 100u); + // The sequence shows where the gap is + EXPECT_GT(captured.records.front().header.seq, 1u); +} + +TEST_F(PacketCaptureTest, DisarmSendsWhatIsLeftThenStops) { + Start(ServiceType::WORLD); + Arm(0, 17, eCaptureTarget::EVERYTHING); + PacketCapture::SetSettings({ .flushIntervalMs = 60000, .flushBytes = 1024 * 1024, .maxBufferBytes = 1024 * 1024 }); + ClientPackets::LoadStaticZone zone; + Send(Address(1, 1), Bytes(zone)); + MessageCaptureControl control; + control.action = eMessageCaptureControl::DISARM; + control.captureId = 17; + PacketCapture::Control(control); + PacketCapture::Update(); + ASSERT_EQ(captured.records.size(), 1u); + EXPECT_EQ(captured.batches.front().slots[0], 17u) << "sent under the capture it was recorded for"; + EXPECT_FALSE(PacketCapture::g_Armed); +} + +TEST(PacketDecoderTests, NamesEveryService) { + WorldPackets::PositionUpdate position; + position.update.position = NiPoint3(1, 2, 3); + RakNet::BitStream stream; + position.WritePacket(stream); + const std::string bytes(reinterpret_cast(stream.GetData()), stream.GetNumberOfBytesUsed()); + const auto decoded = PacketDecoder::Decode(bytes, true); + EXPECT_EQ(decoded.service, "WORLD"); + EXPECT_EQ(decoded.name, "POSITION_UPDATE"); + ASSERT_TRUE(decoded.fields); + EXPECT_FLOAT_EQ((*decoded.fields)["position"][1].get(), 2.0f); + const auto where = PacketDecoder::Position(bytes); + ASSERT_TRUE(where); + EXPECT_FLOAT_EQ(where->z, 3.0f); + + const std::string raknet(1, static_cast(ID_REPLICA_MANAGER_CONSTRUCTION)); + EXPECT_EQ(PacketDecoder::Decode(raknet, false).name, "ID_REPLICA_MANAGER_CONSTRUCTION"); + EXPECT_EQ(PacketDecoder::Name(ServiceType::MASTER, static_cast(MessageType::Master::SESSION_KEY_RESPONSE)), "SESSION_KEY_RESPONSE"); + EXPECT_TRUE(PacketDecoder::HasSecrets(ServiceType::MASTER, static_cast(MessageType::Master::SET_SESSION_KEY))); + EXPECT_FALSE(PacketDecoder::HasSecrets(ServiceType::WORLD, static_cast(MessageType::World::POSITION_UPDATE))); +} + +TEST(PacketDecoderTests, SessionKeysBetweenServersAreBlanked) { + MasterPackets::SetSessionKey key; + key.sessionKey = 0xDEADBEEF; + key.username = LUString("alice"); + RakNet::BitStream stream; + key.WritePacket(stream); + std::string bytes(reinterpret_cast(stream.GetData()), stream.GetNumberOfBytesUsed()); + ASSERT_TRUE(PacketDecoder::Redact(bytes)); + const uint32_t secret = 0xDEADBEEF; + EXPECT_EQ(bytes.find(std::string(reinterpret_cast(&secret), 4)), std::string::npos); +} + +namespace { + CaptureBundle::Record MakeRecord(const LUBitStream& packet, int64_t timeUs, eCaptureSource source, ePacketDirection direction, LWOOBJID character = 0) { + CaptureBundle::Record record; + record.bytes = Bytes(packet); + record.header.timeUs = timeUs; + record.header.source = static_cast(source); + record.header.direction = static_cast(direction); + record.header.characterId = character; + record.header.zoneId = 1100; + record.header.length = static_cast(record.bytes.size()); + record.header.bits = record.header.length * 8; + return record; + } +} + +TEST(CaptureToolsTests, TimelineTracksAndBundles) { + constexpr LWOOBJID character = 1152921510436607007LL; + WorldPackets::PositionUpdate a, b; + a.update.position = NiPoint3(0, 0, 0); + b.update.position = NiPoint3(10, 0, 0); + WorldPackets::CharacterLoginRequest login; + login.playerID = character; + CaptureBundle::Bundle bundle; + bundle.records.push_back(MakeRecord(b, 3000000, eCaptureSource::WORLD, ePacketDirection::RECEIVED, character)); + bundle.records.push_back(MakeRecord(a, 1000000, eCaptureSource::WORLD, ePacketDirection::RECEIVED, character)); + bundle.records.push_back(MakeRecord(login, 500000, eCaptureSource::WORLD, ePacketDirection::RECEIVED, character)); + CaptureTools::SortTimeline(bundle.records); + EXPECT_EQ(bundle.records.front().header.timeUs, 500000); + + const auto tracks = CaptureTools::Tracks(bundle.records, 0); + ASSERT_EQ(tracks.size(), 1u); + ASSERT_EQ(tracks[0].samples.size(), 8u); + EXPECT_FLOAT_EQ(tracks[0].samples[0], 1.0f); + EXPECT_FLOAT_EQ(tracks[0].samples[5], 10.0f); + + // Portable: the character's ID is gone from the bytes, a placeholder is in its place + const auto characters = CaptureTools::MakePortable(bundle); + ASSERT_EQ(characters.size(), 1u); + EXPECT_EQ(characters.at("char#1"), character); + const auto decoded = PacketDecoder::Decode(bundle.records.front().bytes, true); + EXPECT_EQ((*decoded.fields)["playerID"], std::to_string(CaptureTools::PLACEHOLDER_BASE + 1)); + EXPECT_EQ(bundle.meta["ids"]["char#1"]["kind"], "character"); + + // Saved and read back + const auto path = std::filesystem::temp_directory_path() / "dlu_capture_tools_test.bundle"; + ASSERT_TRUE(CaptureBundle::Save(path, bundle)); + CaptureBundle::Bundle read; + std::string error; + ASSERT_TRUE(CaptureBundle::Load(path, read, error)) << error; + EXPECT_EQ(read.records.size(), 3u); + EXPECT_EQ(read.records[1].bytes, bundle.records[1].bytes); + EXPECT_TRUE(read.meta["portable"].get()); + std::filesystem::remove(path); +} + +TEST(CaptureToolsTests, AnonymiseKeepsSizes) { + WorldPackets::GeneralChatMessage chat; + chat.message = u"my secret plans"; + CaptureBundle::Bundle bundle; + bundle.records.push_back(MakeRecord(chat, 1, eCaptureSource::WORLD, ePacketDirection::RECEIVED)); + const auto size = bundle.records[0].bytes.size(); + EXPECT_EQ(CaptureTools::Anonymise(bundle), 1u); + EXPECT_EQ(bundle.records[0].bytes.size(), size); + EXPECT_EQ(bundle.records[0].bytes.find(Wide("secret")), std::string::npos); +} + +TEST(CaptureToolsTests, DiffPairsAnswersAndIgnoresVolatileFields) { + ClientPackets::LoadStaticZone zone, otherInstance, otherChecksum; + zone.mapID = otherInstance.mapID = otherChecksum.mapID = 1100; + zone.instanceID = 1; + otherInstance.instanceID = 7; // differs every run: not a difference + otherChecksum.mapChecksum = 1234; + ClientPackets::TransferToWorld transfer; + std::vector expected{ MakeRecord(zone, 1, eCaptureSource::WORLD, ePacketDirection::SENT), + MakeRecord(zone, 2, eCaptureSource::WORLD, ePacketDirection::SENT), MakeRecord(transfer, 3, eCaptureSource::WORLD, ePacketDirection::SENT) }; + std::vector actual{ MakeRecord(otherInstance, 1, eCaptureSource::WORLD, ePacketDirection::SENT), + MakeRecord(otherChecksum, 2, eCaptureSource::WORLD, ePacketDirection::SENT), MakeRecord(zone, 3, eCaptureSource::WORLD, ePacketDirection::SENT) }; + const auto report = CaptureTools::Diff(expected, actual); + EXPECT_EQ(report.expected, 3u); + EXPECT_EQ(report.matched, 1u); + EXPECT_EQ(report.differing, 1u); + EXPECT_EQ(report.missing, 1u); + EXPECT_EQ(report.extra, 1u); + EXPECT_EQ(report.missingByName.at("TRANSFER_TO_WORLD"), 1u); +} + +/** + * The cost of capturing everything (docs/CaptureReplay.md, "Overhead"): a synthetic load of world packets through the + * tap with an EVERYTHING capture armed, then what the dashboard does with the batches: append them to a file, or + * (for comparison) insert every packet as a row in one SQLite transaction per batch. + */ +TEST_F(PacketCaptureTest, OverheadOfCapturingEverything) { + using Clock = std::chrono::steady_clock; + constexpr int PACKETS = 300000; + Start(ServiceType::WORLD); + Arm(0, 18, eCaptureTarget::EVERYTHING); + PacketCapture::SetSettings({ .flushIntervalMs = 1000, .flushBytes = 256 * 1024, .maxBufferBytes = 64 * 1024 * 1024 }); + std::vector batches; + PacketCapture::SetSink([&](MessageCaptureData& data) { batches.push_back(std::move(data.packets)); return true; }); + + WorldPackets::PositionUpdate position; + position.hasVelocity = true; + const auto bytes = Bytes(position); + const auto* data = reinterpret_cast(bytes.data()); + const auto bits = static_cast(bytes.size() * 8); + + const auto start = Clock::now(); + for (int i = 0; i < PACKETS; i++) { + PacketCapture::RecordForTest(Address(static_cast(i % 100), 1000), (i & 1) != 0, false, data, bits); + if (i % 1000 == 0) PacketCapture::Update(); // about one main loop frame + } + PacketCapture::Update(); + const auto tapNs = std::chrono::duration_cast(Clock::now() - start).count(); + + size_t total = 0; + for (const auto& batch : batches) total += batch.size(); + ASSERT_EQ(PacketCapture::GetStats().recorded, static_cast(PACKETS)); + + // The dashboard's side: one append per batch + const auto path = std::filesystem::temp_directory_path() / "dlu_capture_overhead.bundle"; + const auto fileStart = Clock::now(); + { + std::ofstream file(path, std::ios::binary | std::ios::trunc); + for (const auto& batch : batches) { + file.write(batch.data(), static_cast(batch.size())); + file.flush(); + } + } + const auto fileNs = std::chrono::duration_cast(Clock::now() - fileStart).count(); + std::filesystem::remove(path); + + // For comparison: a row per packet, one transaction per batch + const auto dbPath = std::filesystem::temp_directory_path() / "dlu_capture_overhead.sqlite"; + std::filesystem::remove(dbPath); + sqlite3* db = nullptr; + ASSERT_EQ(sqlite3_open(dbPath.string().c_str(), &db), SQLITE_OK); + sqlite3_exec(db, "PRAGMA journal_mode=WAL; CREATE TABLE e (session INTEGER, seq INTEGER, t INTEGER, payload BLOB, PRIMARY KEY (session, seq));", nullptr, nullptr, nullptr); + sqlite3_stmt* insert = nullptr; + sqlite3_prepare_v2(db, "INSERT INTO e VALUES (1, ?, ?, ?);", -1, &insert, nullptr); + const auto dbStart = Clock::now(); + int seq = 0; + for (const auto& batch : batches) { + sqlite3_exec(db, "BEGIN;", nullptr, nullptr, nullptr); + PacketRecord::ForEach(batch, [&](const PacketRecordHeader& header, std::string_view payload) { + sqlite3_bind_int(insert, 1, ++seq); + sqlite3_bind_int64(insert, 2, header.timeUs); + sqlite3_bind_blob(insert, 3, payload.data(), static_cast(payload.size()), SQLITE_STATIC); + sqlite3_step(insert); + sqlite3_reset(insert); + }); + sqlite3_exec(db, "COMMIT;", nullptr, nullptr, nullptr); + } + const auto dbNs = std::chrono::duration_cast(Clock::now() - dbStart).count(); + sqlite3_finalize(insert); + sqlite3_close(db); + std::filesystem::remove(dbPath); + std::filesystem::remove(dbPath.string() + "-wal"); + std::filesystem::remove(dbPath.string() + "-shm"); + + const double seconds = static_cast(tapNs) / 1e9; + std::printf("[overhead] %d packets of %zu bytes through the tap: %.0f ns each, %.0f packets/s on one core, %.1f MB/s of records, %zu batches\n", + PACKETS, bytes.size(), static_cast(tapNs) / PACKETS, PACKETS / seconds, static_cast(total) / 1e6 / seconds, batches.size()); + std::printf("[overhead] dashboard: file append %.1f ms (%.0f ns/packet); SQLite row per packet %.1f ms (%.0f ns/packet)\n", + static_cast(fileNs) / 1e6, static_cast(fileNs) / PACKETS, static_cast(dbNs) / 1e6, static_cast(dbNs) / PACKETS); + // Generous bounds: this is a smoke test, the numbers above are the measurement + EXPECT_LT(static_cast(tapNs) / PACKETS, 20000.0); +} + +/** + * Local fixtures (docs/CaptureReplay.md): bundles exported with anonymise=1 and put in tests/fixtures-local (never + * committed). Every packet the server has a struct for must read and write back to the same bytes. + */ +TEST(CaptureFixtureTests, RecordedPacketsRoundTrip) { + const std::filesystem::path folder = std::filesystem::path(DLU_SOURCE_DIR) / "tests" / "fixtures-local"; + std::error_code ec; + if (!std::filesystem::is_directory(folder, ec)) GTEST_SKIP() << "No local fixtures in " << folder.string(); + size_t checked = 0, bundles = 0; + for (const auto& entry : std::filesystem::directory_iterator(folder)) { + if (entry.path().extension() != ".bundle") continue; + CaptureBundle::Bundle bundle; + std::string error; + ASSERT_TRUE(CaptureBundle::Load(entry.path(), bundle, error)) << entry.path() << ": " << error; + bundles++; + for (const auto& record : bundle.records) { + if (record.header.flags & (PacketRecordFlags::GAP | PacketRecordFlags::CUT)) continue; + const auto same = PacketDecoder::RoundTrip(record.bytes); + if (!same) continue; + EXPECT_TRUE(*same) << entry.path().filename() << " record " << record.header.seq << " " + << PacketDecoder::Decode(record.bytes, CaptureTools::FromClient(record.header)).name << " doesn't write back to the same bytes"; + checked++; + } + } + if (bundles == 0) GTEST_SKIP() << "No .bundle files in " << folder.string(); + std::printf("[fixtures] %zu bundle(s), %zu packets checked\n", bundles, checked); +} diff --git a/thirdparty/raknet/Source/RakPeer.cpp b/thirdparty/raknet/Source/RakPeer.cpp index 80ef6cfb1..454664c56 100644 --- a/thirdparty/raknet/Source/RakPeer.cpp +++ b/thirdparty/raknet/Source/RakPeer.cpp @@ -975,6 +975,8 @@ bool RakPeer::GetConnectionList( SystemAddress *remoteSystems, unsigned short *n // Returns: // False if we are not connected to the specified recipient. True otherwise // -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +RakPeerSendHook g_RakPeerSendHook = 0; + bool RakPeer::Send( const char *data, const int length, PacketPriority priority, PacketReliability reliability, char orderingChannel, SystemAddress systemAddress, bool broadcast ) { #ifdef _DEBUG @@ -990,6 +992,9 @@ bool RakPeer::Send( const char *data, const int length, PacketPriority priority, if ( broadcast == false && systemAddress == UNASSIGNED_SYSTEM_ADDRESS ) return false; + if (g_RakPeerSendHook) + g_RakPeerSendHook(this, (const unsigned char*)data, BYTES_TO_BITS(length), systemAddress, broadcast); + if (broadcast==false && router && IsConnected(systemAddress)==false) { return router->Send(data, BYTES_TO_BITS(length), priority, reliability, orderingChannel, systemAddress); @@ -1017,6 +1022,9 @@ bool RakPeer::Send( const RakNet::BitStream * bitStream, PacketPriority priority if ( broadcast == false && systemAddress == UNASSIGNED_SYSTEM_ADDRESS ) return false; + if (g_RakPeerSendHook) + g_RakPeerSendHook(this, bitStream->GetData(), bitStream->GetNumberOfBitsUsed(), systemAddress, broadcast); + if (broadcast==false && router && IsConnected(systemAddress)==false) { return router->Send((const char*)bitStream->GetData(), bitStream->GetNumberOfBitsUsed(), priority, reliability, orderingChannel, systemAddress); diff --git a/thirdparty/raknet/Source/RakPeer.h b/thirdparty/raknet/Source/RakPeer.h index 1251d3c5d..5561410fa 100644 --- a/thirdparty/raknet/Source/RakPeer.h +++ b/thirdparty/raknet/Source/RakPeer.h @@ -42,6 +42,11 @@ int RAK_DLL_EXPORT SystemAddressAndIndexComp( const SystemAddress &key, const Sy /// The primary interface for RakNet, RakPeer contains all major functions for the library. /// See the individual functions for what the class can do. /// \brief The main interface for network communications +/// DLU addition: called on the caller's thread for every packet the application sends with Send (not RakNet's own +/// connection messages), before it is queued. The server's packet capture sets it only while a capture is armed. +typedef void (*RakPeerSendHook)(RakPeerInterface* peer, const unsigned char* data, BitSize_t bits, SystemAddress systemAddress, bool broadcast); +extern RakPeerSendHook g_RakPeerSendHook; + class RAK_DLL_EXPORT RakPeer : public RakPeerInterface { public: